用SwiftUI框架绘制视图

首先在Xcode里新建一个Swift Package包,要写UI库选择Library,git版本管理可以不勾,带SwiftTesting确保在Swift6的较新环境。 项目结构:

ImmutableState
├── Package.swift
├── Sources
│   └── ImmutableState
│       └── ImmutableState.swift
└── Tests
    └── ImmutableStateTests
        └── ImmutableStateTests.swift

Package.swift(模板生成):

let package = Package(
    name: "ImmutableState",
    products: [
        // Products define the executables and libraries a package produces, making them visible to other packages.
        .library(
            name: "ImmutableState",
            targets: ["ImmutableState"]),
    ],
    targets: [
        // Targets are the basic building blocks of a package, defining a module or a test suite.
        // Targets can depend on other targets in this package and products from dependencies.
        .target(
            name: "ImmutableState"),
        .testTarget(
            name: "ImmutableStateTests",
            dependencies: ["ImmutableState"]
        ),
    ]
)

上方展示了创建时的工程结构和模板生成Package.swift。 其次在ImmutableState.swift 旁边创建 ImmutableDemo 文件夹安放接下来的整套代码,也就在ImmutableDemo文件夹下使用 SwiftUIView 模板创建代码,然后右键struct的名称,refactor重构rename改名为 ImmutableView。

import SwiftUI

struct ImmutableView: View {
    var body: some View {
        Text("Hello, World!")
    }
}

#Preview {
    ImmutableView()
}

此时报错提到“‘View’ is only available in iOS 13.0 or newer”等字样,说明库包模板生成的初始代码有可能和SwiftUI所要求的不符,来想办法修复它。 不了解Package.swift的朋友可能想问类似“如何在Swift Package中支持iOS等问题”。 最后通过阅读各种形式的文档搞清楚Package.swift的配置格式后,了解到解决办法是在name和products之间增加一行配置: platforms: [.iOS(.v13)], 这个字段指定整个包支持的版本,代码中指定了iOS13,以后根据报错要求,不妨逐渐提升。 ImmutableView.swift标签页右方尽头,在倒数第2个按钮的多条横线样式的按钮上单击,在弹出的菜单里把Canvas预览画板点上刷新,已经可以看到第一行SwiftUI代码了。 这些UI代码写到库包里,不仅可以把工程结构和依赖关系撘得更清晰,还可以在换地方搬砖时复用(小心违法)。

SwiftUI绘制原理

所有人都在让SwiftUI新手写下第一个@State属性:

struct ImmutableView: View {
    @State var counter = 0
    
    var body: some View {
        Button(action: {
            
        }, label: {
            Text("Hello, World!\(counter)")
        })
    }
}

counter代表计数器。 预览画板中将会出现蓝色样式的按钮,按时是灰色。鼠标放到@State上按Option键单击,查看快速帮助(也可以在右上角打开右侧边栏点问号那个按钮,或把光标移动到位之后按下Ctrl+Command+?)。 学过Swift的朋友一定知道属性包装器和协议,这种@xx字样的不是attribute特性,就是属性包装器。还是鼠标放到@State上按 Command 键单击,进去看看,果然有@propertyWrapper,还发现它是遵循了DynamicProperty协议,再点进这个协议,可以看到它要求名为update的可变异方法。 这个方法会在@State包装的属性发生变化时,要求View重新绘制。 有人说Swift语言为编程引入了一种面向协议的范式。 同样的道理,出来到 ImmutableView.swift 标签页,点击 ImmutableView: View 的View进去看看,可以看到View也是协议,要求实现body属性,且需要是另一个遵循View协议的类型。 甚至能以一种不推荐的方式改写而不报错:

struct ImmutableView {
    @State var counter = 0
}

extension ImmutableView: View {
    var body: some View {
        Button(action: {
            
        }, label: {
            Text("Hello, World!\(counter)")
        })
    }
}

这只是展示,还是Command+Z撤销还原回去。为了让counter计数器生效,可以在action闭包中间写入 counter = counter + 1 这行代码给counter赋值为比原值大1的值。 把预览画板作为UI测试器,点击按钮,计数器的结果已经如预期增加了。 再写一个一次增加5点计数的按钮:

struct ImmutableView: View {
    @State var counter = 0
    
    var body: some View {
        VStack {
            Text("counter: \(counter)")
            Button(action: {
                counter = counter + 1
            }, label: {
                Text("+1!")
            })
            Button(action: {
                counter = counter + 5
            }, label: {
                Text("+5!")
            })
        }
    }
}

可以发现写了很多相似的逻辑代码,而且分散在视图之间,编程里的重复总是不太好,可以这么写:

struct ImmutableView: View {
    @State var counter = 0
    
    var body: some View {
        VStack {
            Text("counter: \(counter)")
            Button(action: tapButtonPlus1, label: {
                Text("+1!")
            })
            Button(action: tapButtonPlus5, label: {
                Text("+5!")
            })
        }
    }
    
    func tapButtonPlus1() {
        plus(with: 1)
    }
    
    func tapButtonPlus5() {
        plus(with: 5)
    }
    
    func plus(with number: Int) {
        counter = counter + number
        print(counter)
    }
}

这次改动把按钮们的action放到方法内,集中在同一块区域写出,并且把相似的逻辑整合到一个方法里。 可以想象到随着视图逐渐变大,在涉及执行未必成功的代码时,还要写相应的异常处理、界面提示,方法区会随着这些的增长大到一定地步。 下面的测试写在/Tests/ImmutableStateTests/ImmutableStateTests.swift中,分别+1和+5之后,counter不等于预期的结果6:

@MainActor // ImmutableView要求@MainActor或async/await,这行把测试方法放在主线程的行为体中运行,避免引入await
@Test func example() async throws {
    let view = ImmutableView()
    
    view.tapButtonPlus1()
    view.tapButtonPlus5()
    
    assert(view.counter == 6) // Thread 1: Assertion failed
}

没有人乐意每次都手动点开一个路由很深的页面,甚至维护在XCTest中录下自动打开的过程也会在工程长大之后变得繁琐。 我们需要另外一个语义来让视图与逻辑清晰地区分开,尤其要便于测试。

M-V-VM模式

SwiftUI原生的模式是M-V模式,这种模式把逻辑集成在视图里,数据流向是Model <-> View,测试起来不太方便,极其依赖预览画板的展示,好处是在写一些小视图时表达性很强。 本文推荐的模式是通过苹果官方支持的Combine响应式框架实现的M-V-VM模式,这种模式把状态和逻辑摘到类中,可以写出不用预览画板就能在单元测试中使用的代码,视图规模但凡稍微上去一点就建议这么写,数据流向是Model <-> ViewModel <-> View。 其中,Model表示结构与逻辑,View表示视图与动作源,ViewModel表示动作与响应源。 工程中只有合适与不合适之分,没有绝对的好坏。 从import SwiftUI点进去,可以看到SwiftUI自身也有import Combine,它背后做了一些事,因此有SwiftUI的地方绝大部分时候不需要导入Combine。

用Combine框架发布视图状态更新

简单介绍一下要用到的Combine框架的属性包装器。 @Published可以理解为是在可观察类中的@StateObservableObject可观察类是一个能在观察到@Published成员属性的值发生变化时发出通知的类协议。 @StateObject是供可观察类使用的属性包装器,接到存放的可观察类的变化通知时会调用update方法使View更新。@StateObject要求iOS14。 试试把counter和那些方法搬到新的可观察类ImmutableViewModel里,然后用@StateObject存放类实例viewModel:

struct ImmutableView: View {
    @StateObject var viewModel = ImmutableViewModel()
    
    var body: some View {
        VStack {
            Text("counter: \(viewModel.counter)")
            Button(action: viewModel.tapButtonPlus1, label: {
                Text("+1!")
            })
            Button(action: viewModel.tapButtonPlus5, label: {
                Text("+5!")
            })
        }
    }
}

class ImmutableViewModel: ObservableObject {
    @Published var counter = 0
    
    func tapButtonPlus1() {
        plus(with: 1)
    }
    
    func tapButtonPlus5() {
        plus(with: 5)
    }

    func plus(with number: Int) {
        counter = counter + number
        print(counter)
    }
}

这些属性包装器在类型中增加了以下划线为前缀加上原名的属性,在需要手写初始化代码时可以在初始化器中显式赋值。 有发出通知的可观察类ObservableObjectView中就要有存放实例并接收通知的属性包装器@StateObject。 顺便一提,enum枚举体虽然可以实现View协议,但是不能含有如@StateObject属性、@State属性等作为视图状态变量存放的存储属性。以enum为载体的View讲好听点叫纯函数式,作为单状态视图是可行的,不过它更适合当Model。 好,可以到ImmutableStateTests.swift中测试了:

// 这行不需要@MainActor,是因为没有@StateObject等属性包装器要求
// 生产代码中,ViewModel自身和面向View层的成员应当尽量被@MainActor修饰
@Test func example() async throws {
    let viewModel = ImmutableViewModel()
    
    viewModel.tapButtonPlus1()
    viewModel.tapButtonPlus5()
    
    assert(viewModel.counter == 6)
}

分别输出1和6并且测试通过。 这看似很简单的测试,原先M-V代码来是无法运行通过的。

对外不可变的封装

有一个很自然而然的想法:既然已经把代码写在ViewModel中了,就不太希望成员被外部的View层修改。当然如TextFeild等双向绑定时依旧要能修改,不过对于其他不需要双向绑定的成员,仍希望在代码中明确它单向发布通知的语义时,可以通过setter设置器的访问控制来达成。写全了是这样的:

@Published internal private(set) var counter = 0

其中internal可以和private(set)互换位置,当然也可以照常把internal省略以使用类型的总访问级别。 这也还是不全,counter = 0被我们写的方法和无小数点的语境推断为Int,如果有位数的要求可以写明,并在plus方法中修改:

@Published private(set) var counter: Int64 = 0 // 此处以64位为例

这个counter的类型就是所谓Model。 一般来说,Model用struct结构体作为载体是比较灵活的,enum枚举体稍显死板却具备语义。实际上大部分Model都会写成struct,其中内嵌一层可解析JSON字段的enum。这里重点介绍enum枚举体的Model写法。 在函数中,只依赖参数值、不依赖外部可变值的函数是纯函数。 纯函数理念重视把可变的部分转移到函数体外部,保证在函数体中涉及的量都是可预测的,重视可靠性。尽管写程序总是需要可变的部分,不变的部分的保障对代码可靠性提升却不可忽视。 在业务流程图中,总是会有节点面临不同的分支流向,它们往往会在某个节点折返,或者有着错综复杂的变化走向,这些分支流向就是业务状态。 业务状态的抽象形式与enum枚举体相性很好,它们都接收一些固定的参数,都可以根据自身所在位置推断出某个结果。 另外,ViewModel原则上不应当拥有子类,因此也应在class前添加final关键词,这还将使编译速度得到些许提高。 试试写一个以enum枚举体为载体的Model,忽略进位,仅关注个位:

enum ImmutableModel {
    case zero
    case increment(counter: Int)
    
    mutating func plus(with number: Int) {
        // 限定number是正整数
        guard number > 0 else { return }
        let rest = number % 10
        switch self {
        case .zero:
            self = .increment(counter: rest)
        case .increment(counter: let counter):
            let sumaryRest = (counter + rest) % 10
            if sumaryRest == 0 {
                self = .zero
            } else {
                self = .increment(counter: sumaryRest)
            }
        }
    }
}

其中.zero用例是初始状态,.increment用例是计数器不为0的状态,在plus方法中,计数器超过9时,跳转到zero状态。 无论你的工程的业务状态有没有复杂到必须要引入enum来管理状态,你都需要在心中建立一个状态模型。边缘状态和最经常出现的状态同样重要。

总结

我们建立了一个小型工程,绘制了一个简单的视图并通过迭代使得它易于测试。为了易于测试,我们基于MVVM模式创建了ViewModel,并借助Combine响应式框架抽象、发布、更新视图状态。最后,我们引入了setter的访问级别、final class和纯函数理念,用于使代码明确封装的不可变性。