入坑SwiftUI

今年推出的SwitUI可以说是一个十分重量级的界面编程框架,它是首批完全使用Swift编写的系统级框架,使用声明式编程语法(这也是近年来界面编程的一种趋势),与Xcode11深度结合动态预览等等新特性都值得每一个iOS开发者来学习一下。

声明式编程范式

区别于UIKit的传统指令式编程范式的界面编程,在SwiftUI中使用的是声明式的编程范式。直观上就是为了支持声明式的编程范式,Swift语法上引入了许多相关特性。比如官网这段SwiftUI的示例代码:

1
2
3
4
5
6
import SwiftUI
struct ContentView: View {
var body: some View {
Text("Hello SwiftUI!")
}
}

使用UIKit的代码编写方式大概如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class ContentView: UIView {
let textView = UITextView()

override init(frame: CGRect) {
super.init(frame: frame)
textView.frame = CGRect(x: 0, y: 0, width: frame.width, height: frame.height)
textView.text = "Hello SwiftUI!"
self.addSubview(textView)
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

可以直观得感受到两种编程范式上的区别,指令式的界面编程需要告知视图组织的具体步骤,而声明式界面编程只需要告知要什么样的视图。而为了支持声明式的编程范式,Apple专门修改了部分Swift的语言特性。例如:

  1. some View
  2. 唯一表达式与省略return(当函数体中只存在一个表达式的时候会直接可以省略return)

some这个关键词是Swift5.1中出现的针对泛型语法改进特性Opaque Result Types, 默认的Protocol是没有具体类型信息的,但是用some修饰后,编译器会让Protocol的实例类型对外透明。
比如在Swift5.1之前我们要返回遵守某协议的实例对象这个场景中,要么直接指出这个实例对象类型,要么返回泛型类型对象,编译器才能推断出实例对象的具体类型。

1
2
3
4
5
6
7
func makeInt() -> Int {
return 5
}

func makeInt<T: Equatable>() -> T {
return 5 as! T
}

而改进之后,返回值的类型对编译器就变成透明的了。直到这个值使用的时候编译器再根据返回值进行类型推断得到具体类型。

1
2
3
func makeInt() -> some Equatable {
return 5
}

显然改进前使用泛型约束的方案在代码维护上是比较繁琐的,所以这本质是个语法糖可以让SwiftUI更加优雅地声明视图。更多具体语法层面上的变更可以看一看SwiftUI的DSL语法分析这篇文章中的分析。

数据驱动视图

在SwiftUI中显然是不遵循MVC的设计的,它是基于数据来驱动视图的。那么在SwiftUI中数据具体是如何驱动视图变更的呢?首先在SwiftUI中应当遵循单一数据源输入(Single Source of Truth)即一个视图树应该由同一个来源的数据驱动变更,显然多份数据维护成本会很高。

然后是单向数据流动,即数据变更触发动作修改视图状态,最后再触发视图更新,以此形成一个单向的闭循环。
更多设计细节可以参考Data Flow Through SwiftUI

这里以Handling-User-Input这个示例中Toggle开关来简单分析一下SwiftUI中的数据流动。

从数据侧可以看到使用了@Published(依赖Property Wrapper特性实现的语法,通过$操作符取值)声明下面这两个属性是Combine框架中的Publisher。而在SwiftUI中就可以订阅这个发布者的变动以实现监听数据的变动。

1
2
3
4
final class UserData: ObservableObject  {
@Published var showFavoritesOnly = false
@Published var landmarks = landmarkData
}

那么视图侧具体要如何利用这个数据呢?打开文档可以看到Toggle这个视图中定义isOn参数是一个Binding类型的对象(一个遵循Publisher协议的对象),当我们把这个发布者对象递交给Toggle,那么这个按钮就可以通过订阅这个发布者来进行开关动作(Subscriber协议由这个Toggle内部实现)

1
2
3
4
5
6
7
8
9
public struct Toggle<Label> : View where Label : View {
public init(isOn: Binding<Bool>, @ViewBuilder label: () -> Label)
public var body: some View { get }
public typealias Body = some View
}

Toggle(isOn: $userData.showFavoritesOnly) {
Text("Favorites only")
}

关于SwiftUI中的数据流动实现需要关注以下几个使用Property Wrapper实现的修饰器:

  • @Published:声明对象为Combine中的发布者。
  • @State:声明对象为视图的状态值,当状态变动时使视图重新计算。
  • @Binding:使用Binding创建视图和它的模型间双向链接。
  • @ObservedObject:声明一个数据为可以观察对象。
  • @EnvironmentObject:声明视图树的全局环境变量。

在SwiftUI中我们通过@Published声明数据源模型中变动的部分,然后通过@State@Binding@ObservedObject或者@EnvironmentObject接收这些数据变动最终反馈到界面变化上,它们底层的数据流动都是依赖于Combine框架实现,接下来更详细了解一下这几个修饰器的具体作用。
ps:既然SwiftUI是依赖于Combine实现的界面更新的,UIKit同样可以结合Combine实现类似特性。

@Published

通过它将一个对象的属性标记为Combine框架中发布者。

1
2
3
4
5
6
7
8
9
10
11
class MyFoo {
@Published var bar: String
init(bar: String) {
self.bar = bar
}
}
let foo = MyFoo(bar: "bar")
let barSink = foo.$bar
.sink() {
print("bar value: \($0)")
}

@State

这是一种视图内部访问数据的方式,State的实例不是值本身而是一种存取方法,如果要访问它的值应该直接访问这个属性,本质上这个修饰器是用于声明一个可被监听变更的视图内部值属性,建议声明为Private

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct ContentView : View {
@State private var userIsLoggedIn = false

var body: some View {
VStack {
Toggle(isOn: $userIsLoggedIn) {
if (userIsLoggedIn) {
Text("Log in")
} else {
Text("Log out")
}
}.padding()
}
}
}

@Binding

这是一种视图间的双向访问方式,在SwiftUI中值是值类型的方式传递,即传递下去的是一个拷贝过的值。但是通过@Binding修饰器修饰后,属性变成了一个引用类型,即变成了引用传递。比如我们在父视图中声明了一个@State属性然后想同样在子视图中通过这个属性来进行刷新,可以这么做:

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
31
32
33
struct ContentView : View {
@State private var isOn: Bool = false

var body: some View {
VStack {
Toggle(isOn: $isOn) {
if (isOn) {
Text("Supper Control On")
} else {
Text("Supper Control Off")
}
}.padding()

ChildView(isOn: $isOn)
}
}
}

struct ChildView : View {
@Binding var isOn: Bool

var body: some View {
VStack {
Toggle(isOn: $isOn) {
if (isOn) {
Text("Child Control On")
} else {
Text("Child Control Off")
}
}.padding()
}
}
}

@ObservedObject与ObservableObject

在SwiftUI中数据类型需要遵循ObservableObject协议才能被视图监听,而用@ObservedObject来声明一个属性是一个可监听对象,然后用@Published修饰这个类里的属性,表示这个属性是可以被SwiftUI监听的。

1
2
3
4
final class UserData: ObservableObject  {
@Published var showFavoritesOnly = false
@Published var landmarks = landmarkData
}

@EnvironmentObject

这是一种全局视图树间接访问数据的方式,每个被设置了.environmentObject()的顶层视图及其子视图都能够共享访问这些数据,意味着不用显式地将父视图的数据传递给子视图,同时这些数据只被允许每种类型拥有一个相同的实例对象。比如在下面这么一段测试代码中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@available(iOS 13.0, *)
struct TestView: View {
@EnvironmentObject private var A0Model: TestDataA
@EnvironmentObject private var A1Model: TestDataA
@EnvironmentObject private var B0Model: TestDataB
@EnvironmentObject private var B1Model: TestDataB

var body: some View {
VStack {
Text(A0Model.string)
Text(A1Model.string)
Text(B0Model.string)
Text(B1Model.string)
}
}
}

TestView()
.environmentObject(TestDataA("A0"))
.environmentObject(TestDataA("A1"))
.environmentObject(TestDataB("B0"))
.environmentObject(TestDataB("B1"))

最终只有A0和B0的数据会被显示出来。显然这也遵循了SwiftUI的单一数据来源的设计原则,数据被当做顶层视图树的环境变量,视图树中的节点都可以隐式地去访问每个类型实例唯一的数据单元。

拓展SwiftUI的能力——混合UIKit

目前而言SwiftUI的能力还比较有限,比如表单深度自定义能力相比于成熟的UIKit还有许多的不足。不过SwiftUI与UIKit是可以完美混合的,毕竟抛开Swift的语法糖它们的底层实现实际上是相似的。目前为止UIKit中的部分ViewControllers/Views与SwiftUI中的对应关系如下:

上诉的视图控件的SwiftUI中与UIKit中的表现基本是一致的,接下来让我们看看如何混合UIKit与SwiftUI进行界面编程。

将UIKit嵌入到SwiftUI中

在SwiftUI中混合UIKit的关键在于实现UIViewRepresentableUIViewControllerRepresentable这两个协议上。

比如这里我们给SwiftUI定义一个ActivityIndicator视图,只需要实现makeUIViewupdateUIView这两个方法就将UIActivityIndicatorView包裹成一个SwiftUI的View了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@available(iOS 13.0, *)
struct ActivityIndicator: UIViewRepresentable {
typealias UIViewType = UIActivityIndicatorView
let style: UIActivityIndicatorView.Style
var isAnimating: Bool = false

func makeUIView(context: UIViewRepresentableContext<ActivityIndicator>) -> ActivityIndicator.UIViewType {
return UIActivityIndicatorView(style: style)
}

func updateUIView(_ uiView: ActivityIndicator.UIViewType, context: UIViewRepresentableContext<ActivityIndicator>) {
isAnimating ? uiView.startAnimating() : uiView.stopAnimating()
}
}

将SwiftUI嵌入到UIKit中

而在UIKit中使用SwiftUI相对来说就简单多了,我们只需要将SwiftUI的控件包裹在UIHostingController这个控制器中便可以了。

参考

Protocol-Oriented Programming in Swift
泛型语法改进第一弹——Opaque Result Types
swiftUI的foreach
SwiftUI 与 Combine 编程
Apple 官方异步编程框架:Swift Combine 简介
关于 SwiftUI,看这一篇就够了
Fucking SwiftUI
Interfacing with UIKit