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 读的是 currencySymbol 和 isVip,看起来人畜无害。但埋点那个 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)),表面上只"调用了一个方法",实际上往注册表里塞了 exchangeRate 和 isVip 两个 keyPath。方法体里访问了什么就注册什么。项目里这类方法有二十几个,我一个个翻出来才把依赖理清。
迁移过程中还踩了个更隐蔽的坑,跟性能无关:withObservationTracking 的 onChange 只会被调用一次。
我写了个调试工具想监听 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。