通过dylib实现iOS运行时Native代码注入(动态调试)
背景
在我们调试React Native或是Weex程序时,借助于JavaScript的动态执行能力,可以实现代码的动态注入与热更新调试,从而大大提高了UI和逻辑的调试效率。相反的,在Native代码编程中,一般而言都需要不断地重启App来调试新代码,对于一些编译和链接脚本复杂的项目这无疑大大降低了开发效率,这时候,可以借助dlopen
打开动态库和切面编程
的思想来实现运行时动态库加载和逻辑替换,从而实现动态代码注入。需要注意的是,该方式在Release到App Store的App中是被明令禁止的,且真机也无法通过dlopen
打开一个没有跟随App一起签名的动态库,所以此方法仅能用于模拟器调试。
笔者通过上述原理实现了一个Native代码热部署的调试框架,命名为Dyamk,本文将介绍其原理和使用方式。
效果
下面的GIF演示了一个简单的代码注入。

源码
原理
概述

上图是Dyamk的架构和工作流程图,Dyamk主要包括两个部分,一个是用于创建和分发动态库的DyamkInjector
,另一个是运行于宿主Main App当中的DyamkClient
。
DyamkInjector
是一个iOS动态库工程,当动态库完成编译后,会运行一系列脚本,将动态库签名、移动到共享目录、通过Socket通知DyamkClient
有新的动态库可加载。
宿主Main App中的DyamkClient
在收到Socket消息后,会从共享目录中加载新生成的动态库,由于Dyamk已经约定好了动态库的切面执行方式,因此动态库加载后会按照约定的接口进行执行,从而动态修改已有的逻辑,实现动态Native代码调试。
注入器部分
注入器主要由两个Target构成,一个是Xcode动态库工程DyamkInjector
,用于编译和生成动态库,另一个是前者的Aggregate对象BuildMe
,用于实现在动态库签名之后的移动和通知,这里之所以使用了一个Aggregate对象,是为了保证动态库签名完成后才执行后续脚本。

在DyamkInjector
工程中,包含了一个编译前脚本Do symbol replace
,用于实现动态符号替换,这里替换的是动态库源码的类名,做这个替换的目的在于Objective-C的运行时动态库加载限制。在Objective-C中使用dlopen
打开动态库后,不能通过dlclose
将其关闭,也不能通过dlopen
实现同名覆盖,有关内容可以参考stackoverflow.com/questions/8…。因此在每次生成动态库时,对动态库的名称以及动态库内的类名都进行了动态替换,替换的方式为提供一个计数后缀,形如SomeClass_1
、SomeClass_2
。
为了保证注入器生成的动态库及其符号和宿主App中的DyamkClient
读取的相关内容的一致性,需要通过一个共享文件来记录当前动态库的名称以及符号名称,这个文件被命名为framework_version
,并通过数字存储当前的符号后缀值,这个文件和动态库被保存在同一目录下,以便为注入器和宿主中的Client共享,在Dyamk中,使用了/opt/Dyamk/dylib
作为共享文件夹,这也利用了iOS模拟器能够读取macos文件系统这一特性。
通过上述描述,Do symbol replace
脚本的功能变得清晰起来,它需要读取共享文件下的framework_version
文件,并完成动态库的符号替换。
#!/bin/sh
# 拼接framework_version的路径
cd /opt/Dyamk/dylib
path=`pwd`'/'
number_name='framework_version'
number=$path$number_name
v=0
# 判断文件是否存在
if [ -e $number ]; then
# 存在则直接读取
v=`cat $number_name`
else
# 不存在则按照0处理
echo 0 > $number_name
fi
# 通过正则表达式动态替换动态库源码中的符号
sed -i -e 's/DyamkNativeInjector_[0-9]*/DyamkNativeInjector_'$v'/g' ${SRCROOT}'/DyamkInjector/core/DyamkNativeInjector.m'
在Aggregate对象BuildMe
中包含了四个脚本,他们均在动态库完成编译、链接、签名后才执行。
-
Delete old dylib
该脚本用于删除共享目录中已生成的动态库,从而保证新生成的能够正确的将其替换。
-
Copy dylib
该脚本使用了Xcode自带的
Copy File Phase
功能,将新生成的动态库复制到共享目录。 -
Process with dylib
该脚本用于替换动态库的名称,与
DyamkInjector
对象中的符号修改逻辑一致,在完成动态库名称修改后,要将framework_version
自增一,从而保证下次能够使用新的名称和符号。#!/bin/sh cd /opt/Dyamk/dylib path=`pwd`'/' number_name='framework_version' number=$path$number_name v=0 if [ -e $number ]; then v=`cat $number_name` else echo 0 > $number_name fi # 获取并替换动态库名称 from="DyamkInjector.framework/DyamkInjector" to="DyamkInjector.framework/DyamkInjector_"$v mv $from $to # 增加framework_version文件中的动态库符号计数 v="$(($v+1))" echo $v > $number_name
-
Trig Update
该脚本用于通知宿主中的
DyamkClient
有新的动态库可以加载,通知管道为Socket。# -*- coding: utf-8 -*- import socket import sys def conn(): args = sys.argv ip = args[1] port = int(args[2]) s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((ip, port)) # 通知消息的内容为当前动态库版本号 f = open('/opt/Dyamk/dylib/framework_version', 'r') number = int(f.readlines()[0]) if number > 0: number -= 1 msg = "{}".format(number) s.send(msg.encode()) s.close() if __name__ == '__main__': conn()
通过上述内容可以知道,DyammInjector
完成了对动态库的生成和加工,以及对宿主App中Client的通知工作,这也是Dyamk中最复杂的部分,Client端部分仅仅需要监听Socket消息并且完成动态库加载,因此逻辑会变成比较简单。
Client部分
Client通过添加一个无侵入的DyamkClient
框架来实现动态库加载,笔者已经将其封装为一个CocoaPods库以方便使用。
Client通过Socket实现消息监听,这里使用了CocoaAsyncSocket
来实现这一功能,有关Socket的监听代码不再赘述,这里主要介绍动态库加载有关的代码。
// 该方法在Socket收到消息后调用,在调用之前已经将当前动态库版本号存储在`_currentDylibNo`成员变量中
- (void)performDylib {
// 共享目录中的dylib根目录
NSString *libPath = @"/opt/Dyamk/dylib/DyamkInjector.framework";
// 在共享目录中拼接动态库二进制路径
libPath = [libPath stringByAppendingPathComponent:[NSString stringWithFormat:@"DyamkInjector_%@", @(self.currentDylibNo)]];
// 打开动态库
void *handle = dlopen(libPath.UTF8String, RTLD_NOW);
if (!handle) {
NSLog(@"Error: cannot find <%@>", libPath);
return;
}
// 拼接动态库符号
NSString *className = [NSString stringWithFormat:@"DyamkNativeInjector_%@", @(self.currentDylibNo)];
// 类加载和切面方法执行
Class class = NSClassFromString(className);
if (class == nil) {
NSLog(@"Error: cannot find class %@", className);
dlclose(handle);
return;
}
[class performSelector:@selector(run)];
// 关闭动态库,由于Objective-C的运行时限制,实际上这一句并不能将动态库卸载
dlclose(handle);
}
每当DyamkInjector
工程的Target BuildMe
编译时,就会通过Socket通知Client,读取和加载动态库,并执行切面方法,从而完成动态代码注入。
切面编程部分
在DyamkInjector
的工程中有一个DyamkCodePlayground.m
文件,其中的__dyamk_debug_code_goes_here
函数是动态库运行的起点,所有需要动态注入的代码都需要在这里去编写,由于所有的代码均以切面的形式存在,因此在处理事件绑定时需要进行运行时方法添加,添加的步骤如下。
处理动态事件绑定
-
新建一个函数,函数的前两个参数类型分别为
id
和SEL
,这是由Objective-C的消息转发机制决定的,其中第一个参数id
为消息接收者,第二个参数SEL
为方法的选择器,这里我们假设为SomeClass的一个添加一个add实例方法,它接收一个参数n,来累加类内的计数器v。void __SomeClass__add(id self, SEL _cmd, int n) { self.v += n; }
-
通过class_replaceMethod实现方法的添加或替换,这里使用replace而不是add是因为在多次加载时,需要对原来已经添加的方法进行覆盖。
class_replaceMethod(NSStringFromClass(@"SomeClass"), @selector(add:), (IMP)__SomeClass__add, "v@:i");
这里需要注意的是最后一个参数,它是方法的
Type Encoding
,可以通过 nshipster.com/type-encodi… 进一步了解。 -
在完成了上述步骤后,就可以以切面形式对某个实例动态添加事件处理函数了,随后即可通过selector的形式将其绑定到特定事件,由于编译期检查不到动态绑定的selector,所以会出现警告,因此
__dyamk_debug_code_goes_here
函数使用预编译指令消除了这一警告。#pragma clang diagnostic push #pragma clang diagnostic ignored "-Wundeclared-selector" void __dyamk_debug_code_goes_here() { // code goes here } #pragma clang diagnostic pop
通过宏函数简化操作
上述事件绑定过程在使用中非常不便,且为了避免符号冲突,需要添加繁琐而冗长的前缀,为了解决这个问题,笔者封装了一系列的宏函数,来解决这一问题,例如函数的定义可以通过宏函数进行简化,下面是对比。
// 原来的实现
void __SomeClass__add(id self, SEL _cmd, int n) {
self.v += n;
}
// 通过宏函数实现
Dyamk_Method_1(void, add, int, n) {
self.v += n;
}
宏函数将每个用于Objective-C消息接收的函数的公共部分进行了抽象,开发者只需要填写返回值类型、函数名和参数列表,这里的参数列表是以type、name、type、name...的形式存在,Dyamk_Method_N
中的N代表所定义的函数除去前两个公共参数外的参数个数。
同样的,动态方法添加也通过宏函数进行了相应简化。
// 原来的实现
class_replaceMethod(NSStringFromClass(@"SomeClass"), @selector(add:), (IMP)__SomeClass__add, "v@:i");
// 通过宏函数实现
Dyamk_AddMethod(SomeClass, @selector(add:), add, v@:i);
使用教程
有关使用的文档可以参考GitHub上的Dyamk Wiki,目前使用Wiki依然在完善中。
不足与展望
笔者曾经尝试将dylib利用网络传送到iOS真机的沙盒中进行真机动态调试,奈何真机的dlopen函数总是失败,同样的动态库如果随着App静态打包则可以进行加载,因此笔者猜测与签名机制有关,这一机制导致该框架暂时只能在模拟器上使用。
对于越狱开发而言,每次修改了dylib后都要进行deb打包和重新安装,以及App重启,对于一些体量较大的App,例如SpringBoard.app会耽误较多的时间,如果能够将Dyamk用于越狱设备插件的动态调试,将能够极大的提高开发效率。
转载自:https://juejin.cn/post/6844903635021725704