SwiftUI|SwiftUI 初探

十月份参加极光黑客马拉松一天时间写了个简单的 火车票 OCR 应用“票夹”,当时由于时间和熟练程度原因,并没有试下今年 WWDC 刚推出的 SwiftUI 框架。最近抽空用了 SwiftUI + Combine 进行重写,顺便感受了一下这两个新框架的魅力。先说个人感受,SwiftUI 看起来挺美好的,但是目前有 Bug 和完善度不高,比较适合用在不关心设计的 Demo 或者个人功能性项目上。Combine 完成度尚可,但 Xcode 对复杂闭包的自动推断经常失效,比较影响编码体验。
SwiftUI 总体使用起来和 React 框架很像,都有对应的概念,一般就是 HStackVStack 当作视图层级使用,Spacer 用于自动填充剩余部分,比如在一个水平 HStack 中,A-Spacer-B,那么 A 靠最左,B 靠最右。
在使用 Swift UI 的过程中,碰到了一些问题,分享一下。
视图的默认行为

  1. 常用的 padding 是有默认值,且不为 0。
  2. List 默认是有分隔线,目前好像没法做到单独去掉,只能用下面的代码进行全局去除,并且是一种非官方做法,毕竟 List 的实现后续可能不一定是 UITableView
    List([]) { //… } .onAppear { UITableView.appearance().separatorColor = .clear }

  3. 视图的属性顺序会影响表现,比如下面两段代码
    // 1 HStack { Spacer() } .frame(height: 300) .background(Color.blue) .padding(30)

    // 2 HStack { Spacer() } .frame(height: 300) .padding(30) .background(Color.blue)

    要实现想要的效果,得使用第一种,第二种会是没有边距的蓝色矩形,这个我怀疑是 Bug。
数据交互 @State
这个修饰符和 React 的 State 差不多,就当 State 改变时会触发所有使用了 State 地方的 UI 刷新。比 React 好用的地方是可以用多个修饰符分别修饰多个变量,而不用放在一起,然后也不用调用 setState 进行刷新,只需要正常赋值就会触发刷新。
@Binding
这个修饰符用于解决数据是从上层传入的,上层数据改变时需要通知下层 UI 的刷新,这个时候下层的数据就应该用 @Binding 修饰,这样不像 @State 修饰的数据会在传递时遵循值语义发生复制,从而导致数据不同步的问题。
@EnvironmentObject
这个修饰符用于解决多层嵌套时,下层视图想访问上层数据的问题,除了用 @Binding 一层层传递外,通过声明这个修饰符也可以在任意嵌套层级内使用该数据。
@ObservedObject & ObservableObject
这个修饰符可以用于在多个视图里共享一份数据模型时使用,可以将已有的数据模型集合进 SwiftUI。遵循 Observable 协议,并在接收数据改变的地方用 @ObservedObject 修饰,这样该 Observable 类型里所有的 Publisher 在发生改变时都会通知 @ObservedObject。对于已经存在的属性,可以加上 @Published 修饰符或者使用自定义的 Publisher 发送通知。
// 1. class GlobalModel: ObservableObject { @Published var name = "myName" }

// 2. class GlobalModel: ObservableObject { let didChange = PassthroughSubject()var name: = "myName" { didSet { didChange.send() } } }

实现模态展示视图 在 App 开发中,必不可少有需要 Modal 方式弹出 UIViewController 的情况,在 UIKit 中,只需要简单的 vc1.present(vc2, animated: true) 一行代码就能完成,但是在 SwiftUI 中,要完成这个操作却显繁琐。
struct ContentView: View { @State var isShowModal = false var body: some View { Button(action: { self.isShowModal = true }){ Text("show") } .sheet(isPresented: $isShowModal) { ModalView(isShow: self.$isShowModal) } } }struct ModalView: View { @Binding var isShow:Boolvar body: some View { Button(action: { self.isShow = false }){ Text("dismiss") } } }

可以看到,不仅需要传递一个标志位代表是否展示,还需要在需要关闭时改变该状态告诉原始视图让其消失。这样会带来不必要的状态传递和维护。笔者推荐通过定义闭包的方式来进行状态传递,并且方便两个视图之间数据传递。
struct ContentView: View { @State var isShowModal = false var body: some View { Button(action: { self.isShowModal = true }){ Text("show") } .sheet(isPresented: $isShowModal) { ModalView { intent in self.isShowModal = false // intent 处理 } } } }struct ModalView: View { typealias Intent = String let onViewResult:((Intent?) -> ())var body: some View { Button(action: { self.onViewResult(nil) }){ Text("dismiss") } } }

UIKit 的适配 在现阶段,即便是没有任何历史的新应用,全用 SwiftUI 进行构建也是不太现实的,在某些系统的视图和第三方库没有适配 SwiftUI 之前,继续和 UIKit 打交道是很正常的。
SwiftUI 分别为 UIView 和 UIViewController 提供了 UIViewRepresentableUIViewControllerRepresentable 协议进行适配。这两个协议的要求几乎一致,只需要在某个类型里遵循协议,在要求的方法里处理需要适配的 UIViewUIViewController,这个类型就能用于 SwiftUI 的视图中。
class BView: UIView { }struct AView { }extension AView: UIViewRepresentable { func makeUIView(context: UIViewRepresentableContext) -> BView { // 初始化 UIView BView() }func updateUIView(_ uiView: BView, context: UIViewRepresentableContext) { } }

但是很多时候,UIKit 的视图里面不仅仅 UI 展示,更耦合了数据的变化,这里有两方面的数据流:SwiftUI 数据往 UIViewUIView 数据往 SwiftUI (UIViewController 也是类似的)。
SwiftUI -> UIView
蛮简单的,协议里提供了方法。
class BView: UIView { var isDark:Bool = false { didSet { backgroundColor = isDark ? .black : .white } } }struct AView { @State var isDark = false }extension AView: UIViewRepresentable { func makeUIView(context: UIViewRepresentableContext) -> BView { BView() }func updateUIView(_ uiView: BView, context: UIViewRepresentableContext) { // 更新 UIView uiView.isDark = isDark } }

UIView -> SwiftUI
这种情况略微复杂,SwiftUI 里面提供了 Coodinator 来处理这种情况,简单来说,Coodinator 就是中间人,用于接收 UIView 变化的实例。
class BView: UIView { var isDark:Bool = false { didSet { didChangeDark?(isDark) } }// UIKit 常用的数据回调方式,闭包或者代理等 var didChangeDark:((Bool) -> ())? }struct AView { // 需要接收变化的属性 @Binding var isDark: Bool// 定义 Coordinator,里面持有 AView class Coordinator { let parent:AViewinit(_ view:AView) { parent = view } } }extension AView: UIViewRepresentable { // 实现方法 func makeCoordinator() -> Coordinator { Coordinator(self) }func makeUIView(context: UIViewRepresentableContext) -> BView { let view = BView() view.didChangeDark = { // 将改变传递到 context 里面的 coordinator 中 context.coordinator.parent.isDark = $0 } return view }func updateUIView(_ uiView: BView, context: UIViewRepresentableContext) { } }

接入 Combine Combine 和 SwiftUI 直接结合还是有点别扭,特别是对于常见的网络请求,建议通过 @ObservedObjectObservableObject 进行中转一下。下面给出了 Combine 和 SwiftUI 直接结合的例子,SwiftUI 只提供了 onReceive 方法进行接收。
struct ContentView: View { // 请求参数 @State var name = "" // 返回结果 @State var resultCode = 0// 请求操作,如网络请求 func fetch(_ name:String) -> AnyPublisher { Just(name.isEmpty ? 0 : 1) .setFailureType(to: Error.self) .eraseToAnyPublisher() }// 将请求转化为错误 Never 的,处理兜底 var nameRequest: AnyPublisher { fetch(name) .catch { _ in Just(0) .setFailureType(to: Error.self) } .assertNoFailure() .eraseToAnyPublisher() }var body: some View { VStack { Button(action: { // 触发请求 self.name = "Request" }) { Text("send request") } Text("code is \(resultCode)") } // 监听请求,错误类型必须为 Never .onReceive(nameRequest) { resultCode in self.resultCode = resultCode } } }

最后 “票夹” App 可以识别照片里的火车票并自动整理展示和汇总,用 SwiftUI + Combine 编写,基本不使用第三方库。可以作为 SwiftUI 实际运用的例子参考。
参考链接 Interfacing with UIKit
【SwiftUI|SwiftUI 初探】SwiftUI 数据流

    推荐阅读