0

The lifecycle and semantics of a SwiftUI view

Một trong những điểm khác biệt chính giữa SwiftUI và các phiên bản tiền nhiệm của nó UIKit và AppKit, là các views chủ yếu được khai báo dưới dạng tham trị, chứ không phải là các tham chiếu cụ thể đến những gì đang được vẽ trên màn hình.

Sự thay đổi đó trong thiết kế không chỉ đóng một vai trò quan trọng trong việc làm cho API của SwiftUI trở nên gọn hơn mà còn có thể trở thành nguyên nhân gây nhầm lẫn, đặc biệt là đối với các developer đã quen với các quy ước hướng đối tượng mà giao diện người dùng của Apple, các quy ước đã được sử dụng cho đến thời điểm này.

Vì vậy, tuần này, chúng ta hãy xem xét kỹ lưỡng hơn ý nghĩa của việc SwiftUI trở thành một khung giao diện người dùng định hướng giá trị, và cách chúng ta có thể cần phá vỡ một số giả định nhất định và các phương pháp hay nhất trước đây dựa trên UIKit và AppKit khi bắt đầu áp dụng SwiftUI trong các dự án của chúng ta.

Vai trò của thuộc tính body

Thuộc tính body của View có lẽ là nguồn dễ hiểu lầm phổ biến nhất về SwiftUI nói chung, đặc biệt khi nói đến mối quan hệ của thuộc tính đó với chu kỳ cập nhật và hiển thị của view.

Trong thế giới bắt buộc của UIKit và AppKit, chúng ta có các phương thức như viewDidLoadlayoutSubviews, về cơ bản hoạt động như các hook cho phép chúng ta phản hồi một sự kiện hệ thống nhất định bằng cách thực thi một đoạn logic. Mặc dù có thể dễ dàng xem thuộc tính nội dung SwiftUI như một sự kiện khác (cho phép chúng ta hiển thị lại chế độ xem của mình), nhưng thực sự không phải vậy.

Thay vào đó, thuộc tính body cho phép chúng ta mô tả cách chúng ta muốn chế độ xem của mình được hiển thị với trạng thái hiện tại của nó và sau đó hệ thống sẽ sử dụng mô tả đó để xác định xem liệu chế độ xem của chúng ta có thực sự nên được hiển thị hay không.

Ví dụ: khi xây dựng ViewController dựa trên UIKit, việc kích hoạt cập nhật mô hình trong phương thức viewWillAppear thực sự phổ biến để đảm bảo rằng bộ điều khiển chế độ xem luôn hiển thị dữ liệu mới nhất hiện có:

class ArticleViewController: UIViewController {
    private let viewModel: ArticleViewModel
    
    ...

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        viewModel.update()
    }
}

Sau đó, khi chuyển sang SwiftUI, ý tưởng ban đầu về cách sao chép mẫu ở trên có thể là thực hiện như sau và thực hiện cập nhật viewModel của chúng ta khi tính toán phần body của view như sau:

struct ArticleView: View {
    @ObservedObject var viewModel: ArticleViewModel

    var body: some View {
        viewModel.update()

        return VStack {
            Text(viewModel.article.text)
            ...
        }
    }
}

Tuy nhiên, vấn đề với cách tiếp cận ở trên là phần body của view sẽ được đánh giá lại bất cứ khi nào viewModel của chúng ta được thay đổi và cũng như mỗi khi bất kỳ view gốc nào của chúng ta được cập nhật - có nghĩa là việc triển khai ở trên rất có thể dẫn đến nhiều cập nhật model không cần thiết (hoặc thậm chí các chu kỳ cập nhật).

Vì vậy, hóa ra body của view không phải là nơi tuyệt vời để kích hoạt các hiệu ứng. Thay vào đó, SwiftUI cung cấp một số công cụ sửa đổi khác nhau hoạt động rất giống với những hook mà chúng tôi đã truy cập trong UIKit và AppKit. Trong trường hợp này, chúng ta có thể sử dụng công cụ sửa đổi onAppear để có được hành vi tương tự như khi sử dụng phương thức viewWillAppear trong ViewController:

struct ArticleView: View {
    @ObservedObject var viewModel: ArticleViewModel

    var body: some View {
        VStack {
            Text(viewModel.article.text)
            ...
        }
        .onAppear(perform: viewModel.update)
    }
}

Nói chung, bất cứ khi nào chúng ta cần sử dụng từ khóa return trong phần body của SwiftUI, chúng ta có thể đang làm sai điều gì đó, vì vai trò của thuộc tính đó là mô tả hệ thống phân cấp view của chúng ta bằng cách sử dụng DSL của SwiftUI - không thực hiện các thao tác và không kích hoạt bên các hiệu ứng.

Sự cố khi khởi tạo

Cùng với đó, chúng ta cũng nên cẩn thận không đưa ra bất kỳ giả định nào về vòng đời của các quan điểm của chúng ta. Trên thực tế, có thể lập luận rằng các views SwiftUI thậm chí không có vòng đời thích hợp, vì chúng là tham trị, không phải tham chiếu.

Ví dụ: bây giờ giả sử chúng tôi muốn sửa đổi ArticleView ở trên để làm cho nó cập nhật ViewModel bất cứ khi nào ứng dụng được tiếp tục sau khi được chuyển xuống nền, thay vì mỗi khi view đó xuất hiện. Một cách để biến điều đó thành hiện thực là một lần nữa thực hiện theo cách tiếp cận hướng đối tượng và quan sát NotificationCenter mặc định của ứng dụng từ bên trong trình khởi tạo view của chúng ta như sau:

struct ArticleView: View {
    @ObservedObject var viewModel: ArticleViewModel
    private var cancellable: AnyCancellable?

    init(viewModel: ArticleViewModel) {
        self.viewModel = viewModel

        cancellable = NotificationCenter.default.publisher(
    for: UIApplication.willEnterForegroundNotification
)
.sink { _ in
    viewModel.update()
}
    }

    var body: some View {
        VStack {
            Text(viewModel.article.text)
            ...
        }
    }
}

Tuy nhiên, mặc dù việc triển khai ở trên sẽ hoạt động hoàn toàn tốt trong hoàn toàn riêng biệt, nhưng ngay sau khi chúng tôi bắt đầu nhúng ArticleView của mình vào các view khác, nó sẽ bắt đầu trở nên khá vấn đề.

Để minh họa, ở đây chúng tôi đang tạo nhiều giá trị ArticleView trong một ArticleListView, sử dụng các thành phần ListNavigationLink được tích hợp sẵn để cho phép người dùng điều hướng đến từng bài viết được hiển thị trong danh sách có thể cuộn:

struct ArticleListView: View {
    @ObservedObject var store: ArticleStore

    var body: some View {
        List(store.articles) { article in
            NavigationLink(article.title,
                destination: ArticleView(
    viewModel: ArticleViewModel(
        article: article,
        store: store
    )
)
            )
        }
    }
}

Vì NavigationLink yêu cầu chúng ta chỉ định từ trước từng đích đến (điều này ban đầu có vẻ khá lạ, nhưng có ý nghĩa rất nhiều khi chúng ta bắt đầu nghĩ về các views SwiftUI của mình như các giá trị đơn giản) và vì chúng ta hiện đang thiết lập các quan sát NotificationCenter của mình khi khởi tạo các giá trị ArticleView của chúng ta, tất cả những quan sát đó sẽ được kích hoạt ngay lập tức - ngay cả khi những chế độ xem đó chưa thực sự được hiển thị.

Vì vậy, thay vào đó, chúng ta hãy triển khai chức năng đó theo cách chi tiết hơn nhiều để chỉ có ArticleView hiện đang hiển thị mới được cập nhật khi ứng dụng chuyển sang nền trước, thay vì cập nhật từng ArticleViewModel cùng một lúc, điều này sẽ không hiệu quả.

Để làm điều đó, chúng tôi sẽ lại sử dụng một công cụ sửa đổi chuyên dụng, onReceive, thay vì định cấu hình thủ công quan sát NotificationCenter như một phần của trình khởi tạo view. Như một phần bổ sung, khi làm điều đó, chúng ta không cần phải tự mình duy trì một Kết hợp có thể hủy được nữa vì hệ thống hiện sẽ thay mặt chúng ta quản lý đăng ký đó:

struct ArticleView: View {
    @ObservedObject var viewModel: ArticleViewModel

    var body: some View {
        VStack {
            Text(viewModel.article.text)
            ...
        }
        .onReceive(NotificationCenter.default.publisher(
    for: UIApplication.willEnterForegroundNotification
)) { _ in
    viewModel.update()
}
    }
}

Vì vậy, chỉ bởi vì một view SwiftUI được tạo không có nghĩa là nó sẽ được hiển thị hoặc sử dụng theo cách khác, đó là lý do tại sao hầu hết các API SwiftUI yêu cầu chúng ta tạo tất cả các view của chúng ta từ trước, thay vì một lần mỗi view sắp được hiển thị. Một lần nữa, chúng tôi chỉ tạo ra các mô tả về views, thay vì thực sự tự hiển thị chúng, vì vậy giống như cách lý tưởng nên giữ các đặc tính body không có ảnh hưởng nào, điều tương tự cũng đúng với các trình khởi tạo view

Đảm bảo rằng các chế độ xem UIKit và AppKit có thể được sử dụng lại đúng cách

Việc tuân theo đúng thiết kế dự kiến của SwiftUI có lẽ đặc biệt quan trọng khi đưa các view UIKit hoặc AppKit vào SwiftUI bằng cách sử dụng các protocols như UIViewRepresentable vì khi làm như vậy trên thực tế chúng ta có trách nhiệm tạo và cập nhật các phiên bản cơ bản mà các views được hiển thị bằng cách sử dụng.

Tất cả các biến thể của các protocols bắc cầu khác nhau của SwiftUI đều bao gồm hai phương pháp một để tạo (hoặc, theo cách nói của phương pháp gốc, tạo) phiên bản cơ bản và một để cập nhật nó. Tuy nhiên, ban đầu có vẻ như phương pháp cập nhật chỉ cần thiết cho các thành phần động, tương tác và phương thức make có thể chỉ định cấu hình một phiên bản đang được tạo ở nơi khác.

Ví dụ: ở đây, chúng tôi đang làm điều đó để hiển thị một NSAttributedString bằng cách sử dụng một phiên bản của UIKit’s UILabel, mà chúng tôi đang quản lý bằng cách sử dụng thuộc tính riêng:

struct AttributedText: UIViewRepresentable {
    var string: NSAttributedString

    private let label = UILabel()

    func makeUIView(context: Context) -> UILabel {
        label.attributedText = string
        return label
    }

    func updateUIView(_ view: UILabel, context: Context) {
        // No-op
    }
}

Tuy nhiên, có hai vấn đề khá lớn đối với việc triển khai trên:

  • Đầu tiên, thực tế là chúng tôi đang tạo UILabel cơ bản của mình bằng cách gán nó cho một thuộc tính có nghĩa là chúng tôi sẽ kết thúc việc tạo lại phiên bản đó mỗi khi cấu trúc của chúng tôi được tạo lại (điều này, như chúng tôi đã khám phá, có thể xảy ra đối với một số lý do, bao gồm cả thời điểm một trong các views chính của chúng ta được cập nhật).
  • Thứ hai, bằng cách không cập nhật view của chúng t trong phương thức updateUIView, label của chúng tôi sẽ tiếp tục hiển thị cùng một văn bản thuộc tính được gán trong makeUIView, ngay cả khi thuộc tính chuỗi của chúng tôi đã được sửa đổi.

Để khắc phục hai vấn đề đó, thay vào đó, hãy tạo UILabel của chúng tôi một cách lười biếng trong phương pháp makeUIView và thay vì tự giữ lại, chúng ta sẽ để hệ thống quản lý nó cho chúng ta. Sau đó, chúng ta sẽ luôn chỉ định lại chuỗi của chúng tôi cho thuộc tính AttutedText của label mỗi khi updateUIView đó được gọi điều này mang lại cho chúng tôi cách triển khai sau:

struct AttributedText: UIViewRepresentable {
    var string: NSAttributedString

    func makeUIView(context: Context) -> UILabel {
        UILabel()
    }

    func updateUIView(_ view: UILabel, context: Context) {
        view.attributedText = string
    }
}

Với những điều trên, UILabel của chúng ta giờ đây sẽ được sử dụng lại một cách chính xác và văn bản thuộc tính của nó sẽ luôn được cập nhật với thuộc tính chuỗi của wrapper của chúng ta. Thực sự tốt đẹp.

Điểm hay của những thay đổi ở trên là chúng thực sự đã làm cho mã của chúng ta đơn giản hơn nhiều, vì chúng ta một lần nữa tận dụng các quy ước và cơ chế quản lý của riêng hệ thống, thay vì phát minh ra mã của riêng chúng ta. Trên thực tế, đó có lẽ là điều quan trọng nhất khi làm việc với SwiftUI, đó là cố gắng luôn tuân theo cách nó được thiết kế và sử dụng hợp lý các cơ chế tích hợp của nó điều này có thể yêu cầu chúng ta phải “mở rộng” một số patterns nhất định mà chúng ta đã quen khi làm việc với UIKit và AppKit.

Như vậy chúng ta vừa sử dụng đi qua các cách để sử dụng các func tương ứng với các vòng đời của view trong SwiftUI, tôi hy vọng nó sẽ hữu ích. Bài viết được dịch từ bài viết cùng tên của tác giả John Sundell.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí