时光荏苒,SwiftUI 技术已经推出一年,从 WWDC 2020 来看,SwiftUI 团队付出了空前的努力,使得 SwiftUI 无论是在开发体验,还是性能上都得到了很大的提升。如果说 SwiftUI 是去年苹果在开发技术转型上的小试牛刀,那么今年的 SwiftUI 基本已经成为了未来 5-10 年苹果生态开发技术的主流方式。
众所周知, SwiftUI 是一种数据驱动型的 DSL。也就是说,所有的界面显示效果,必须去改变数据然后通过绑定才能体现到相应的视图上。那么接下来,设计和组织好这些数据结构,才能让 SwiftUI 发挥出它的特性和优势。
WWDC 2019 Data Flow Through SwiftUI[1] 对应的文章 SwiftUI 数据流[2] 有幸也是笔者操刀撰写,文章内介绍了SwiftUI 数据流的基本原理、绘制流程以及发展趋势,并且对比了各种不同的编程范式,感兴趣的读者可以回顾阅读。
去年 SwiftUI 给出了 @State, @ObservedObject,@EnvironmentObject[3] 三个 PropertyWrapper 来处理视图和数据之间的绑定依赖关系,但并没有详细的讲解这几个 PropertyWrapper 的使用场景以及区别,后续大家都是根据自己的编码和调试经验得出一些使用上的技巧,今年苹果直接拿出一个 Book Club App[4] 做为案例,全方位的给你讲解如何使用它们,并给出一些参考规范,真香!本 Session 是由 Curt Clifton[5] 、Luca[6]、Raj Ramamurthy 三位大神为我们讲解,议题主要围绕以下三个方面:
从本质上讲,编写 SwiftUI 应用程序还是属于前端技术的范畴,所以写界面是每位开发者都应该掌握的技能,那么当大家拿到设计稿开始编码的时候,首先要思考以下三个问题:
那么现在我们用上面这个设计图来回答以上三个问题:
通过回答上面问题,很容易就可以编写上述代码,可以看出 BookCard 视图的数据是由 Book 这个结构体提供的,Book 数据结构中包含了书籍的封面、标题、作者等信息,Progress 用来标记读书的进度;这个书籍列表视图只是用来展示数据,所以 book 和 progress 都用 let 声明为常量。那这些数据从哪来?他们可以通过在实例化 BookCard 的时候,通过构造函数从父视图传进来,每次渲染调用 body 计算属性获取绘制信息时,BookCard 都会实例一次。这个新实例化的 BookCard 生命周期只局限于本次渲染,如果下次渲染流程里它不再需要展示,那么它就被销毁了。它的可视化视图层级以及对应的数据关系图,如下:
接下来我们进入书籍详情页面,设计图如下,当点击 Update Progress 按钮的时候,需要弹出一个弹层,来记录读书进度。
根据信息展示的对应关系,我们需要一个 Boolean 变量 isEditorPresented 来控制弹层的显示与否,需要一个 String 类型的 note 来记录备注信息,需要一个 Double 类型的 progress 来记录读书进度,代码如下:
通常情况下可以把这些数据组装到一个 EditorConfig 的结构体中,如下图:
这样组装成 EditorConfig 后,BookCard 代码一下就清晰了,也非常方便进行独立测试。由于 EditorConfig 是一个值类型,改变它的内部属性它本身也会发生改变。
以上我们处理完弹层视图与数据之间的信息展示对应关系,那么接下来处理第二个问题 - “视图和数据之间的操作逻辑有哪些“,当我们要展示弹层的时候,我们需要将 isEditorPresented 属性设置为 true,那么就需要给 EditorConfig 添加一个 mutating 方法来进行更改并且初始化一些其他信息,如下图:
最后来处理第三个问题 - ”数据由谁持有“,从代码看 EditorConfig 上的数据都是 BookView 视图本地持有,没有从父视图传递进来,再结合上面数据需要被更改的情况,这时候需要创建一个单一数据源以维持数据与视图之间的同步。在 SwiftUI 中创建单一数据源最简单的方法就是 @State Property Wrapper,用 @State 来修饰属性后,SwiftUI 便接管了被修饰属性的存储 (简单理解就是 Get, Set 方法)。那么 SwiftUI 为什么要这么处理呢?因为如果是一个单纯的结构体,每次调用 body 进行重绘的时候,都是实例化一个全新 EditorConfig 结构体,对于只是展示数据的视图是没问题的,但是如果视图中有修改数据的行为,那么被修改的数据在结构体被销毁的时候也丢失了,而 SwIftUI 通过 @State PropertyWrapper,帮我们缓存住 EditorConfig,每次绘制的时候从内存缓存的数据中进行恢复。
那接下来让我们再用三步走法则,思考下 ProgressEditor (弹层视图) 该怎么实现,在这里值得注意的是弹层的数据从哪来的,由谁持有?如果我们先假设 ProgressEditor (弹层视图) 自己持有 EditorConfig,直接在它内部声明为一个属性,然后在实例化的时候从 BookView 传递进来,这样做只是传递了一份数据拷贝,当 ProgressEditor (弹层视图) 要修改 EditorConfig 的值时,也只是对自己内部的拷贝进行更改,不会影响到 BookView 中的 EditorConfig 实例,所以两边的数据是不同步的。那么我们给 ProgressEditor (弹层视图) 的 EditorConfig 也添加上一个 @State PropertyWrapper 可以吗?答案是否定的,这样做相当于在 BookView 和 ProgressEditor (弹层视图) 里创建了两个数据源,但是我们需要一个数据源来保持两个视图的数据同步,在 SwiftUI 中这种共享数据源的方式可以用 @Binding PropertyWrapper 来实现。使用 @Binding 后,现在 SwiftUI 帮你接管 EditorConfig 而且 BookView 和 ProgressEditor 都依赖于这个共享数据源。所以当数据发生变化时,SwiftUI 会对两个视图进行重绘。
接下来 ProgressEditor 还需要把修改好的数据传递给 BookView 进行展示,SwiftUI 采用了 PropertyWrapper 的 Projection 特性来实现了双向数据绑定,所以只需要在参数前面加一个 $ 符号即可。最终相当于 ProgressEditor (弹层视图) 直接跟 BookView 里的 EditorConfig 数据建立依赖关系。很多 SwiftUI 默认控件也采用的了这种绑定声明机制。
@State 主要是针对视图内短暂的状态处理,但是通常情况下,UI 代码和业务逻辑代码是分开的。这个时候想要把业务逻辑代码绑定到 SwiftUI 视图中,就需要用到 ObservableObject。首先我们来看下ObservableObject 协议是如何定义的:
我们可以把 ObservableObject 理解为视图和数据建立依赖关系的中间层(类似 ViewModel),ObservableObject 内部不仅仅包含要展示到视图上的数据,也可以处理业务逻辑,数据缓存,网络请求等操作。当然你可以根据自己的业务逻辑来定义 ObservableObject 的生命周期。比如可以将所有的数据都包含在一个 ObservableObject 中,所有的视图都通过这个 ObservableObject 与相对应的数据建立依赖关系,如下图:
当你的数据结构非常复杂的时候,也可以拆分多个 ObservableObject 分别管理对应的视图数据,处理起来非常灵活性,如下图:
接下来,我们结合 Book App,看下代码是如何编写的:
我们这里定义了一个 CurrentlyReading 类来处理当前阅读书籍的业务逻辑,它内部的 Book 数据是不变的,可以用 let 声明,当我们要更新阅读进度时,我们直接对 @Published 修饰的属性进行更改,SwiftUI 通过之前对该属性的监听,就能感知到数据变化,触发 SwiftUI 重绘,然后在各 View 节点获取到定义的视图结构信息,组装成渲染树,最终将数据的变化呈现到新渲染的视图上,所以 @Published 就是 SwiftUI 感知 ObservableObject 数据变化的标记,它有以下特性:
上面讲解了 ObservableObject 中间层如何定义,那么如何绑定到视图呢?SwiftUI 提供了三个 PropertyWrapper (属性修饰器):
下面给出一些案例代码的使用场景:
上面我们已经了解到如何为 SwiftUI 定义好数据中间层,接下来讨论下如何让 SwiftUI App 有更好的性能。首先我们深入思考下 View 到底是什么?
View 其实就是一部分 UI 属性信息的定义,SwiftUI 根据这些定义好的属性信息来进行渲染绘制,同时帮你标记好不同的 View 以管理他们的生命周期。由于 View 只是一些基本视图信息的定义,所以它非常轻量而且廉价。在 SwiftUI App 中所有界面上展示的视图都是一个 View。这里要注意的是 View 的生命周期和定义它的结构体的生命周期是不一样的,遵循了 View 协议的结构体,生命周期非常短,用完即销毁。也就是意味着 SwiftUI 只通过每个结构体的 body 属性来获取绘制信息,然后在内部对这些信息做一次拷贝,这时遵循 View 协议的结构体已经没用了,就会被销毁掉。SwiftUI 内部根据拷贝过来的信息进行绘制处理,当然通过信息计算后,如果有些 View 不需要显示,也会被被销毁掉。
我们可以通过下图,了解到基本的渲染流程,当视图上有事件触发后,会对本地的数据进行更改,这个数据更改会影响到数据源的更改,当数据源发生了变化,SwiftUI 会触发重绘,重新获取新的视图定义结构,然后内部渲染成新的 UI 视图呈现给用户。
为了更好的理解,我们可以把上面的渲染流程简化成一个简单的圆环,如下图:
每次渲染的过程这个圆环就会重复执行相关操作。所以如何维持这个圆环执行流畅是保证 SwiftUI App性能的关键。如果圆环流程卡住了,SwiftUI 通常称这种现象为 Slow Update(慢更新),出现这种情况就说明你的 App 有丢帧的情况 。
那么如何避免上述情况呢?下面给出三点注意:
接下来,我们通过下面 Book Club App 中的代码来看一个 SlowUpdate 的示例:
这里注意在 ReadingList 中定义的 ObservedObject 数据,看起来定义挺合理的,其实是有个 Bug 的,每当 ReadingListViewer 被创建的时候,ReadingListStore 都会再被实例化一次,因为每次 SwiftUI 都会把 ReadingList 实例化一次,进行拷贝。这样就导致了 SlowUpdate 的出现,同时也会导致数据的丢失。那么怎么解决呢?按照去年的实现标准,需要把 store 放到父视图中定义,然后用参数把它传递进来。但能不能能保持这种内部的编写方式呢?这样做更容易分割组件。今年新推出的 @StateObject PropertyWrapper[8] 就可以解决这个问题。就像上面提到的,StateObject PropertyWrapper 告诉 SwiftUI 在合适的时机再去实例化 ObservableObject,相当于在内部做了缓存机制,这样数据不会丢失,同时也避免了不必要的实例化。
在这里要注意的是,SwiftUI 不仅要响应界面操作事件,还要处理很多其他的外部通知事件,所以今年提供了一些新事件处理的 Modifier,如下图,这些 Modifier 可能会用一个 closure 回调来处理操作,但回调函数是在 UI 线程上执行的,如果要做逻辑复杂的处理,建议单独在后台线程执行。
我们再回到三步走法则,仔细想想数据由谁持这个问题,其实这个问题是最难回答的,因为它没有一个标准答案。在这里只能给出一些参考场景:
由于今年 SwiftUI 不仅只有 View 这一种视图,还增加了 Scene 和 App,所以数据的生命周期,也可以根据这些不同的视图定义发生变化,在 Scene 中你可以为每个 Window 定义一个全局的数据源,这样不同 Window 之间的数据操作就会相互隔离,如下图:
当然你可以用用 @StateObject 为 App 定义一个 App 级别的全局数据源,如下图:
到目前为止,虽然我们可以处理各种数据与视图之间的依赖关系,但是每当 App 重新后,内存数据也就不存在了,这种情况通常我们要自己处理缓存逻辑,今年 SwiftUI 新增了本地缓存机制的 PropertyWrapper。它们拓展了数据的生命周期,而且可以自动从本地缓存中恢复过来:
上述文章中介绍了基本上所有 SwiftUI PropertyWrapper[9],笔者总体汇总下各自的使用场景,方便大家记忆和区分:
通过上述对比,推荐下我的编码流程,通常情况下,我先不会管这些属性修饰器,上来我会先定义视图对应的数据结构,直接在 SwiftUI 里面用上,等处理到相关 View 和数据绑定的时候,再回过头来加这些属性修饰器,或者更改一些数据结构的定义方式,相反如果一上来就考虑这些属性修饰器该怎么用,个人感觉很容易就陷入无限的思考,导致自己无法下手写代码,大家可以做为参考。
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8