iOS 17 迁移踩坑:@Observable 换掉 ObservableObject 后,我的 SwiftUI 列表从 60 帧掉到 43 帧

🔑 关键词:SwiftUI,@Observable,ObservableObject,性能优化,iOS 17

📖 摘要:一次真实的 @Observable 迁移事故复盘:47 个类全量替换,三天排期变成两周半 debug,Instruments 显示 ProductCell 的 body 在 20 秒滚动里被调用 11000 多次。记录排查过程、Observation 框架真实的追踪粒度、withObservationTracking 的 one-shot 陷阱,以及最后把单一大 store 拆成 6 个 @Observable 类的做法。

iOS 17 迁移踩坑:@Observable 换掉 ObservableObject 后,我的 SwiftUI 列表从 60 帧掉到 43 帧

图片

去年 11 月把 App 最低版本从 iOS 16 提到 17,我顺手做了件现在想起来挺蠢的事——一次性把项目里 47 个 ObservableObject 全换成 @Observable

动机很朴素。WWDC23 那场 Discover Observation in SwiftUI 我看了两遍,Apple 反复讲"自动追踪属性访问,只重算真正依赖它的视图"。我理解成:依赖收集更细 → body 重算更少 → 滑动更流畅。于是在排期里塞了三天,觉得能顺手干完。实际花了两周半,其中一周半在查一个掉帧。测试同学报的是:商品瀑布流滑动时掉帧,iPhone 12 + iOS 17.2 上平均 43 fps,最低 28 fps。动之前这个页面是稳定 59-60 的,SE 3 上更惨,最低掉到 21。


查的过程没什么技术含量,就是用 Instruments 的 SwiftUI 模板跑。Xcode 15 之后这个模板多了 View Body 这一栏,能直接看到每个 View 的 body 被调了多少次、总耗时多少。

20 秒的滚动,ProductCell 的 body 被调了 11000 多次。这个数字一眼不对——屏幕内常驻 9 个 cell,滚 20 秒大概新建 200 个左右,正常应该在 400-600 次量级。

图片

顺手加了 Self._printChanges()(在 body 第一行写 let _ = Self._printChanges() 就行),控制台刷得飞快,几乎每帧都在打 ProductCell: _store changed.

问题出在我给商品曝光埋点写的一段逻辑。为了统计"用户实际看到多深",我在列表外面套了层 GeometryReader + PreferenceKey,滚动时把可见区域偏移量写回 store 上的一个属性:

.onPreferenceChange(ScrollDepthKey.self) { depth in
    store.scrollDepth = depth
}

store 是通过 @Environment(Store.self) 注入的。ProductCell 的 body 里读了几次 store(汇率符号、会员标记、库存状态),于是每个 cell 都成了 scrollDepth 的观察者。60fps × 20 秒 = 1200 次写入,乘以屏幕上那 9 个 cell,差不多就是 10800 次。

这是第一次让我意识到:ObservableObject 时代这个问题也存在(objectWillChange 一发全炸),但那时候我清楚 store 是"整体一发",会本能地避免在 cell 里直接读它。换成 @Observable 之后我产生了一种幻觉,觉得"它只追踪我真正读到的属性",于是放松了警惕。

图片


这里得把 @Observable 到底追踪什么说清楚。它追踪的是"你在 body 求值期间访问过的具体属性"——读 store.currencySymbol 只注册 currencySymbol 这一个 keyPath,改 store.pendingEvents 不会让这个 cell 重算。这一点 Apple 没骗人。

但有两个口子。

一,读大对象上的任何属性,依赖都挂在那个大对象上。 我的 store 是个 900 多行的 God Object,管汇率、会员、购物车、库存、埋点队列、特性开关,一共 63 个存储属性。ProductCell 读的是 currencySymbolisVip,看起来人畜无害。但埋点那个 scrollDepth 也在同一个对象上,每帧改一次,registrar 里的所有观察者全被通知——Observation 框架不做新旧值比较,withMutation 一调用就通知,没有 Equatable 短路。

二,计算属性和方法调用会把依赖藏起来。 后来我把 scrollDepth 挪到单独的 ScrollTracker 类里,情况好了一半但没完全好。因为 store 上有个方法:

func displayPrice(for product: Product) -> String {
    let base = product.price * exchangeRate   // 依赖 exchangeRate
    if isVip { return base * 0.95 }           // 依赖 isVip
    return base
}

图片

body 里写一句 Text(store.displayPrice(for: product)),表面上只"调用了一个方法",实际上往注册表里塞了 exchangeRateisVip 两个 keyPath。方法体里访问了什么就注册什么。项目里这类方法有二十几个,我一个个翻出来才把依赖理清。


迁移过程中还踩了个更隐蔽的坑,跟性能无关:withObservationTrackingonChange 只会被调用一次。

我写了个调试工具想监听 store 变化打日志:

withObservationTracking {
    _ = store.scrollDepth
} onChange: {
    print("scrollDepth changed")
}

图片

跑起来只打了一次,之后再改就没反应。翻文档才确认,这个 API 设计上就是 one-shot,onChange 触发完观察就失效了,要持续监听必须在外层递归重新注册。文档里写了,但埋在一段不太显眼的话里,我查了半天 Stack Overflow 才确认不是自己写错。

这个设计其实挺合理(避免野生观察者泄漏),但对刚从 Combine 过来的人不友好。store.$scrollDepth.sink { } 是持久订阅,语义完全不一样。


最后说结论,可能跟主流不太一样。

@Observable 本身没问题,观察粒度确实比 ObservableObject 细,代码也短很多——我删掉了 47 个类里所有的 @Published,净减 800 多行。@Environment(Store.self) 代替 @EnvironmentObject,编译期就能查错。这些收益是实打实的。

图片

问题在于它把依赖从显式变成了隐式。ObservableObject 时代,一个 view 依赖什么,扫一眼它引用的 @Published 属性就能列出来。@Observable 之后,依赖藏在一层层方法调用、计算属性、甚至数组下标访问里,code review 根本看不出来。

我现在的做法是:

  • 先跑一遍 Instruments 的 SwiftUI 模板,把 View Body 调用次数从高到低排,Top 5 就是嫌疑对象
  • 在嫌疑 view 的 body 第一行加 let _ = Self._printChanges(),看是谁在推它
  • 顺着推它的人往上找,看是不是某个高频写入的属性挂在共享对象上
  • 如果是,把这个属性挪到单独的 @Observable 类里,用 @Environment 单独注入

按这个思路,我把 store 拆成了 6 个小 @Observable 类(用户、汇率、购物车、库存、埋点、开关),ProductCell 只注入它真正需要的两个。改完 iPhone 12 回到 58-59 fps,SE 3 是 55-57。没回到当初的绝对 60,但这 1-2 帧的差距我说不清是 @Observable 的锅还是 iOS 17 本身在 A14 上的调度策略变化。

如果你也在做这个迁移,我的建议是:先别全量换。挑一个层级浅、依赖清晰的模块试,把拆大 store 这件事放前面。不然你会像我一样,用一个三天排期的重构,换来两周半的 debug。

🏷️ 标签: