自 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
6
final class Task {
func execute() {}
@objc func executeWithObjC() {}
dynamic func executeWithDynamic() {}
@objc dynamic func executeWithObjCAndDynamic() {}
}

为了规避继承对函数调用形式的影响,我们加上了 final 让函数默认直接通过函数地址调用。加上断点,通过汇编很容易得到验证:

1
2
let task = Task()
task.execute() // 断点处

1
2
3
4
5
6
7
8
9
10
11
12
@objcvsdynamic`main:
0x100003bf0 <+0>: stp x20, x19, [sp, #-0x20]!
0x100003bf4 <+4>: stp x29, x30, [sp, #0x10]
0x100003bf8 <+8>: add x29, sp, #0x10
0x100003bfc <+12>: mov x0, #0x0
0x100003c00 <+16>: bl 0x100003c2c ; type metadata accessor for _objcvsdynamic.Task at <compiler-generated>
0x100003c04 <+20>: mov x20, x0
0x100003c08 <+24>: bl 0x100003c50 ; _objcvsdynamic.Task.__allocating_init() -> _objcvsdynamic.Task at main.swift:10
0x100003c0c <+28>: adrp x8, 5
0x100003c10 <+32>: str x0, [x8, #0x148]
-> 0x100003c14 <+36>: ldr x20, [x8, #0x148]
0x100003c18 <+40>: bl 0x100003c88 ; _objcvsdynamic.Task.execute() -> () at main.swift:11

在汇编语言中,bl 指令是 “Branch with Link” 的缩写,这是一种用于函数调用的指令。后面的注释也告诉了我们答案,这个地址就是 Task 中 execute 的函数地址。有了这个基本依据,后续的过程就是照葫芦画瓢了~

在 Swift 中调用

在 main.swift 中依次调用剩下的三个函数并加上断点:

1
2
3
task.executeWithObjC() // 断点处
task.executeWithDynamic() // 断点处
task.executeWithObjCAndDynamic() // 断点处

关键汇编代码如下:
1
2
3
4
5
6
7
8
9
0x100003bd8 <+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
2
OBJC_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
8
po 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
7
extension Task {
@_dynamicReplacement(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
8
po 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
10
class 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 来完成,如果你感兴趣,详见参考文章,👋~

Reference

@objc and dynamic