iOS 动态库懒加载

iOS 动态库懒加载

引入 动态库 之后会增加 app 的启动耗时,我们希望将一些不影响主页面显示的动态库进行懒加载,从而减少动态库引入的带来的启动耗时负面影响。此外,我们也希望动态库懒加载方案能够做到对上层透明,也就是说上层代码调用动态库的接口是保持不变的,这样才能无痛迁移,下面是我们调研动态库懒加载方案当中遇到的问题和最终的解决方案。

原理简介

为了保证动态库能够进行懒加载,需要将动态库依赖进行完全移除,然后在运行时的时候重新加载动态库,并且修正所有的动态库接口依赖。

动态库 oc class 处理

1
2
3
4
5
6
//这个类来自于 动态库
@interface A_From_DynamicFramework : NSObject

+ (void)foo;

@end
  • 业务层调用 例子
1
2
3
- (void)test {
[A_From_DynamicFramework foo]
}

当动态库移除之后,此处就会编译失败,报错找不到 A_From_DynamicFramework 这个 class,那么如何调整呢?我们可以利用 oc runtime 的特性处理这种问题。

1
2
3
- (void)test {
[NSClassFromString(@"A_From_DynamicFramework") foo];
}
  • 懒加载对业务 透明

通过 宏 进行重定向,将接口调用转发到一个中间类当中,这样可以做到业务代码不需要变动,只需要定义一些宏进行替换即可。

1
2
3
4
#define A_From_DynamicFramework A_From_DynamicFramework_forwarder
- (void)test {
[A_From_DynamicFramework foo];
}

中间类将会截获所有的方法,转发到动态库的类当中

1
2
3
4
5
6
7
8
@implementation A_From_DynamicFramework_forwarder

+ (void)forwardInvocation:(NSInvocation *)invocation {
//转发到动态库类当中
[invocation invokeWithTarget:NSClassFromString(@"A_From_DynamicFramework")]
}

@end

宏 + 中间类

至此,看起来这个 宏 + 中间类 方案比较简单易行,很快就把所有编译问题解决了,而运行起来的时候,却发现调用动态库一些方法的时候发生了 crash,下面会详细介绍说明。

1
2
3
4
5
6
//这个类来自于 上层业务,继承了 动态库当中的类
@interface B_From_MainBundle : A_From_DynamicFramework

@property (nonatomic, assign) uint64_t a;

@end

按照上面的 宏 + 中间类 的方案,实际上 B_From_MainBundle 继承的是 中间类 A_From_DynamicFramework_forwarder,这就导致其实 B_From_MainBundle 的实例变量的大小以及位置 跟 直接继承 A_From_DynamicFramework 是有差异的(假设 A_From_DynamicFramework_forwarder 的大小是 100 字节,A_From_DynamicFramework 的大小是 200 字节,那么继承 A_From_DynamicFramework_forwarder 的 子类 B_From_MainBundle 大小应该是 100 + 8 = 108 字节,而继承 A_From_DynamicFramework 的子类 B_From_MainBundle 大小应该是 200 + 8 = 208 字节)。

也就是说 宏 + 中间类 的方案具有一个重大缺陷: 继承的子类结构是错误的

继承链修正

其实 oc runtime 在设计的时候已经支持了调整 class 内部实例变量的支持(包括 内存偏移位置,实例大小等等),所以当系统库的 class 升级的时候,app 不需要重新编译也能继续运行在新系统上,也同时可以动态的去给一个 class 增添变量。所以,我们期望能够直接修正整个继承链,那么都不需要通过中间类进行方法查找,也能正确的执行 oc 方法了。

1
2
3
4
5
未修正前:
B_From_MainBundle -> A_From_DynamicFramework_forwarder

修正后:
B_From_MainBundle -> A_From_DynamicFramework_forwarder -> A_From_DynamicFramework
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//oc 运行时 修正 子类 变量结构的核心代码
static void reconcileInstanceVariables(Class cls, Class supercls, const class_ro_t*& ro) {
if (ro->instanceStart >= super_ro->instanceSize) {
return;
}
//这里实际是通过判断 父类的大小是否变更 来进行 内存布局的修正
if (ro->instanceStart < super_ro->instanceSize) {
class_ro_t *ro_w = make_ro_writeable(rw);
ro = rw->ro;

//修正 内存布局
moveIvars(ro_w, super_ro->instanceSize);
}
}

上述修正内存布局的代码只会在 realizeClass 当中进行调用,而这个是在加载二进制 image 的时候就会触发,所以如果要在运行时重新修正继承链,目前是通过直接操作 class 的结构去触发一次 realizeClass 调用,比较 trick。

伪代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
Class cls = [A_From_DynamicFramework_forwarder class];
Class superclass = NSClassFromString(@"A_From_DynamicFramework");

//重设 父类
class_setSuperclass(cls, superclass);

//清除 realized 标志位,使其能触发 realizeClass
cls->rw->flags = ~RW_REALIZED;

//NSClassFromString 调用 look_up_class 会触发 realizeClass
NSClassFromString(clsName);

最终方案

宏 + 中间类 + 继承链修正

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#define A_From_DynamicFramework A_From_DynamicFramework_forwarder
- (void)test {
//通过 宏 进行中间类截获
[A_From_DynamicFramework foo];
}

@implementation A_From_DynamicFramework_forwarder

+ (void)forwardInvocation:(NSInvocation *)invocation {
//中间类 截获方法,并且进行 继承链修正
[A_From_DynamicFramework_forwarder tryChangeClass:self];

//修正完继承链之后,重新进行一次方法转发,此后不需要通过方法转发进行调用,因为继承链修正后方法已经能查找到,所以这个方法只会调用一次
[invocation invokeWithTarget:[self superclass]];
}

@end

拓展方案

其实除了上述这种 宏 + 中间类 + 继承链修正 的方案之外,经验证还有另外一种可行方案 – 通过模拟 dyld symbol binding 的过程进行符号的修正。

  • dyld symbol binding

二进制 image 当中包含 dyld load 的段信息,我们可以解析这一部分的信息,获知到 哪些 symbol 需要 binding,并且知道这些 symbol 的具体地址。

举例:

1
2
如果工程当中 使用了动态库的 A_From_DynamicFramework 这个类,那么将会在 dyld load 段当中插入信息表明要求 dyld 载入的时候去查找相关的 symbol,比如查找 A_From_DynamicFramework 类的位置:
_OBJC_CLASS_$_A_From_DynamicFramework

所以,我们可以在运行时的时候重新读取 dyld load 的信息,在载入动态库之后重新进行一次 symbol binding 操作即可,由于这套方案实现更加复杂,此处暂不展开讨论,关键技术 如下所示:

1
2
3
4
5
1,
dyld load 段信息读取

2,
__objc_classrefs 段修正