本文将聊聊一个与创立杂乱的 SwiftUI 运用很契合的结构 —— The Composable Architecture( 可拼装结构,简称 TCA )。包括它的特色和优势、最新的发展、运用中的注意事项以及学习途径等问题。

原文宣布在我的博客wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】

TCA 简介

本节的内容来自 TCA 官网阐明的中文版别

The Composable Architecture ( 简写为 TCA ) 让你用共同、便于了解的办法来搭建运用程序,它统筹了拼装,测验,以及成效。你能够在 SwiftUI,UIKit,以及其他结构,和任何苹果的平台( iOS、macOS、tvOS、和 watchOS )上运用 TCA。

TCA 供给了用于搭建适用于各种意图、杂乱度的 app 的一些中心东西,你能够一步步地跟从它去处理许多你在日常开发中时常会碰到的问题,比方:

  • 状况办理(State Management)
    用简略的值类型来办理运用的状况,以及在不同界面调用这些状况,使一个界面内的改变能够立刻反映在另一个界面中。
  • 拼装(Composition)
    将巨大的功用离散为小的能够独立运转的组件,然后再将它们重新拼装成原本的功用。
  • 副作用(Side Effects)
    用最可测验和便于了解的办法来让 app 的某些部分与外界沟通。
  • 测验(Testing)
    除了测验某个功用,还能集成测验它与其他功用组合成为的更杂乱的功用,以及用端到端测验来了解副作用怎么影响你的运用。这样就能够有力地确保事务逻辑和预期相符。
  • 工效(Ergnomics)
    用一个有最少概念和可动部分,且简略的 API 来做到上面的全部。

本文将不对 State、Action、Reducer、Store 这些概念做进一步的阐明

TCA 的特色和优势

强壮的拼装才干

已然结构被命名为可拼装结构( The Composable Architecture ),那么必定在拼装才干上有其独到之处。

TCA 鼓舞开发者将大型功用分解成选用同样开发逻辑的小组件。每个小组件均可进行单元测验、视图预览乃至真机调试,并经过将组件代码提取到独立模块的办法来进一步改进项意图编译速度。

所谓的拼装,便是将这些独立的组件按预设的层级、逻辑粘合到一起组成愈加完好功用的过程。

拼装这一概念在多数的状况办理结构中都存在,而且仅需少数的代码便能够供给一些根底的拼装才干。但有限的拼装才干限制并影响了开发者对杂乱功用的切分意愿,拼装的初衷并没有被彻底履行。

TCA 供给了许多的东西来丰厚其拼装手法,当开发者发现拼装已不是难事时,在开发的初始阶段便会从更小的粒度来思考功用的构成,然后创立出愈加强壮、易读、易扩展的运用。

TCA 供给的部分用于拼装的东西:

CasePaths

能够将其了解为 KeyPath 的枚举版别。

在其他 Redux-like 结构中,在拼装上下级组件时需求供给两个独立的闭包来映射不同组件之间的 Action ,例如:

func lift<LiftedState, LiftedAction, LiftedEnvironment>(
    keyPath: WritableKeyPath<LiftedState, AppState>,
    extractAction: @escaping (LiftedAction) -> AppAction?, // 将下级组件的 Action 转化为上级组件的 Action
    embedAction: @escaping (AppAction) -> LiftedAction, // 将上级 Action 转化为下级的 Action
    extractEnvironment: @escaping (LiftedEnvironment) -> AppEnvironment
) -> Reducer<LiftedState, LiftedAction, LiftedEnvironment> {
    .init { state, action, environment in
        let environment = extractEnvironment(environment)
        guard let action = extractAction(action) else {
            return Empty(completeImmediately: true).eraseToAnyPublisher()
        }
        let effect = self(&state[keyPath: keyPath], action, environment)
        return effect.map(embedAction).eraseToAnyPublisher()
    }
}
let appReducer = Reducer<AppState,AppAction,AppEnvironment>.combine(
    childReducer.lift(keyPath: \.childState, extractAction: {
        switch $0 {  // 需求为每个子组件的 Action 别离映射
            case .childAction(.increment):
                return .increment
            case .childAction(.decrement):
                return .decrement
            default:
                return .noop
        }
    }, embedAction: {
        switch $0 {
            case .increment:
                return .childAction(.increment)
            case .decrement:
                return .childAction(.decrement)
            default:
                return .noop
        }
    }, extractEnvironment: {$0}),
    parentReducer
)

CasePaths 为这一转化过程供给了主动处理的才干,我们仅需在上级组件的 Action 中界说一个包括下级 Action 的 case 即可:

enum ParentAction {
    case ...
    case childAction(ChildAction)
}
let appReducer = Reducer<AppState,AppAction,AppEnvironment>.combine(
  counterReducer.pullback(
    state: \.childState,
    action: /ParentAction.childAction, // 经过 CasePaths 直接完成映射
    environment: { $0 }
  ),
  parentReducer
)

IdentifiedArray

IdentifiedArray 是一个具有字典特征的类数组类型。它具有数组的全部功用和挨近的功用,要求其间的元素有必要契合 Identifiable 协议,且 id 在 identifiedArray 仅有。如此一来,开发者就能够不依靠 index ,直接以字典的办法,经过元素的 id 访问数据。

IdentifiedArray 确保了将父组件中状况( State )中的某个序列属性切分成独立的子组件状况时的体系安稳性。避免呈现因运用 index 修正元素而导致的反常甚至运用崩溃的状况。

如此一来,开发者在对序列状况进行拆分时将更有决心,操作也愈加便利。

例如:

struct ParentState:Equatable {
    var cells: IdentifiedArrayOf<CellState> = []
}
enum ParentAction:Equatable {
    case cellAction(id:UUID,action:CellAction) // 在父级组件上创立用于映射子 Action 的 case,运用元素的 id 作为标识
    case delete(id:UUID)
}
struct CellState:Equatable,Identifiable { // 元素契合 Idntifiable 协议
    var id:UUID
    var count:Int
    var name:String
}
enum CellAction:Equatable{
    case increment
    case decrement
}
let parentReducer = Reducer<ParentState,ParentAction,Void>{ state,action,_ in
    switch action {
        case .cellAction:
            return .none
        case .delete(id: let id):
            state.cells.remove(id:id) // 运用相似字典的办法操作 IdentifiedArray ,避免呈现 index 对应过错或超出范围的状况
            return .none
    }
}
let childReducer = Reducer<CellState,CellAction,Void>{ state,action,_ in
    switch action {
        case .increment:
            state.count += 1
            return .none
        case .decrement:
            state.count -= 1
            return .none
    }
}
lazy var appReducer = Reducer<ParentState,ParentAction,Void>.combine(
    // 
    childReducer.forEach(state: \.cells, action: /ParentAction.cellAction(id:action:), environment: { _ in () }),
    parentReducer
)
// 在视图中,能够直接选用 ForEachStore 来进行切分
ForEachStore(store.scope(state: \.cells,action: ParentAction.cellAction(id: action:))){ store in
    CellVeiw(store:store)
}

WithViewStore

除了运用于 Reducer、Store 上的各种拼装、切分办法外,TCA 还特别针对 SwiftUI 供给了在视图内进行进一步细分的东西 —— WithViewStore 。

经过 WithViewStore ,开发者能够在视图中进一步操控当时视图所要关注的状况以及操作,不只改进了视图中代码的朴实性,也在必定程度减少了不必要的视图改写,提高了功用。例如:

struct TestCellView:View {
    let store:Store<CellState,CellAction>
    var body: some View {
        VStack {
            WithViewStore(store,observe: \.count){ viewState in // 只关注 count 的改变,即便 cellState 中的 name 属性发生改变,本视图也不会重新改写
                HStack {
                    Button("-"){viewState.send(.decrement)}
                    Text(viewState.state,format: .number)
                    Button("-"){viewState.send(.increment)}
                }
            }
        }
    }
}

相似的东西还有不少,更多材料请阅览 TCA 的官方文档

完善的副作用办理机制

在现实的运用中,不可能要求一切的 Reducer 都是纯函数,关于保存数据、获取数据、网络连接、记载日志等等操作都将被视为副作用( TCA 中称之为 Effect )。

关于副作用,结构首要供给两种服务:

  • 依靠注入

    在 0.41.0 版别之前,TCA 关于外部环境的注入办法与大多其他的结构相似,并没有什么特别之处,但在新版别中,依靠注入的办法有了巨大的变动,下文中会有更详细的阐明。

  • 副作用的包装和办理

    在 TCA 中,Reducer 处理任何一个 Action 之后都需求回来一个 Effect,开发者能够经过在 Effect 中生成或回来新的 Action 然后形成一个 Action 链路。

    在 0.40.0 版别之前,开发者需求将副作用的处理代码包装成 Publisher ,然后转化成 TCA 可接受的 Effect。从 0.40.0 版别开端,我们能够经过一些预设的 Effect 办法( run、task、fireAndForget 等 )直接运用根据 async/await 语法的异步代码,极大地降低了副作用的包装本钱。

    别的,TCA 还供给了不少预设的 Effect ,以便利开发者应对包括杂乱且许多副作用的运用场景,例如:timer、cancel、debounce、merge、concatenate 等。

总之,TCA 供给了完善的副作用办理机制,仅需少数的代码,便能够在 Reducer 中应对不同的场景需求。

便利的测验东西

相较其在拼装方面的体现,TCA 对测验方面的关注与支撑也是它另一大特色。这方面它具有了其他中小结构所不具有的才干。

在 TCA 或相似的结构中,副作用都是以异步的办法运转的。这意味着,假如我们想测验一个组件的完好功用,一般无法避免都要涉及异步操作的测验。

而关于 Redux-like 类型的结构来说,开发者一般无需在测验功用逻辑时进行真实的副作用操作,只需让 Action -> Reducer -> State 的逻辑准确地运转即可。

为此,TCA 供给了一个专门用于测验的 TestStore 类型以及对应的 DispatchQueue 扩展,经过 TestStore ,开发者能够在一条虚拟的时刻线上,进行发送 Action,接纳 mock Action,比对 State 改变等操作。不只安稳了测验环境,而且在某些状况下,能够将异步测验转化为同步测验,然后极大地缩短了测验的时刻。例如( 下面的代码选用 0.41.0 版别的 Protocol 办法编写 ):

struct DemoReducer: ReducerProtocol {
    struct State: Equatable {
        var count: Int
    }
    enum Action: Equatable {
        case onAppear
        case timerTick
    }
    @Dependency(\.mainQueue) var mainQueue // 注入依靠
    var body: some ReducerProtocol<State, Action> {
        Reduce { state, action in
            switch action {
            case .onAppear:
                return .run { send in
                    while !Task.isCancelled {
                        try await mainQueue.sleep(for: .seconds(1)) // 运用依靠供给的 queue,便利测验
                        await send(.timerTick)
                    }
                }
            case .timerTick:
                state.count += 1
                return .none
            }
        }
    }
}
@MainActor
final class TCA_DemoReducerTests: XCTestCase {
    func testDemoStore() async {
        // 创立 TestStore
        let testStore = TestStore(initialState: DemoReducer.State(count: 0), reducer: DemoReducer())
        // 创立测验 queue ,TestSchedulerOf<DispatchQueue> 是 TCA 为了便利单元测验编写的 DispatchQueue 扩展,支撑时刻调整功用
        let queue = DispatchQueue.test
        testStore.dependencies.mainQueue = queue.eraseToAnyScheduler() // 修正成测验用的依靠
        let task = await testStore.send(.onAppear) // 发送 onAppear Action
        await queue.advance(by:.seconds(3))  // 时刻向前推移 3 秒中( 测验中并不会占用 3 秒的时刻,会以同步的办法进行)
        _ = await testStore.receive(.timerTick){ $0.count = 1} // 收到 3 次 timerTick Action,并比对 State 的改变
        _ = await testStore.receive(.timerTick){ $0.count = 2}
        _ = await testStore.receive(.timerTick){ $0.count = 3}
        await task.cancel() // 结束任务
    }
}

上述代码,让我们无需等候,便能够测验一个原本需求履行三秒才干取得成果的单元测验。

除了 TestStore 外,TCA 还为测验供给了 XCTUnimplemented( 声明未完成的依靠办法 )、若干用于测验的新断语以及便利开发者创立截图的 SnapshotTesting 东西。

如此一来,开发者将能够经过 TCA 构建愈加杂乱、安稳的运用。

活泼的社区与翔实的材料

TCA 现在应该是受欢迎程度最高的根据 Swift 言语开发的该类型结构。到本文写作时,TCA 在 GitHub 上的 Star 现已达到了 7.2K 。它具有一个适当活泼的社区,问题的反应和解答都十分迅速。

TCA 是从 Point Free 的视频课程中走出来的,Point Free 中有适当多的视频内容都与 TCA 有关,涉及当时开发中所面临的问题、处理思路、规划计划、施行细节等等方面。几乎没有其他的结构会有如此多翔实的伴生内容。这些内容能够除了起到了推广 TCA 的作用外,也让广阔开发者逐步了解并把握了 TCA 的各个环节,愈加简略投入到 TCA 的社区奉献中。两者之间起到了非常好的相互促进作用。

TCA 的最新改变( from 0.40.0 )

最近一段时刻,TCA 进行了两次具有重大意义的晋级( 0.40.0、0.41.0 ),本节将对部分的晋级内容做以介绍。

更好的异步支撑

在 0.40.0 之前的版别中,开发者需求将副作用的包装成 Publisher ,如此一来不只代码量较多,也不利于运用现在日益增多的根据 async/await 机制的 API。本次更新后,开发者将能够在 Reducer 的 Effect 中直接运用这些新式的 API ,在减少了代码量的一起,也能够享受到 Swift 言语供给的更好的线程和谐机制。

经过运用 SwiftUI 的 task 修饰器,TCA 完成了对需求长时刻运转的 Effect 的生命周期进行主动办理。

因为 onAppear 和 onDisappear 在某些场合会在视图的存续期中多处呈现,因此运用 task 坚持的 Effect 生命周期并不必定与视图共同

例如,下面的代码,在 0.40.0 版别之后,将愈加地清晰和天然:

// 老版别
switch action {
  case .userDidTakeScreenshotNotification:
    state.screenshotCount += 1
    return .none
  case .onAppear:
    return environment.notificationCenter
      .publisher(for: UIApplication.userDidTakeScreenshotNotification)
      .map { _ in LongLivingEffectsAction.userDidTakeScreenshotNotification }
      .eraseToEffect()
      .cancellable(id: UserDidTakeScreenshotNotificationId.self)
  case .onDisappear:
    return .cancel(id: UserDidTakeScreenshotNotificationId.self)
  }
// in View
Text("Hello")
    .onAppear { viewStore.send(.onAppear) }
    .onDisappear { viewStore.send(.onDisappear) }

运用 Task 方式:

 switch action {
    case .task:
      return .run { send in
        for await _ in await NotificationCenter.default.notifications(named: UIApplication.userDidTakeScreenshotNotification).values { // 从 AsyncStream 中读取
          await send(.userDidTakeScreenshotNotification)
        }
      }
    case .userDidTakeScreenshotNotification:
      state.screenshotCount += 1
      return .none
    }
  }
// in View
Text("Hello")
    .task { await viewStore.send(.task).finish() } // 在 onDisappear 的时分主动结束

另一方面,经过新的 TaskResult( 相似 Result 的机制 )类型,TCA 对 Task 的回来成果进行了巧妙地包装,让用户无需在 Reducer 中运用以前 Catch 的办法来处理过错。

Reducer Protocol —— 用声明视图的办法来编写 Reducer

从 0.41.0 开端,开发者能够用全新的 ReducerProtocol 的办法来声明 Reducer( 上文中介绍测验东西中展现的代码 ),并可经过 Dependency 的办法,跨层级的在 Reducer 中引入依靠。

Reducer Protocol 将带来如下优势:

  • 更简略了解的界说逻辑

    每个 Feature 都具有自己的命名空间,其间包括它所需的 State、Action 以及引入的依靠,代码的安排愈加合理。

  • 愈加友爱的 IDE 支撑

    在未运用 Protocol 方式之前,Reducer 是经过一个具有三个泛型参数的闭包生成的,在此种方式下,Xcode 的代码补全功用将不起作用,开发者只能经过回忆来编写代码,效率适当低下。运用了 ReducerProtocol 后,因为一切的需求用到的类型都声明在一个命名空间中,开发者将能够充分运用 Xcode 的主动补全高效地进行开发

  • 与 SwiftUI 视图相似的界说方式

    经过运用 result builder 重构了 Reducer 的拼装机制,开发者将选用与声明 SwiftUI 视图相同的办法来声明 Reducer,愈加地简练和直观。因为调整了 Reducer 拼装的构成视点,将从子 Reducer pullback 至父 Reducer 的办法修正为从父 Reducer 上 scope 子 Reducer 的逻辑。不只愈加易懂,而且也避免了一些简略呈现的拼装过错( 因父子 Reducer 拼装时过错的摆放顺序所导致 )

  • 更好的 Reducer 功用

    新的声明办法,对 Swift 言语编译器愈加地友爱,将享受到更多的功用优化。在实践中,对同一个 Action 的调用,选用 Reducer Protocol 的办法所创立的调用栈更浅

  • 愈加完善的依靠办理

    选用了全新的 DependencyKey 办法来声明依靠( 与 SwiftUI 的 EnvironmentKey 非常相似),然后完成了同 EnvironmentValue 相同的能够跨 Reducer 层级的依靠引入。而且,在 DependencyKey 中,开发者能够一起界说用于 live、test、preview 三种场景别离对应的完成,进一步简化了在不同场景下调整依靠的需求

注意事项

学习本钱

同其他具有强壮功用的结构相同,TCA 的学习本钱是不低的。虽然了解 TCA 的用法并不需求太多的时刻,但假如开发者无法真实地把握其内涵的拼装逻辑,很难写出让人满意的代码。

貌似 TCA 为开发者供给了一种从下至上的开发途径,但假如没有对完好功用进行杰出地构思,到最后会发现无法拼装出料想的作用。

TCA 对开发者的抽象和规划才干要求较高,牢记不要简略学习后就投入到开发具有杂乱需求的生产实践中。

功用

在 TCA 中,State、Action 都被要求契合 Equatable 协议,而且同许多 Redux like 处理计划相同,TCA 无法供给对引证值类型状况的支撑。这意味着,在有必要运用引证类型的一些场景,假如仍想坚持单一 State 的逻辑,需求对引证类型进行值转化,在此种状况下,将有必定的功用丢失。

别的,选用 WithViewStore 关注特定属性的机制在内部都是经过 Combine 来进行的。当 Reducer 的层级较多时,TCA 也需求支付不小的本钱进行切分和比对的作业。一旦其所支付的代价超出了优化的成果,便会呈现功用问题。

最后,TCA 现在仍无法应对高频次的 Action 调用,假如你的运用可能会发生高频次的 Action ( 每秒几十次 ),那么就需求对事件源进行必定的限制或调整。否则就会呈现状况不同步的状况。

怎么学习 TCA

虽然 TCA 在很大程度上减少了在视图中运用其他依靠项( 契合 DynamicProperty 协议 )的机会,但开发者仍应对 SwiftUI 供给的原生依靠计划有深刻的知道和把握。一方面在许多轻量开发中,我们不需求运用如此重量级的结构,另一方面,即便在运用 TCA 的时分,开发者仍需求运用这些原生依靠作为 TCA 的补充。在 TCA 供给的 CaseStudies 代码中,现已充分地展现了这一点。

假如你是 SwiftUI 的初学者,而且对 Redux 或 Elm 也没有多少了解,能够先尝试运用一些比较轻量级的 Redux-like 结构。在对这种开发方式有了必定的熟悉后,再学习 TCA 。我推荐我们能够阅览 Majid 创造的有关 Redux-like 的 系列文章。

王巍有关 TCA 的系列文章 —— TCA – SwiftUI 的救星? 也是极好的入门材料,主张对 TCA 感兴趣的开发者进行阅览。

TCA 项目中供给了不少的典范代码,从最简略的 Reducer 创立 到功用完善的 上架运用。这些典范代码也跟着 TCA 的版别更新而不断改变,其间不少现已运用 Reducer Protocol 进行了重构。

当然,想了解有关 TCA 最新、最深入的内容仍是需求观看 Point Free 网站上的视频课程。这些视频课程都供给了完好的文字版别以及对应的代码,即便你的听力有限也能经过文字版别把握一切的内容。

假如你有订阅 Point Free 课程的计划,能够考虑运用我的 指引链接。

总结

按照计划,TCA 在不久之后将运用 async/await 代码替换掉当时剩余的 Combine 代码( Apple 的闭源代码 )。这样它将能够成为一个支撑多平台的结构。没准到时 TCA 将有机会被移植到其他言语。

希望本文能够对你有所帮助。一起也欢迎你经过 Twitter、 Discord 频道 或博客的留言板与我进行沟通。

我正以聊天室、Twitter、博客留言等讨论为灵感,从中选取有代表性的问题和技巧制作成 Tips ,发布在 Twitter 上。每周也会对当周博客上的新文章以及在 Twitter 上发布的 Tips 进行汇总,并经过邮件列表的方式发送给订阅者。

订阅下方的 邮件列表,能够及时取得每周的 Tips 汇总。

原文宣布在我的博客wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】