记录一次对 Swift 动态的探索
背景
有不少 iOS 的库依赖 Objective-C 的动态特性,这对于 Swift 用户来说并不舒适。首先你经常需要把类型声明为 NSObject 子类,无法使用 struct / enum 等值类型;经常还必须加上 @objc
注释,否则无法将方法动态地暴露给 Objective-C。
有没有办法在纯 Swift 的基础上,进行一定的反射呢?
Swift.Mirror
Swift 前几年出了一个 Mirror
API,确实一定程度上提供了这样的能力。
有很多人看见一个新 API 就说“强大”,目前还看不懂 Mirror
哪里强大。基本上只能看到成员变量,连赋值也都还不行,更不要说动态派发方法了。
C
于是我开始在 C/C++ 的层面上找办法。
众所周知,Swift 是基于 C++ 的,在运行时,Swift 的函数会被装载为一个函数指针。于是我们可以通过 dlsym
找到这个 symbol:
dlsym(RTLD_DEFAULT, <function name>)
那么,现在的思路就是通过传入函数的名称,找到它的指针,调用并返回它的返回值:
void* _performMethod(const char* funcName, const void* onObject) {
void* (*implementation)(void*);
*(void **) (&implementation) = dlsym(RTLD_DEFAULT, funcName);
char *error;
if ((error = dlerror()) != NULL) {
printf("CHelper: method not found\n");
} else {
return implementation(onObject);
}
return NULL;
}
兴冲冲地,我把这段函数作为一个 target,包装进我的 Swift Package 中:
targets: [
.target(
name: "MyLibrary",
dependencies: [
"CHelper"
]
),
.target(
name: "CHelper",
publicHeadersPath: "./"
),
]
但是,发现了两个问题。
第一个,dlsym
函数是谁提供的?在 Windows 中,我们是无法调用这个函数的,而应该改成 <windows.h>
中的某个方法。在微软 Xamarin 的相关文档中可以看到,它被归类到了 ObjcRuntime.Dlfcn
下。
另一个问题在上面微软这篇文档中也有提到:
The symbol name passed to dlsym() is the name used in C source code.
If you looking up a C++ symbol, you need to use the mangled C++ symbol name.
我们必须传入 mangle 过的符号名。Manging 是编译器对于符号重名问题的解决方案,它将函数的名字和它的参数、返回类型,它所处的命名域等信息收集起来,通过既定的符号映射规则,形成一个独特的符号名。
怎么才能知道一个 Swift 方法的符号名是什么呢?
Runtime 库
发现了一个库,叫做 Runtime,提供纯 Swift 的反射功能:wickwirew/Runtime
苹果 Swift 官方在公布 Mirror
的同时(也可能不是同时),也提供了 Swift 的类型元数据的存放位置,可以看这篇文档,在文档的 Nominal Type Descriptor 一节:
The metadata records for class, struct, and enum types contain a pointer to a nominal type descriptor, which contains basic information about the nominal type such as its name, members, and metadata layout. For a generic type, one nominal type descriptor is shared for all instantiations of the type. The layout is as follows:
- The kind of type is stored at offset 0, which is as follows:
- 0 for a class,
- 1 for a struct, or
- 2 for an enum.
- The mangled name is referenced as a null-terminated C string at offset 1. This name includes no bound generic parameters.
类型的 mangled name 就存储在这个 descriptor 中,就是从它的第1个比特开始读,读到 null 为止的这个字符串。
但遗憾的是,文档也标注了:
Warning: this is all out of date!
实际使用这个库也确实取不到的类型的 mangled name:
(lldb) p try! typeInfo(of: Example.self).mangledName
(String) "Example"
再说,最终我们要取到的是函数的符号名,拿到类型的元数据也没用。
LLDB 符号表
但我们可以先使用 lldb 的查找功能看看。
出于试验目的,我定义了一个 struct,名叫 ABCDEFGHIJKLMNOPQRSTUVWXYZ
,以便浏览查找结果:
(lldb) image lookup -rvs ABCDEFGHIJKLMNOPQRSTUVWXYZ
得到了很多结果,比如它的初始化函数的信息:
Function: id = {0x100000471},
name = “MyDSBridgeExample.ABCDEFGHIJKLMNOPQRSTUVWXYZ.init() -> MyDSBridgeExample.ABCDEFGHIJKLMNOPQRSTUVWXYZ”,
mangled = “$s17MyDSBridgeExample26ABCDEFGHIJKLMNOPQRSTUVWXYZVACycfC”
由于我是在一个叫 MyDSBridgeExample 的项目里操作的,因此名字前面的 module 名是这样的。可以看到这个 init()
最终的 mangle 结果是:“$s17MyDSBridgeExample26ABCDEFGHIJKLMNOPQRSTUVWXYZVACycfC”。
眼睛尖的人可能一眼就看到一个 26,和我们的类型名,从 A-Z 的 26 个字母,正好长度一样;而数字 17 也正好就是 MyDSBridgeExample 的长度。因此数字就是“后X个字符”的意思。
那么前面的 s 是什么意思呢?后面的 VACycfC 是什么呢?
我们再定义几个函数:
struct ABCDEFGHIJKLMNOPQRSTUVWXYZ {
func f1() { }
func f2() { }
}
查找符号:
name = “MyDSBridgeExample.ABCDEFGHIJKLMNOPQRSTUVWXYZ.f1() -> ()”,
mangled = “$s17MyDSBridgeExample26ABCDEFGHIJKLMNOPQRSTUVWXYZV2f1yyF”
name = “MyDSBridgeExample.ABCDEFGHIJKLMNOPQRSTUVWXYZ.f2() -> ()”,
mangled = “$s17MyDSBridgeExample26ABCDEFGHIJKLMNOPQRSTUVWXYZV2f2yyF”
可以看到,2f2 和 2f1 之前都有一个 V,那么这个 V 应该是指 struct。
f1 和 f2 只有名字不同,其余都相同的情况下,得到的后缀都是 yyF,那么这应该就是无参数、无返回值的方法的格式了。
Dl_Info
这时候,在 wickwirew/Runtime 的 Issue 里,我看到一种取到类型的 mangle 名的方式:
func mangledName(for type: Any.Type) -> String {
let pointer = UnsafeRawPointer(bitPattern: unsafeBitCast(type, to: Int.self))
var info = Dl_info()
dladdr(pointer, &info)
return String(cString: info.dli_sname)
}
我试着传入了一个类型对象:
(lldb) p mangledName(for: ABCDEFGHIJKLMNOPQRSTUVWXYZ.self)
(String) "$s17MyDSBridgeExample26ABCDEFGHIJKLMNOPQRSTUVWXYZVN"
又试了几个基础类型:
(lldb) p mangledName(for: Int.self)
(String) "$sSiN"
(lldb) p mangledName(for: String.self)
(String) "$sSSN"
(lldb) p mangledName(for: Void.self)
(String) "$sytN"
现在就看能不能传入函数。首先函数不是一种 Any.Type
,只能先试试对函数取类型。
(lldb) p mangledName(for: type(of: ABCDEFGHIJKLMNOPQRSTUVWXYZ.f1))
(String) "_ZL21InitialAllocationPool"
(lldb) p mangledName(for: type(of: ViewController.viewDidLoad))
(String) "_ZL21InitialAllocationPool"
(lldb) p mangledName(for: type(of: mangledName))
(String) "_ZL21InitialAllocationPool"
不论传入的是哪个函数的类型对象,最后得到的都是 _ZL21InitialAllocationPool。
再说了,函数的类型,似乎不会跟什么模块、类型、命名域之类的有关吧。
弄了半天,一是连函数具体存储在哪里还不知道,二是 Swift 函数和 C 指针之间似乎并不兼容:
unsafeBitCast(ABCDEFGHIJKLMNOPQRSTUVWXYZ.f1, to: Int.self)
// Fatal error: Can't unsafeBitCast between types of different sizes
按照 Swift 论坛上的说法,只有 @convention(c)
的函数或者说闭包才可以和指针互相兼容:
There isn’t any way to bitcast a pointer into a Swift function value. If you can make it so that the pointer uses the C calling convention, can you cast to a
@convention(c)
type instead?
Swift Macro
事实上,苹果已经提供了 Mangling 规则的文档,根据这个文档,应该是可以做出来一个 mangle 函数的。
那就假设 mangle 函数已经写好了。问题就变成了,怎么动态地获得函数的从模块到类型(甚至嵌套类型)到函数本身的完整信息?
不用 Runtime 的话,好像也没有什么好办法吧。
也许可以用 Swift Macro?使用 macro 的话,在编译前我们就可以获取到任意信息。
可是如果决定了要用 Macro 的话,其实完全不需要反射。
最后,用 Swift Macro,我做成了这个库:DSBridge-Swift,用一种简单粗暴的方式,在静态时获取函数名、参数类型、返回类型等全部信息。