Hiểu rõ Vòng đời của ViewController trong UIKit

Chào mừng các bạn quay trở lại với series “iOS Developer Roadmap“! Trên con đường trở thành một lập trình viên iOS chuyên nghiệp, việc nắm vững các khái niệm cốt lõi của UIKit là điều không thể thiếu. Chúng ta đã cùng nhau khám phá những kiến thức Swift cơ bản, tìm hiểu về OOP và Lập trình Hàm, và làm quen với Quản Lý Bộ Nhớ trong Swift. Hôm nay, chúng ta sẽ đi sâu vào một trong những viên gạch nền tảng quan trọng nhất khi xây dựng giao diện người dùng trên iOS: Vòng đời của ViewController.

Đối với các bạn mới bắt đầu, ViewController có thể hơi trừu tượng, nhưng việc hiểu rõ nó “sống” và “chết” như thế nào trong ứng dụng của bạn là chìa khóa để viết code hiệu quả, tránh lỗi và tạo ra trải nghiệm người dùng mượt mà. Hãy cùng nhau giải mã những giai đoạn trong vòng đời kỳ diệu này nhé!

ViewController Là Gì và Tại Sao Vòng đời lại Quan Trọng?

Trong UIKit, UIViewController là trung tâm quản lý một phần giao diện người dùng (UI) của ứng dụng của bạn, thường là một màn hình hoặc một phần của màn hình. Mỗi ViewController quản lý một tập hợp các View (UIView) và xử lý tương tác của người dùng với các View đó, cũng như phối hợp với các View Controller khác để quản lý luồng dữ liệu và điều hướng trong ứng dụng.

Giống như con người, View Controller cũng có vòng đời riêng. Nó được tạo ra, “sống” trên màn hình một thời gian, có thể tạm thời khuất đi rồi xuất hiện lại, và cuối cùng là bị giải phóng khỏi bộ nhớ. Vòng đời này được điều khiển bởi hệ thống và được biểu diễn thông qua một chuỗi các phương thức đặc biệt mà bạn có thể override (ghi đè) trong lớp con của UIViewController.

Việc hiểu rõ khi nào các phương thức này được gọi giúp bạn biết nên đặt logic nào ở đâu:

  • Thiết lập giao diện ban đầu?
  • Tải dữ liệu từ mạng?
  • Cập nhật UI khi ViewController sắp xuất hiện?
  • Lưu trạng thái khi ViewController biến mất?
  • Dọn dẹp tài nguyên khi không còn sử dụng?

Đặt đúng code vào đúng phương thức trong vòng đời sẽ giúp ứng dụng của bạn hoạt động hiệu quả, sử dụng tài nguyên hợp lý và tránh các lỗi phổ biến.

Các Giai đoạn Chính trong Vòng đời của ViewController

Vòng đời của một ViewController có thể được chia thành các giai đoạn chính:

  1. Khởi tạo (Initialization): ViewController được tạo ra.
  2. Tải View (Loading Views): Các View mà ViewController quản lý được tải và thiết lập.
  3. Xuất hiện (Appearance): ViewController sắp hoặc đã xuất hiện trên màn hình, sẵn sàng cho người dùng tương tác.
  4. Biến mất (Disappearance): ViewController sắp hoặc đã bị ẩn khỏi màn hình.
  5. Giải phóng (Deallocation): ViewController bị loại bỏ khỏi bộ nhớ.

Mỗi giai đoạn này tương ứng với một hoặc nhiều phương thức mà hệ thống UIKit sẽ tự động gọi cho ViewController của bạn. Chúng ta sẽ đi sâu vào từng phương thức quan trọng nhất.

Khám phá Chi tiết Các Phương thức Vòng đời

Đây là nơi chúng ta sẽ tìm hiểu về các phương thức mà bạn thường làm việc cùng:

init(nibName:bundle:)

Phương thức này được gọi khi ViewController được khởi tạo từ một tệp Nib (hoặc Storyboard). Nếu bạn khởi tạo ViewController bằng cách lập trình (không dùng Storyboard/Nib), bạn có thể sử dụng `init()` hoặc các initializer tùy chỉnh khác. Tuy nhiên, khi làm việc với Storyboard, init(coder:) mới là initializer được sử dụng.

Khi nào được gọi: Khi một instance của ViewController được tạo ra lần đầu.

Sử dụng điển hình: Thực hiện các bước khởi tạo cơ bản, thiết lập các thuộc tính ban đầu không phụ thuộc vào View.

Lưu ý: Tại thời điểm này, View của ViewController (`self.view`) vẫn chưa được tạo hoặc tải từ Storyboard/Nib. Do đó, bạn không nên truy cập hoặc thao tác với `self.view` hoặc bất kỳ subview nào tại đây.

class MyViewController: UIViewController {
    var someData: String

    init(someData: String) {
        self.someData = someData
        // Gọi initializer của lớp cha
        super.init(nibName: nil, bundle: nil)
        print("ViewController được khởi tạo với dữ liệu: \(someData)")
    }

    // Required initializer khi sử dụng Storyboard
    required init?(coder: NSCoder) {
        // Cần cung cấp giá trị ban đầu cho someData hoặc xử lý từ coder
        self.someData = "" // Cần logic thực tế hơn
        super.init(coder: coder)
        print("ViewController được khởi tạo từ Storyboard")
    }

    // ... các phương thức vòng đời khác
}

loadView()

Phương thức này chịu trách nhiệm tạo hoặc tải View chính cho ViewController (`self.view`). Nếu bạn sử dụng Storyboard hoặc Nib, UIKit sẽ tự động thực hiện điều này cho bạn bằng cách tải View từ tệp đó. Nếu bạn không sử dụng Storyboard/Nib và không override phương thức này, UIKit sẽ tạo một UIView trống cho bạn và gán vào `self.view`. Nếu bạn override loadView(), bạn phải tự tạo View gốc (root view) và gán nó vào `self.view`. Tuyệt đối không gọi `super.loadView()` nếu bạn override phương thức này và tự tạo View.

Khi nào được gọi: Khi View của ViewController được yêu cầu lần đầu tiên nhưng chưa được tải.

Sử dụng điển hình: Rất hiếm khi override. Chỉ override khi bạn muốn tạo toàn bộ View bằng lập trình từ đầu mà không dựa vào Storyboard hay Nib nào cả. Hầu hết các trường hợp, bạn nên để UIKit xử lý việc tải View và thực hiện thiết lập trong viewDidLoad().

class CustomViewOnlyController: UIViewController {
    // Chỉ override khi bạn không dùng Storyboard/XIB VÀ muốn tự tạo View gốc
    override func loadView() {
        // Tạo một UIView gốc tùy chỉnh
        let customView = UIView()
        customView.backgroundColor = .systemBlue
        self.view = customView // Gán View gốc
        print("loadView() được gọi, View gốc được tạo lập trình.")
        // KHÔNG gọi super.loadView() ở đây
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        // View đã sẵn sàng, thêm subviews vào self.view tại đây
        print("viewDidLoad() được gọi sau khi View gốc được gán.")
    }
}

viewDidLoad()

Đây là một trong những phương thức vòng đời được sử dụng phổ biến nhất. Phương thức này được gọi chỉ một lần trong suốt vòng đời của ViewController, ngay sau khi View của nó đã được tải vào bộ nhớ (từ Storyboard, Nib, hoặc được tạo lập trình trong loadView()). Tại thời điểm này, tất cả các outlets từ Storyboard/Nib đã được kết nối.

Khi nào được gọi: Sau khi View của ViewController đã được tải vào bộ nhớ và các outlets đã được kết nối, trước khi View xuất hiện trên màn hình.

Sử dụng điển hình:

  • Thiết lập giao diện ban đầu không thay đổi trong suốt vòng đời của ViewController (ví dụ: thêm subviews, thiết lập constraints, cấu hình giao diện tĩnh).
  • Tải dữ liệu ban đầu không thay đổi thường xuyên (ví dụ: đọc dữ liệu cấu hình tĩnh).
  • Thiết lập các đối tượng cố định (ví dụ: delegates, data sources).

Lưu ý: Kích thước cuối cùng của View có thể chưa được xác định tại thời điểm này, vì vậy không nên dựa vào kích thước frame của View để thiết lập layout động. Sử dụng Auto Layout là cách tốt nhất.

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad() // LUÔN gọi super ở các phương thức vòng đời khác trừ loadView
        print("viewDidLoad()")

        // Thiết lập màu nền
        view.backgroundColor = .white

        // Thêm label lập trình
        let label = UILabel()
        label.text = "Chào mừng đến với ViewController!"
        label.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(label)

        // Thiết lập constraints (ví dụ đơn giản)
        NSLayoutConstraint.activate([
            label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            label.centerYAnchor.constraint(equalTo: view.centerYAnchor)
        ])

        // Tải dữ liệu ban đầu (ví dụ mô phỏng)
        loadInitialData()
    }

    func loadInitialData() {
        print("Đang tải dữ liệu ban đầu...")
        // Thực hiện tải dữ liệu (có thể là call API, đọc từ database,...)
    }
}

viewWillAppear(_:)

Phương thức này được gọi ngay trước khi View của ViewController sắp xuất hiện trên màn hình. Điều này có thể xảy ra khi ViewController lần đầu tiên được hiển thị, hoặc khi nó quay trở lại màn hình sau khi một ViewController khác đã che phủ nó (ví dụ: hiển thị modal, push lên navigation stack). Phương thức này có thể được gọi nhiều lần trong suốt vòng đời của ViewController.

Khi nào được gọi: Ngay trước khi View của ViewController trở nên hiển thị.

Sử dụng điển hình:

  • Cập nhật UI hoặc dữ liệu cần hiển thị mỗi khi ViewController xuất hiện (ví dụ: làm mới dữ liệu từ database, kiểm tra trạng thái người dùng).
  • Bắt đầu các hoạt động yêu cầu tài nguyên (ví dụ: bắt đầu animation, đăng ký lắng nghe thông báo).
  • Điều chỉnh trạng thái thanh navigation bar hoặc tab bar.

Lưu ý: Tránh thực hiện các tác vụ tốn thời gian hoặc chặn main thread trong phương thức này, vì nó sẽ làm chậm quá trình chuyển đổi màn hình và tạo cảm giác giật lag cho người dùng.

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    print("viewWillAppear()")

    // Làm mới dữ liệu trên UI (ví dụ)
    updateUIWithLatestData()

    // Bắt đầu animation
    startEntranceAnimation()
}

func updateUIWithLatestData() {
    print("Cập nhật UI trước khi hiển thị...")
    // Lấy dữ liệu mới và cập nhật các nhãn, hình ảnh, bảng, ...
}

func startEntranceAnimation() {
     print("Bắt đầu animation xuất hiện...")
     // Code để chạy animation
}

viewDidAppear(_:)

Phương thức này được gọi ngay sau khi View của ViewController đã hoàn toàn xuất hiện trên màn hình và sẵn sàng cho người dùng tương tác. Tương tự như viewWillAppear(_:), phương thức này cũng có thể được gọi nhiều lần.

Khi nào được gọi: Sau khi View của ViewController đã hiển thị đầy đủ trên màn hình.

Sử dụng điển hình:

  • Bắt đầu các tác vụ yêu cầu ViewController phải hiển thị hoàn toàn (ví dụ: bắt đầu animation lặp lại, hiển thị popup, bắt đầu lấy dữ liệu từ mạng nếu cần hiển thị ngay sau khi load).
  • Ghi log hoặc theo dõi phân tích khi màn hình xuất hiện.

Lưu ý: Mặc dù View đã hiển thị, việc thực hiện các tác vụ rất nặng trong phương thức này vẫn có thể ảnh hưởng đến hiệu suất tổng thể của ứng dụng. Hãy cân nhắc sử dụng queue nền cho các tác vụ dài.

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    print("viewDidAppear()")

    // Bắt đầu timer hoặc animation lặp lại
    startRefreshTimer()

    // Hiển thị hướng dẫn (ví dụ)
    showTutorialIfNeeded()
}

func startRefreshTimer() {
    print("Bắt đầu timer làm mới...")
    // Code để khởi chạy timer
}

func showTutorialIfNeeded() {
    print("Kiểm tra và hiển thị hướng dẫn nếu cần...")
    // Code để kiểm tra lần chạy đầu và hiển thị popup hướng dẫn
}

viewWillDisappear(_:)

Phương thức này được gọi ngay trước khi View của ViewController sắp bị ẩn khỏi màn hình. Điều này xảy ra khi một ViewController khác sắp được hiển thị đè lên nó, hoặc khi ViewController đang bị loại bỏ khỏi navigation stack hoặc đóng lại (dismiss).

Khi nào được gọi: Ngay trước khi View của ViewController bị ẩn đi.

Sử dụng điển hình:

  • Lưu lại trạng thái hiện tại của ViewController (ví dụ: nội dung văn bản trong text field, vị trí scroll của table view).
  • Dừng các hoạt động đang chạy có thể gây tốn tài nguyên hoặc không cần thiết khi ViewController không hiển thị (ví dụ: dừng animation, dừng timer, hủy bỏ các yêu cầu mạng đang chờ).
  • Ẩn bàn phím.

Lưu ý: Đảm bảo các tác vụ dọn dẹp ở đây được hoàn thành nhanh chóng để không cản trở quá trình chuyển đổi màn hình.

override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    print("viewWillDisappear()")

    // Lưu trạng thái hiện tại (ví dụ)
    saveCurrentState()

    // Dừng các hoạt động đang chạy
    stopRefreshTimer() // Dừng timer đã start trong viewDidAppear
    cancelNetworkRequests()
}

func saveCurrentState() {
    print("Lưu trạng thái ViewController...")
    // Code lưu dữ liệu vào UserDefaults, Core Data, v.v.
}

func cancelNetworkRequests() {
    print("Hủy các yêu cầu mạng đang chờ...")
    // Code để hủy các task URLSession
}

viewDidDisappear(_:)

Phương thức này được gọi ngay sau khi View của ViewController đã hoàn toàn bị ẩn khỏi màn hình. Tại thời điểm này, người dùng không còn nhìn thấy View đó nữa.

Khi nào được gọi: Sau khi View của ViewController đã bị ẩn hoàn toàn.

Sử dụng điển hình:

  • Dọn dẹp các tài nguyên không còn cần thiết sau khi ViewController đã bị ẩn (ví dụ: hủy đăng ký lắng nghe thông báo, giải phóng các đối tượng tạm thời).
  • Kết thúc các tác vụ nền chỉ cần chạy khi View hiển thị.

Lưu ý: Phương thức này đảm bảo rằng View đã hoàn toàn biến mất trước khi bạn thực hiện dọn dẹp cuối cùng liên quan đến UI.

override func viewDidDisappear(_ animated: Bool) {
    super.viewDidDisappear(animated)
    print("viewDidDisappear()")

    // Hủy đăng ký lắng nghe thông báo
    NotificationCenter.default.removeObserver(self)

    // Dọn dẹp các tài nguyên khác nếu cần
    cleanUpTemporaryResources()
}

func cleanUpTemporaryResources() {
    print("Dọn dẹp tài nguyên tạm thời...")
    // Code giải phóng bộ nhớ hoặc các đối tượng không cần thiết
}

deinit

Phương thức này (không phải là một phương thức của UIViewController mà là một phần của ngôn ngữ Swift) được gọi ngay trước khi một instance của lớp bị giải phóng khỏi bộ nhớ. Đối với ViewController, điều này xảy ra khi nó không còn được tham chiếu bởi bất kỳ đối tượng nào khác trong ứng dụng, và hệ thống quyết định thu hồi bộ nhớ của nó. Việc này thường xảy ra sau khi ViewController đã bị loại bỏ khỏi navigation stack hoặc bị đóng lại (dismiss) và không được giữ bởi bất kỳ đâu khác.

Khi nào được gọi: Ngay trước khi instance ViewController bị hủy và bộ nhớ được giải phóng.

Sử dụng điển hình: Thực hiện các bước dọn dẹp cuối cùng cho các tài nguyên không được quản lý tự động bởi ARC (Automatic Reference Counting). Tuy nhiên, nhờ có ARC trong Swift, bạn thường không cần phải làm nhiều ở đây, ngoại trừ các tài nguyên bên ngoài hoặc debugging để kiểm tra xem ViewController có bị rò rỉ bộ nhớ hay không.

deinit {
    print("ViewController đang được giải phóng khỏi bộ nhớ.")
    // Thực hiện dọn dẹp thủ công nếu có tài nguyên không phải Swift object
    // Ví dụ: giải phóng C pointers, đóng file handles...
    // Với hầu hết các Swift/UIKit objects, ARC sẽ tự động giải phóng.
}

Kiểm tra xem deinit có được gọi hay không là một cách hiệu quả để phát hiện Retain Cycles.

Tóm tắt Vòng đời ViewController

Để dễ hình dung, đây là thứ tự các phương thức vòng đời chính được gọi trong một luồng thông thường:

  1. ViewController được khởi tạo (ví dụ: từ Storyboard hoặc code).
  2. loadView() được gọi (nếu View chưa được tạo).
  3. viewDidLoad() được gọi (Chỉ 1 lần sau khi View được tải).
  4. … (Các phương thức Layout như viewWillLayoutSubviews()viewDidLayoutSubviews() có thể được gọi nhiều lần ở đây)
  5. viewWillAppear(_:) được gọi (Trước khi xuất hiện).
  6. viewDidAppear(_:) được gọi (Sau khi xuất hiện).
  7. … (ViewController hiển thị và tương tác)
  8. Khi ViewController sắp bị ẩn: viewWillDisappear(_:) được gọi.
  9. Khi ViewController đã bị ẩn: viewDidDisappear(_:) được gọi.
  10. … (Quá trình 5-8 có thể lặp lại nếu ViewController hiển thị rồi ẩn đi nhiều lần)
  11. Khi ViewController không còn được tham chiếu và cần giải phóng bộ nhớ: deinit được gọi.

Dưới đây là bảng tóm tắt các phương thức chính và mục đích sử dụng của chúng:

Phương thức Khi nào được gọi? Mục đích sử dụng điển hình Số lần gọi
init (hoặc init(coder:)) Khi ViewController được tạo instance. Khởi tạo các thuộc tính, thiết lập ban đầu không liên quan đến View. 1 lần
loadView() Khi View được yêu cầu nhưng chưa được tải. Tạo View gốc bằng lập trình (rất hiếm khi override). 1 lần (nếu không dùng Storyboard/XIB hoặc override)
viewDidLoad() Sau khi View đã được tải vào bộ nhớ. Thiết lập View tĩnh, tải dữ liệu ban đầu, kết nối outlets. 1 lần
viewWillAppear(_:) Ngay trước khi View sắp xuất hiện trên màn hình. Cập nhật UI/dữ liệu động, bắt đầu các hoạt động cần hiển thị. Nhiều lần
viewDidAppear(_:) Ngay sau khi View đã xuất hiện hoàn toàn. Bắt đầu animation, các tác vụ cần View hiển thị đầy đủ. Nhiều lần
viewWillDisappear(_:) Ngay trước khi View sắp bị ẩn. Lưu trạng thái, dừng các hoạt động tốn tài nguyên. Nhiều lần
viewDidDisappear(_:) Ngay sau khi View đã bị ẩn hoàn toàn. Dọn dẹp tài nguyên không còn cần thiết sau khi ẩn. Nhiều lần
deinit Ngay trước khi ViewController được giải phóng khỏi bộ nhớ. Dọn dẹp cuối cùng cho tài nguyên không do ARC quản lý, debugging retain cycles. 1 lần (nếu được giải phóng đúng cách)

Các Lưu ý Quan trọng Khác

Responding to Events (Xử lý Sự kiện)

Ngoài vòng đời xuất hiện/biến mất, ViewController còn có các phương thức để phản hồi lại các sự kiện của hệ thống:

  • viewWillTransition(to:with:): Được gọi khi kích thước vùng chứa của ViewController sắp thay đổi (ví dụ: xoay màn hình, thay đổi split view). Bạn sử dụng phương thức này để cập nhật layout hoặc animations trước khi thay đổi xảy ra.
  • didReceiveMemoryWarning(): Được gọi khi hệ thống phát hiện bộ nhớ đang cạn kiệt. Đây là cơ hội để bạn giải phóng các tài nguyên không quan trọng hoặc có thể tải lại sau để giảm áp lực bộ nhớ. Việc hiểu Quản Lý Bộ Nhớ trong Swift rất quan trọng tại đây.

Nested View Controllers (Container View Controllers)

Khi bạn nhúng một ViewController con vào một ViewController cha (ví dụ: dùng Container View trong Storyboard, hoặc thêm child view controller bằng code), vòng đời của ViewController con sẽ liên kết chặt chẽ với vòng đời của ViewController cha. Các phương thức `viewWillAppear`, `viewDidAppear`, `viewWillDisappear`, `viewDidDisappear` của ViewController con sẽ được gọi tương ứng với các phương thức của ViewController cha khi ViewController cha xuất hiện hoặc biến mất.

Common Pitfalls and Best Practices (Các Lỗi Thường Gặp và Thực hành Tốt)

  • Tải dữ liệu nặng trong viewWillAppear/viewDidAppear: Như đã đề cập, điều này có thể làm chậm quá trình chuyển đổi màn hình. Hãy thực hiện các tác vụ tải dữ liệu trên background thread và cập nhật UI trên main thread sau khi dữ liệu sẵn sàng.
  • Thiết lập UI động trong viewDidLoad: viewDidLoad chỉ chạy một lần và trước khi kích thước View được xác định cuối cùng. Các thiết lập UI phụ thuộc vào kích thước View (trừ khi dùng Auto Layout) nên được thực hiện trong viewWillLayoutSubviews hoặc viewDidLayoutSubviews (mặc dù thường thì Auto Layout sẽ xử lý việc này).
  • Không hủy đăng ký lắng nghe thông báo hoặc observers: Nếu bạn đăng ký lắng nghe thông báo (ví dụ: từ NotificationCenter) hoặc thêm key-value observers (KVO) trong viewWillAppear hoặc viewDidLoad, bạn phải đảm bảo hủy đăng ký chúng tại thời điểm thích hợp (thường là trong viewWillDisappear, viewDidDisappear hoặc deinit) để tránh rò rỉ bộ nhớ hoặc các lỗi không mong muốn.
  • Quên gọi super: Ngoại trừ loadView() khi bạn tự tạo View gốc, bạn luôn phải gọi phương thức của lớp cha (super.viewDidLoad(), super.viewWillAppear(animated), v.v.) trong các phương thức vòng đời override của mình. Việc quên gọi super có thể dẫn đến hành vi không mong muốn hoặc lỗi.

Kết Luận

Hiểu rõ vòng đời của ViewController là một kỹ năng nền tảng và cực kỳ quan trọng đối với bất kỳ lập trình viên iOS nào làm việc với UIKit. Nó giúp bạn viết code có cấu trúc, quản lý tài nguyên hiệu quả, xử lý đúng đắn các sự kiện và tránh các lỗi phổ biến liên quan đến trạng thái giao diện và bộ nhớ.

Hãy coi các phương thức vòng đời như những điểm neo mà hệ thống cung cấp cho bạn để can thiệp và thực hiện các hành động cụ thể tại những thời điểm quan trọng trong “cuộc đời” của ViewController. Bằng cách đặt đúng logic vào đúng vị trí, bạn sẽ xây dựng được những ứng dụng mạnh mẽ, ổn định và mang lại trải nghiệm tốt cho người dùng.

Đừng ngần ngại thử nghiệm! Tạo một ViewController đơn giản, in log trong mỗi phương thức vòng đời và quan sát thứ tự chúng được gọi khi bạn điều hướng qua lại giữa các màn hình hoặc thực hiện các hành động khác nhau. Thực hành là cách tốt nhất để nắm vững khái niệm này.

Chúng ta đã đi thêm một bước nữa trên con đường trở thành lập trình viên iOS chuyên nghiệp. Hẹn gặp lại các bạn trong bài viết tiếp theo của series iOS Developer Roadmap, nơi chúng ta sẽ tiếp tục khám phá những khía cạnh thú vị khác của phát triển ứng dụng trên nền tảng Apple!

Chỉ mục