普通视图

发现新文章,点击刷新页面。
昨天以前首页

做中后台业务,为什么我不建议你用 Tailwind CSS?

作者 ErpanOmer
2026年4月14日 10:33

1___f27S-qQF2CAASt5bOwqg.png

大家好,我又来了😁

最近我接手了一个隔壁组转过来的中后台重构项目。

交接的时候,对方的技术负责人特意跟我强调,说这个项目采用了最新的技术栈,全面拥抱了 Tailwind CSS,开发体验极其丝滑。

我当时心里还挺期待,毕竟这两年 Tailwind 的风刮得太大了,各种国内外大佬都在疯狂带货。结果周末我抽空把代码拉下来,打开 VSCode 准备梳理一下业务主流程。盯了屏幕不到十分钟,我感觉自己的眼睛快瞎了。

光说理论没意思,我直接给你们截取一段真实的、承载了各种表单校验和状态联动的业务侧边栏组件。你们自己品鉴一下所谓的极致开发体验👇:

const OrderCard = ({ order, isAdmin, isExpanded }) => {
  return (
    <div 
      className={`flex flex-col w-full p-5 mb-4 border rounded-lg shadow-sm transition-all duration-300 ${
        isAdmin ? 'bg-red-50 border-red-200' : 'bg-white border-gray-200'
      } ${
        isExpanded ? 'max-h-[800px]' : 'max-h-24 overflow-hidden'
      } hover:shadow-md cursor-pointer`}
    >
      <div className='flex items-center justify-between pb-3 mb-3 border-b border-gray-100'>
        <span className='text-sm font-semibold text-gray-800 truncate w-[60%]'>
          {order.id}
        </span>
        <span 
          className={`px-2 py-1 text-xs rounded-full ${
            order.status === 'PAID' 
              ? 'bg-green-100 text-green-700' 
              : 'bg-orange-100 text-orange-700'
          }`}
        >
          {order.statusText}
        </span>
      </div>
      {/* 内部极其复杂的业务字段渲染... */}
    </div>
  );
}

代码跑得通吗?当然跑得通。UI 还原度高吗?也挺高。 但是作为接下来要维护这个项目的组长,我只觉得一阵头皮发麻。

很多前端新人,甚至是做惯了 C 端 独立开发的兄弟,对 Tailwind 简直是顶礼膜拜。因为它不用取名字,不用在 JSCSS 文件之间来回切换,写起来确实有快感。

但作为趟过无数中后台项目的深水区,我今天必须给这股跟风热潮泼一盆冷水: 在绝大多数重型中后台业务场景里,Tailwind CSS 并不是什么神兵利器,而是给后期维护带来不方便。

来,咱们拿真实代码说话,看看它到底是怎么摧毁中后台工程的👇。


彻底掩盖你的业务主线?

做中后台系统,最难的从来不是画 UI,而是处理极度复杂的数据状态流转。

上面那段代码最大的问题在于信噪比极低。作为一个接手代码的前端,我点开这个文件,首先想看的是:这个组件在不同权限、不同展开状态下,业务逻辑是怎么走的?

但在 Tailwind 的体系下,我的视线全被 flexp-5mb-4max-h-[800px] 这种毫无业务价值的视觉原子类给强暴了。如果退回到老古董的 CSS Modules 方案,这段代码在 JS 侧应该长什么样?咱们对比一下:

import classNames from 'classnames';
import styles from './OrderCard.module.less';

const OrderCard = ({ order, isAdmin, isExpanded }) => {
  return (
    <div 
      className={classNames(styles.orderCard, {[styles.adminMode]: isAdmin,
        [styles.expanded]: isExpanded
      })}
    >
      <div className={styles.cardHeader}>
        <span className={styles.orderId}>{order.id}</span>
        <span 
          className={classNames(styles.statusBadge, {
            [styles.statusPaid]: order.status === 'PAID',
            [styles.statusPending]: order.status === 'PENDING'
          })}
        >
          {order.statusText}
        </span>
      </div>
      {/* 业务字段一目了然 */}
    </div>
  );
}

发现区别了吗?重构后的 JSX 变得极其纯粹。我只通过 adminModeexpanded 这种类名,就极其清晰地传达了业务语义。至于那个订单编号到底占百分之几十的宽度,那是 UI 层 该关心的事情,它安静地待在 .less 文件里,绝不会来污染我的业务主逻辑。


跟现成的组件库水土不服

中后台业务是不可能脱离 Ant DesignElement Plus 这类重型组件库的。而组件库的本质,是封装好了一整套内部的 DOM 结构和 ClassName 规范。

这就带来了一个极其致命的冲突:当你用 Tailwind 去覆盖 Ant Design 的内部样式时,你会写出极其恶心的 Hack 代码😖。

比如产品经理要求:在这个特定页面里,把 Ant Design 表格的表头背景色改成浅蓝色,单元格的 padding 改小一点。

正常的 Less 做法是用样式穿透,精确打击:

/* 样式文件覆盖 */
.myCustomTable {
  :deep(.ant-table-thead > tr > th) {
    background-color: #f0f8ff;
    padding: 8px 16px;
  }
}

你知道那个拥抱 Tailwind 的小伙子是怎么写的吗?为了不写 CSS 文件,他强行使用了 Tailwind v3+ 的任意变体语法:

// Tailwind 强行覆盖组件库内部样式
<Table 
  className='[&_.ant-table-thead>tr>th]:bg-blue-50[&_.ant-table-thead>tr>th]:py-2 [&_.ant-table-thead>tr>th]:px-4 [&_.ant-table-tbody>tr>td]:text-gray-600[&_.ant-table-tbody>tr>td]:py-2' 
  dataSource={data} 
  columns={columns} 
/>

这段代码合进主分支的时候,我都替后续维护的兄弟感到悲哀😢。

这玩意儿连换行都没有,密密麻麻挤在一起。未来如果要做主题切换,或者升级 Ant Design 导致内部类名变了,谁敢去动这坨连正则都极难匹配的字符串?

不仅没有提高开发效率,反而为了强行凑 Tailwind 的语法,写出了一堆极难维护的代码。


负边距引发的问题

一行代码被写出来的成本,远远低于它在未来三年里被维护的成本。

Tailwind 本质上就是披着 ClassName 外衣的行内样式。它把所有的样式固化在了 HTML 结构上。

设想真实的维护场景,前任开发为了让一个按钮和旁边的输入框对齐,极其随意地写了一个向左偏移负间距的类名:

<Button className='-ml-2 mt-1'>提交</Button>

半年后你接手这个需求,产品要求在这俩元素中间加一个 Icon。你看着这个 -ml-2mt-1 会陷入极其痛苦的挣扎:他当时为什么要写负边距?是因为外层父元素加了错的 padding 导致的?还是为了抵消 Button 内部自带的 margin?🤷‍♂️

在传统 CSS 中,我们往往会留有注释说明抵消输入框自带的右侧留白。但在 Tailwind 里,没有注释的容身之所。

为了保证不出线上 Bug,你绝对不敢删掉那个 -ml-2。你会选择在它后面再打个补丁,加个 pl-4 试图把它顶回来。 第二年,另一个接手的同事遇到了错位,又在后面补了一个 mt-[-5px]🤣🤣🤣。

日积月累,HTML 标签上的类名越来越长,死代码和冲突代码全堆在 DOM 上,最终变成一座没人敢碰的屎山💩。


别瞎搞,先认清你的场景🫡

说了这么多,难道 Tailwind 真的就是垃圾吗?当然绝对不是。

如果你在做偏 C端 的炫酷落地页、做 SaaS 官网,或者你是独立开发者,没有沉重的历史包袱,不需要配合复杂的重型组件库,那 Tailwind 绝对是神作。它自带极其优秀的设计规范,能让你极速堆出好看的界面。

但咱们讨论的是中后台。中后台是干嘛的?团队十几个人来回交接,动辄几百个页面,充斥着极其复杂的表单联动和权限校验,生命周期长达五年甚至十年。

在这种重型项目中,保持业务逻辑的纯粹性,分离关注点,远比你少写几行 CSS 要重要一万倍。

好了,今天分享到这,谢谢大家😁

谢谢大家.gif

大型 iOS 工程单元测试 — 变更驱动测试与跨模块 Mock

作者 visual_zhang
2026年3月14日 22:44

一、定位

本文是 为大型 iOS 工程补充单元测试方法论补充篇。前文提供了"画链路 → 选节点 → 写测试 → 融入迭代"的完整框架,覆盖了基础的 Mock 策略和用例设计原则。

本文聚焦以下问题:

  • 当测试目标不是"从零覆盖"而是"验证一次代码变更"时,应如何设计用例?
  • 当被测方法依赖的实验开关位于另一个模块、通过 Service Locator 解析时,如何 Mock?
  • 当同一份数据有两个来源、且实验开关决定是否去重时,如何构造测试场景?
  • 当方法的聚合语义不是"求和"而是"计数"时,如何防止未来开发者误改?

二、变更驱动测试设计(Change-Driven Test Design)

2.1 原则

传统的"从零覆盖"思路是:遍历方法的所有分支,为每个分支写用例。而在实际业务迭代中,更常见的场景是你刚修改了一段逻辑,需要快速验证变更的正确性

变更驱动测试的核心思路:

识别本次变更引入的新分支或行为差异
为新分支写正向用例(验证新行为)
为旧分支写回归用例(验证未被破坏)
如果变更引入了新的数据源排除/包含逻辑,为排除和包含各写至少一条用例

2.2 双态特性开关测试

当一次变更由特性开关(Feature Flag)控制时,同一个方法在 flag=true 和 flag=false 下有不同行为。此时必须成对测试

用例类型 目的 示例
flag=true 正向 验证新行为生效 shouldCountUnreadByCell=true 时 notice groups 被跳过,unreadCount 仅含 interactor 贡献
flag=false 回归 验证旧行为未被破坏 shouldCountUnreadByCell=false 时 notice groups 仍参与累加
flag=true 复合 新行为 + 多种过滤条件叠加 flag=true + muted groups + shop groups + redPoint groups → 全部被跳过,仅剩 interactor

关键原则:回归用例的重要性不低于正向用例。开发者常犯的错误是只测了新路径而遗漏了旧路径的回归验证,有可能改坏了旧路径的功能而未及时发现。

2.3 案例

BizScenarioItemInboxTabNumber.checkHasNoticeTabbarUnreadCount: 在引入 shouldCountUnreadByCell 分支后,新增了 4 条用例:flag=true 跳过 notice groups、flag=true 时 cellCount 不受影响、flag=false 包含 notice groups(回归)、flag=true 复合场景。每条用例的 assertion message 都明确标注了 flag 状态和预期计算过程。


三、Service Center Protocol Mock(跨模块协议依赖的 Mock 策略)

3.1 问题场景

在 Service Locator 架构中,模块间的依赖通过协议(Protocol)解耦。被测代码通过 GET_CLASS(IMModuleService) 获取另一个模块提供的 Class,再调用其类方法。这带来了一个 Mock 难题:

  • IMModuleService协议,不是类,无法直接 swizzle
  • 协议的实现类位于另一个模块,测试工程可能没有链接该模块
  • 在测试环境中,Service Center 默认为空,GET_CLASS 返回 nil

3.2 解法:注册 Mock Class 到测试 Service Center

SwiftTestCaseserviceBehavior = .newCenter 会为每个测试创建隔离的 Service Center。利用基类提供的 mockGetStatelessProtocolService(_:andReturn:) 方法,将一个轻量 Mock 类注册到该 Center 中:

// 定义只实现所需类方法的 Mock 类
private class MockIMModuleServiceTrue: NSObject {
    @objc class func shouldCountUnreadByCell() -> Bool { return true }
}

// 在测试中注册
if let proto = NSProtocolFromString("IMModuleService") {
    mockGetStatelessProtocolService(proto, andReturn: MockIMModuleServiceTrue.self)
}

工作原理

被测代码: [GET_CLASS(IMModuleService) shouldCountUnreadByCell]
         ↓
GET_CLASS 查询 Service Center → 返回 MockIMModuleServiceTrue.class
         ↓
[MockIMModuleServiceTrue shouldCountUnreadByCell] → YES

3.3 与 Runtime Swizzle 的对比

维度 Runtime Swizzle Service Center Mock
适用场景 目标类已知且已链接 目标是协议,实现类不可见或位于其他模块
隔离性 全局替换,需手动恢复 仅在测试的隔离 Service Center 内生效,自动还原
tearDown 负担 必须手动调用 restore() 无需手动清理
限制 需要已知类名和方法签名 仅适用于通过 GET_CLASS / Service Locator 解析的依赖

3.4 适用准则

当被测方法通过以下宏/方式获取依赖时,优先使用 Service Center Mock:

  • GET_CLASS(Protocol) / GET_PROTOCOL(Protocol)
  • ServiceCenter.defaultCenter.getStatelessProtocolService()
  • 任何通过 Service Locator 模式解析的跨模块协议依赖

四、Fake Environment 模式(Context Protocol Mock)

4.1 问题场景

某些组件通过一个宽接口的 "Context" 协议获取运行时环境(数据字典、配置管理器、事件分发器等)。直接构造真实 Context 需要初始化整个管理器链路,测试成本极高。

4.2 解法:实现仅含测试所需数据的 Fake Context

private class MockUnreadCountContext: NSObject, UnreadCountContext {
    var mockEntranceCountModelDict: [String: InboxEntranceUnreadCountModel] = [:]
    var checkNeedUpdateCalled = false
    var lastCheckScene: BizScenarioItemCheckScene = []

    func entranceCountModelDict() -> [String: InboxEntranceUnreadCountModel]? {
        return mockEntranceCountModelDict
    }
    func checkNeedUpdate(_ scene: BizScenarioItemCheckScene) {
        checkNeedUpdateCalled = true
        lastCheckScene = scene
    }
    // 其余方法空实现
}

4.3 设计要点

要点 说明
只实现被测路径依赖的方法 非必需方法留空实现,降低维护成本
var 暴露可控数据 测试通过直接修改 mockEntranceCountModelDict 来控制输入
Spy 能力 添加 checkNeedUpdateCalled / lastCheckScene 等标记,验证被测方法是否正确触发了 Context 上的副作用
弱引用安全 Context 属性通常为 weak,确保 Mock 对象在测试期间被持有(存为实例属性)

4.4 与协议 Mock 对象的区别

前文的"协议 Mock 对象"聚焦于数据提供者(如 NoticeUnreadCountItemProtocol),每个 Mock 只需返回数值。Fake Environment 聚焦于运行时环境,需要同时提供数据字典、触发副作用(如 checkNeedUpdate:)、并可能被多个被测方法共享。


五、聚合语义测试(Aggregation Semantics Testing)

5.1 问题

聚合方法有两种常见语义,外部签名几乎相同,但行为差异大:

语义 含义 示例方法
Sum 将所有符合条件的项的值相加 countForNumber: → 返回 5 + 3 = 8
Count 统计符合条件且值 > 0 的项的个数 countForUnreadCell: → 返回 2(有 2 项非零)

如果未来开发者误将 Count 语义改为 Sum 语义(或反过来),逻辑上仍然"能跑通",但业务含义错误。

5.2 方法:用数据设计锁定语义

构造让 Sum 和 Count 结果必然不同的测试数据,使得任何语义变更都会导致断言失败:

// count 为 5 和 3 → Sum = 8, Count = 2
// 如果断言 Count == 2,则改为 Sum 后结果变为 8,测试失败
func test_countForUnreadCell_countsEntrancesNotSumsCount() {
    mockContext.mockEntranceCountModelDict = [
        combinedKey(1): makeEntranceModel(entranceID: 1, count: 5),
        combinedKey(2): makeEntranceModel(entranceID: 2, count: 3),
    ]
    XCTAssertEqual(item.count(forUnreadCell: nil), 2,
                   "Cell count = number of entrances with unread, not sum of counts")
}

关键:选择每项 count > 1 的数据。如果所有 count 都为 1,则 Sum 和 Count 结果相同,无法区分语义。

5.3 推广

此方法适用于所有存在语义歧义的聚合操作:

  • Max vs Sum:确保数据中有多项,且各项值不同
  • Any vs All:确保数据中有 true 和 false 的混合
  • Distinct count vs Total count:确保数据中有重复项

六、数据源去重测试(Deduplication Testing)

6.1 问题

当同一份业务数据通过两个独立渠道到达聚合点时(如 notice_countentrance_count 都包含通知未读数),需要在特定条件下去重,否则会出现重复计算。

6.2 测试策略

构造"两个渠道都有数据"的场景,验证在去重开关开启时只有一路生效:

dataSource (notice groups) = { group100: 10, group200: 3 }
interactor (entrance items) = countForNumber: 5

shouldCountUnreadByCell=true  → result = 5  (只用 interactor,跳过 dataSource)
shouldCountUnreadByCell=false → result = 18 (dataSource 13 + interactor 5)

设计要点

  • 两路数据都给非零值,使得去重与不去重的结果有明显差异
  • 对去重路径和非去重路径各写至少一条用例
  • Assertion message 中明确标注"哪一路被跳过"以及"预期计算过程"

七、变更传播测试(Mutation-Aggregation Vertical Slice)

7.1 问题

底层数据的变更(标记已读、静音)需要正确传播到上层的聚合结果中。仅测试"model.count 被置 0"是不够的,因为聚合层可能因为缓存、过滤条件等原因未感知到变更。

7.2 方法:跨层断言

在一个测试用例中同时操作底层和观察上层,形成"垂直切片":

func test_updateMuteStatus_muteExcludesFromCount() {
    // 底层:构造 model
    let model = makeEntranceModel(entranceID: 1, count: 5)
    setUpEntranceModels([combinedKey(1): model])

    // 上层:构造 aggregation item,共享同一个 model
    let countItem = InboxEntranceUnreadCountItem()
    let ctx = MockUnreadCountContext()
    ctx.mockEntranceCountModelDict = [combinedKey(1): model]
    countItem.context = ctx

    XCTAssertEqual(countItem.count(forNumber: nil), 5, "Before mute")

    // 执行变更
    service.updateMuteStatus(true, forEntranceID: 1, subEntranceKey: nil)

    // 验证传播:上层聚合结果反映了底层变更
    XCTAssertEqual(countItem.count(forNumber: nil), 0, "After mute, excluded from count")
}

7.3 适用场景

  • 标记已读 → 未读数归零
  • 静音 → 从聚合计算中排除
  • 归档 → 从聚合计算中排除
  • 任何"底层状态变更应影响上层输出"的链路

7.4 与纯单元测试的关系

垂直切片测试严格来说介于单元测试和集成测试之间。在大型工程中,它的性价比很高:不需要启动完整的 Service 链路,但能验证两层之间的契约是否正确。推荐在以下情况使用:

  • 两层通过共享可变对象(同一个 model 实例)交互
  • 上层的聚合逻辑包含过滤条件(muted、archived 等),变更后的状态可能被过滤

八、短路行为测试(Short-Circuit Testing)

8.1 问题

某些遍历方法在找到第一个匹配项后会 stop*stop = YES),不再继续遍历。如果去掉 stop,方法签名和大部分行为不变,但在存在多个同类型 item 时会错误地累加。

8.2 方法:构造多个同类型 item,验证只取第一个

func test_countForNumberWithType_stopsAfterFirstMatch() {
    addMockItem(showType: .number, countForNumber: 10, itemType: .entranceCountItem)
    addMockItem(showType: .number, countForNumber: 5, itemType: .entranceCountItem)
    XCTAssertEqual(
        interactor.count(forNumber: nil, withUnreadCountItemType: .entranceCountItem), 10,
        "Should stop after first matching item"
    )
}

如果 stop 被移除,结果会变为 15,测试失败。

8.3 适用场景

  • 按类型过滤的方法(预期每种类型只有一个活跃实例)
  • 优先级查找方法(返回第一个满足条件的结果)
  • 任何使用 enumerateObjectsUsingBlock: + *stop = YES 的 ObjC 代码

九、总结:何时使用哪种模式

场景 推荐模式 本文章节
验证一次代码变更 变更驱动测试 + 双态 Flag 测试
被测方法依赖跨模块协议(通过 Service Locator) Service Center Protocol Mock
被测对象通过宽接口 Context 获取环境 Fake Environment 模式
聚合方法的 Sum/Count 语义容易被误改 聚合语义测试
同一数据有两个来源、需条件性去重 数据源去重测试
底层变更需传播到上层聚合结果 Mutation-Aggregation 垂直切片
遍历方法有 stop/短路行为 短路行为测试

这些模式与前文的基础方法论互补使用。基础方法论解决"测什么"和"怎么 Mock"的问题,本文解决"改了代码后怎么精准验证"和"复杂依赖场景下怎么构造可控环境"的问题。

iOS NotificationCenter Observer 的隐性性能代价

作者 visual_zhang
2026年3月12日 00:17

引言

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 状态(不是锁等待)。堆栈顶部如下:

_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 从旧内存地址搬迁到新地址。这个操作涉及:

  1. _weak_unregister_no_lock:从 runtime 的 side table 中注销旧地址
  2. _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 即将显示时注册,在不可见时移除:

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 个独立注册:

// 在 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

deinit {
    NotificationCenter.default.removeObserver(self)
}

这一行代码的作用不是防 crash(iOS 9+ 不需要),而是及时清理 NotificationCenter 内部的注册条目,避免 dead entries 累积。

考虑使用 block-based API + 显式 token 管理

block-based API 返回一个 opaque token,移除时通过 token 精确定位,语义更清晰:

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 的性能问题有两个维度:

  1. 单个 observer 的生命周期管理:短生命周期对象不调 removeObserver,导致 dead entries 累积
  2. 同一通知名下的 observer 总量:即使每个 observer 都正确管理了生命周期,大量 live observers 本身就是性能风险

第一个问题比较符合直觉,容易在 Code Review 中发现。第二个问题更隐蔽——每个模块的注册看起来都合理,但当一个大型 App 中有几十个模块同时注册同一个通知时,总量就可能超出 NotificationCenter 内部数据结构的性能安全边界。

Apple 的"不需要 removeObserver"是关于正确性的保证,不是关于性能的保证。在大型 App 中,NotificationCenter observer 需要像内存一样被视为一种有限资源来管理。

❌
❌