前言:做了一个 iOS 专注计时工具,聊聊架构上的一次冒险
我在做一个叫「声境护照」的 iOS App——简单说就是「白噪音 + 番茄钟 + 游戏化成长」的专注计时工具。开发过程中,我做了一个比较大胆的决定:把项目里所有 ViewModel 从 class 换成 struct,彻底干掉了 @Published 和 Combine pipeline。
结果是代码量少了差不多三分之一,测试变得极其简单——但中间有个坑差点让我全部回滚。
这篇文章完整记录这次重构的动机、做法、踩坑和收获,希望对同样在用 SwiftUI 做项目的朋友有参考价值。
为什么要干掉 ObservableObject
项目初期我按 SwiftUI 社区最常规的套路写:每个页面一个 class ViewModel,继承 ObservableObject,里面塞一堆 @Published 属性,View 用 @StateObject 持有。
写到第三个页面的时候,我开始觉得不对劲。
问题很具体:
- 重复管道代码太多。 每个 ViewModel 的数据来源其实都是同一个全局
AppStore,但各自要写订阅、写转换,Combine pipeline 写了一堆,实际干的事无非是从 AppStore 读数据算一下。 -
- 测试成本高。 我只想验证「给定这组 FocusLog,算出来的统计数据对不对」,但 mock 一个
ObservableObject需要处理 publisher、等异步回调,一个简单的断言被包在一堆样板代码里。
- 测试成本高。 我只想验证「给定这组 FocusLog,算出来的统计数据对不对」,但 mock 一个
-
- 生命周期心智负担。 每个 class ViewModel 都要操心自己的生命周期——什么时候创建、什么时候销毁、订阅什么时候取消,而这些其实跟我的业务逻辑毫无关系。
某天晚上我试着把一个 ViewModel 从 class 改成 struct,发现跑得通。然后花了一个周末,把全部 6 个页面的 ViewModel 都换掉了。
- 生命周期心智负担。 每个 class ViewModel 都要操心自己的生命周期——什么时候创建、什么时候销毁、订阅什么时候取消,而这些其实跟我的业务逻辑毫无关系。
改完之后长什么样
整个 App 的数据架构变成了这样:
- 一个
AppStore(class,作为唯一的全局状态源,这个没动) -
- 6 个 struct ViewModel,全部只读引用 AppStore,通过 computed property 做数据转换
以统计页面的 ViewModel 为例:
- 6 个 struct ViewModel,全部只读引用 AppStore,通过 computed property 做数据转换
struct StatsSheetViewModel {
let store: AppStore
let rangeKey: StatsRangeKey
private var logs: [FocusLog] {
store.focusLogs.isEmpty
? StatsService.createDemoFocusLogs()
: store.focusLogs
}
var stats: StatsData {
StatsService.buildStatsData(
focusLogs: logs,
streakDays: store.streakDays,
rangeKey: rangeKey,
now: Date(),
isDemo: store.focusLogs.isEmpty
)
}
var growth: GrowthProfile {
GrowthService.buildGrowthProfile(
focusLogs: logs,
streakDays: store.streakDays
)
}
}
```
6 个 ViewModel 风格完全统一:持有 `store` 引用,暴露几个 computed property,没有可变状态,没有 init 里的副作用。
**测试的时候怎么写?** 塞一个预填好数据的 `AppStore` 进去,直接断言 `.stats` 的返回值。不需要等异步,不需要 mock publisher,一个测试函数五六行搞定。这种体验一旦尝过就回不去了。
## 差点让我全部回滚的坑
问题出在 **UI 临时状态**上。
统计页面有个时间范围切换器(7天 / 30天 / 全部),用户点了之后要记住当前选的是哪个 tab。我下意识就想在 ViewModel 里加一个 `var selectedRange: StatsRangeKey`。
编译没报错。但运行的时候发现:切换之后 View 刷新,struct 被重新创建,`selectedRange` 回到默认值。
原因很简单——**struct 是值类型,SwiftUI 的 View 重建会重新构造它**。class 不会有这个问题,因为引用类型的实例活在堆上,View 重建只是拿到同一个引用。
第一反应是「完了,这条路走不通」。冷静了一下想了想,这个 `selectedRange` 本质上是 **View 的 UI 状态**,跟业务数据没关系,它就不应该出现在 ViewModel 里。
最终方案是严格区分两种状态:
- **struct ViewModel 只负责「给定输入,算出输出」**,纯函数思维,零可变状态
- - **UI 临时状态全部用 View 层的 `@State` 管理**(选中的 tab、展开/折叠、输入框内容等)
- - **View 把 `@State` 的值作为参数传给 ViewModel**(通过 init 参数或 computed property 参数)
想清楚这个边界之后,再没遇到过类似问题。而且我发现这个约束反过来帮我理清了架构——以前 ViewModel 里混着业务逻辑和 UI 状态,现在泾渭分明。
## 远征系统中 Definition/State 分层的设计
再聊一个我觉得挺值得分享的设计。App 里有个远征系统——完成指定次数或时长的专注,就能推进到下一个城市章节,这是游戏化成长的核心玩法。
一开始任务定义和用户进度混在同一个 struct 里。加第三个城市的时候改得很痛苦,因为根本分不清哪些字段是静态配置、哪些是运行时数据。最后重构拆成了两套模型:
```swift
// 静态配置:描述「这个任务要求什么」
struct ExpeditionMissionDefinition: Identifiable, Codable {
let id: String
let title: String
let kind: ExpeditionMissionKind // .sessionCount / .focusMinutes / .deepFocusCount
let targetValue: Int
let rewardMiles: Int
}
// 运行时状态:记录「用户当前进度」
struct ExpeditionMissionState: Identifiable, Codable {
let id: String
var progress: Int
var completedAt: Date?
var completed: Bool { completedAt != nil }
}
```
两者通过 `id` 关联。`Definition` 从 JSON 配置文件读取,全部是 `let`;`State` 存本地持久化,用 `var`。新增一个城市章节就是加一段 JSON,状态层零改动。
这个拆法跟 struct ViewModel 的思路是一脉相承的——**把不变的东西和会变的东西分开放**。Definition 像 ViewModel 一样是只读的数据转换层,State 才是真正需要持久化和变更的部分。`ExpeditionMissionKind` 用枚举区分任务类型,以后加新任务类型只需要加一个 case 和对应的进度计算逻辑,Definition 结构不用动。
## 这套方案适合什么项目
说几个前提条件,不满足的话建议慎用。
**适合的情况:**
- 有一个单一数据源(全局 Store 或 Redux 风格的状态管理)
- - ViewModel 的主要工作是「读数据 → 转换 → 给 View 展示」
- - 页面不需要在 ViewModel 层持有复杂的可变状态
**不太适合的情况:**
- ViewModel 需要管理网络请求的生命周期(取消、重试、loading 状态流转)
- - 页面有大量交互状态且互相依赖
- - 团队已经习惯 MVVM + Combine 那套,贸然切换反而增加认知负担
声境护照刚好属于前者:数据从 AppStore 流出来,ViewModel 做纯转换,View 负责展示和管理 UI 状态。没有复杂的异步编排,struct 完全够用。
## 总结
这个项目还在早期,很多东西在摸索。struct ViewModel 这套方案用了几个月,目前我自己还挺满意:
- 代码量实打实少了三分之一左右
- - 测试从「写起来烦」变成「写起来爽」
- - 架构上「业务数据 vs UI 状态」的边界被强制理清了
但我也不确定项目复杂度继续上升之后会不会撑不住——目前 6 个页面,如果扩展到 15-20 个页面,或者加入复杂的网络请求层,可能需要重新评估。
最后抛个问题:你在 SwiftUI 项目里用 struct 还是 class 做 ViewModel?如果用 class,有没有什么轻量化的写法可以避免 Combine 的模板代码?欢迎评论区交流。
1万+



被折叠的 条评论
为什么被折叠?



