Dec 27

基于OC符号动态绑定的iOS动态库懒加载实践

Lrdcq , 2023/12/27 02:24 , 程序 , 閱讀(2683) , Via 本站原創
背景

我们知道iOS项目产物中是可以加入开发者的动态库(framework)的,如我们工程中使用的UnityFramework.framework。
正常情况下,在工程中link的动态库是程序启动时就会加载的(通过macho的LC_LOAD_DYLIB指令),会加载起包括用户加入的动态库和所有系统库。不过如果我们将framework构建完成并签名,不参与link而是直接放入资源中,这个动态库就会像资源一样正常的放在那里,程序中则可以通过dlopen或者CFBundleLoadExecutable将动态库手动加载起来,实现部分代码的用时加载,即iOS的动态库懒加载
點擊在新視窗中瀏覽此圖片

动态库懒加载这件事虽然从iOS之初就可以做到,但是事实上国内开始有公司开始尝试至少是19年左右。目前事实上做得最好的应该是快手/Kwai。同时内部主端也是20年左右就开始探索,目前有一些落地。本文档我们的方案也是在巨人的肩膀上进行添砖加瓦。

收益逻辑闭环

很明确的是,App的启动速度和运行时的基础内存均受到App代码量的绝对影响。我们启动耗时优化里总是说减少启动阶段加载的framework(系统的与自己的)与减少class就是这个道理。但是业务复杂度摆在这里我们没有可减的,随着包大小在用户体验的优先级降低,与其说减少App,不如把启动不需要的代码和加载过程从主App中拆除——包装到动态库中进行懒加载——这便是这个项目的核心收益。

附带:
- Framework懒加载本质上类似于Android的插件化,这是苹果的AppStore包的Sign机制限制了市场包的代码动态下发,但是在本地或者inhouse包的对内App中利用这个机制进行动态更新/壳工程开发提升开发效率也是有一定空间的。
- 同时,进行Framework抽象也能更有效的进行代码隔离与抽象服用,方案整体如果能有效落地长期对工程结构有相当正向的影响。

技术难点

动态库懒加载的技术难点其实很明确,本文套路的所有问题都是围绕以下两个大点:

【问题1】构建被依赖
由于Framework不再参与Main App的link过程,如果有SomeBiz业务代码依赖Framework,会找不到符号(Undefined Symbols)。那么我们的Main App如何构建才能正常使用Framework呢?

【问题2】构建依赖
对应的,我们的Framework有可能也会依赖一些更下层的base代码,而这些base代码因为大面积复用的缘故还是保持在Main App中的。那Framework如何构建才能调用到相应代码呢。
點擊在新視窗中瀏覽此圖片

附加问题【问题3】尽可能少入侵的进行懒加载
当构建问题解决了,在运行时,如何在合适的时机进行动态库的加载,与这样的加载行为如何尽可能减少对业务代码的入侵呢。特别是我们修改的目标还包含不适合进行入侵的二三方代码的情况。当然,这个问题相对比较好解决——毕竟它是一个工程问题而不是技术问题。
实际会面临的问题,比这三个问题看起来复杂很多,在下文具体展开。



历史方案演进

我们的方案是站在过往经验上的,因此我们需要从过往看看到底动态库懒加载方案经历了如何的迭代。

主端Effect方案

首先,要让一个Framework启动过程不进行加载,核心参数是link过程中的
     -framework name[,suffix]
             This option tells the linker to search for `name.framework/name'
             the framework search path.  If the optional suffix is specified
             the framework is first searched for the name with the suffix and
             then without (e.g. look for `name.framework/name_suffix' first,
             if not there try `name.framework/name').

当然还有weak_framework等其它命令,这些命令做的事情是:

1. 让当前这个framework参与link过程。
2. 将framework加入构建产物(macho)的LC_LOAD_DYLIB与LC_LOAD_WEAK_DYLIB两个section部分,这两部分的数据用于App启动时操作系统同步加载相关动态库到进程中。当然如果weak的加载失败就算了,否则程序启动不起来。比如如下数据(@rpath即当前程序相对路径的动态库,绝对路径均是系统动态库,在iOS/Mac的特定路径下):
點擊在新視窗中瀏覽此圖片

结论上,要让App启动过程中不加载某个动态库,就确实不能让这个动态库加入link。

要克服【问题1】即Main App对动态库的依赖缺少符号的情况,Effect方案区分OC与C/C++符号进行了讨论。

【问题1.1】C/C++符号依赖

- 事实上对于C/C++这种直接调用的符号,不同的二进制中有同名的符号其实是允许的。比如framework中提供了一个void testFoo(void)方法,我们可以在Main App提供一个同名的方法,解决link时找不到符号的问题同时,动态库可以加载出独立的同名方法并且进行独立的方法获取函数指针。

- 因此方案是:
  1. 在MainApp中提供所有framework提供的函数的同名函数。link时所有调用framework函数的代码均会实际调用到这些函数。
  2. 这些函数中实现:保证加载动态库,并且利用动态库的handler拿到其中的同名函数指针,转发调用动态库中的函数,代码看起如下:
void testFoo(void) {
    static dispatch_once_t onceToken;
    static void * framework_testFoo;
    dispatch_once(&onceToken, ^{
        CFBundleRef handler = ensure_framework_initialization();
        framework_testFoo = (void *)CFBundleGetDataPointerForName(handler, CFSTR("testFoo"));
    });
    return (*framework_testFoo)();
}

- 这个方案的成本是需要在MainApp里加入这么一套Wrap函数实现,多少是有些成本的。除此之外看起来极好。

【问题1.2】OC符号依赖

- OC符号可以利用如上方案么,在MainApp中提供一个包裹转发的OC Class——判断起来是不行的,OC中如果SomeClass已经在runtime中完成注册,那么动态库中的SomeClass就无法成功初始化。

- 因此Effect方案下,放弃了解决OC方案的问题——他们将所有的oc调用重新封装为了c函数调用,转换为问题1.1的解法进行耦合。
同时,Effect SDK正好是一个偏底层的SDK,因此也没有去解决【问题2】,实际上直接将Effect依赖的仅有的几个base函数直接打包进了framework中。这样MainApp和Framework中会出现重复符号的问题。
  - 当然,不管是link时还是运行时,重复符号都不太所谓不会引起异常,顶多是占了包大小。

方案总结

核心逻辑:主端Effect方案,利用讲所有调用转换为C/C++调用,利用C/C++符号可以在不同macho中同名存在的特性,利用wrap函数解决【问题1.1】。
方案优势:对于偏底层/偏C/C++的代码,这个方案可以对原代码和调用方完全无感,仅仅添加一个wrap层,就可以解决懒加载问题。另外这个方案很自然的解决了【问题3】。
方案劣势:对于但凡有较为复杂的OC符号无法忽略【问题1.2】,或者下层代码依赖较多无法直接忽略【问题2】,这个方案即完成不可行。
应用场景:很显然,这个方案适合C/C++的相对独立基础类库,如音视频解码工具,编辑工具,端智能相关工具,大型三方库如Flutter/RN核心部分。
  - 实际上这个方案在主端侧就是这样的定位,不过考虑到wrap层的维护成本,实际上目前也只落地了EffectSDK这一项。

我们的Unity方案

根据主端Effect落地情况,咱们在23年4月利用UnityFramework尝试了一波落地。整体的思路和Effect方案一致,唯一的区别是——UnityFramework对外暴露的都是较为复杂的OC类,因此我们需要解决【问题1.1】。
既然不入侵代码确实无法做到这件事儿,同时OC符号确实不能同名,那解决方案很明确了。

- 为依赖的OC符号创建别名

因此解决【问题1.1】我们做了以下动作:

1. 脚本修改所有被依赖的头文件,如:
//UnityOCClass.h

@interface UnityOCClass: NSObject
...

在每个头文件一开始添加一个wrap.h依赖
// UnityFramework_wrap.h

#define UnityOCClass UnityOCClass_Wrapper
#define UnityOCClass2 UnityOCClass2_Wrapper
...

通过宏的方式,将实际业务代码中的OC类依赖实际转换为了另一个名字。

2. wrap库实现所有的wrapper类。wrapper调用OC类方法时,进行类似于effect的C/C++方案那样的动作
  - 即保证动态库已经被加载
  - 利用NSClassFromString调用到原类,通过selector调用到原方法。完成OC方法调用的转发。

当然,同时要保证调用到这些类的地方,更新依赖了wrap头文件后重新编译。这样MainApp就没依赖动态库的OC类,而是依赖了我们的WrapOC类,完成动态库符号包裹转发的过程。

方案总结

核心逻辑:在主端Effect方案基础之上,利用宏进行OC类的wrap动作,完全解决【问题1】以覆盖更多的动态库。
方案优势:对于偏底层代码,这个方案可以对原代码和调用方基本无感。优势和主端Effect方案类似。
方案劣势:相对于主端Effect方案,其实对调用代码有入侵——需要保障依赖wrap宏之后重新编译,调用的类名才会改变。也就是说如果依赖方是源码不可控的二三方二进制库,这个方案就无法执行。
  - 当然,OC的wrap代码成本还是更高。
应用场景:这个方案类似,也是以覆盖无继续向下依赖的基础库为主。
  - 具体放在我们,同时满足偏底层代码并且调用代码完全可控的场景,代码量最大的也就UnityFramework这一处拿收益,因此Unity落地后也没更多的场景可选。
  - 如果是主端那样有权限全源码,并且类似于monorepo的构建方案,这个方案可落地性反而更高。

主端业务库方案

到此处,主端决定尝试落地业务库了。经过前面的调研,既然【问题1】要覆盖到OC,不可避免的要入侵到业务代码或者造成较为大影响,那么方案干脆就以业务代码需要入侵与重构为前提进行。

解决【问题1】核心思路

即干脆避免其它代码直接依赖动态库的代码。
- 动态库对外不直接暴露函数/方法符号,而是提供一套协议(类似于DI)或者将自己注册到router上。
- Service DI与Router支持AppMain中注册的在某个framework中实现,并且在加载过程中进行同步dlopen过程。

这类方案也是快手采用的方案,快手动态库方案起步较早,这套入侵性非常强的方案推广得相当广泛,因此落地效果极好。而主端从23年初到现在也就落地了3个比较小(入口相对收敛)的业务库。看起来代码入侵任重而道远。

解决【问题2】核心思路

既然要落地业务库,即代码分层上比较上层的库,就不可避免的需要面对解决【问题2】了。
最合理的思路是:
1. 将动态库需要的依赖独立抽象为一个BaseFramework。
2. 让无论动态库还是MainApp都依赖这个BaseFramework即可。

当然显然,合理的抽象出这么一个基础层是非常非常具有挑战性的工作。在目前大型iOS工程不合理的反向依赖/反射注入大行其道的工程现状下,几乎无法做到。因此主端采用了一个符合演进路线并且取巧的方案:
1. 将App的绝大部分非动态代码都下沉到BaseFramework,也就是说BaseFramework是一个事实上的MainFramework。
2. 因为【问题1】的解决思路,MainApp或者MainFramework均不直接依赖动态库(而是DI依赖),因此也不会出现问题。
點擊在新視窗中瀏覽此圖片

方案总结

核心逻辑:在以业务库懒加载,并且有业务代码入侵的前提下,按照最理想的方案重新设计插件化懒加载的理想演进路线,以确保动态库懒加载的长期可行性。。
方案优势:方案合理性高,不trick。方案有利于代码拆分分层。
方案劣势:落地成本极高。相当于需要迁移到动态库的代码的所有依赖方都需要协同改动。对于业务层代码还可以接受(本来业务层代码从组件化设计上就尽量DI/Router解耦的),但是对于基础库代码完全不能接受。
应用场景:因此,这个方案主要以落地业务代码为主。
  - 主端将这个方案,结合Effect方案在一起,实现业务层与底层的动态库方案的全覆盖。



新方案

咱们的现状与诉求

我们希望落地动态库懒加载的背景与目标其实很直观:
1. 【背景】我们应用了大量的各方中台库来支撑起相对复杂的业务,二三方依赖繁多占用资源高。
2. 【背景】大部分二三方依赖中的代码实际上并没有被调用,或者只是部分场景被调用。完全无用代码率75%。大量二三方场景集中在非核心路径上,可以懒加载。
3. 【思考】同时,因为业务形态复杂,用户特征也复杂,比如我们有大量用户完全不消费直播,但是他们依然需要加载初始化硕大的直播相关SDK(这类大型二方库也会有一定自加载逻辑驱动起整个SDK),很难针对用户进行千人千面的减负。

因此,为了整体客户端启动耗时与性能角度,希望能较大面积落地这类方案。因为我们希望落地的主要是大型二方库与三方库,因此我们会面临以下痛点:
1. 由于大量的二三方库,甚至很多大型二方库不给我们开放源码,我们很难入侵依赖代码与被依赖代码。
  - 对应的对于【问题1.2】OC依赖问题,主端方案与我们Unity时的方案均不适用,我们需要正面解决OC依赖问题。
2. 同时由于没有预期对业务代码的入侵,同时我们目前也没有jojo这样较为灵活的构建基建,我们希望整个方案对现状项目影响尽可能的少。
  - 对应的主端通过MainFramework那样大构建流程改动解决【问题2】的方案,我们并不倾向于使用,我们希望选择更轻并且同样具有合理性的MainApp依赖方案。

解决OC符号依赖问题【问题1.2】

正面解决这个问题,首先我们需要解决

解决OC符号Undefined Symbols问题

因为原理上macho本身是支持动态库中的符号弱引用的。正如weak_framework一样:

如对于某一个系统Framework A,只在>=iOS15才存在。
对于我们编译的代码来说,iOS15以下工程里调用相关代码是可以存在的,只是真的调到了的话会找不到函数或者找不到方法发生异常,因此我们需要用if (@available(iOS 15, *))给保护起来。

因此,一定有一种方法标记某一些framework或者某一些符号不是强依赖的。
__attribute__((weak_import))
// https://clang.llvm.org/docs/AttributeReference.html#weak-import

所有符号无论是C函数还是OC类,添加上这个attribute后就会被视为弱引用,运行时可以不存在,就像系统的weak framework一样。

不过如果要让我们的动态库中的符号都是弱引用,我们依然需要修改它的所有头文件,将所有class都加上这个标记,并且依赖的代码重新进行构建——这样还是对业务代码有所入侵。有无更平滑的方案?
设想对于一个link过程,某个符号有些构建产物是weak引入的,有些构建产物是正常引用的,一定会发生冲突。因此ld实际给出了解决方案:
     -weak_reference_mismatches treatment
             Specifies what to do if a symbol is weak-imported in one object
             file but not weak-imported in another.  The valid treatments are:
             error, weak, or non-weak.        The default is non-weak.

这个link参数便是决定在遇到冲突时怎么处理,并且可以选择都处理为weak。那么打开思路,目前当前项目中某个符号所有正常代码都是强引用的情况下,我只需要单独生成一份将目标符号强行声明为weak,并且保障加入link过程,就可以让某个符号强行变为weak的。

实践上,比如如果我们缺少:
Undefined symbols for architecture arm64:
  "_OBJC_CLASS_$_MyClass", referenced from:

我们根据缺少的符号列表生成如下代码加入构建:
__attribute__((weak_import)) void OBJC_CLASS_$_MyClass(void); // 声明weak版的MyClass
//...other class...

@interface DCD_AutoCode_FrameworkKeeper : NSObject
@end
@implementation DCD_AutoCode_FrameworkKeeper
+ (void)useSymbols {
    NSLog(@"%p", &OBJC_CLASS_$_MyClass);// 在一个需要编译的imp中使用这个符号
    //...other class...
}
@end

顺带一提这个代码看起来满满的不合理,但是实际上是合理的

对于编译器来说,所谓符号就是一个字符串对应一个地址
@interface MyClass : NSObject @end 和 void OBJC_CLASS_$_MyClass(void); 包括extern void *OBJC_CLASS_$_MyClass;都是字符串_OBJC_CLASS_$_MyClass,因此以上代码是有效的。
甚至&OBJC_CLASS_$_MyClass == MyClass.class。以上NSLog也是打印出了MyClass。
这个特性也有三方库在应用,比如protobuf的GPBObjCClass宏。

同理,如果我缺少MyClass,我完全可以在代码中实际写入一个:
void OBJC_CLASS_$_MyClass(void) {
}
就不会有Undefined symbols问题了。只是在实际oc runtime初始化时,会因为MyClass指向的不是一个有效的OC Class而抛出异常。

我们通过正常link时的Undefined Symbols错误信息,生成keep文件,再进行一次link,就可以让运行时这些符号是weak符号了——和我们引用了iOS版本限定的系统符号结果一致。
但是实际上link时还是会报错——因为weak符号这个动作骗过了运行时macho,ld却不认识。因此link时还需要另一个参数辅助:
     -U symbol_name
             Specified that it is ok for symbol_name to have no definition.
             With -two_levelnamespace, the resulting symbol will be marked
             dynamic_lookup which means dyld will search all loaded images.

通过在link参数上串接型似-Wl,-U,_OBJC_CLASS_$_MyClass的参数,ld也会认为这个符号是会动态查找,没定义就没定义吧。以上这两者结合,就可以克服OC符号,同时也包括C/C++符号Undefined Symbols的问题了。
點擊在新視窗中瀏覽此圖片

解决OC符号动态绑定问题

以上事件做了之后,我们的App能编译过并且启动不会因为缺少符号而崩溃,按道理说就可以正常使用OC动态库了吧,然而事实却打脸了——C/C++符号确实好使,但是OC符号还是不行。

试想我们有如下代码:
void foo_fromframework(void);
@interface Class_FromFramework : NSObject @end

codeInMainApp {
    NSLog(@"%p", &foo_fromframework); // 空
    NSLog(@"%p", Class_FromFramework.class); // 空
    NSLog(@"%p", NSClassFromString(@"Class_FromFramework")); // 空
    
    do_myframework_dlopen();
    
    NSLog(@"%p", &foo_fromframework); // 有值
    NSLog(@"%p", Class_FromFramework.class); // 空
    NSLog(@"%p", NSClassFromString(@"Class_FromFramework")); // 有值
}

dlopen之前的3条NSLog都是空指针是必然的,但是完成动态库加载,函数指针能加载出来,NSClassFromString能打印出来,直接使用Class_FromFramework.class的地方却还是为空。也就是说:

OC本身确实支持动态库懒加载。
但是代码中的Weak OC符号并不支持动态绑定。

动态库加载的OC符号只能通过runtime调用,这应该才是weak OC符号方案没有人尝试落地的核心原因。

要解决这个问题,我们要探寻,对于一个weak的符号,调用如如上的NSLog(@"%p", Class_FromFramework.class);,macho到底如何取到这个类的。写一段demo如[MainChild callSomething]放在了一个独立方法里,并且利用hopper看看汇编就很清晰了:
點擊在新視窗中瀏覽此圖片
b/bl是调用方法callSomething,我们知道调用子程序的第一个参数x0即caller即MainChild.class,而这里x0是通过一个固定的地址0x100015000加上一个固定的offset查询取到的。hopper很好心的帮我指明那个地址是什么东西,我们直接点过去一看——
點擊在新視窗中瀏覽此圖片
原来是一个macho的section:__objc_classrefs

稍微谷歌一下,结合系统的动态库与我们自己的动态库情况,我们就能明白__objc_classrefs段落在此处的作用。
1. __objc_classrefs记录的是动态依赖的OC Class的地址,如上大量的Class调用都是通过这个表查询的Class实际地址。
2. __objc_classrefs的记录分为两类,一类类的实现已经在MainApp即同一个macho中了只是有可能创建失败,因此这里已经记录上了地址。另一类即我们会从动态库加载的类,构建后macho这里是空的。正常情况下后者基本上就是各种系统类,UIColor/UIView,但凡是动态库提供的类都会经过如此操作。
3. 结合实际情况,显然在app启动动态库加载(LC_LOAD_DYLIB)完成后,会有一个Bind动作,这个动作中会将动态库中实际的class地址填入__objc_classrefs数据中,完成动态库OC符号绑定操作。
4. 而这个操作,对于我们懒加载dlopen打开的动态库并没有执行。
结果上导致了,动态库中的OC符号,没有进行动态绑定。

因此如果要解决这个问题,我们的思路也很明确了:

尝试模拟操作系统的Bind动作,手动完成OC符号动态绑定过程。

这个思路可行的原因很明确:

显然系统的绑定动作是在App启动的premain阶段并且在动态库加载之后,Runtime初始化之前,也就是说是一个InApp的动作,操作的内存区域也是进程本身可以访问的内存区域。抛开OC Runtime会不会被破坏不谈,既然程序内存是我们自己的,我们当然什么都可以改。何况Class调用过程从如上汇编结果看确实没经过Runtime。

接下来,我们继续寻找操作系统到底是依据什么信息来完成__objc_classrefs的绑定动作的就可以了。经过macho白皮书查询,很快就明确出:
1. __objc_classrefs的绑定并不是一个特殊的过程,和其它C/C++符号绑定一致,通过LC_DYLD_INFO_ONLY端的Bind段落的bind operation数据,完成了动态库中的符号到某个macho中的地址的绑定。
2. 而如果这个macho中的地址是落到__objc_classrefs数据段中的地址,这就是一个WeakClass的绑定过程。
3. 同时我们也注意到,这个数据是在bind段落中,不是weak_bind也不是lazy_bind,很明确bind段落中的操作确实只会执行一次。

那么整个动态绑定过程我们就可以设计出来了:
點擊在新視窗中瀏覽此圖片

简化后的核心逻辑代码看起来如下,详细逻辑看注释:
// [2] 启动时的主要处理逻辑
static void DCDOCSymbolBindService_load_image(const struct mach_header *header, intptr_t slide) {
    Dl_info info, info_my;
    if(dladdr(header, &info) == 0) {
        return;
    }
    if(dladdr(&DCDOCSymbolBindService_load_image, &info_my) == 0) {
        return;
    }
    // [3] 判断当前回调的是主macho即MainApp
    if (strcmp(info.dli_fname, info_my.dli_fname) == 0) {
        [DCDOCSymbolBindService startBuildSymbolInfo:header slide:slide];
    }
}

// 待加载OC符号-地址映射
static NSDictionary<NSNumber *, NSString *> *kDCDOCSymbolBindService_SymbolInfo = nil;

@implementation DCDOCSymbolBindService

+ (void)load {
    // [1] 逻辑入口,这个会立刻回调所有的二进制文件,我们从中找到主程序的二进制,之所以用这个是因为会直接给到macho头
    _dyld_register_func_for_add_image(DCDOCSymbolBindService_load_image);
}

+ (void)startBuildSymbolInfo:(const struct mach_header *)header slide:(intptr_t)slide {
    segment_command_t *cur_seg_cmd;
    
    // 需要记录在下文中使用的信息
    section_t *sec_objc_classrefs;// 找到__objc_classrefs 这个section,这个是实际动态加载的地址
    segment_command_t *linkedit_segment = NULL;// 记录linkedit信息用于计算偏移
    struct dyld_info_command *dyld_info_command = NULL; // 找到dynamic loader部分
    uint64_t base_addr = 0;// 记录一个基础地址用于dynamic loader处理
    
    // [4] 遍历所有的segment和核心segment(LC_SEGMENT_64)中的section
    
    // 顺带记录下segment信息,主要是查地址
    NSMutableArray<NSDictionary *> *segmentInfos = [NSMutableArray arrayWithCapacity:header->ncmds];
    
    uintptr_t cur = (uintptr_t)header + sizeof(mach_header_t);
    for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
        cur_seg_cmd = (segment_command_t *) cur;
        segmentInfos[i] = @{
            @"name": [NSString stringWithUTF8String:cur_seg_cmd->segname] ?: @"",
            @"vmaddr": [NSNumber numberWithUnsignedLongLong:cur_seg_cmd->vmaddr],
            @"vmsize": [NSNumber numberWithUnsignedLongLong:cur_seg_cmd->vmsize]
        };// 记录了下信息
        if (strcmp(cur_seg_cmd->segname, SEG_LINKEDIT) == 0) {
            linkedit_segment = cur_seg_cmd;
        }
        if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) { // 核心segment
            base_addr = cur_seg_cmd->vmaddr;
            for (uint j = 0; j < cur_seg_cmd->nsects; j++) {
                section_t *section = (section_t *) (cur + sizeof(segment_command_t)) + j;
                if ([[NSString stringWithCString:section->sectname encoding:NSUTF8StringEncoding] hasPrefix:@"__objc_classrefs"] && [[NSString stringWithCString:section->segname encoding:NSUTF8StringEncoding] isEqualToString:@"__DATA"]) { // 找到__objc_classrefs记录下来
                    sec_objc_classrefs = section;
                }
            }
        } else if (cur_seg_cmd->cmd == LC_DYLD_INFO_ONLY) { // 找到dyld_info_command记录下来
            dyld_info_command = (struct dyld_info_command *)cur_seg_cmd;
        }
    }
    
    // [4] 遍历结束
    if (!sec_objc_classrefs) {
        return;
    }
    if (!dyld_info_command || !linkedit_segment) {
        return;
    }
    // [4] 遍历结束,判空
    
    // [5] 接下来要遍历dyld_info_command
    // 遍历这个的目的是查询动态绑定的符号中,所有的(BIND_SPECIAL_DYLIB_FLAT_LOOKUP)也就是运行时查找的oc符号,并且找到符号对应在objc_classrefs中的地址
    // 要遍历dyld_info_command,由于dyld_info_command的普通绑定区(Dyld binds)是由一条条操作(OPCODE operation code)组成的,所以实际上等小鱼依次执行每条operation,重现出整个启动时的绑定动作
    // 这里的关键点是,动态查找oc类是在普通绑定区中(Dyld bind)而不是懒绑定区(Dyld lazy_bind),这也是为啥动态库加载后这个不会自动变的原因
    // 所以实际上我们做的事情是,通过查询普通绑定区的数据,得到我们想要动态绑定的oc符号的地址,然后手工执行动态绑定
    uintptr_t linkedit_base = (uintptr_t) slide + linkedit_segment->vmaddr - linkedit_segment->fileoff;
    uintptr_t bindinfo_location = dyld_info_command->bind_off + linkedit_base;
    uintptr_t bindinfo_location_end = bindinfo_location + (uintptr_t)dyld_info_command->bind_size;
    __block BOOL isDone = NO;
    
    // 这些都是遍历dyld_info_command需要的中间变量
    int64_t libOrdinal = 0;
    uint32_t type = 0;
    int64_t addend = 0;
    NSString * symbolName = nil;
    uint32_t symbolFlags = 0;
    size_t ptrSize = sizeof(uint64_t);
    uint64_t address = base_addr;
    
    // [7] 如果是绑定过程,这个block就是在执行绑定,但是这里其实是在记录绑定操作,在下文遍历中使用
    // 用了一个字典来记录目标的动态绑定符号->地址的关系
    NSMutableDictionary *ocDylibSymbolInfo = [NSMutableDictionary dictionary];
    void (^recordBindInfo)(NSString *, uint64_t, uint32_t, int64_t, uint32_t) = ^(NSString * _symbolName, uint64_t _addr, uint32_t _type, int64_t _libOrdinal, uint32_t _symbolFlags) {
        isDone = YES;
        if (_libOrdinal == BIND_SPECIAL_DYLIB_FLAT_LOOKUP) {
            isDone = NO;
            if ([_symbolName hasPrefix:@"_OBJC"]) { // 如果是oc符号, 并且是动态查找那些
                section_t *classrefs = sec_objc_classrefs;
                if (_addr >= classrefs->addr && _addr < classrefs->addr + classrefs->size) { // 并且符号虚拟地址落到了classref区
                    uint64_t offset = _addr - classrefs->addr;
                    uint64_t act_addr = sec_objc_classrefs->offset + (uintptr_t)header + offset; //计算得到实际内存中classref区的地址
                    ocDylibSymbolInfo[[NSNumber numberWithUnsignedLongLong:act_addr]] = _symbolName; // 记录 内存地址 -> 符号名
                }
            }
        }
    };
    
    
    // [6] 开始遍历dyld_info_command
    while (bindinfo_location < bindinfo_location_end && !isDone) {
        uint8_t byte = *(uint8_t *)bindinfo_location;
        bindinfo_location += sizeof(int8_t);
        uint8_t opcode = byte & BIND_OPCODE_MASK;
        uint8_t immediate = byte & BIND_IMMEDIATE_MASK;
        switch (opcode) {
            case BIND_OPCODE_DONE:
                isDone = YES;
                break;
            case BIND_OPCODE_SET_DYLIB_ORDINAL_IMM:
                libOrdinal = immediate;
                break;
            case BIND_OPCODE_SET_DYLIB_ORDINAL_ULEB:
                libOrdinal = dcdutils_read_uleb128_length(&bindinfo_location);
                break;
            case BIND_OPCODE_SET_DYLIB_SPECIAL_IMM:
                if (immediate == 0) {
                    libOrdinal = 0;
                } else {
                    int8_t signExtended = immediate | BIND_OPCODE_MASK;
                    libOrdinal = signExtended;
                }
                break;
            case BIND_OPCODE_SET_SYMBOL_TRAILING_FLAGS_IMM:
                symbolFlags = immediate;
                symbolName = [NSString stringWithCString:bindinfo_location encoding:NSASCIIStringEncoding];
                bindinfo_location += symbolName.length + 1;
                break;
            case BIND_OPCODE_SET_TYPE_IMM:
                type = immediate;
                break;
            case BIND_OPCODE_SET_ADDEND_SLEB:
                addend = dcdutils_read_uleb128_length(&bindinfo_location);
                break;
            case BIND_OPCODE_SET_SEGMENT_AND_OFFSET_ULEB: {
                uint32_t segmentIndex = immediate;
                uint64_t val = dcdutils_read_uleb128_length(&bindinfo_location);
                address = ((NSNumber *)(segmentInfos[segmentIndex][@"vmaddr"])).unsignedLongLongValue + val;
                break;
            }
            case BIND_OPCODE_ADD_ADDR_ULEB: {
                uint64_t val = dcdutils_read_uleb128_length(&bindinfo_location);
                address += val;
                break;
            }
            case BIND_OPCODE_DO_BIND:
                recordBindInfo(symbolName, address, type, libOrdinal, symbolFlags);
                address += ptrSize;
                break;
            case BIND_OPCODE_DO_BIND_ADD_ADDR_ULEB: {
                recordBindInfo(symbolName, address, type, libOrdinal, symbolFlags);
                uint64_t val = dcdutils_read_uleb128_length(&bindinfo_location);
                address += ptrSize + val;
                break;
            }
            case BIND_OPCODE_DO_BIND_ADD_ADDR_IMM_SCALED: {
                uint32_t scale = immediate;
                recordBindInfo(symbolName, address, type, libOrdinal, symbolFlags);
                address += ptrSize + scale * ptrSize;
                break;
            }
            case BIND_OPCODE_DO_BIND_ULEB_TIMES_SKIPPING_ULEB: {
                uint64_t count = dcdutils_read_uleb128_length(&bindinfo_location);
                uint64_t skip = dcdutils_read_uleb128_length(&bindinfo_location);
                for (uint64_t index = 0; index < count; index++) {
                    recordBindInfo(symbolName, address, type, libOrdinal, symbolFlags);
                    address += ptrSize + skip;
                }
                break;
            }
            default:
                break;
        }
    }
    // [6] 开始遍历dyld_info_command-结束
    
    // [8] 在[7]记录的动态符号字典保存下来了
    kDCDOCSymbolBindService_SymbolInfo = ocDylibSymbolInfo.copy;
}

// [9] 这是流程的最后一步,这个方法在每个动态库完成绑定后调用一下,当然也可以利用其它方式调用,再说
+ (void)doSymbolRebind {
    NSDictionary *dylibSymbolInfo = kDCDOCSymbolBindService_SymbolInfo.copy;
    NSMutableDictionary *dylibSymbolInfoUpdate = dylibSymbolInfo.mutableCopy;
    // 遍历我们在[7][8]记录的这个动态符号字典
    for (NSNumber *key in dylibSymbolInfo) {
        NSString *symbol = dylibSymbolInfo[key];
        if ([symbol hasPrefix:@"_OBJC_CLASS_$_"]) {// 找出其中的class
            NSString *className = [symbol substringFromIndex:14];
            Class clz = NSClassFromString(className);// 尝试初始化class
            uintptr_t *addr = key.unsignedLongLongValue;
            if ((uintptr_t)*addr > 0) {
                [dylibSymbolInfoUpdate removeObjectForKey:key];
            } else  if (clz) {// 如果找到了
                *addr = clz;//[核心代码] 把class赋予给记录中的地址,这一行代码实际完成class动态绑定,名副其实的核心代码
                [dylibSymbolInfoUpdate removeObjectForKey:key];
            }
        }
    }
    if (dylibSymbolInfoUpdate.count != dylibSymbolInfo.count) {
        kDCDOCSymbolBindService_SymbolInfo = dylibSymbolInfoUpdate.copy;
    }
}

@end

利用以上过程,在动态库加载完成后重新执行一次OC符号绑定,代码中的OC符号就可以正常使用了。
另外:

- 上面这段逻辑,其实显然最麻烦的部分是遍历dyld_info_command,等效于又重新执行了一次app启动过程中的bind过程。实际测试在普通后台线程执行,我们在低端机执行这段逻辑要消耗150-200ms,简直爆炸。不过很巧的是,macho实际上把动态查询的符号放在了dyld_info_command最最最前面,也就是说实际上只需要将符号绑定数据遍历到动态符号结束,就可以结束逻辑了。实际是这段逻辑在低端机上只需要执行3-5ms。

- 另外熟悉的大家已经注意到了,这段逻辑与fishhook非常相似——fishhook的原理也是修改动态绑定的C/C++函数地址(就是上面验证中本来就支持的那部分),以实现hook的。当然,具体的实现涉及到的section数据,绑定逻辑和OC符号绑定逻辑完全不一样(核心是__la_symbol_ptr数据),因此没有可参考性。

- 同时,上面我们这段OC符号动态绑定逻辑,也就意味着,我们实际上可以在运行时对所有通过动态库依赖,或者声明通过classref依赖上的OCClass做类级别的替换。比如代码写的NSLog(@"%@", UIViewController.class),结果我们把UIViewController替换为了AMViewController。

解决主应用依赖问题【问题2】

回到【问题2】,当我们用debug包进行动态库加载的时候,其实什么问题也没有,但是用inhouse配置进行构建,进行dlopen的时候报错了:还是找不到符号
对于release构建,动态库从MainApp依赖的符号需要特殊处理保证存在。
这整个过程有2个点:

1. 动态库,本身的构建,缺少符号。
  - 主端方案利用MainFramework,将动态库构建时的符号问题给依赖上了。
  - 而我们选择更符合苹果设计预期的方案:
     -undefined treatment
             Specifies how undefined symbols are to be treated. Options are:
             error, warning, suppress, or dynamic_lookup.  The default is
             error. Note: dynamic_lookup that depends on lazy binding will not
             work with chained fixups.

  - 利用-Wl,-undefined,dynamic_lookup,让动态库构建时缺少的符号一律标记为dynamic_lookup。
  - 主端文档中也有讨论这个方案,他们没有实施的主要原因是,认为构建时将符号无脑标记为dynamic_lookup并不可控。不过实际是另一个角度,framework最后要合入MainApp也会经历MR构建过程,只需要在MainApp构建过程中保证所有符号都存在。也可以保证这个过程的稳定性。

2. 那么具体在MainApp中如何保证符号存在——当然就是link时显式的要求符号存在。
  - 我们在第一点是构建出来动态库产物,可以通过如下命令:
nm - list symbols from object files
nm -m ../xxxx.framework/xxxx

點擊在新視窗中瀏覽此圖片

  - 即可获得一份这个动态库的依赖符号列表,其中筛选出标记为dynamically looked up的符号即是在动态库构建中我们跳过的那些依赖符号。
  - 回到MainApp,在link的时候强调必须要哪些符号也很简单。
     -u symbol_name
             Specified that symbol symbol_name must be defined for the link to
             succeed.  This is useful to force selected functions to be loaded
             from a static library.

  - 当然,我们不但要保持住相关符号,还需要将这些符号导出出来,因此在实际我们用了另一个参数
     -exported_symbols_list filename
             The specified filename contains a list of global symbol names
             that will remain as global symbols in the output file.  All other
             global symbols will be treated as if they were marked as
             __private_extern__ (aka visibility=hidden) and will not be global
             in the output file. The symbol names listed in filename must be
             one per line.  Leading and trailing white space are not part of
             the symbol name.  Lines starting with # are ignored, as are lines
             with only white space.  Some wildcards (similar to shell file
             matching) are supported.  The * matches zero or more characters.
             The ? matches one character.  [abc] matches one character which
             must be an 'a', 'b', or 'c'.  [a-z] matches any single lower case
             letter from 'a' to 'z'.

  - 这个动作不仅会让我们export的符号明确的在MainApp的中导出,还可以:
    - 和-u相同的作用,保证符号必须存在否则报错。
    - 结合STRIP_STYLE设置为non-globel symbols,加上保证GCC_SYMBOLS_PRIVATE_EXTERN总是为No,我们可以保证相关符号在release的link过程中完全不被裁剪。就像被确实的引用到了一样。

  - 因此,这里的过程结合上面解决OC符号的过程,MainApp的link又多了一些输入
點擊在新視窗中瀏覽此圖片

上面提到了strip过程,这也是在构建依赖时我们在意的:

在动态库改造之后,无用符号如何有效的裁剪。

如上的MainApp的过程已经把主工程里的无用符号裁剪干净了,那动态库里的怎么办呢?
- 原则上动态库是不能进行无用符号裁剪的,毕竟整体都是提供给别人使用的,自己怎么知道哪些是无用符号
但是我们将动态库纳入特定MainApp中,我们就有足够的信息了:
- MainApp第一步正常构建的Undefined Symbols本质上就是我们向动态库提出的暴露符号诉求。
- exported_symbols_list再结合strip确实可以做到动态库本身的裁剪。

因此如果完整梳理出我们预期的完整构建流程,会变成这样:
點擊在新視窗中瀏覽此圖片

其中排除方框中的环节,其实就是一个基于现有动态库的情况下,最理想的正常构建过程。而放在一起,则是一个经历了两次link的动态库与MainApp最小化协同构建过程。



新方案工程落地

基于Cocoapods工程落地

我们目前还保持的传统的Cocoapods+XCodeBuild工程结构,并且公司的各种二方组件也是以静态依赖为主。这个体系下:
1. 实际上是不太可能完整的完成如上流水线的,但凡要完成两次link,产物符号导入导出,都需要打破XCodeBuild自定义构建step流水线才能完成。
2. 同时上面这个过程会明确的拉长我们的构建耗时,单是两次link就是平均40s的构建耗时增长,整个过程累加上来耗时增长会很离谱。

因此目前落地的选择上:
并没有采用完整的协同构建方案,而是线下单独完成了动态库构建过程(即如上方框过程),App构建在XCodeBuild驱动下是一次正常的构建。
实际项目中的补充过程包括:

1. 提供动态库
  - 同时提供每一个动态库输出的符号列表,也就是MainApp依赖的符号列表

2. 大量post_install过程,假如我们要将EEVideoEditor以动态库加入工程,并且动态库已经准备好,我们需要
  - 将EEVideoEditor的静态链接从link参数中移除
  - 将EEVideoEditor动态库版本加入cocoapods的[CP] Embed Pods Frameworks过程,即拷贝与签名过程
  - 扫描动态库,列出动态库依赖的符号列表,生成exported_symbols_list
  - 根据上一步的动态库输出符号列表,生成keeper文件加入构建源码;生成-U与其它相关link参数加入其中
这样,主工程的构建落地就完成了。

在我们应用中的加载时机【问题3】

回到工程上,其实解决【问题3】的最佳实践确实是主端业务库方案
- 所有调用利用DI / 路由等中间件调用,动态库加载在这个过程中同步进行。
- 注册动态库生命周期,动态库自己负责自己的初始化。(类似于客户端微服务化的路径)

不过落地到我们,我们的方案前提是:代码可以直接调用动态库中的代码,并且业务代码并不进行介入。因此再三考虑,我们【问题3】的综合落地方案是:
- 动态库加载不与动态库耦合,而是以启动任务的形式注册在MainApp,注册时也包含注册完成回调与后续处理,以实现简单的生命周期的效果——实际上就是有一些启动任务的SDK初始化过程,需要从正常启动任务转移到动态库加载完成回调。
- 加载时机同样与DI与Router耦合,不过不同的是,并不和加载过程本身耦合。在加载任务注册的时候,选择一些感觉可以在这个时候调起动态库的前驱路径即可。
  - 在这些路径上,可以选择是同步加载还是异步加载,如果即将打开的页面就是动态库提供的,那么就得在拦截阶段同步完成动态库加载与其回调比如补充注册路由。这样才能在拦截结束后正常打开页面。
  - 当然如果一些路径是,页面中的某些功能,或者是二级页面才会使用到动态库的东西,就异步加载就好,就当是一个预热。
- 当然,也设计了常用用户的特殊懒加载路径,如果判断某个动态库用户经常访问,则启动首页加载完成后就后台拉进来。

有一个疑问是,注意到无论是前面的历史方案,还是目前设计的方案,大量采用同步加载甚至阻塞主线程:

动态库在主线程同步加载合理么?

原则上,我们不应该随意阻塞主线程的,但是考虑到动态库加载的特殊性:
1. 大量Router/DI调用的预期本身就是同步调用,直接处理为异步反而可能导致不必要的边界条件。
2. 从UnityFramework(30-40MB,目前接触的最大的动态库),实际加载耗时在50-200ms,低端机也不过500ms顶天了,事实上App里页面进出场景偶尔卡顿一个200ms用户感知也不会很强。
3. 同时目前统计到的动态库加载成功率,在排除特定系统在特定用法下有缺陷的问题,明确是100%的成功率也未出现过超时,因此本身加载过程也不存在风险。

结论上,看起来没问题。一段实际注册代码看起来如下:
        NSMutableDictionary *config = 
        @{
            @"checkClz": @"HTSPlayer",
            @"syncRoute": @[@"publish", @"take_driving_license_picture", @"co_create_publish", @"graphic_post", @"ugc_repost", @"graphic_post_with_publisher"],
            @"asyncRoute": @[@"new_publish"]
        };
        [DCDLazyFrameworkLoaderTask createLoaderTaskWithFramework:@"DCDVideoEditor_Lazy"
                                                           config:config
                                                       beforeHook:nil
                                                        afterHook:^BOOL{
            //启动设置SDK
            [EEParamModule sharedInstance].useOpenGLES3 = YES;
            dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
                [EEPreloadModule prepareVEContext]; //耗时操作,放子线程(该初始化不要求放主线程)
            });
            [EEParamModule setResourceFinder:[[EEEffectManager manager] getResourceFinder]];
            return YES;
                                                                    }
                                                         callback:nil];

點擊在新視窗中瀏覽此圖片

实际落地的困难与方案瓶颈

虽然以上流程看起来已经非常顺畅了,实际执行的时候还是遇到了一些困难。

仍然不可懒加载的OC符号

比如,在实验的阶段,我们尝试将AMap3D(3D版高德地图)进行动态化,当我们开始处理符号后,就会注意到,OC相关的常见符号其实有3类:
_OBJC_CLASS_$_MyClass
_OBJC_METACLASS_$_MyClass
_OBJC_CATEGORY_$_MyClass

- 其中METACLASS是指的,如果ClassA继承于MyClass,那么就是依赖了METACLASS符号。
- 而CATEGORY是指的如果某个文件编写了MyClass的category,则是依赖了CATEGORY符号。

这两者在我们代码中处处可见。而显然,动态库加载并且按照我们的方案进行动态绑定之后,这两个符号是对应的继承/拓展行为依然是无效的。(毕竟苹果对于OC符号动态加载的一系列工作都没做)

比如以继承为例子,ClassA继承于MyClass,那到底是在什么时机消费的_OBJC_METACLASS_$_MyClass呢?翻阅runtime源码,我们可以看到:
Class readClass(Class cls, bool headerIsBundle, bool headerIsPreoptimized)
{
    const char *mangledName = cls->nonlazyMangledName();

    if (missingWeakSuperclass(cls)) {
        // No superclass (probably weak-linked).
        // Disavow any knowledge of this subclass.
        if (PrintConnecting) {
            _objc_inform("CLASS: IGNORING class '%s' with "
                         "missing weak-linked superclass",
                         cls->nameForLogging());
        }
        addRemappedClass(cls, nil);
        cls->setSuperclass(nil);
        return nil;
    }
    ...

在App启动,即每个macho加载阶段的OC Class初始化阶段,一个Class找不到自己的super class,就会放弃初始化被置为nil了。

可以想想我们有一个类DCDButtonConfiguration继承于UIButtonConfiguration,而UIButtonConfiguration是iOS>=15才在UIKit存在的。那么在iOS14真机上启动App时,runtime尝试初始化DCDButtonConfiguration就会失败,代码中最后感知到DCDButtonConfiguration.class == nil。
同样在启动时初始化的category,当然行为同理。

如何解决这个问题呢?

现阶段,我们用了笨办法:
1. 我们可以通过MainApp的Undefined Symbols情况知道有哪些文件以继承/拓展的方式依赖动态库的。
2. 入侵业务代码把,把相关代码copy一份到构建到动态库中。这样动态库相当于是目标库本身+继承/拓展目标库的.o文件。当然动态库的向下依赖也会扩大,但是还是符合我们整体的规则。
3. 这个做法的主要问题是本质上需要入侵依赖方的业务代码。比如我们目前面临直播LiveCore继承依赖了RTC基础framework,这样RTC就完全无法做懒加载——因为LiveCore是闭源二方库我们搞不动——除非我们把整个LiveCore也打入RTC或者LiveCore是一个更晚加载进App的动态库,总的来说即保证LiveCore继承于RTC的类,在类初始化时RTC已存在。

当然这个问题,以上主端的方案当然也存在,不过他们已经限定只能通过DI/Router依赖动态库了,也自然就规避了这样的问题。
另外长期看,组合大于继承,对于基础库我们还是尽量避免继承,就算要写category也尽量收敛/符号解耦。这样工程代码的独立性也会好很多。
当然,长期看。虽然上文中OC符号动态绑定本身确实没有涉及到OC Runtime的魔改,但是思路还是这样:

只要是InApp的,我们进程内存里的东西,我们都可以改。

长期看,我们依然可以努力寻找方案,让继承类/拓展失效后,动态库加载后重新注册生效。



后续落地规划

目前整个方案在技术灰度验证与寻找场景落地的阶段。因此对于未来来说,我们的动态库懒加载会是一个逐步成熟应用的过程:

1. 广泛覆盖我们iOS基础库与部分业务能力,优化用户体验或者收益增长
  - 确实的落地目前的可动态库懒加载覆盖范围,针对用户习惯优化各个懒加载/预热行为,对应用启动耗时与整体运行时负担带来明确收益与增长。
  - 结合千人千面的应用形态相关,利用动态库能力构建千人千面的我们应用形态。

2. 建设动态库协同构建过程,优化线下业务动态库开发体验
  - 依托于构建系统切换bazel带来的高度构建过程可自定性与拓展性,我们得以更自然高效的实现设计中我们预想的协同构建过程。
  - 完善动态库InApp开发,配置与迁移能力,力图在尽可能少的配置和完全避免业务入侵的情况下完成基础与业务动态库的开发建设能力。

3. 攻克技术难点,打造适配绝大部分业务代码的动态库迁移基建
  - 继续攻克如上描述继承/拓展等动态库方案遗留困难点,保证代码总在最理想的状态下更自由的拆解组合,打造高通用型iOS动态库加载能力。

4. 动态库懒加载能力与其中间能力插件化服务化,利用动态库与符号相关能力落地更广泛的线上线下优化方案
  - 利用动态化能力构建线下独立构建 / 插件化 / 模块hotreload能力,优化开发 / 联调 / 测试效率与体验。
关键词:ios , objective-c
logo