SwiftUI 初探

1,396 阅读4分钟

十月份参加极光黑客马拉松一天时间写了个简单的 火车票 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<Void, Never>()
  
    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:Bool
    
    var 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<AView>) -> BView {
       	// 初始化 UIView
        BView()
    }
    
    func updateUIView(_ uiView: BView, context: UIViewRepresentableContext<AView>) {
    }
}

但是很多时候,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<AView>) -> BView {
        BView()
    }
    
    func updateUIView(_ uiView: BView, context: UIViewRepresentableContext<AView>) {
      	// 更新 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:AView
        
        init(_ view:AView) {
            parent = view
        }
    }
}

extension AView: UIViewRepresentable {
    // 实现方法
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    func makeUIView(context: UIViewRepresentableContext<AView>) -> BView {
        let view = BView()
        view.didChangeDark = {
          	// 将改变传递到 context 里面的 coordinator 中
            context.coordinator.parent.isDark = $0
        }
        return view
    }
    
    func updateUIView(_ uiView: BView, context: UIViewRepresentableContext<AView>) {
    }
}

接入 Combine

Combine 和 SwiftUI 直接结合还是有点别扭,特别是对于常见的网络请求,建议通过 @ObservedObjectObservableObject 进行中转一下。下面给出了 Combine 和 SwiftUI 直接结合的例子,SwiftUI 只提供了 onReceive 方法进行接收。

struct ContentView: View {
  	// 请求参数
    @State var name = ""
    // 返回结果
    @State var resultCode = 0
    
    // 请求操作,如网络请求
    func fetch(_ name:String) -> AnyPublisher<Int, Error> {
        Just(name.isEmpty ? 0 : 1)
            .setFailureType(to: Error.self)
            .eraseToAnyPublisher()
    }
    
    // 将请求转化为错误 Never 的,处理兜底
    var nameRequest: AnyPublisher<Int, Never> {
        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 数据流