Highlight
2020 年的新 iOS 与 macOS Objective-C runtime 通过拆分类的读写数据、改用相对方法列表、调整 arm64 tagged pointer 格式来降低系统级内存占用,同时要求 App 只通过公开 runtime 与 Foundation API 读取对象信息。
核心内容
多数开发者不会每天打开 Objective-C runtime 的头文件。问题是,几乎每个 iOS 和 macOS App 都站在它上面:Objective-C class 用它,Swift class 也共享同一套 class 基础结构。Apple 在这场 session 开场就说得很直接:这不是一场教你迁移新 API 的演讲,理想状态下,你不改代码,App 会因为系统运行时变小而受益。(00:11)
真正的风险来自另一类代码:调试工具、性能工具、历史遗留库,或者某个第三方依赖,直接去读 runtime 的内部结构。过去这些字段位置看起来稳定,不代表它们是 API。2020 年的 runtime 正好移动了这些内部布局:类的读写数据被拆开,方法列表从 64 位指针改成相对偏移,arm64 tagged pointer 的 bit 布局也变了。直接读 bit 和结构体的代码会崩溃,或者在更糟的情况下错误处理用户数据。(00:32, 21:24)
Apple 给出的路线很清楚。runtime 内部可以继续变,因为外部应该通过 class_getName、method_getImplementation、isKindOfClass:、CFGetTypeID 这类公开 API 读取信息。系统因此能把更多内存保持为 clean memory,让 iOS 设备少背 dirty memory 的成本,也让 App 获得更大的可用内存空间。(07:01, 22:27)
详细内容
类数据:把少用的读写字段移出去
(01:16)runtime 中的 class object 保存最常访问的信息,例如 metaclass、superclass 和 method cache。更多静态信息放在 class_ro_t 里,ro 表示 read only,包含类名、方法、协议和实例变量。类第一次被使用时,runtime 还会分配 class_rw_t,保存运行时才生成、可能变化的数据。
关键差异在 clean memory 和 dirty memory。class_ro_t 读入后不再改变,可以在系统需要内存时丢弃并从磁盘重新载入。class_rw_t 会在进程运行时被修改,iOS 没有 swap,因此 dirty memory 的成本更高。Apple 在真实设备上测到,系统中 class_rw_t 约占 30 MB,但只有约 10% 的类真的会修改方法,Swift demangled name 字段也不是每个 Swift class 都需要。于是 runtime 把少用字段移到 extended record,约 90% 的类无需分配这部分数据,系统级节省约 14 MB。
heap Mail | egrep 'class_rw|COUNT'
关键点:
heap Mail查看 Mail 进程的堆内存分配。egrep 'class_rw|COUNT'只保留class_rw_t相关行和统计头。- session 演示里,Mail 大约使用 9000 个
class_rw_t,只有稍多于 900 个需要 extended information。 - 这条命令不是 App 代码路径,适合在 Mac 上验证 runtime 数据结构变化带来的内存影响。(05:37)
不要用内部字段判断一个类。类名、父类和方法列表都有公开 runtime API。(07:35)
class_getName
class_getSuperclass
class_copyMethodList
关键点:
class_getName读取类名,避免依赖class_ro_t的具体布局。class_getSuperclass读取父类,避免假设 class object 里字段顺序不变。class_copyMethodList获取方法列表,runtime 会处理新版和旧版内部格式。
方法列表:64 位指针改成 32 位相对偏移
(08:04)每个 Objective-C 方法条目包含三个字段:selector、type encoding 和 implementation。旧格式在 64 位系统上每个字段都是指针,一个方法条目占 24 bytes。方法实现和元数据位于同一个 binary image 内,不需要指向整个 64 位地址空间,所以 2020 年的工具链可以把这些字段变成 binary 内部的 32 位相对偏移。
这样做有三个结果。第一,方法条目大小减半。Apple 在典型 iPhone 上测到系统级方法数据约 80 MB,新格式能节省约 40 MB。第二,相对偏移不需要 dynamic linker 在加载后修正绝对地址,可以保留在真正的 read-only memory 里。第三,swizzling 不再改写原方法列表,而是通过一个全局表把方法映射到被替换后的 implementation,避免一次 swizzle 就 dirty 掉整页方法列表。(10:40, 11:30)
Xcode 会根据 minimum deployment target 生成对应格式。目标系统足够新时,工具链自动生成相对方法列表;仍需支持旧系统时,Xcode 会继续生成旧格式。风险出现在构建产物的 deployment target 比实际运行系统更新,旧 runtime 会把两个 32 位字段当成一个 64 位指针读取,最终在读取方法信息时崩溃。(12:23, 13:31)
公开 API 可以读取同样的三个字段。(14:38)
method_getName
method_getTypeEncoding
method_getImplementation
关键点:
method_getName返回方法 selector。method_getTypeEncoding返回参数和返回值编码,供 introspection 和 message forwarding 使用。method_getImplementation返回方法实现入口,不需要调用方知道方法列表是旧指针格式还是新相对偏移格式。
Tagged pointer:arm64 格式改变,类型检查走 API
(14:53)Tagged pointer 利用真实对象指针中固定为 0 的空闲 bit,把小对象直接编码进指针值。NSNumber、NSDate 等类型可以避免为小值分配堆对象。session 也提醒,这些值会在进程启动时用随机值混淆,试图直接查看内存中的 payload 会看到被扰乱后的结果。
Intel 上使用低 bit 标记 tagged pointer。arm64 旧格式使用最高 bit,原因是 objc_msgSend 可以用一次比较同时处理普通指针、tagged pointer 和 nil 的常见路径。2020 年的 arm64 格式继续保留最高 bit 标记,但把 tag number 移到底部三个 bit;extended tag 放进 ARM Top Byte Ignore 可忽略的高位区域。这样 extended tagged pointer 的 payload 可以容纳一个指向 binary 中常量数据的普通指针,减少原本需要 dirty memory 保存的数据。(19:03, 20:08)
旧代码如果用 bit mask 自己判断 tagged pointer,在 iOS 14 之后可能得到错误答案。对象和 CF 类型都应该走标准 API。(21:52)
if ([obj isKindOfClass:[NSString class]]) {
// a string
}
NSUInteger length = [obj length];
if (CFGetTypeID(obj) == CFStringGetTypeID()) {
// a string
}
CFIndex length = CFStringGetLength(obj);
关键点:
isKindOfClass:让 Foundation 处理 tagged pointer 与真实对象指针的差异。[obj length]对NSString正常工作,调用方不需要读取 payload。CFGetTypeID和CFStringGetTypeID是 Core Foundation 层的类型判断入口。CFStringGetLength通过 CF API 读取长度,同样不依赖 tagged pointer bit 布局。
核心启发
做一次 runtime 依赖审计
做什么:列出工程和第三方依赖里所有直接访问 Objective-C runtime 内部结构、手写 tagged pointer bit mask、假设方法列表布局的代码。
为什么值得做:session 反复强调,2020 年这些内部布局已经移动。旧代码可能在用户升级系统后崩溃,或者把对象类型判断错。
怎么开始:先搜索 class_rw_t、method list、tagged pointer、位运算类型判断,再把读取类和方法的代码替换为 class_getName、class_copyMethodList、method_getImplementation。
给二进制产物加 deployment target 检查
做什么:在 CI 里检查 App、framework 和外部二进制库的 minimum deployment target,确保它们没有高于产品实际支持的最低系统版本。
为什么值得做:relative method list 依赖新 runtime 理解新格式。用新格式构建的库跑在旧系统上,会把两个 32 位字段拼成错误指针。
怎么开始:把构建设置、第三方 framework 的最低系统版本和实际测试矩阵放进同一个检查脚本,并在最老支持系统上跑一次启动与动态调用 smoke test。
建一个 dirty memory 对比流程
做什么:在升级 Xcode、SDK 或 deployment target 后,用固定 App 和固定场景对比 runtime 相关 dirty memory。
为什么值得做:session 用 heap 演示了 class_rw_t 拆分后的收益。团队也可以用同样方法确认自己的 App 是否从系统优化中受益。
怎么开始:准备一组稳定操作路径,采集 heap <AppName> | egrep 'class_rw|COUNT' 输出,把结果写入性能记录,和上一版构建对比。
封装一层安全对象类型判断
做什么:把历史代码里的 Foundation/CF 类型判断集中到少数 helper,内部只调用 isKindOfClass:、CFGetTypeID 和对应类型 API。
为什么值得做:tagged pointer 格式是 runtime 维护者才能安全解释的细节。集中封装后,代码审查时更容易阻止新的 bit 级判断进入主干。
怎么开始:先覆盖 NSString、NSNumber、NSDate 和常见 CF 类型,给 tagged pointer 可能出现的输入补单元测试,再删除旧的 bit mask 分支。
关联 Session
- Refine Objective-C frameworks for Swift — 继续处理 Objective-C API 如何被 Swift 正确、自然地调用。
- What’s new in Swift — 补充 Swift 2020 的运行时性能和语言库更新。
- Safely manage pointers in Swift — 讲 Swift unsafe pointer、raw pointer 和绑定内存的正确边界。
- Unsafe Swift — 从语言安全模型解释何时需要 unsafe 操作,以及如何避免 undefined behavior。
评论
GitHub Issues · utterances