struct ViewModel 全替代 ObservableObject:我在 iOS 专注 App 里的实践与踩坑

2 阅读5分钟

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 配置文件读取,全部是 letState 存本地,用 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 的模板代码?评论区聊聊。