通过dylib实现iOS运行时Native代码注入(动态调试)

7,002 阅读8分钟

背景

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

笔者通过上述原理实现了一个Native代码热部署的调试框架,命名为Dyamk,本文将介绍其原理和使用方式。

效果

下面的GIF演示了一个简单的代码注入。

源码

github.com/Soulghost/D…

原理

概述

上图是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_1SomeClass_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函数是动态库运行的起点,所有需要动态注入的代码都需要在这里去编写,由于所有的代码均以切面的形式存在,因此在处理事件绑定时需要进行运行时方法添加,添加的步骤如下。

处理动态事件绑定

  • 新建一个函数,函数的前两个参数类型分别为idSEL,这是由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用于越狱设备插件的动态调试,将能够极大的提高开发效率。