SwiftUI 项目里我把所有 ViewModel 换成了 struct,干掉了全部 @Published 和 Combine pipeline——代码少了差不多三分之一,但有个坑差点让我全部回滚。
这个实践来自我正在做的一个 iOS 专注计时工具,叫声境护照,大概理解成「白噪音 + 番茄钟 + 游戏化成长」就行。下面聊聊具体怎么做的,以及哪些地方摔了跤。
为什么不用 ObservableObject
项目初期我按常规套路写:每个页面一个 class ViewModel,继承 ObservableObject,里面一堆 @Published 属性,View 用 @StateObject 持有。
写到第三个页面的时候我开始烦了。
每个 ViewModel 都要管自己的生命周期,数据来源是同一个全局 AppStore,但各自订阅、各自转换,Combine pipeline 写了一堆,实际干的事就是从 AppStore 读数据算一下。更烦的是测试——mock 一个 ObservableObject 需要处理 publisher,而我只想验证「给定这组 FocusLog,算出来的统计数据对不对」。
某天晚上我把一个 ViewModel 从 class 改成了 struct 试试,发现跑得通。然后花了一个周末全部换掉了。
实际长什么样
整个 App 有一个 AppStore(这个还是 class,作为全局状态源),6 个页面各有一个 struct ViewModel,全部只读引用 AppStore:
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 临时状态上。
StatsSheet 里有个时间范围切换器(7天 / 30天 / 全部),用户点了之后要记住当前选的是哪个。我下意识就想在 ViewModel 里加一个 var selectedRange: StatsRangeKey。
编译没报错,但运行的时候发现:切换之后 View 刷新,struct 被重新创建,selectedRange 回到默认值。struct 是值类型,SwiftUI 的 View 重建会重新构造它。
第一反应是「完了,这条路走不通,得换回 class」。冷静了一下想了想,这个 selectedRange 本质上是 View 的 UI 状态,跟业务数据没关系,它就应该活在 View 层。
最终方案:
- struct ViewModel 只负责「给定输入,算出输出」,纯函数思维
- UI 临时状态全部用 View 层的
@State管理 - View 把
@State的值传给 ViewModel 的 init 参数或 computed property 的参数
说白了就是严格区分两种状态:业务数据从 AppStore 流过来,UI 状态在 View 上自己管。想清楚这个边界之后,再没遇到过类似问题。
远征系统里 Definition/State 分层的收益
聊另一个我觉得挺值得分享的设计。App 里有个远征系统——完成指定次数或时长的专注,推进到下一个城市章节。
一开始任务定义和用户进度混在同一个 struct 里。加第三个城市的时候改得很痛苦,因为分不清哪些字段是配置、哪些是运行时数据。重构拆成两套模型:
// 静态配置:描述「这个任务要求什么」
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。如果以后要加「连续天数」之类的新类型,加一个 case 再写对应的进度计算逻辑就行,Definition 的结构不用动。
这套方案适合什么项目
说几个前提条件,不满足的话建议别用:
适合的情况:
- 有一个单一数据源(全局 Store / Redux 风格的状态管理)
- ViewModel 的主要工作是「读数据 → 转换 → 给 View 展示」
- 页面不需要在 ViewModel 层持有复杂的可变状态
不太适合的情况:
- ViewModel 需要管理网络请求的生命周期(取消、重试、loading 状态)
- 页面有大量交互状态且互相依赖
- 团队习惯 MVVM + Combine 那套,换了反而增加认知负担
我的 App 刚好属于前者:数据从 AppStore 流出来,ViewModel 做转换,View 展示。没有复杂的异步编排,struct 够用。
写在最后
这个项目还在早期,很多东西在摸索。struct ViewModel 这套方案用了几个月,目前我自己还挺满意——代码量确实少了,测试写起来也痛快。但我不确定项目复杂度上来之后会不会撑不住。
有个问题想听听大家的看法:你在 SwiftUI 项目里用 struct 还是 class 做 ViewModel?如果用 class,有没有什么轻量化的写法可以避免 Combine 的模板代码?评论区聊聊。