SwiftUI 0 从头开始

SwiftUI 0 从头开始

终于在Swift推出的第六个年头,我开始着手准备将iOS开发技术栈全面从OC迁移到Swift上来,这个决定有些迟缓,但确实到时候了。

之所以说到时候了,是基于这样几件事的判断:

  1. 手机淘宝iOS端开始全面迁移Swift,从底层重构摒弃OC;
  2. WWDC2020之后,SwiftUI成为了Apple全产品平台的跨平台解决方案;
  3. WWDC2018之后,出现了数个仅支持Swift的标准库,并且连续两年OC的优化都是底层优化,变相为Swift服务;
  4. 国外的OC的热门第三方类库基本停更,很多OC问题在Stackoverflow上变得无人问津;
  5. Swift第三方支持逐渐成熟,开源社区活跃,并且有重量级的服务器程序和机器学习项目出现;
  6. Swift从5.0开始ABI稳定;
  7. 最后也是最重要的一点,对于个人开发者和小团队来说,OC无论通过什么构架和黑科技加持,生产效率都无法和最新的Swift技术栈抗衡,SwiftUI的效率更是碾压之前所有iOS上原生UI的开发门道;

但不得不说,国内的Swift学术氛围真的不够好,没有社区。

谨以此文记录我这个写了十年ObjectiveC的Swift初学者在实际上手Swift以后遇到的问题,排名不分先后。

0 struct有什么特点

Swift的struct(结构体)跟OC/C中的struct,不是同一个东西,在Swift中结构体非常常见,常见到基础类型String(字符串)和Array(数组)都是结构体定义的,SwiftUI中的View(视图)也是结构体定义。Swift中结构体,涵盖Class(类)的大部分功能,但有三点显著区别(我目前只发现这些,肯定还有别的):

  1. 结构体赋值拷贝本体,类赋值是拷贝指针
  2. 结构体不能继承
  3. 类不允许有没有初始化的属性,结构体允许有

1 SwiftUI如何和现有项目混编

首先OC是不能直接引用SwiftUI,因为OC没有这个类库,所以要桥接Swift使用;Swift在引入SwiftUI库以后,使用UIHostingController来初始化并持有SwiftUI页面,下面代码来自SwiftUI工程自带的代码。

1
2
3
4
5
6
7
8
9
let contentView = ContentView() // 这里的ContentView是SwiftUI部分定义的结构体
// Use a UIHostingController as window root view controller.
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView) // 这里链接两种技术
self.window = window
window.makeKeyAndVisible()
}

2 some关键字有什么作用

1
2
3
var body: some View {
}

官方模版中的这段代码,body的类型是不是View,而是some View,这里View是Protocol,some表示不透明结果类型,具体作用我还要再研究研究,待补全。

3 SwiftUI中的List、Form有什么区别

感觉From就是Grouped外观的List,其他没什么区别。

4 ForEach循环数组要怎么写

ForEach循环数组要求要有hash值作为唯一标志,写法如下

1
2
3
ForEach(self.settings.groups, id: \.id) { group in
...
}

这里的数组中的group结构体是这么定义的:

1
2
3
4
5
6
struct SettingGroup {
var id: Int
var header: String?
var footer: String?
var childrens: [SettingGroupItem<Any>]
}

id: \.id部分的写法就是设置hash的参数,这个东西很像Vue和小程序中For循环的设置的id。

5 @propertyWrapper 是什么关键字

@propertyWrapper关键字紧跟着的结构体,是定义了一种属性包装器,这种包装器的结构是固定的,包装好的属性,可以在其他结构体或者类做声明用,以简化代码。

其实@符号在ObjectiveC中非常常见,在Swift中也不少,尤其是SwiftUI中出现了大量的这种标记:@State,@Binding,@EnvironmentObject等等。

先说结论,@propertyWrapper可以理解为setter和getter的封装结构体,详细用法可以参考这篇文章(属性包装)[https://www.cnswift.org/properties#spl-8],内容实在是多,我暂时还没研究个彻底明白,就先不多讨论了。

就只从网上一段操作Userdefault的代码稍加分析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@propertyWrapper
struct UserDefault<T> {
let key: String
let defaultValue: T
init(_ key: String, defaultValue: T) {
self.key = key
self.defaultValue = defaultValue
}
var wrappedValue: T {
get {
return UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
}
set {
UserDefaults.standard.set(newValue, forKey: key)
}
}
}
final class UserSettings: ObservableObject {
let objectWillChange = PassthroughSubject<Void, Never>()
@UserDefault("UserName", defaultValue: "")
var username: String {
willSet {
objectWillChange.send()
}
}
}

上面这段是一个@propertyWrapper的简单应用,是网上流传很广的一段代码。要注意的一点是,属性包装器中除了wrappedValue属性外,其他都不是必须属性。

wrappedValue就是包装器包装的实际内容,在上面的例子中,init方法记录了初始化的key和defaultValue,用来配合UserDefault使用。wrappedValue则是通过set,get封装成了计算属性。

这样写的好处就是将下面UserSettings实例的username属性和UserDefault的UserName键值对实现了绑定,隐藏了操作UserDefault的细节,方便重复实现类似的逻辑。

这里的UserDefault包装器,使用范型来兼容不同类型的参数,比如给UserSettings增加一个名为Sort的Bool型属性,就可以这么写:

1
2
3
4
5
6
7
8
9
10
11
final class UserSettings: ObservableObject {
... //这里包含之前的代码
@UserDefault("Sort", defaultValue: false)
var sort: Bool {
willSet {
objectWillChange.send()
}
}
}

同样地,UserSettings的sort属性和UserDefalut存储的Sort字段关联了起来,而不用重复写UserDefualt的操作逻辑。

最后说一下ObservableObject协议和PassthroughSubject类,这两个东西都是Combine框架提供的,说实话对于刚开始学Swift的我来说,这个头文件真的看的一头雾水,因为涉及了太多的不懂的写法。简单来看Demo的话,objectWillChange明显是用来通知SwiftUI的页面刷新用的,而ObservableObject协议则于SwiftUI的View中定义@ObservedObject关键字搭配使用,例如:

1
2
3
4
5
6
7
struct ContentView: View {
@ObservedObject var settings = UserSettings()
var body: some View {
Text("some text")
}
}

6 ContentView_Previews是做什么用的

ContentView_Previews在SwiftUI刚出的时候,是用DEBUG宏包裹的,最后正式版出来把宏去掉了。具体作用就是用来绘制每一个SwiftUI文件的实时显示页面。

因为UI整体是由很多部件拼组的树形结构,数据在每个层级需要贯穿始终,在写一些部件时候真实数据满足不了UI测试的方方面面,甚至可能开发时候还没有真实数据,这时候就可以通过在PreviewProvider里面给SwiftUI的页面组件设置测试数据,包括设置@State,@Binding这些关键字修饰的属性。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct SelectorListView: View {
...
@State var sections: [SettingGroupItemSection]
@Binding var value: Int
var body: some View {
...
}
}
struct SelectorListView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
SelectorListView(sections: [
SettingGroupItemSection(id: 1, name: "红色"),
SettingGroupItemSection(id: 2, name: "绿色")
], value: .constant(1))
}
}
}

在这个例子里面,SelectorListView_Previews结构体就给我们制作的SelectorListView提供了sections和value属性的测试数据。在实时浏览中就可以看到测试数据,这样方便我们在开发阶段进行完备的UI预测,多说一句xcode的实时预览支持各种设备和模式以及系统设置的搭配测试,也支持真机实时测试。

7 如何用控制语句控制View渲染

我在实践中遇到过用if和swifch两种控制语句渲染的情况,在5.1里面是不支持直接渲染控制的,WWDC2020以后好像新版本支持了,但正式版没出我也没有尝鲜。目前的替代解决方案是将控制语句放到func里面返回实际View。举个例子:

1
2
3
4
5
6
7
8
struct Tech: View {
@State var show = false
var body: some View {
if show {
Text("Hello, World!")
}
}
}

这样一段在SwiftUI里面会报错的,没法这样控制渲染,变通的办法是写成func:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Tech: View {
func textView(show: Bool) -> AnyView? {
if show {
return AnyView(Text("Hello, World!"))
}
return nil
}
var body: some View {
VStack {
self.textView(show: true)
self.textView(show: false)
self.textView(show: true)
}
}
}

要注意一点,func的返回值,可以是Text,但这里我是用的AnyView,是为了防止返回的View种类不一,这里AnyView相当于是个可以给任何类型View包装的外壳。不过在上面例子里只返回一种Text的情况下,可以把AnyView的包装去掉。

另外这种写法有隐患,比如body的第一层级要求有且只能有一个View,写两个编译器会报错,不写也会报错。但是用func返回View的写法,可以骗过编译器,这样如果返回的View是nil,会导致程序BUG,所以使用时候要谨慎。

我这还有一个Swifch控制流的Demo供参考,这里就是返回不同类型的View:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/// 根据Item的Type返回对应类型的Cell
/// - Parameters:
/// - type: 类型枚举
/// - title: 标题
/// - Returns: 对应Cell的外包装AnyView
func containedView(item: SettingGroupItem<Any>) -> AnyView {
switch item.type {
case .Toggle:
return AnyView(CellToggleView(settings: settings.boolValue(key: item.key), title: item.name))
case .TextFiled:
return AnyView(CellTextView(settings: settings.stringValue(key: item.key), title: item.name))
case .Selector:
return AnyView(
NavigationLink(destination: SelectorListView(
sections: item.sections ?? [],
value: settings.intValue(key: item.key)
)) {
CellSelectLinkView(
title: item.name,
value: $settings.selector,
sections: item.sections ?? []
)
}
)
}
}

8 如何理解双向绑定 @Binding

双向绑定不是什么新概念,ObjectiveC时代,ReactiveCocoa框架里有个RACChannel类专门负责实现这种效果。
单向监听就是我们熟悉的概念KVO(Key-Value-Observer)模式,而双向就是属性和Control挂钩,双向监听,任何一方值发生变化都一同改变。

先一句话简单说一下双向绑定的优势:极大减少UI的状态总数,以提高UI可预见性,减少不可知问题。

举个简单的完整例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import SwiftUI
struct Tech: View {
@State var name: String
@State var pwd: String
var body: some View {
Form {
Section {
TextField("用户名", text:$name)
TextField("密码", text:$pwd)
}
Section {
Button(action: {
print(self.name , self.$name)
}) {
Text("确认")
}.disabled(name.count == 0 || pwd.count == 0)
}
}
}
}
struct Tech_Previews: PreviewProvider {
static var previews: some View {
Tech(name: "", pwd: "")
}
}

这是一个简单的两个输入框,一个确认按钮的登录UI,实现了必须用户名和密码都输入按钮才可以按的逻辑。使用到双向绑定的部分是TextField("用户名", text:$name),注意name变量前面的$符号。在按钮的action里面我们将带$符号和不带的参数对比,就会发现,前者是Stirng类型,后者是Binding<String>类型,所以$符号是把普通属性简易包装成Binding结构体的缩写。

查看TextField接口,就会发现他的text参数要求传入的就是Binding<String>结构体。效果是在TextField内部进行了双向绑定的处理,修改TextField的内容,绑定属性name会跟随变化,反向操作也是同步的。

然后我们说一下有双向绑定支持的好处,首先是数据驱动UI的构架减少了UI的状态量,上面这个简单UI,两个输入框(下称AB)有四种情况,分别是:AB都没有内容,AB都有内容,A有内容B没有,B有内容A没有。如果按照数据驱动的理念,只需要两个状态属性来标记这四种情况,实际就是状态数的以2为底的对数值;当输入框有三个,UI的状态数就是8种状态,是指数级增长,而数据驱动UI的理念下我们实际关心的属性数量还是3个,是线性增长的。

数据导向设计是不依赖于双向绑定的,没有双向绑定,也可以实现数据驱动UI设计,但有了双向绑定,这种构架实现起来如虎添翼。

SwiftUI最吸引我的地方就是这里了,我用ObjectiveC尝试的各种项目构架,难点都集中在数据如何各种组件中传递的问题上,SwiftUI提供了一个简单可行的方案。Swift是强类型语言,View中各种数据的管道定义都要类型匹配,保证了软件的健壮,如果觉得这样的设计不够灵活,可以将数据通过protocol来定义,这样将大大降低组件之间的耦合。

9 @State、@Binding和@ObservedObject修饰符分别有什么用

这里基本唯一没包括的是@EnvironmentObject,这个我真的目前没用到,但是根据官方教程里不难理解@EnvironmentObject提供的参数是全局生效,不需要在View的层级结构中不断传递,可以在组件中直接声明就使用的参数。

首先这些修饰符,是给View的外层使用的,而并非是给内部使用的。比如在上面例子中:

1
2
3
4
5
6
7
8
import SwiftUI
struct Tech: View {
@State var name: String
@Binding var pwd: String
...
}

这里的name和pwd,用@State修饰,或用@Binding修饰都可以,内部使用方式是一样的,区别在初始化的传参上。@State修饰属性,直接传String就好,而@Binding修饰属性,则要传Banding结构体,这里是String类型,则是Banding<String>结构体,看到这里如果不懂尖括号是意思的可以看这里Swift泛型

前面我们说了,在SwiftUI中普通属性转Banding结构体的快捷方式是前面加$符号,在PreviewProvider中如果要创建的Banding结构体,需要使用.contrast()静态方法,用来返回一个不能修改的Banding结构体:

1
2
3
4
5
struct Tech_Previews: PreviewProvider {
static var previews: some View {
Tech(name: "", pwd: .constant(""))
}
}

Banding相较于State,就像是管道的标记,告诉外层View:你传进来的参数,如果在我内部被修改,我会通知你。,State则类似于标记这个属性修改只要我组件内部知道就够了,不会通知外部。

然后说一下@ObservableObject,这个修饰和ObservableObject协议是配对使用的,这是一个Class协议,结构体不能使用,所以@ObservableObject修饰的一定是类实例,不会是结构体。

在实现了ObservableObject协议的类内部,使用PassthroughSubject来通知UI更新:

1
2
3
4
5
6
7
8
let objectWillChange = PassthroughSubject<Void, Never>()
@UserDefault("Sort", defaultValue: false)
var sort: Bool {
willSet {
objectWillChange.send()
}
}

这个具体原理我还不是特别明白,以后补充。不过从名称不难看出,subject、send这些都近似于设计模式中订阅者模式的名词,应该是一种邮局模式,多方订阅数据,一方发送订阅,多方都可以收到数据,这里就不细说了,现阶段我也只能照猫画虎来使用,因为头文件看不懂(不懂的关键词和写法太多),只能慢慢补充基础知识以后来分析。

10 如何初始化普通的Binding结构体

我在使用中还真遇到了这样的需求,Banding结构体是可以普通初始化的,下面就是一个自定义Binding结构体的例子,有点类似于计算属性设置setter和getter:

1
2
3
4
5
6
Binding.init(get: {
...
return 0
}) { (x) in
...
}

也许目前你在SwiftUI开发过程中用不到这种写法,整个官方教程里也没有这样的写法,是因为确实没必要。但真要用到的时候,确实很有用。

比如你的数据ObservableObject实例,带参数的计算属性,你就必须用func来计算结果,但func在SwiftUI里面不能用$来包装,所以就要求func本身要定义返回一个Banding结构体,这时候就要用到Banding的普通初始化写法了。

11 Swift没有valueForKeyPath方法,怎么用String获取属性值

如果在Swift里面使用OC对象,还是可以用的OC的接口的,如果不是OC对象怎么办,那就要用到Mirror了,Mirror是Swift4.1引入的功能,可以用来生成一个实例的反射对象,这个反射对象可以用来获取和设置对象的属性值,具体用法如下:

1
2
3
4
5
6
final class UserSettings: ObservableObject {
var mirror: Mirror?
init() {
self.mirror = Mirror(reflecting: self)
}
}

上面是初始化方法,这里我设置了一个局部变量用来存储mirror。这里牵扯到一个懒加载的写法问题,我们后面专门开一篇讨论。下面是Mirror的使用,这里是搭配前面说的Binding使用的,整段func的功能是通过String类型的key来生成一个对应属性名的Banding结构体,供给SwiftUI使用,如此操作就不用在SwiftUI中将属性名写死,而是通过key来获取,非常灵活:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func boolValue(key: String) -> Binding<Bool> {
return Binding.init(get: {
if let mirror = self.mirror {
for (_, attr) in mirror.children.enumerated() {
guard attr.label == "_" + key else {
continue
}
let v = attr.value as! UserDefault<Bool>
return v.wrappedValue
}
}
return true
}) { (x) in
if let mirror = self.mirror {
for (_, attr) in mirror.children.enumerated() {
guard attr.label == "_" + key else {
continue
}
var v = attr.value as! UserDefault<Bool>
v.wrappedValue = x
}
}
}
}

mirror.children.enumerated()枚举中包含attr对象,拥有label和value两个属性,label是类的方法名,value是值,上面方法是根据key来生成banding结构体,所以要用for循环匹配key和label的值。这里label比实际的key名要多一个下划线。

另外因为我这里的用到的属性,都是@propertyWrapper属性包装过的,所以attr.value要强转类型为包装结构体UserDefault<Bool>,get返回的是包装体的wrappedValue,set修改的也是wrappedValue

小结

我这个老OC用户学Swift的确有不少新概念需要慢慢理解,一些理念在其他语言中也能窥见一二,Swift语言的设计毋庸置疑是很前卫的,有其他心得我也会继续扩充这个栏目,与各位共勉。