SwiftUI 编程指南

Easter79
• 阅读 1031

作者:CoderAFI,  iOS 开发者

Session: https://developer.apple.com/videos/play/wwdc2020/10040/

前言

时光荏苒,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  三位大神为我们讲解,议题主要围绕以下三个方面:

  • 视图和数据的生命周期

  • @StateObject 和一些新特性

  • 值类型和引用类型数据在 SwiftUI  中的处理

三步走编写 SwiftUI

从本质上讲,编写 SwiftUI 应用程序还是属于前端技术的范畴,所以写界面是每位开发者都应该掌握的技能,那么当大家拿到设计稿开始编码的时候,首先要思考以下三个问题:

  • 如何建立视图元素与数据的对应关系

  • 视图和数据之间的操作逻辑有哪些

  • 数据由谁持有

Property(属性绑定)

SwiftUI 编程指南

那么现在我们用上面这个设计图来回答以上三个问题:

  • 这个界面是个读书的列表,每条书籍信息里有书籍的封面、数据的名称作者以及读书的进度

  • 这个列表只是用来展示书籍列表,没有对数据的更改操作

  • 这个图书数据是由每个 BookCard 持有,实例化的时候从父视图传递进来

SwiftUI 编程指南

通过回答上面问题,很容易就可以编写上述代码,可以看出 BookCard 视图的数据是由 Book 这个结构体提供的,Book 数据结构中包含了书籍的封面、标题、作者等信息,Progress 用来标记读书的进度;这个书籍列表视图只是用来展示数据,所以 book 和 progress 都用 let 声明为常量。那这些数据从哪来?他们可以通过在实例化 BookCard 的时候,通过构造函数从父视图传进来,每次渲染调用 body 计算属性获取绘制信息时,BookCard 都会实例一次。这个新实例化的 BookCard 生命周期只局限于本次渲染,如果下次渲染流程里它不再需要展示,那么它就被销毁了。它的可视化视图层级以及对应的数据关系图,如下:

SwiftUI 编程指南

接下来我们进入书籍详情页面,设计图如下,当点击 Update Progress 按钮的时候,需要弹出一个弹层,来记录读书进度。

SwiftUI 编程指南

根据信息展示的对应关系,我们需要一个 Boolean 变量 isEditorPresented 来控制弹层的显示与否,需要一个 String 类型的 note 来记录备注信息,需要一个 Double 类型的 progress 来记录读书进度,代码如下:

SwiftUI 编程指南

通常情况下可以把这些数据组装到一个 EditorConfig 的结构体中,如下图:

SwiftUI 编程指南

这样组装成 EditorConfig 后,BookCard 代码一下就清晰了,也非常方便进行独立测试。由于 EditorConfig 是一个值类型,改变它的内部属性它本身也会发生改变。

@State(状态绑定)

以上我们处理完弹层视图与数据之间的信息展示对应关系,那么接下来处理第二个问题 - “视图和数据之间的操作逻辑有哪些“,当我们要展示弹层的时候,我们需要将 isEditorPresented 属性设置为 true,那么就需要给 EditorConfig 添加一个 mutating 方法来进行更改并且初始化一些其他信息,如下图:

SwiftUI 编程指南

最后来处理第三个问题 - ”数据由谁持有“,从代码看 EditorConfig 上的数据都是 BookView 视图本地持有,没有从父视图传递进来,再结合上面数据需要被更改的情况,这时候需要创建一个单一数据源以维持数据与视图之间的同步。在 SwiftUI 中创建单一数据源最简单的方法就是 @State Property Wrapper,用 @State 来修饰属性后,SwiftUI 便接管了被修饰属性的存储 (简单理解就是 Get, Set 方法)。那么 SwiftUI 为什么要这么处理呢?因为如果是一个单纯的结构体,每次调用 body 进行重绘的时候,都是实例化一个全新 EditorConfig 结构体,对于只是展示数据的视图是没问题的,但是如果视图中有修改数据的行为,那么被修改的数据在结构体被销毁的时候也丢失了,而 SwIftUI  通过 @State PropertyWrapper,帮我们缓存住 EditorConfig,每次绘制的时候从内存缓存的数据中进行恢复。

SwiftUI 编程指南

@Binding(共享绑定)

那接下来让我们再用三步走法则,思考下 ProgressEditor (弹层视图) 该怎么实现,在这里值得注意的是弹层的数据从哪来的,由谁持有?如果我们先假设 ProgressEditor (弹层视图) 自己持有 EditorConfig,直接在它内部声明为一个属性,然后在实例化的时候从 BookView 传递进来,这样做只是传递了一份数据拷贝,当 ProgressEditor (弹层视图) 要修改 EditorConfig 的值时,也只是对自己内部的拷贝进行更改,不会影响到 BookView 中的 EditorConfig 实例,所以两边的数据是不同步的。那么我们给 ProgressEditor (弹层视图) 的 EditorConfig 也添加上一个 @State PropertyWrapper 可以吗?答案是否定的,这样做相当于在 BookView 和 ProgressEditor (弹层视图) 里创建了两个数据源,但是我们需要一个数据源来保持两个视图的数据同步,在 SwiftUI  中这种共享数据源的方式可以用 @Binding PropertyWrapper 来实现。使用 @Binding 后,现在 SwiftUI 帮你接管 EditorConfig 而且 BookView 和  ProgressEditor 都依赖于这个共享数据源。所以当数据发生变化时,SwiftUI 会对两个视图进行重绘。

SwiftUI 编程指南

接下来 ProgressEditor 还需要把修改好的数据传递给 BookView 进行展示,SwiftUI 采用了 PropertyWrapper 的 Projection 特性来实现了双向数据绑定,所以只需要在参数前面加一个 $ 符号即可。最终相当于 ProgressEditor (弹层视图) 直接跟 BookView 里的 EditorConfig 数据建立依赖关系。很多 SwiftUI 默认控件也采用的了这种绑定声明机制。

SwiftUI 编程指南

三步走法则

  • 如何建立视图元素与数据的对应关系

  • 视图和数据之间的操作逻辑有哪些

  • 数据由谁持有

小技巧

  • 当视图上只是对数据的展示时,用 Property (一般属性)

  • 当视图要修改数据,而且只是在当前视图中短暂用到时,用 @State (状态绑定)

  • 当 @State 或者 @Published 修饰的数据要被传递到其他视图访问修改时,用 @Binding(共享绑定)

设计好数据中间层

使用 ObservableObject

@State 主要是针对视图内短暂的状态处理,但是通常情况下,UI 代码和业务逻辑代码是分开的。这个时候想要把业务逻辑代码绑定到 SwiftUI 视图中,就需要用到 ObservableObject。首先我们来看下ObservableObject 协议是如何定义的:

SwiftUI 编程指南

  • 它遵循 AnyObject 协议,所以只能是引用类型遵循来实现它

  • 需要实现 objectWillChange 属性,这个属性是个 Publisher,当数据发生变化时要通过它来告诉 SwiftUI,然后触发重绘

  • 提供了默认的 Publisher 来处理 @Publisher 修饰的属性,当然也可以通过自定义 Publisher 来处理数据变化

ObservableObject 中间层

我们可以把 ObservableObject 理解为视图和数据建立依赖关系的中间层(类似 ViewModel),ObservableObject 内部不仅仅包含要展示到视图上的数据,也可以处理业务逻辑,数据缓存,网络请求等操作。当然你可以根据自己的业务逻辑来定义 ObservableObject 的生命周期。比如可以将所有的数据都包含在一个 ObservableObject 中,所有的视图都通过这个 ObservableObject 与相对应的数据建立依赖关系,如下图:

SwiftUI 编程指南

当你的数据结构非常复杂的时候,也可以拆分多个 ObservableObject 分别管理对应的视图数据,处理起来非常灵活性,如下图:

SwiftUI 编程指南

@Published 属性修饰器

接下来,我们结合 Book App,看下代码是如何编写的:

SwiftUI 编程指南

我们这里定义了一个 CurrentlyReading 类来处理当前阅读书籍的业务逻辑,它内部的 Book 数据是不变的,可以用 let 声明,当我们要更新阅读进度时,我们直接对 @Published 修饰的属性进行更改,SwiftUI 通过之前对该属性的监听,就能感知到数据变化,触发 SwiftUI 重绘,然后在各 View 节点获取到定义的视图结构信息,组装成渲染树,最终将数据的变化呈现到新渲染的视图上,所以 @Published 就是 SwiftUI 感知 ObservableObject 数据变化的标记,它有以下特性:

  • ObservableObject 默认集成

  • 在 willSet 的时候触发数据变化通知

  • 内部是通过一个 Publisher 来实现的,所以可以与任何响应式框架相结合

SwiftUI 编程指南

ObservableObject 绑定到视图

上面讲解了 ObservableObject 中间层如何定义,那么如何绑定到视图呢?SwiftUI 提供了三个 PropertyWrapper (属性修饰器):

  • @ObservedObject - 最灵活的绑定方式,生命周期需要自己手动管理,使用不恰当会造成卡顿,建议是在一个顶级 View 中使用,传递给子视图共享数据源

  • @StateObject - WWDC20 新增的绑定方式,修饰的属性由 SwiftUI 控制创建与销毁,保持与 View 的生命周期一致,相当于 @State 和 @ObservedObject 的结合体 . [7]

  • @EnvironmentObject - 全局绑定方式,在最顶级视图设置后,不需要参数传递,直接在子视图就可以使用

下面给出一些案例代码的使用场景:

SwiftUI 编程指南 SwiftUI 编程指南

性能优化与新特性

上面我们已经了解到如何为 SwiftUI 定义好数据中间层,接下来讨论下如何让 SwiftUI App 有更好的性能。首先我们深入思考下 View 到底是什么?

View 生命周期

View 其实就是一部分 UI 属性信息的定义,SwiftUI 根据这些定义好的属性信息来进行渲染绘制,同时帮你标记好不同的 View 以管理他们的生命周期。由于 View 只是一些基本视图信息的定义,所以它非常轻量而且廉价。在 SwiftUI App 中所有界面上展示的视图都是一个 View。这里要注意的是 View 的生命周期和定义它的结构体的生命周期是不一样的,遵循了 View 协议的结构体,生命周期非常短,用完即销毁。也就是意味着 SwiftUI 只通过每个结构体的 body 属性来获取绘制信息,然后在内部对这些信息做一次拷贝,这时遵循 View 协议的结构体已经没用了,就会被销毁掉。SwiftUI 内部根据拷贝过来的信息进行绘制处理,当然通过信息计算后,如果有些 View 不需要显示,也会被被销毁掉。

SwiftUI 渲染流程

我们可以通过下图,了解到基本的渲染流程,当视图上有事件触发后,会对本地的数据进行更改,这个数据更改会影响到数据源的更改,当数据源发生了变化,SwiftUI 会触发重绘,重新获取新的视图定义结构,然后内部渲染成新的 UI 视图呈现给用户。

SwiftUI 编程指南

为了更好的理解,我们可以把上面的渲染流程简化成一个简单的圆环,如下图:

SwiftUI 编程指南

每次渲染的过程这个圆环就会重复执行相关操作。所以如何维持这个圆环执行流畅是保证 SwiftUI App性能的关键。如果圆环流程卡住了,SwiftUI 通常称这种现象为 Slow Update(慢更新),出现这种情况就说明你的 App 有丢帧的情况 。

那么如何避免上述情况呢?下面给出三点注意:

  • 尽量保持 View 内绑定的数据信息轻量

  • body 计算属性只包含视图信息定义,不处理其他业务逻辑

  • 避免错误的猜测 SwiftUI 渲染流程

接下来,我们通过下面 Book Club App 中的代码来看一个  SlowUpdate 的示例:

SwiftUI 编程指南

这里注意在 ReadingList 中定义的 ObservedObject 数据,看起来定义挺合理的,其实是有个 Bug 的,每当 ReadingListViewer 被创建的时候,ReadingListStore 都会再被实例化一次,因为每次 SwiftUI 都会把 ReadingList 实例化一次,进行拷贝。这样就导致了 SlowUpdate 的出现,同时也会导致数据的丢失。那么怎么解决呢?按照去年的实现标准,需要把 store 放到父视图中定义,然后用参数把它传递进来。但能不能能保持这种内部的编写方式呢?这样做更容易分割组件。今年新推出的 @StateObject PropertyWrapper[8] 就可以解决这个问题。就像上面提到的,StateObject PropertyWrapper 告诉 SwiftUI 在合适的时机再去实例化 ObservableObject,相当于在内部做了缓存机制,这样数据不会丢失,同时也避免了不必要的实例化。

SwiftUI 编程指南

在这里要注意的是,SwiftUI 不仅要响应界面操作事件,还要处理很多其他的外部通知事件,所以今年提供了一些新事件处理的 Modifier,如下图,这些 Modifier 可能会用一个 closure 回调来处理操作,但回调函数是在 UI 线程上执行的,如果要做逻辑复杂的处理,建议单独在后台线程执行。

SwiftUI 编程指南

全局数据源

我们再回到三步走法则,仔细想想数据由谁持这个问题,其实这个问题是最难回答的,因为它没有一个标准答案。在这里只能给出一些参考场景:

  • 有可能是子视图与父视图共享数据源,共同持有

  • 有可能是 View 自己持有的 ObservableObject,可以用 @StateObject 修饰的数据源

  • 也可以定义一些全局数据源,所有视图共同持有

由于今年 SwiftUI  不仅只有 View 这一种视图,还增加了 Scene 和 App,所以数据的生命周期,也可以根据这些不同的视图定义发生变化,在 Scene 中你可以为每个 Window 定义一个全局的数据源,这样不同 Window  之间的数据操作就会相互隔离,如下图:

SwiftUI 编程指南

当然你可以用用 @StateObject 为 App  定义一个 App 级别的全局数据源,如下图:

SwiftUI 编程指南

缓存数据源

到目前为止,虽然我们可以处理各种数据与视图之间的依赖关系,但是每当 App 重新后,内存数据也就不存在了,这种情况通常我们要自己处理缓存逻辑,今年 SwiftUI 新增了本地缓存机制的 PropertyWrapper。它们拓展了数据的生命周期,而且可以自动从本地缓存中恢复过来:

  • SceneStorage - 可以用来缓存一些自定义视图中的简单数据,在 App 重启后,会在这个视图中重新加载这些缓存数据并做为数据源

  • AppStroage - App 级别全局的数据缓存,相当于对 UserDefault  的封装,任何子视图都可以访问这部分缓存

SwiftUI 编程指南

SwiftUI 编程指南

属性修饰器对比

上述文章中介绍了基本上所有 SwiftUI PropertyWrapper[9],笔者总体汇总下各自的使用场景,方便大家记忆和区分:

  • Property - 一般数据展示,不需要同步数据修改操作

  • @State - 数据修改需要同步 UI, 生命周期只局限于当前 View,一般修饰数据为结构体或枚举

  • @ObservedObject - 修饰数据为引用类型,数据的生命周期可以根据情况灵活控制,通常情况下一个顶级视图对应一个 ObservedObject

  • @StateObject - 修饰数据为引用类型,生命周期跟 View 保持一致,可看做 @State 和 @ObservedObject 的结合体

  • @EnvironmentObject - 全局的数据绑定机制,生命周期与绑定到视图的生命周期一致

  • @SceneStorage - View 级别的数据缓存,注意只需要针对需要缓存的数据进行缓存

  • @AppStorage - App 级别的数据缓存,所有子视图都可以访问

  • @Binding - 父子视图之间进行数据源共享,双向绑定,一般只接受处理值类型

通过上述对比,推荐下我的编码流程,通常情况下,我先不会管这些属性修饰器,上来我会先定义视图对应的数据结构,直接在 SwiftUI 里面用上,等处理到相关 View 和数据绑定的时候,再回过头来加这些属性修饰器,或者更改一些数据结构的定义方式,相反如果一上来就考虑这些属性修饰器该怎么用,个人感觉很容易就陷入无限的思考,导致自己无法下手写代码,大家可以做为参考。

推荐阅读

✨ 详解 WWDC 20  SwiftUI 的重大改变及核心优势

WWDC20 10041 - What's new in SwiftUI

WWDC20 10048 - 在 SwiftUI 中创建复杂功能

WWDC20 10039 - 如何用 SwiftUI 写一个独立的 App?

WWDC20 10033 - 为小组件构建 SwiftUI 视图

WWDC20 10037 - SwiftUI 中的 App 要领

WWDC20 10149 - 打造更容易 Preview 的 SwiftUI 应用

WWDC20 10649 - 为 Xcode Library 添加自定义 views 和 modifiers

关注我们

我们是「老司机技术周报」,每周会发布一份关于 iOS 的周报,也会定期分享一些和 iOS 相关的技术。欢迎关注。

SwiftUI 编程指南

支持作者

这篇文章的内容来自于 《WWDC20 内参》。在这里给大家推荐一下这个专栏,专栏目前已经创作了 97 篇文章,只需要 29.9 元,一杯咖啡的价格,点击【阅读原文】,就可以畅读所有文章了~

WWDC 内参 系列是由老司机周报、知识小集合以及 SwiftGG 几个技术组织发起的。已经做了几年了,口碑一直不错。 主要是针对每年的 WWDC 的内容,做一次精选,并号召一群一线互联网的 iOS 开发者,结合自己的实际开发经验、苹果文档和视频内容做二次创作。

参考资料

[1]

WWDC 2019 Data Flow Through SwiftUI: https://developer.apple.com/videos/play/wwdc2019/226/

[2]

SwiftUI 数据流: https://xiaozhuanlan.com/topic/0528764139

[3]

What's the difference between @ObservedObject, @State, and @EnvironmentObject?: https://www.hackingwithswift.com/quick-start/swiftui/whats-the-difference-between-observedobject-state-and-environmentobject

[4]

Building a Feature-Rich App with SwiftUI: https://developer.apple.com/documentation/swiftui/fruta\_building\_a\_feature-rich\_app\_with\_swiftui

[5]

Curt Clifton: https://github.com/curtclifton

[6]

Luca: https://github.com/lukabernardi

[7]

What's the difference between @StateObject and @ObservedObject? - Donny Wals: https://www.donnywals.com/whats-the-difference-between-stateobject-and-observedobject/

[8]

What is the @StateObject property wrapper?: https://www.hackingwithswift.com/quick-start/swiftui/what-is-the-stateobject-property-wrapper

[9]

Swift UI Property Wrappers: https://swiftuipropertywrappers.com/

本文分享自微信公众号 - 老司机技术周报(LSJCoding)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

点赞
收藏
评论区
推荐文章
blmius blmius
2年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
Jacquelyn38 Jacquelyn38
2年前
2020年前端实用代码段,为你的工作保驾护航
有空的时候,自己总结了几个代码段,在开发中也经常使用,谢谢。1、使用解构获取json数据let jsonData  id: 1,status: "OK",data: 'a', 'b';let  id, status, data: number   jsonData;console.log(id, status, number )
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
Easter79 Easter79
2年前
SwiftUI 中的 App 要领
作者:倾寒,iOS开发者,目前就职于阿里巴巴,手淘iOS架构组Session:https://developer.apple.com/videos/play/wwdc2020/10037/概述这个主题主要讲述使用SwiftUI构建APP的核心概念。在SwiftUI可以使用Views简洁强大的
Easter79 Easter79
2年前
SwiftUI 的可视化编辑工具
作者:希德,iOS开发者,前“有经验的前端开发工程师”,就职于网易严选。正在写书《ThinkableSwiftUI》(严重拖稿中)Session10185:https://developer.apple.com/videos/play/wwdc2020/10185前言SwiftUI带来的描述性构建界面能
Wesley13 Wesley13
2年前
mysql设置时区
mysql设置时区mysql\_query("SETtime\_zone'8:00'")ordie('时区设置失败,请联系管理员!');中国在东8区所以加8方法二:selectcount(user\_id)asdevice,CONVERT\_TZ(FROM\_UNIXTIME(reg\_time),'08:00','0
Wesley13 Wesley13
2年前
00:Java简单了解
浅谈Java之概述Java是SUN(StanfordUniversityNetwork),斯坦福大学网络公司)1995年推出的一门高级编程语言。Java是一种面向Internet的编程语言。随着Java技术在web方面的不断成熟,已经成为Web应用程序的首选开发语言。Java是简单易学,完全面向对象,安全可靠,与平台无关的编程语言。
Stella981 Stella981
2年前
Django中Admin中的一些参数配置
设置在列表中显示的字段,id为django模型默认的主键list_display('id','name','sex','profession','email','qq','phone','status','create_time')设置在列表可编辑字段list_editable
Wesley13 Wesley13
2年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
Python进阶者 Python进阶者
4个月前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这
Easter79
Easter79
Lv1
今生可爱与温柔,每一样都不能少。
文章
2.8k
粉丝
5
获赞
1.2k