27

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

 5 years ago
source link: http://www.cocoachina.com/ios/20181112/25437.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

user.png 游星啊· 2018-11-12 阅读数 2171
本文来自 Soulghost ,作者 游星啊

背景

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

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

效果

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

1.gif

源码

https://github.com/Soulghost/Dyamk

原理

概述

2.jpg

上图是Dyamk的架构和工作流程图,Dyamk主要包括两个部分,一个是用于创建和分发动态库的DyamkInjector,另一个是运行于宿主Main App当中的DyamkClient。

DyamkInjector是一个iOS动态库工程,当动态库完成编译后,会运行一系列脚本,将动态库签名、移动到共享目录、通过Socket通知DyamkClient有新的动态库可加载。

宿主Main App中的DyamkClient在收到Socket消息后,会从共享目录中加载新生成的动态库,由于Dyamk已经约定好了动态库的切面执行方式,因此动态库加载后会按照约定的接口进行执行,从而动态修改已有的逻辑,实现动态Native代码调试。

注入器部分

注入器主要由两个Target构成,一个是Xcode动态库工程DyamkInjector,用于编译和生成动态库,另一个是前者的Aggregate对象BuildMe,用于实现在动态库签名之后的移动和通知,这里之所以使用了一个Aggregate对象,是为了保证动态库签名完成后才执行后续脚本。

3.jpg

在DyamkInjector工程中,包含了一个编译前脚本Do symbol replace,用于实现动态符号替换,这里替换的是动态库源码的类名,做这个替换的目的在于Objective-C的运行时动态库加载限制。在Objective-C中使用dlopen打开动态库后,不能通过dlclose将其关闭,也不能通过dlopen实现同名覆盖,有关内容可以参考https://stackoverflow.com/questions/8793099/unload-dynamic-library-needs-two-dlclose-calls。因此在每次生成动态库时,对动态库的名称以及动态库内的类名都进行了动态替换,替换的方式为提供一个计数后缀,形如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用于越狱设备插件的动态调试,将能够极大的提高开发效率。

作者:Soulghost

链接:https://juejin.im/post/5b41a908e51d4519962e87e8


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK