SwiftUI和它的前身UIKit和AppKit之间的一个关键区别是,视图主要声明为值类型,而不是对屏幕上绘制的内容的具体引用。

设计上的转变不仅在SwiftUI的API更加轻量化上扮演了重要角色,而且它还让人感到疑惑,尤其是对于那些(像我一样)已经习惯了一直使用苹果面向对象的UI框架的开发人员。

所以本周,让我们更深入地了解一下,对于一个声明式的、值驱动的UI框架来说,SwiftUI意味着什么,以及当我们开始在我们的项目中采用SwiftUI时,我们怎样打破某些假设和之前基于UIKit和AppKit的最佳实践。

The role of the body property

视图协议的body属性可能是对SwiftUI整体误解最常见的来源,特别是当涉及到该属性与视图更新和渲染循环的关系时

在UIKit和AppKit的命定世界中,我们有像viewDidLoad和layoutSubviews这样的方法,它们本质上就像钩子一样,让我们通过执行一段逻辑来响应给定的系统事件。虽然很容易将SwiftUI body属性看作另一个这样的事件(让我们重新渲染视图),但事实并非如此。

相反,body属性让我们描述我们希望视图在给定当前状态下如何被渲染,然后系统将使用该描述来确定我们的视图是否、何时以及如何被渲染。

例如,当构建一个基于UIKit的视图控制器时,在viewWillAppear方法中触发模型更新是很常见的,以确保视图控制器总是渲染最新可用的数据:

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

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

然后,当用SwiftUI实现时,关于如何复制上述模式的最初想法可能是执行下列操作,并在计算视图的body时执行视图模型更新——像这样

struct ArticleView: View {
    @ObservedObject var viewModel: ArticleViewModel

    var body: some View {
        viewModel.update()

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

然而,上述方法的问题是,每当视图模型被更改时,我们的视图主体将被重新评估,而且每次更新我们的父视图时——这意味着上面的实现很可能会导致很多不必要的模型更新(甚至更新周期)。

所以视图的主体不是触发副作用的好地方,相反,SwiftUI提供了一些不同的修饰符,它们的行为与我们在UIKit和AppKit中访问的钩子非常相似。 在这种情况下,我们可以使用onAppear修饰符来获得与在视图控制器中使用viewWillAppear方法相同的行为

struct ArticleView: View {
    @ObservedObject var viewModel: ArticleViewModel

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

一般来说,每当我们需要在SwiftUI主体中使用return关键字时,我们很可能会做错一些事情,因为该属性的作用是使用SwiftUI的DSL来描述我们的视图层次结构——而不是执行操作,也不是触发副作用。

The initializer problem

同样,我们也应该注意不要对视图本身的生命周期做任何假设。事实上,SwiftUI视图甚至没有适当的生命周期,因为它们是值,而不是引用。

例如,让我们现在说,我们想要修改上面的ArticleView,使它更新它的视图模型,每当应用程序被移动到后台后恢复,而不是每次视图出现。实现这一目标的方法之一是再次采用面向对象的方法,并在视图的初始化器中观察应用程序的默认NotificationCenter——像这样:

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)
            ...
        }
    }
}

然而,尽管上面的实现在完全隔离的情况下可以很好地工作,但一旦我们开始将我们的ArticleView嵌入到其他视图中,它就会开始变得相当有问题。

为了说明这一点,我们在ArticleListView中创建了多个ArticleView值,它使用内置的List和NavigationLink组件,使用户能够导航到显示在可滚动列表中的每一篇文章:

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
                    )
                )
            )
        }
    }
}

因为NavigationLink要求我们预先指定每个目的地(这在一开始可能有点奇怪,但一旦我们开始把SwiftUI视图看作简单的值,就会有很多意义)。当初始化我们的ArticleView值时,我们正在设置我们的NotificationCenter观察,所有这些观察都会立即被激活——即使这些视图实际上还没有被渲染。

所以让我们以一种更细粒度的方式来实现这个功能,这样,当应用程序移动到前台时,只有当前显示的ArticleView将被更新, 而不是一次更新每个ArticleViewModel,这将是非常低效的。

为了做到这一点,我们将再次使用一个专用的修饰符onReceive,而不是手动配置我们的NotificationCenter观察作为视图初始化器的一部分。作为一个额外的奖励,当这样做时,我们不再需要维护一个combine cancellable-因为系统现在将代表我们管理订阅:

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()
        }
    }
}

因此,仅仅因为创建了SwiftUI视图,并不意味着它将被渲染或以其他方式使用,这就是为什么大多数SwiftUI api要求我们预先创建所有的视图,而不是在每个视图将要被显示的时候创建。再次强调,我们只是在创建我们的视图的描述,而不是实际地自己渲染它们——所以就像我们应该如何理想地保持我们的body属性不受副作用的影响, 同样的事情也适用于视图初始化器(以及一般的初始化器)。

Ensuring that UIKit and AppKit views can be properly reused

当使用像uiviewrepresentation这样的协议将UIKit或AppKit视图引入SwiftUI时,正确地遵循SwiftUI的预期设计可能特别重要,因为这样做时,我们实际上负责创建和更新视图渲染使用的底层实例。

SwiftUI的各种桥接协议的所有变体都包括两个方法——一个用于创建(或者用工厂方法的说法,创建)底层实例,另一个用于更新实例。然而,最初看起来update方法似乎只需要动态的、交互式的组件,而 make方法可以简单地配置在其他地方创建的实例。

例如,这里我们这样做是为了使用UIKit的UILabel的实例来渲染一个NSAttributedString,我们使用一个私有属性来管理它:

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
    }
}

然而,上述实现存在两个相当大的问题:

首先,我们通过将UILabel赋值给一个属性来创建我们的底层UILabel,这意味着当我们的结构体每一次被重新创建时,我们最终将重新创建那个实例(正如我们已经探讨过的,这可能是由于许多原因发生的,包括当我们的一个父视图被更新时)

其次,通过不在updateUIView方法中更新我们的视图,我们的标签将继续呈现在makeUIView中分配的相同的attributedText,即使我们的string属性已经被修改。

为了解决这两个问题,让我们在makeUIView方法中惰性地创建我们的UILabel,而不是自己保留它,我们会让系统替我们管理的。 然后,每次updateUIView被调用时,我们都会重新将字符串赋值给标签的attributedText属性——这给了我们以下实现:

struct AttributedText: UIViewRepresentable {
    var string: NSAttributedString

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

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

有了上面的内容,我们的UILabel现在将被正确地重用,它的attributedText将始终与我们的包装器的string属性保持一致。真的很不错。

上述更改的美妙之处在于,它们实际上使我们的代码变得简单得多,因为我们再次利用了系统自己的约定和状态管理机制,而不是发明我们自己的。事实上,在与SwiftUI合作时,最重要的一件事可能就是始终遵循其设计初衷,并正确使用其内置机制。——这可能需要我们“忘记”在使用UIKit和AppKit等框架时已经习惯的某些模式。

Conclusion

让开发者变得既有趣又有时让人筋疲力尽的是,我们永远不会停止学习。每年,每个月,有时候甚至每周,都会有一些新的框架、工具或API需要我们学习,而当我们在单一平台(如iOS)上工作时,这些知识往往会逐渐增加,但与之前的平台相比,SwiftUI远没有增加。

因此,无论是在现有的项目中,还是在构建一个全新的项目时,要充分利用SwiftUI,通常需要我们使用新的模式和技术,以充分利用将要创建的视图的语义和生命周期。

原文链接