在咱们调试React Native或是Weex程序时,借助于JavaScript的动态执行能力,能够实现代码的动态注入与热更新调试,从而大大提升了UI和逻辑的调试效率。相反的,在Native代码编程中,通常而言都须要不断地重启App来调试新代码,对于一些编译和连接脚本复杂的项目这无疑大大下降了开发效率,这时候,能够借助dlopen
打开动态库和切面编程
的思想来实现运行时动态库加载和逻辑替换,从而实现动态代码注入。须要注意的是,该方式在Release到App Store的App中是被明令禁止的,且真机也没法经过dlopen
打开一个没有跟随App一块儿签名的动态库,因此此方法仅能用于模拟器调试。python
笔者经过上述原理实现了一个Native代码热部署的调试框架,命名为Dyamk,本文将介绍其原理和使用方式。git
下面的GIF演示了一个简单的代码注入。github
上图是Dyamk的架构和工做流程图,Dyamk主要包括两个部分,一个是用于建立和分发动态库的DyamkInjector
,另外一个是运行于宿主Main App当中的DyamkClient
。macos
DyamkInjector
是一个iOS动态库工程,当动态库完成编译后,会运行一系列脚本,将动态库签名、移动到共享目录、经过Socket通知DyamkClient
有新的动态库可加载。编程
宿主Main App中的DyamkClient
在收到Socket消息后,会从共享目录中加载新生成的动态库,因为Dyamk已经约定好了动态库的切面执行方式,所以动态库加载后会按照约定的接口进行执行,从而动态修改已有的逻辑,实现动态Native代码调试。bash
注入器主要由两个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文件系统这一特性。app
经过上述描述,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经过添加一个无侵入的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用于越狱设备插件的动态调试,将可以极大的提升开发效率。