ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

深入理解 swift-composable-architecture 的两种状态驱动导航:Tree-based 与 Stack-based 完整指南

深入理解 swift-composable-architecture 的两种状态驱动导航:Tree-based 与 Stack-based 完整指南 前端移动开发【免费下载链接】swift-composable-architectureA library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind.项目地址https://gitcode.com/GitHub_Trending/sw/swift-composable-architecture点击查看免费下载导读本文以 swift-composable-architecture 官方文档 Navigation.md 为主线系统讲解该库中状态驱动state-driven导航的完整工具链。你将掌握 tree-based 与 stack-based 两种导航形态的领域建模方法、Reducer 与视图层集成方式、Dependency(\.dismiss)的 reducer 内自关闭机制以及基于TestStore的导航测试写法并在仓库源码Presents()宏、PresentationAction、StackState、ifLet/forEach操作符的佐证下理解其底层原理与取舍。什么是导航从“模式切换”到“状态存在性”官方文档对“导航”给出了一个宽泛但精确的定义导航Navigation是应用程序中的一种模式切换change of mode。drill-down、sheet、popover、full-screen cover、alert、confirmation dialog乃至自定义导航组件本质都是“模式切换”。而要定义一个“模式切换”又需要回答“什么是存在existence”于是文档给出第二个定义模式切换是指某一段状态从不存在的状态变为存在的状态或反之。当一个状态从nil变成非nil导航发生当它回到nil导航被撤销。基于“存在性如何表达”状态驱动导航分成两大阵营Tree-based 导航用 Swift 的Optional以及enum表达状态存在性嵌套的 optional 形成树状结构Stack-based 导航用集合collection表达状态存在性配合 SwiftUI 的NavigationStack使用。绝大多数真实应用会混合使用两种风格因此理解它们的强弱项是建模前的必修课。本文档体系的完整入口见 Navigation.md而两种导航各自的详细教程分别位于 TreeBasedNavigation.md 与 StackBasedNavigation.md。Tree-based 导航用 Optional 与枚举建模导航Tree-based 导航的核心工具只有三个Presents()宏、PresentationAction类型、ifLetreducer 操作符。完成集成后SwiftUI 原生的一切导航修饰符sheet(item:)、popover(item:)、navigationDestination(item:)、alert等都可直接使用。基础集成两个步骤集成父子 feature 的过程分为“领域集成”与“视图集成”两步。先看领域集成在父 feature 中用Presents声明可被呈现的子 feature 的 optional 状态用PresentationAction包裹子 actionReducer struct InventoryFeature { ObservableState struct State: Equatable { Presents var addItem: ItemFormFeature.State? var items: IdentifiedArrayOfItem [] // ... } enum Action { case addItem(PresentationActionItemFormFeature.Action) // ... } // ... }注意addItem必须是 optional。非nil表示该 feature 正在被呈现nil表示已关闭。然后用ifLet操作符把子 reducer 组合进父 reducer。导航的触发方式很直接把addItem状态填充为非nil即执行导航Reducer struct InventoryFeature { ObservableState struct State: Equatable { /* ... */ } enum Action { /* ... */ } var body: some ReducerOfSelf { Reduce { state, action in switch action { case .addButtonTapped: // 填充状态即执行导航 state.addItem ItemFormFeature.State() return .none // ... } } .ifLet(\.$addItem, action: \.addItem) { ItemFormFeature() } } }注意ifLet的 key path 使用的是PresentationState的投影值$语法action 参数使用的是 case path——类比 key path 但专为枚举设计。领域集成完成后进行视图集成只需把 store 的 binding 传给 SwiftUI 的视图修饰符struct InventoryView: View { Bindable var store: StoreOfInventoryFeature var body: some View { List { // ... } .sheet( item: $store.scope(\.addItem, action: \.addItem) ) { store in ItemFormView(store: store) } } }Bindable为 store 生成 binding再通过scope聚焦到呈现领域。addItem翻转为非nil时 sheet 出现回到nil时关闭。sheet可换成 SwiftUI 提供的任意修饰符popover(item:)、fullScreenCover(item:)、navigationDestination(item:)等实现任意形式的导航。枚举状态用单一 optional 替代多个 optional多个 optional 会带来无效状态问题2 个 optional 同时非nil、3 个 optional 导致 4 种无效状态、4 个产生 11 种、5 个产生 26 种且 SwiftUI 不支持单个视图同时呈现多个视图。正确做法是把多个目的地建模为单一枚举Reducer struct InventoryFeature { // ... Reducer enum Destination { case addItem(AddFeature) case detailItem(DetailFeature) case editItem(EditFeature) } }Reducer宏会把这段枚举描述展开为完整的组合 feature在 Xcode 中展开宏代码可查看全部生成内容。若需为生成的状态类型补充协议可在扩展中声明extension InventoryFeature.Destination.State: Equatable, Sendable {}随后持有单一Presents var destination: Destination.State?与case destination(PresentationActionDestination.Action)并用ifLet(\.$destination, action: \.destination)集成Destination可被自动推断无需 trailing closure。呈现某个 feature 时只需填充枚举的某一 casecase addButtonTapped: state.destination .addItem(AddFeature.State()) return .none视图层利用 binding scope 的 dot-chaining 精确锁定每个 case——sheet、popover、drill-down 分别由枚举的不同 case 驱动var body: some View { List { // ... } .sheet( item: $store.scope(\.destination, action: \.destination).addItem ) { store in AddFeatureView(store: store) } .popover( item: $store.scope(\.destination, action: \.destination).editItem ) { store in EditFeatureView(store: store) } .navigationDestination( item: $store.scope(\.destination, action: \.destination).detailItem ) { store in DetailFeatureView(store: store) } }由于状态互斥从.addItem直接切换到.detailItem时sheet 会立即关闭并执行 drill-down行为可预期。API 统一一套 API 服务所有导航形态Tree-based 导航最大的优势是 API 统一领域层始终只有一个ifLet操作符视图层则给任何 SwiftUI 修饰符传入聚焦于PresentationState/PresentationAction的 store。同一个视图甚至可以同时展示 sheet、popover、drill-down、alert 和 confirmation dialog.sheet( item: $store.scope(\.addItem, action: \.addItem) ) { store in AddFeatureView(store: store) } .popover( item: $store.scope(\.editItem, action: \.editItem) ) { store in EditFeatureView(store: store) } .navigationDestination( item: $store.scope(\.detailItem, action: \.detailItem) ) { store in DetailFeatureView(store: store) } .alert( $store.scope(\.alert, action: \.alert) ) .confirmationDialog( $store.scope(\.confirmationDialog, action: \.confirmationDialog) )Presents()宏的实现细节可参考 PresentationReducer.swift当状态变为非nil时创建导航身份NavigationIDPath当收到PresentationAction.dismiss时向对应导航 ID 发送取消并置空状态实现父子 feature 的完整生命周期管理。向后兼容iOS 16 之前的 drill-down若部署目标早于 iOS 16 / macOS 13 / tvOS 16 / watchOS 9无法使用navigationDestination可改用NavigationLink但需要自己提供一个由数据 binding 驱动的 helper将以下扩展粘贴进项目即可available(iOS, introduced: 13, deprecated: 16) available(macOS, introduced: 10.15, deprecated: 13) available(tvOS, introduced: 13, deprecated: 16) available(watchOS, introduced: 6, deprecated: 9) extension NavigationLink { public initD, C: View( item: BindingD?, onNavigate: escaping (_ isActive: Bool) - Void, ViewBuilder destination: (D) - C, ViewBuilder label: () - Label ) where Destination C? { self.init( destination: item.wrappedValue.map(destination), isActive: Binding( get: { item.wrappedValue ! nil }, set: { isActive, transaction in onNavigate(isActive) if !isActive { item.transaction(transaction).wrappedValue nil } } ), label: label ) } }点击链接时onNavigate被调用以填充状态关闭时状态被置nil。Stack-based 导航用集合建模导航栈Stack-based 导航的核心工具是StackState、StackAction与forEach操作符外加本库为NavigationStack提供的专用初始化器。它支持复杂乃至递归的导航路径并天然适配NavigationStack。基础集成Path reducer 与 StackState先定义一个通常命名为Path的 reducer声明所有可被推入栈的 feature与 tree-based 的Destinationreducer 完全相同Reducer struct RootFeature { // ... Reducer enum Path { case addItem(AddFeature) case detailItem(DetailFeature) case editItem(EditFeature) } }随后在管理导航栈的 feature 中持有StackState与StackActionReducer struct RootFeature { ObservableState struct State { var path StackStatePath.State() // ... } enum Action { case path(StackActionOfPath) // ... } }提示StackAction同时以Path的状态与 action 为泛型可用StackActionOf类型别名简化书写这与只有单个泛型的PresentationAction不同。用forEach集成栈元素 reducerPath()可由Reducer enum Path自动推断Reducer struct RootFeature { // ... var body: some ReducerOfSelf { Reduce { state, action in // 根 feature 的核心逻辑 } .forEach(\.path, action: \.path) } }视图层使用本库提供的NavigationStack专用初始化器它接收三个参数聚焦于StackState/StackAction的 store binding、根视图、以及为Path.State每个 case 生成视图的尾闭包struct RootView: View { Bindable var store: StoreOfRootFeature var body: some View { NavigationStack( path: $store.scope(\.path, action: \.path) ) { // 根视图 } destination: { store in switch store.case { case .addItem(let store): AddView(store: store) case .detailItem(let store): DetailView(store: store) case .editItem(let store): EditView(store: store) } } } }尾闭包中的 store 属于Path领域通过store.case析构枚举的每个 case 获得聚焦于单个 case 的 storeswitch 穷举保证新增 case 时编译期即可发现遗漏。此后想增加新的栈目的地只需给Path加一个新 case。向栈中推入 feature两种方式最简单的方式是使用NavigationLink(state:)初始化器直接指定要推入的完整状态需回溯到Path.StateForm { NavigationLink( state: RootFeature.Path.State.detail(DetailFeature.State()) ) { Text(Detail) } }点击时发送StackAction.push(id:state:)把.detail状态追加进path。缺点是视图必须访问Path.State类型即必须连同Pathreducer 一起编译所有可导航 feature损害模块化。作为替代可只用Button发送子领域 action再由根 feature 监听并追加状态Form { Button(Detail) { store.send(.detailButtonTapped) } }case .path(.element(id: _, action: .list(.detailButtonTapped))): state.path.append(.detail(DetailFeature.State())) return .none集成与元素操作父 feature 通过StackAction.element(id:action:)获取栈内子 feature 的一切动作并附带元素 IDcase let .path(.element(id: id, action: .editItem(.saveButtonTapped))): guard let editItemState state.path[id: id]?.editItem else { return .none } state.path.pop(from: id) return .run { _ in await self.database.save(editItemState.item) }StackState为每个入栈元素自动管理稳定的 ID可用state.path[id: id]查询、pop(from: id)弹出。相关实现见 StackReducer.swiftStackState底层是OrderedDictionaryStackElementID, ElementStackAction提供element(id:action:)、popFrom(id:)、push(id:state:)三种 caseStackElementID是Hashable, Sendable且支持整数字面量。完整示例可参考案例 04-NavigationStack.swift其中展示了state.path.append、state.path.pop(to: id)、state.path.removeAll()与forEach(\.path, action: \.path)的配合使用。关闭栈中 feature关闭即修改StackState例如popLast()或pop(from:)case .closeButtonTapped: state.popLast() return .none与 tree-based 一样若希望在子 feature 内部封装关闭逻辑可以在 reducer 中使用Dependency(\.dismiss)详见下文“关闭与 DismissEffect”它会发送StackAction.popFrom(id:)移除对应元素。两种导航的取舍Tree-based 的优势建模简洁用 optional/enum 静态描述所有合法导航路径天然禁止无效导航如“必须先进入 detail 才能进 edit”直接由状态类型强制。导航路径有限且可枚举状态类型限制了可能的导航组合。模块自包含子 feature 的领域直接嵌入父 featureXcode 预览即可完整测试导航能力。集成测试简单父子 feature 紧耦合可用TestStore编写深入细致的集成测试。API 统一drill-down、sheet、popover、alert、dialog 全部收敛到ifLet 修饰符这一套 API。Tree-based 的劣势复杂的递归导航路径如“电影 → 演员列表 → 演员 → 同一部电影”在 Swift 数据类型中难以建模。特性耦合导致编译变慢编译父 feature 必须连同所有 destination feature 一起编译。历史上更易受 SwiftUI drill-down 导航 bug 影响iOS 16.4 后多数已修复。Stack-based 的优势轻松处理复杂与递归导航路径一个扁平数组即可表达任意深度的往返导航。栈内 feature 完全解耦可独立模块化、独立编译。NavigationStack的 API 相比NavigationLink(isActive:)与navigationDestination(isPresented:)更稳定。Stack-based 的劣势不够简洁可能表达无意义的路径例如先进入 edit 再进入 detail或连续推入多个 edit。模块隔离后Xcode 预览中单个 feature 基本是“死”的detail 里的按钮无法在预览中跳转 edit。多 feature 集成测试困难解耦后只能编译运行整个应用来测试交互。NavigationStack只覆盖 drill-down不解决 sheet、popover、alert 等其他导航形式。关闭与 DismissEffect视图层 vs reducer 层SwiftUI 提供Environment(\.dismiss)让子视图自我关闭但它只存在于视图层无法在 ObservableObject 等逻辑层做校验或异步关闭。swift-composable-architecture 通过依赖管理系统提供了 reducer 层的对应工具DismissEffect见 Dismiss.swiftReducer struct Feature { ObservableState struct State { /* ... */ } enum Action { case closeButtonTapped // ... } Dependency(\.dismiss) var dismiss var body: some ReducerState, Action { Reduce { state, action in switch action { case .closeButtonTapped: return .run { _ in await self.dismiss() } } } } }注意DismissEffect是异步函数不能直接在 reducer 内同步调用必须放进Effect.run。dismiss()通过发送PresentationAction.dismisstree-based或StackAction.popFrom(id:)stack-based把状态置空/移除从而完全在子领域内封装关闭逻辑无需与父 feature 显式通信。两个重要约束dismiss 之后不能再发 action关闭通过发送 action 实现dismiss()之后继续send(.tick)会向已不存在nil的 feature 发 action在 Xcode 触发运行时警告、测试时直接失败。区分 SwiftUI 与 TCA 的 dismissEnvironment(\.dismiss)只能用于 SwiftUI 视图Dependency(\.dismiss)只能用于 reducer二者是完全不同的类型。另外DismissEffect支持动画关闭case .exitButtonTapped: return .run { _ in await self.dismiss(animation: .default) }从源码看dismiss()在找不到由ifLet/forEach呈现的父 feature 时会通过reportIssue报告“reducer requested dismissal…couldnt be dismissed”此时可用Dependency(\.isPresented)判断当前 feature 是否处于被呈现上下文再决定是否调用见 IsPresented.swift。测试子 feature 时若用到dismiss需覆盖该依赖let isDismissInvoked: LockIsolated[Bool] .init([]) let store TestStore(initialState: Child.State()) { Child() } withDependencies: { $0.dismiss DismissEffect { isDismissInvoked.withValue { $0.append(true) } } } await store.send(.exitButtonTapped) { // ... } XCTAssertEqual(isDismissInvoked.value, [true])导航测试穷尽与非穷尽两种模式正确建模领域后导航测试变得非常容易。以“计数器 5 时自我关闭”的子 feature 为例Reducer struct CounterFeature { ObservableState struct State: Equatable { var count 0 } enum Action { case decrementButtonTapped case incrementButtonTapped } Dependency(\.dismiss) var dismiss var body: some ReducerState, Action { Reduce { state, action in switch action { case .decrementButtonTapped: state.count - 1 return .none case .incrementButtonTapped: state.count 1 return state.count 5 ? .run { _ in await self.dismiss() } : .none } } } }将其嵌入父 featuretree-based 用PresentsPresentationActionifLetReducer struct Feature { ObservableState struct State: Equatable { Presents var counter: CounterFeature.State? } enum Action { case counter(PresentationActionCounterFeature.Action) } var body: some ReducerState, Action { Reduce { state, action in // 核心逻辑 } .ifLet(\.$counter, action: \.counter) { CounterFeature() } } }穷尽测试用TestStore从 count3 开始逐步断言每次自增与最终的自关闭Test func dismissal() { let store TestStore( initialState: Feature.State( counter: CounterFeature.State(count: 3) ) ) { CounterFeature() } await store.send(\.counter.incrementButtonTapped) { $0.counter?.count 4 } await store.send(\.counter.incrementButtonTapped) { $0.counter?.count 5 } await store.receive(\.counter.dismiss) { $0.counter nil } }store.receive(\.counter.dismiss)断言子 feature 通过发送 dismiss action 把counter置回nil。若使用枚举目的地断言时要链入具体 caseawait store.send(\.destination.counter.incrementButtonTapped) { $0.destination?.counter?.count 4 }非穷尽测试feature 越复杂穷尽断言越繁琐。开启store.exhaustivity .off后只需断言关心的高层细节Test func dismissal() { let store TestStore( initialState: Feature.State( counter: CounterFeature.State(count: 3) ) ) { CounterFeature() } store.exhaustivity .off await store.send(\.counter.incrementButtonTapped) await store.send(\.counter.incrementButtonTapped) await store.receive(\.counter.dismiss) }Stack 测试ID、XCTModify 与 case 下标Stack 场景下子 action 需要指定元素 ID。StackState自动管理 ID在测试中 ID 是整数且是“代际递增”的——从 0 开始每推入一个元素全局 ID 1。因此初始栈中唯一元素的 ID 是 0Test func dismissal() { let store TestStore( initialState: Feature.State( path: StackState([ .counter(CounterFeature.State(count: 3)) ]) ) ) { CounterFeature() } await store.send(\.path[id: 0].counter.incrementButtonTapped) { XCTModify($0.path[id: 0], case: \.counter) { $0.count 4 } } await store.send(\.path[id: 0].counter.incrementButtonTapped) { XCTModify($0.path[id: 0], case: \.counter) { $0.count 5 } } await store.receive(\.path.popFrom) { $0.path[id: 0] nil } }XCTModify($0.path[id: 0], case: \.counter) { ... }同时完成“按下标取值 → 提取枚举 case 载荷 → 修改 → 写回”case 不匹配时产生测试失败若只需简单修改可用StackState的subscript(id:case:)$0.path[id: 0, case: \.counter]?.count 4若要断言子 feature 收到特定 action如 effect 回传的.response可写store.receive(\.path[id: 0].counter.response)。非穷尽版本同理Test func dismissal() { let store TestStore( initialState: Feature.State( path: StackState([ .counter(CounterFeature.State(count: 3)) ]) ) ) { CounterFeature() } store.exhaustivity .off await store.send(\.path[id: 0].counter.incrementButtonTapped) await store.send(\.path[id: 0].counter.incrementButtonTapped) await store.receive(\.path.popFrom) }StackState 与 NavigationPath 的选择SwiftUI 自带类型擦除的NavigationPath可放入任意Hashable数据、用.navigationDestination(for:)声明类型对应的视图但 API 极其有限——只能append、removeLast()、读count连遍历都不行更无法插入/移除任意位置的元素let path: NavigationPath ... for element in path { // 编译失败 }这使“分析栈上内容、跨栈聚合数据”非常困难。相比之下本库的StackState做了不同取舍完全静态类型化不能随意放入任意数据遵循Collection/RandomAccessCollection/RangeReplaceableCollection支持丰富的集合操作方法popLast()、pop(from:)、pop(to:)、按下标访问等与栈内容自省feature 数据无需Hashable库在底层为每个元素管理稳定 ID 并据此自动派生哈希值。StackState在“运行时灵活性与编译期静态保证”之间提供了更好的平衡是 TCA 中建模导航栈的推荐工具。UIKit 支持NavigationStackControllerTCA 还提供NavigationStackController让UINavigationController也能以状态驱动的方式使用。只要领域已按StackState建模即可实现栈控制器class AppController: NavigationStackController { private var store: StoreOfAppFeature! convenience init(store: StoreOfAppFeature) { UIBindable var store store self.init(path: $store.scope(\.path, action: \.path)) { RootViewController(store: store) } destination: { store in switch store.case { case .addItem(let store): AddViewController(store: store) case .detailItem(let store): DetailViewController(store: store) case .editItem(let store): EditViewController(store: store) } } self.store store } }总结swift-composable-architecture 把导航统一抽象为“状态的存在性”存在性用Optional表达即 tree-based工具为Presents()宏 PresentationActionifLet简洁、可穷举、模块自包含、集成测试容易但对复杂递归路径与编译性能不友好存在性用集合表达即 stack-based工具为StackStateStackActionforEach 专用NavigationStack初始化器擅长复杂递归路径、特征解耦、稳定性更好但允许无意义路径、预览与集成测试受限两者均可通过Dependency(\.dismiss)在 reducer 内实现子 feature 自关闭通过TestStore穷尽或非穷尽完成高保真集成测试实践中多数应用以 stack 承载 drill-down 主干用 tree-based 承载 sheet、popover、alert 等模态导航。相关文档均可继续深入导航总览见 Navigation.md两种风格的完整教程见 TreeBasedNavigation.md 与 StackBasedNavigation.mddismiss依赖的源码见 Dismiss.swift实际应用范例可参考 04-NavigationStack.swift 与 04-Navigation-Multiple-Destinations.swift。赞分享前端移动开发【免费下载链接】swift-composable-architectureA library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind.项目地址https://gitcode.com/GitHub_Trending/sw/swift-composable-architecture点击查看免费下载相关推荐Gatsby 多主题组合实战在 gatsby-config.js 中组合 gatsby-theme-blog 与 gatsby-theme-notesGatsby 多主题组合实战在 gatsby config.js 中组合 gatsby theme blog 与 gatsby theme notes Gat前端移动开发深入理解 swift-composable-architecture 的状态驱动导航树形与栈式两大范式解析深入理解 swift composable architecture 的状态驱动导航树形与栈式两大范式解析 本文聚焦 swift composable arc前端移动开发Zephyr RTOS 上手 Infineon KIT_T2G-B-H_LITETRAVEO T2G Body High 三核评估板的构建、烧录与调试实战Zephyr RTOS 上手 Infineon KIT_T2G B H_LITETRAVEO T2G Body High 三核评估板的构建、烧录与调试实战 K前端移动开发上一篇如何精通Modern C从C11到C23的终极特性对比指南下一篇OpenSpec与敏捷开发如何在迭代中应用规范驱动方法 创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表