⌈Swift⌋这一次,搞懂 @objc 和 dynamic
自 Swift 问世以来,于 iOS 中的运用在很长的一段时间内就注定离不开与 Objective-C 的混编问题。而如何让广大开发者更快地接受并逐步使用这个“亲儿子”,一定程度上会受到它的操作难度的影响。官方也给出了他们的解决方案:编译器标识符 + 混编头文件。即:
XXX-Swift.h
(可在编译设置「Generated Header Name」中修改名称) 由编译器自动生成可供 Objective-C 使用的 Swift 接口(前提是为能够桥接到 Objective-C 的接口加上public @objc
)XXX-Bridging-Header.h
(可在编译设置「Objective-C Bridging Header」中修改路径)管理能被 Swift 访问的 Objective-C 接口
如果你经历过初期的阵痛,就一定会疯狂吐槽因 Xcode 的缓存导致 XXX-Swift.h
无法即使更新而出现的各种莫名其妙的编译错误,对于同一个模块里必须加 public 才能识别的限制使组件封装性被破坏的神奇操作亦让人叫苦不迭,🤣
那么在 @objc
背后发生了什么?dynamic
又有什么作用?调用环境的不同行为是否一致呢?让我们一探究竟!
从 🌰 出发
在开始之间,我们不妨先将测试例程“端上来”:1
2
3
4
5
6final class Task {
func execute() {}
@objc func executeWithObjC() {}
dynamic func executeWithDynamic() {}
@objc dynamic func executeWithObjCAndDynamic() {}
}
为了规避继承对函数调用形式的影响,我们加上了 final
让函数默认直接通过函数地址调用。加上断点,通过汇编很容易得到验证:1
2let task = Task()
task.execute() // 断点处
1 | @objcvsdynamic`main: |
在汇编语言中,bl 指令是 “Branch with Link” 的缩写,这是一种用于函数调用的指令。后面的注释也告诉了我们答案,这个地址就是 Task 中 execute 的函数地址。有了这个基本依据,后续的过程就是照葫芦画瓢了~
在 Swift 中调用
在 main.swift 中依次调用剩下的三个函数并加上断点:1
2
3task.executeWithObjC() // 断点处
task.executeWithDynamic() // 断点处
task.executeWithObjCAndDynamic() // 断点处
关键汇编代码如下:1
2
3
4
5
6
7
8
90x100003bd8 <+48>: bl 0x100003c6c ; _objcvsdynamic.Task.executeWithObjC() -> () at main.swift:12
0x100003bdc <+52>: ldr x8, [sp, #0x8]
0x100003be0 <+56>: ldr x20, [x8, #0x148]
0x100003be4 <+60>: bl 0x100003c80 ; _objcvsdynamic.Task.executeWithDynamic() -> () at main.swift:13
0x100003be8 <+64>: ldr x8, [sp, #0x8]
0x100003bec <+68>: ldr x0, [x8, #0x148]
0x100003bf0 <+72>: adrp x8, 5
0x100003bf4 <+76>: ldr x1, [x8, #0x90]
0x100003bf8 <+80>: bl 0x100003e14 ; symbol stub for: objc_msgSend
前两个函数的调用形式和 execute 如出一辙,第三个函数我们看到了熟悉的老朋友:objc_msgSend。它的函数签名是这样的:1
2OBJC_EXPORT id _Nullable
objc_msgSend(id _Nullable self, SEL _Nonnull op, ...)
在 arm64 的架构中,函数调用的参数会通过通用寄存器 x0 到 x7 存储。根据我们对 executeWithObjCAndDynamic 的定义,可以得知它只会有两个默认参数 self、SEL。而通过 LLDB 指令 register read 可以读取寄存器中的值,如下:1
2
3
4
5
6
7
8po task
-> <Task: 0x600002af0080>
register read x0
-> x0 = 0x0000600002af0080
register read x1
-> x1 = 0x0000000100003e88 "executeWithObjCAndDynamic"
据此可得出结论:当函数被 @objc 和 dynamic 同时标记时,会通过 Objective-C 运行时消息发送机制完成调用。此外,dynamic 的另一个作用是让函数的实现可以被动态替换,类似于 Method Swizzling:1
2
3
4
5
6
7extension Task {
(for: executeWithDynamic)
func executeWithDynamicReplacement() {
// do something
executeWithDynamic()
}
}
在外界调用 executeWithDynamic 时,在汇编层面会通过名为 swift_getFunctionReplacement 的函数调用跳转到 executeWithDynamicReplacement。
在 Objective-C 中调用
在实际测试之前,我们需要改造下演示代码以供 Objective-C 调用:1
2
3
4
5
6
7
8@objc
public final class Task: NSObject {
@objc public func executeWithObjC() {}
@objc dynamic public func executeWithObjCAndDynamic() {}
// 不支持的调用形式
// func execute() {}
// dynamic func executeWithDynamic() {}
}
紧接着:1
2
3
4
5- (void)test {
Task *task = [[Task alloc] init];
[task executeWithObjC]; // 断点处
[task executeWithObjCAndDynamic]; // 断点处
}
汇编结果:1
2
3
4
5
6
7
8
9
10 0x1000039ec <+28>: bl 0x100003d7c ; symbol stub for: objc_alloc_init
0x1000039f0 <+32>: ldr x1, [sp, #0x8]
0x1000039f4 <+36>: add x8, sp, #0x18
0x1000039f8 <+40>: str x8, [sp, #0x10]
0x1000039fc <+44>: str x0, [sp, #0x18]
-> 0x100003a00 <+48>: ldr x0, [sp, #0x18]
0x100003a04 <+52>: bl 0x100003de8 ; objc_msgSend$executeWithObjC
0x100003a08 <+56>: ldr x1, [sp, #0x8]
0x100003a0c <+60>: ldr x0, [sp, #0x18]
0x100003a10 <+64>: bl 0x100003e08 ; objc_msgSend$executeWithObjCAndDynamic
结合注释来看,第一行是非常典型的动态库函数调用,而“正主”的说明却让我们觉得陌生。先瞅瞅 x0、x1 放的是什么:1
2
3
4
5
6
7
8po task
-> <_objcvsdynamic.Task: 0x600001b68000>
register read x0
-> x0 = 0x0000600001b68000
register read x1
-> x1 = 0x00000000000000b0
看起来 x0 依旧是对象本身,x1 并无什么有意义的表示,但我们至少能肯定这绝不是消息发送的函数调用。事实上 objc_msgSend$executeWithObjC 是一个由编译器生成的 Thunk 函数,内部包装了实际的 Swift 函数调用。结合 x0,或许它长成这样:1
2
3
4// 伪代码
void objc_msgSend$executeWithObjC(Task *task) {
task.executeWithObjC()
}
简而言之,Swift 提供给 Objective-C 的接口本质上是一个 Thunk 函数,其内部调用真正的 Swift 函数。
在扩展中的一些特性
默认情况,在扩展中定义的函数是无法被子类重载的,但是 @objc 标记会打破这一限制:1
2
3
4
5
6
7
8
9
10class Animal {}
extension Animal {
func eat() {}
@objc func run() {}
}
class Dog: Animal {
override func eat() {} // ❌ Non-@objc instance method 'eat()' is declared in extension of 'Animal' and cannot be overridden
override func run() {}
}
总结
一个普通函数就像游戏中的角色通过点亮天赋树获得新的技能一般,@objc 赋予它能被 Objective-C 调用的能力,dynamic 让它支持 Swift 式的 Method Swizzling,双管齐下那便“究极进化”为 Objective-C Runtime Message Forwarding。此时,Objective-C 并不会向运行时注册该类(通过 objc_getClass 获取结果为 nil),而是在 objc_msgSend 中做了对 Swift 类型的适配工作。上述验证流程亦可通过 SIL 来完成,如果你感兴趣,详见参考文章,👋~
补充(2024-06-12)
@objc 绝大多数情况下是用于 Target-Action 式的命令模式下接收运行时调用的函数的标记,例如:
1 | class Tester { |
action(命名任意,这里只是例子中的名字)函数可以不带任何参数或者声明一个使用“该命令”的类型参数 sender(习惯上这么命名),并且在指定 selector 时可以不用带上参数,运行时会有一套规则去查找函数地址。但这并不是一个好的编码方式,因为这样即使你增加了参数列表,编译器也无法静态检查出问题。最糟糕的结果就是在 action 的执行过程中出现野指针崩溃,因为此时访问了未分配函数参数的寄存器(如 x0 - x7)或者栈帧内存结构。
1 | class Tester { |
那么考虑到实际情况(除 sender 外,Target-Action 无法传递额外参数),此时所谓的最佳实践就是:
- 不带参数
- 只声明 sender 参数