引言
iOS 9 之后,Apple 为 NSNotificationCenter 的 target-action 模式引入了 zeroing weak reference。当 observer 对象被释放后,系统自动将内部的 weak reference 置为 nil,不再向其投递通知,也不会产生野指针 crash。
Apple 文档对此也有明确说明:
If your app targets iOS 9.0 and later or macOS 10.11 and later, you don't need to unregister an observer in its dealloc method.
这条规则被广泛接受,许多团队因此在代码规范中不再严格要求管理 observer 生命周期。然而,"不会 crash"并不等于"没有性能影响"。本文通过一个真实的线上卡死案例,探讨 NotificationCenter observer 管理不当可能带来的隐性性能问题。
一个线上卡死案例
在一次线上卡死监控中,我们发现一类卡死的比例明显上升。主线程被卡住 13 秒,CPU 占用 98.8%,处于 running 状态(不是锁等待)。堆栈顶部如下:
scss
_weak_unregister_no_lock
_objc_moveWeak
__CFXNotificationRegistrarAddObserver
SomeTimeViewModel.componentMount() ← 调用 NotificationCenter.default.addObserver(...)
卡死发生在一个 ViewModel 的初始化阶段------调用 addObserver(self, selector:, name:, object:) 注册通知时。注册一个通知 observer 本身应该是一个非常轻量的操作,为什么会导致 13 秒的卡死?
经过排查,这个问题的根因并不是"忘记 removeObserver 导致 dead entries 累积"(事实上代码在 dealloc 链路中已经正确调用了 removeObserver),而是一个更容易被忽视的问题:同一个通知名下积累了过多的 live observers。
NotificationCenter 的内部机制
要理解这个问题,需要了解 NSNotificationCenter 在 iOS 9+ 中的内部机制。
注册表结构
当调用 addObserver:selector:name:object: 时,NotificationCenter 在内部维护一个注册表(registrar),按通知名称索引,存储所有注册信息。每条注册信息大致包括:
- 一个指向 observer 的 weak reference
- selector
- notification name
- object filter
这些信息存储在 CoreFoundation 内部的数据结构(类似于哈希表 + 数组)中。
扩容与 weak reference 迁移
和 HashMap 类似,NotificationCenter 的内部存储在容量不足时会扩容------分配更大的存储空间,将现有条目迁移到新位置。
对于包含 weak reference 的条目,迁移过程需要通过 ObjC runtime 的 objc_moveWeak 将 weak reference 从旧内存地址搬迁到新地址。这个操作涉及:
_weak_unregister_no_lock:从 runtime 的 side table 中注销旧地址_weak_register_no_lock:在 side table 中注册新地址
单次操作很快,但如果某个通知名下积累了大量条目,扩容时需要逐个迁移所有 live entries 的 weak reference,累积耗时就可能达到秒级。
两种问题模式
NotificationCenter 的 entries 膨胀来自两个方面,它们可以独立存在,也可以叠加:
模式一:dead entries 累积(不调 removeObserver 的短生命周期对象)
当 observer 被释放时,其 weak reference 自动置 nil,但注册条目本身不会被移除。对于频繁创建和销毁的对象(如 Feed 中的各类 Component),如果不在 deinit 中 removeObserver,NotificationCenter 会持续累积 dead entries。
模式二:live entries 过多(长生命周期对象大量注册同一通知)
即使每个 observer 都正确管理了 removeObserver,如果大量长生命周期对象同时注册同一个通知,live entries 的数量本身就可能很大。
案例分析
回到开头的卡死案例。我们排查后发现,触发卡死的通知名 .tabBarDidChangeSelectedIndex 在整个 App 中有 31 个文件 注册了 observer,涵盖 Feed、社交、个人资料页、IM、Notice、电商 等几乎所有主要模块。
在 IM 模块的 会话 列表中,架构设计如下:
- 每个会话对应一个持久化的 ViewModel(存储在字典中,不会被频繁销毁)
- 每个 ViewModel 在 init 时创建一棵包含 50+ 子组件的组件树
- 其中 ViewModel 本身和 TimeViewModel 各注册了一次
.tabBarDidChangeSelectedIndex
对于一个有 200 个会话的用户,仅 会话 列表就贡献了 400 个 live observers 。这些 observer 的生命周期管理是正确的(dealloc 时通过组件树的 unmount 链路移除),但它们的数量本身就是问题。
再叠加 App 其他模块的 observer(包括可能存在的 dead entries),这个通知名下的总 entries 数量相当可观。当新增一个 observer 触发内部存储扩容时,迁移所有 entries 的累积耗时就造成了 13 秒的卡死。
被忽视的关键点
这个案例有一个容易被忽视的教训:即使 observer 生命周期管理完全正确(deinit 中有 removeObserver),也不意味着没有性能风险。 问题不在于单个 observer 的正确性,而在于同一个通知名下的 observer 总量。
哪些场景容易踩坑
1. 热门通知名 + 大量模块共同注册
像 Tab 切换、App 前后台、网络状态变化这类全局通知,往往被 App 中大量模块同时监听。每个模块的注册看起来都合理,但总量可能超出预期。
2. 持久化对象在 init 阶段无差别注册
如果一个对象会存在很久(如 1:1 对应数据模型的 ViewModel),且在 init 阶段就注册通知,那么所有实例的 observer 都会持续存在。即使只有屏幕上可见的几个实例真正需要响应通知,其余实例的注册也在白白增加 entries 总量。
3. 短生命周期对象不调 removeObserver
对于频繁创建和销毁的对象(如 Feed 滑动过程中的各种 Component),如果不在 deinit 中 removeObserver,每次销毁都会留下一个 dead entry。随着用户使用时间增长,dead entries 不断累积。
4. 组件树放大效应
在 TTKC 等组件化框架中,一个容器可能包含数十个子组件,每个子组件可能独立注册通知。容器的数量 × 子组件的数量 = 总 observer 数量,放大效应显著。
建议
按需注册:只为可见的实例注册 observer
对于列表中的 ViewModel,如果通知只用于更新 UI 展示(如刷新时间文本),那么只有屏幕上可见的实例才需要注册。可以在 Cell 即将显示时注册,在不可见时移除:
swift
override func cellWillDisplay() {
super.cellWillDisplay()
NotificationCenter.default.addObserver(self, selector: #selector(onTabBarChange(_:)),
name: .tabBarDidChangeSelectedIndex, object: nil)
}
override func cellDidEndDisplay() {
super.cellDidEndDisplay()
NotificationCenter.default.removeObserver(self, name: .tabBarDidChangeSelectedIndex, object: nil)
}
对于 200 个会话的用户,这将 observer 数量从 200 减少到 ~10-20(可见 Cell 数量)。
集中式 observer:N 个独立注册 → 1 个集中处理
如果多个同类对象都需要响应同一个通知,考虑用一个集中式 observer 替代 N 个独立注册:
swift
// 在 DataController 中注册一次
NotificationCenter.default.addObserver(self, selector: #selector(onTabBarChange(_:)),
name: .tabBarDidChangeSelectedIndex, object: nil)
@objc func onTabBarChange(_ notification: NSNotification) {
for viewModel in viewModelDict.values {
viewModel.handleTabBarChange()
}
}
N 个 observer 注册变为 1 个,彻底消除了这个通知名下的数量问题。
对于短生命周期对象:在 deinit 中 removeObserver
swift
deinit {
NotificationCenter.default.removeObserver(self)
}
这一行代码的作用不是防 crash(iOS 9+ 不需要),而是及时清理 NotificationCenter 内部的注册条目,避免 dead entries 累积。
考虑使用 block-based API + 显式 token 管理
block-based API 返回一个 opaque token,移除时通过 token 精确定位,语义更清晰:
swift
private var observerToken: NSObjectProtocol?
func setup() {
observerToken = NotificationCenter.default.addObserver(
forName: .someNotification, object: nil, queue: .main
) { [weak self] _ in
self?.handleNotification()
}
}
deinit {
if let token = observerToken {
NotificationCenter.default.removeObserver(token)
}
}
需要注意的是,block-based API 不使用 zeroing weak reference------如果 block 中 strong capture 了 self,会导致循环引用。block 中必须使用 [weak self],且必须在合适时机 remove token。
在 Code Review 中关注
建议在 Code Review 中对以下模式保持敏感:
- 这个通知名在 App 中有多少处注册?是否是"热门通知"?
- 注册 observer 的对象有多少个实例同时存在?
- 是否在 init 阶段就注册,但实际上只在可见时才需要?
- 短生命周期对象是否在 deinit 中调了 removeObserver?
小结
NotificationCenter 的性能问题有两个维度:
- 单个 observer 的生命周期管理:短生命周期对象不调 removeObserver,导致 dead entries 累积
- 同一通知名下的 observer 总量:即使每个 observer 都正确管理了生命周期,大量 live observers 本身就是性能风险
第一个问题比较符合直觉,容易在 Code Review 中发现。第二个问题更隐蔽------每个模块的注册看起来都合理,但当一个大型 App 中有几十个模块同时注册同一个通知时,总量就可能超出 NotificationCenter 内部数据结构的性能安全边界。
Apple 的"不需要 removeObserver"是关于正确性 的保证,不是关于性能的保证。在大型 App 中,NotificationCenter observer 需要像内存一样被视为一种有限资源来管理。