iOS通过注入动态库的方式实现极速编译调试(InjectionIII、热重载、热编译)原理解析

前言

iOS 原生代码的编译调试,都是通过一遍又一遍地编译重启 APP来进行的。所以项目代码量越大,编译时间就越长。虽然我们可以将部分代码先编译成二进制集成到工程里,来避免每次都全量编译来加快编译速度,但即使这样,每次编译都还是需要重启App,需要再走一遍调试流程。幸运的是,John Holdsworth 开发了一个叫做 InjectionIII 的工具可以动态地将 Swift 或 Objective-C 的代码在已运行的程序中执行,以加快调试速度,同时保证程序不用重启。

看过几篇写 Injection 的文章,但都比较老,而且也没有介绍全面,因此决定自己动手写一下,从应用到原理完整介绍一遍。

实践步骤

1. 下载 InjectionIII,并安装好

2. 运行 InjectionIII

InjectionIII 运行后 icon 如下,蓝色针头

3. 修改项目源码

在 - (BOOL)application:(UIApplication *)application willFinishLaunchingWithOptions:(NSDictionary *)launchOptions 方法里添加如下代码

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {

。。。。。。

#if DEBUG
    [[NSBundle bundleWithPath:@"/Applications/InjectionIII.app/Contents/Resources/iOSInjection.bundle"] load];
#endif

}

4. 运行项目,选择项目目录

加载 bundle 的时候会让你选择项目目录,InjectionIII 就是监控的这个目录,里面文件变动会有通知。

5. 注意

  • 只能在模拟器上看改动效果,真机上不行
  • 如果改了一个页面代码,要退出页面然后进入才能看到

原理介绍

一. 总体介绍

InjectionIII 分为server 和 client部分,client部分在你的项目启动的时候会作为 bundle load 进去,server部分在Mac App那边,server 和 client 都会在后台发送和监听 Socket 消息,实现逻辑分别在 InjectionServer.mm 和 InjectionClient.mm 里的 runInBackground 方法里面。InjectionIII 会监听源代码文件的变化,如果文件被改动了,server 就会通过 Socket 通知 client 进行 rebuildClass 重新对该文件进行编译,打包成动态库,也就是 .dylib 文件。然后通过 dlopen 把动态库文件载入运行的 App 里,接下来 dlsym 会得到动态库的符号地址,然后就可以处理类的替换工作。当类的方法被替换后,我们就可以开始重新绘制界面了。整个过程无需重新编译和重载 App,使用动态库方式极速调试的目的就达成了。

原理用一张图表示如下

备注:此图作者戴铭

二. 编译 InjectionIII 源码

要了解一个工具,最好的方式当然直接看源码了。InjectionIII 的源代码链接如下:https://github.com/johnno1962/InjectionIII,可以下载下来对着源码分析。
clone 源码以后直接编译会报错,下面一一解决。
首先如下图,证书问题,直接勾选 Automatically manage signing,同时选择一下团队,注意 InjectionIII 和 InjectionBundle 两个 target 都要选择好。

接着编译还是会出问题,如下图所示,说是找不到 XprobeSwift.swift 和 SwiftSwizzler.swift 两个文件,到 Finder 里面根据目录去找确实找不到,XprobePlugin 文件夹为空。

因此到 InjectionIII 的 github 上去看个究竟,发现 SwiftTrace 和 XprobePlugin 是这样的

点击都能跳转到对应的仓库那里。好了,知道原因了,看来这个文件夹下要把这个仓库 clone 下来。
clone 了以后运行项目,还是报错,如下

看了一下,报错信息里面有 Xcode101.app,这显然不对啊,应该是 Xcode.app,不然路径肯定不对,然后去 Run Script 里面看到了确实有 Xcode101,如下图

将 Xcode101 全部改为 Xcode,然后继续编译,终于可以看到下面这个无比让人欣慰的界面了。接下来就可以放肆地玩了。

最后说一下,如果我们要用源码分析,当然要将源码编译起来,打断点看流程。这样的话就在 willFinishLaunchingWithOptions 里面加载的路径就要相应修改了,我这边是这样的。

#if DEBUG
    [[NSBundle bundleWithPath:@"/Users/xxxxxx/Library/Developer/Xcode/DerivedData/InjectionIII-fvgzelftiqykfxebnrehvynhccwz/Build/Products/Debug/InjectionIII.app/Contents/Resources/iOSInjection.bundle"] load];
#endif

可以在 Products 下选中 InjectionIII.app 然后 Show in Finder,参考我的目录一步一步点进去找到 iOSInjection.bundle。


三. 源码分析

1. InjectionIII 项目运行前

InjectionIII 项目有两个 target,一个 InjectionIII,一个 InjectionBundle。如下图


可以看看 InjectionIII Build Phases 下面的 Run Script,从中能找到项目具体对这个 target 做了什么,脚本如下

SYMROOT=/tmp/Injection
export PLATFORM_DIR_OS=$DEVELOPER_DIR/Platforms/iPhoneSimulator.platform &&
"$DEVELOPER_BIN_DIR"/xcodebuild SYMROOT=$SYMROOT PRODUCT_NAME=iOSInjection LD_RUNPATH_SEARCH_PATHS="$DEVELOPER_DIR/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/iphonesimulator $PLATFORM_DIR_OS/Developer/Library/Frameworks @loader_path/Frameworks" -arch x86_64 -sdk iphonesimulator -config Debug -target InjectionBundle &&
/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild SYMROOT=/tmp/Injection10 PRODUCT_NAME=iOSInjection10 LD_RUNPATH_SEARCH_PATHS="$DEVELOPER_DIR/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/iphonesimulator $PLATFORM_DIR_OS/Developer/Library/Frameworks @loader_path/Frameworks" -arch x86_64 -sdk iphonesimulator -config Debug -target InjectionBundle &&
rsync -au $SYMROOT/Debug-iphonesimulator/iOSInjection.bundle /tmp/Injection10/Debug-iphonesimulator/iOSInjection10.bundle "$CODESIGNING_FOLDER_PATH/Contents/Resources" &&

export PLATFORM_DIR_OS=$DEVELOPER_DIR/Platforms/AppleTVSimulator.platform &&
"$DEVELOPER_BIN_DIR"/xcodebuild SYMROOT=$SYMROOT PRODUCT_NAME=tvOSInjection LD_RUNPATH_SEARCH_PATHS="$DEVELOPER_DIR/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/appletvsimulator $PLATFORM_DIR_OS/Developer/Library/Frameworks @loader_path/Frameworks" -arch x86_64 -sdk appletvsimulator -config Debug -target InjectionBundle &&
/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild SYMROOT=/tmp/Injection10 PRODUCT_NAME=tvOSInjection10 LD_RUNPATH_SEARCH_PATHS="$DEVELOPER_DIR/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/appletvsimulator $PLATFORM_DIR_OS/Developer/Library/Frameworks @loader_path/Frameworks" -arch x86_64 -sdk appletvsimulator -config Debug -target InjectionBundle &&
rsync -au $SYMROOT/Debug-appletvsimulator/tvOSInjection.bundle /tmp/Injection10/Debug-appletvsimulator/tvOSInjection10.bundle "$CODESIGNING_FOLDER_PATH/Contents/Resources" &&

export PLATFORM_DIR_OS=$DEVELOPER_DIR/Platforms/MacOS.platform &&
/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild SYMROOT=/tmp/Injection10 PRODUCT_NAME=macOSInjection10 LD_RUNPATH_SEARCH_PATHS="$DEVELOPER_DIR/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/iphonesimulator $PLATFORM_DIR_OS/Developer/Library/Frameworks @loader_path/Frameworks" -arch x86_64 -config Debug -target InjectionBundle &&
rsync -au /tmp/Injection10/Debug/macOSInjection10.bundle "$CODESIGNING_FOLDER_PATH/Contents/Resources" &&
find $CODESIGNING_FOLDER_PATH/Contents/Resources/*.bundle -name '*.h' -delete

内容比较多,重点关注的是iPhoneSimulator.platform 平台

SYMROOT=/tmp/Injection
export PLATFORM_DIR_OS=$DEVELOPER_DIR/Platforms/iPhoneSimulator.platform &&
"$DEVELOPER_BIN_DIR"/xcodebuild SYMROOT=$SYMROOT PRODUCT_NAME=iOSInjection LD_RUNPATH_SEARCH_PATHS="$DEVELOPER_DIR/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/iphonesimulator $PLATFORM_DIR_OS/Developer/Library/Frameworks @loader_path/Frameworks" -arch x86_64 -sdk iphonesimulator -config Debug -target InjectionBundle &&
/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild SYMROOT=/tmp/Injection10 PRODUCT_NAME=iOSInjection10 LD_RUNPATH_SEARCH_PATHS="$DEVELOPER_DIR/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/iphonesimulator $PLATFORM_DIR_OS/Developer/Library/Frameworks @loader_path/Frameworks" -arch x86_64 -sdk iphonesimulator -config Debug -target InjectionBundle &&
rsync -au $SYMROOT/Debug-iphonesimulator/iOSInjection.bundle /tmp/Injection10/Debug-iphonesimulator/iOSInjection10.bundle "$CODESIGNING_FOLDER_PATH/Contents/Resources"

可以看到首先使用 xcodebuild 命令将 InjectionBundle 编译成名字为 iOSInjection.bundle 的动态库,放到 /tmp/Injection 目录下

然后使用 rsync (rsync命令介绍--可以使用 rsync 同步本地硬盘中的不同目录)命令将 iOSInjection.bundle 同步到 InjectionIII.app 目录下 "$CODESIGNING_FOLDER_PATH/Contents/Resources"
如下图

我们需要热加载的项目的 willFinishLaunchingWithOptions 方法里面要加载 iOSInjection.bundle。这个作为客户端和 InjectionIII 通信。注意,bundle 是不能被链接的 dylib,只能在运行时使用 dlopen() 加载。

2. Injection 初始化

  • 服务端初始化
    在 InjectionIII 启动时调用 SimpleSocket 的 startServer 方法并传入端口号 在后台运行开启服务端socket 服务用于和客户端的通讯,并运行 InjectionServer 类的 runInBackground 方法进行初始化操作,弹出选择项目目录对话框,如果之前选择过的话就不会弹出。
+ (void)startServer:(NSString *)address {
    [self performSelectorInBackground:@selector(runServer:) withObject:address];
}

+ (void)runServer:(NSString *)address {
    struct sockaddr_storage serverAddr;
    [self parseV4Address:address into:&serverAddr];

    int serverSocket = [self newSocket:serverAddr.ss_family];
    if (serverSocket < 0)
        return;

    if (bind(serverSocket, (struct sockaddr *)&serverAddr, serverAddr.ss_len) < 0)
        [self error:@"Could not bind service socket: %s"];
    else if (listen(serverSocket, 5) < 0)
        [self error:@"Service socket would not listen: %s"];
    else
        while (TRUE) {
            struct sockaddr_storage clientAddr;
            socklen_t addrLen = sizeof clientAddr;

            int clientSocket = accept(serverSocket, (struct sockaddr *)&clientAddr, &addrLen);
            if (clientSocket > 0) {
                @autoreleasepool {
                    struct sockaddr_in *v4Addr = (struct sockaddr_in *)&clientAddr;
                    NSLog(@"Connection from %s:%d\n",
                          inet_ntoa(v4Addr->sin_addr), ntohs(v4Addr->sin_port));
                    [[[self alloc] initSocket:clientSocket] run];
                }
            }
            else
                [NSThread sleepForTimeInterval:.5];
        }
}

  • 客户端初始化
    在 InjectionIII 启动后,打开需要调试的 Xcode 工程,Xcode 工程必须在其App启动方法里加载 InjectionIII 目录下对应的 bundle 动态库,bundle 是不能被链接的 dylib,只能在运行时使用 dlopen() 加载。此时运行需要调试的 Xcode 工程,App 会加载 bundle 动态库,并执行动态库里 InjectionClient 类的 +load 方法。在 InjectionClient 类的 +load 方法里会调用其 connectTo 方法传入对应的端口号来连接服务端的 socket 服务用于通讯,并运行其runInBackground 方法进行初始化操作。
+ (void)load {
    // connect to InjetionIII.app using sicket
    if (InjectionClient *client = [self connectTo:INJECTION_ADDRESS])
        [client run];
    else
        printf("💉 Injection loaded but could not connect. Is InjectionIII.app running?\n");

}
  • Injection 初始化详细步骤
    首先服务端和客户端会读取一些数据传给对方保存在 SwiftEval 单例中方便后期进行代码注入,传送的数据包括:Injection App 的沙盒目录、调试 Xcode 工程的物理路径、目标 App 芯片类型和沙盒路径、Xcode App 物理路径和调试工程的 build 物理路径 等。接下来服务端会通过 FileWatcher 开启调试工程目录下文件改变的监听,当文件发生改变后会执行传入的 injector block 方法来进行代码注入。最后客户端和服务端都会通过 socket 的 readInt 来持续获取交互命令来执行对应的操作。

项目启动以后可以在控制台执行 image list -o -f 查看加载的动态库,可以看到 iOSInjection.bundle 确实已经以动态库的形式加载进来了。

3. 重新编译、打包动态库和签名

InjectionIII 运行以后会在后台监听 socket 消息,每隔0.5秒检查一次是否有客户端连接过来,等我们app 启动以后加载了 iOSInjection.bundle,就会启动 client 跟 server 建立连接,然后就可以发送消息了。

当我们在调试工程中修改了代码并保存后,FileWatcher 会立即收到文件改变的回调,FileWatcher 使用 Mac OS 上的 FSEvents 框架实现,并执行如下图的 injector block 方法。

在该方法中会判断是否为自动注入,如果是则执行 injectPending 方法,通过 socket 对客户端下发InjectionInject 代码注入命令并传入需要代码注入的文件名物理路径。如果不是自动注入那么就在控制台输出“xx文件已保存,输入ctrl-=进行注入”告诉我们手动注入的触发方式。

当客户端收到代码注入命令后会调用 SwiftInjection 类的 injectWithOldClass: classNameOrFile: 方法进行代码注入,如下图:

    public class func inject(oldClass: AnyClass?, classNameOrFile: String) {
        do {
            let tmpfile = try SwiftEval.instance.rebuildClass(oldClass: oldClass,
                                    classNameOrFile: classNameOrFile, extra: nil)
            try inject(tmpfile: tmpfile)
        }
        catch {
        }
    }

这个方法分为两步,第一步是调用 SwiftEval 单例的 rebuildClass 方法来进行修改文件的重新编译、打包动态库和签名,第二步是加载对应的动态库进行方法的替换。这里我们先看第一步的操作步骤。

首先根据修改的类文件名在 Injection App 的沙盒路径生成对应的编译脚本,脚本命名为eval+数字,数字以100为基数,每次递增1。脚本生成调用方法如下图:

injectionNumber += 1
        let tmpfile = "\(tmpDir)/eval\(injectionNumber)"
        let logfile = "\(tmpfile).log"

        guard var (compileCommand, sourceFile) = try SwiftEval.compileByClass[classNameOrFile] ??
            findCompileCommand(logsDir: logsDir, classNameOrFile: classNameOrFile, tmpfile: tmpfile) ??
            SwiftEval.longTermCache[classNameOrFile].flatMap({ ($0 as! String, classNameOrFile) }) else {
            throw evalError("""
                Could not locate compile command for \(classNameOrFile)
                (Injection does not work with Whole Module Optimization.
                There are also restrictions on characters allowed in paths.
                All paths are also case sensitive is another thing to check.)
                """)
        }

其中 findCompileCommand 为生成 sh 脚本的具体方法,主要是针对当前修改类设置对应的编译脚本命令。由于脚本太长,这里就不贴上来了,有兴趣的同学可以自行查看。

使用改动类的编译脚本可以生成其.o文件,具体如下图:

let toolchain = ((try! NSRegularExpression(pattern: "\\s*(\\S+?\\.xctoolchain)", options: []))
            .firstMatch(in: compileCommand, options: [], range: NSMakeRange(0, compileCommand.utf16.count))?
            .range(at: 1)).flatMap { compileCommand[$0] } ?? "\(xcodeDev)/Toolchains/XcodeDefault.xctoolchain"

let osSpecific: String
if compileCommand.contains("iPhoneSimulator.platform") {
    osSpecific = "-isysroot \(xcodeDev)/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk -mios-simulator-version-min=9.0 -L\(toolchain)/usr/lib/swift/iphonesimulator -undefined dynamic_lookup"// -Xlinker -bundle_loader -Xlinker \"\(Bundle.main.executablePath!)\""

这里针对模拟器环境进行脚本配置,配置完成后使用 clang 命令把对应的.o文件生成相同名字的动态库,具体如下图:

        guard shell(command: """
            \(xcodeDev)/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -arch "\(arch)" -bundle \(osSpecific) -dead_strip -Xlinker -objc_abi_version -Xlinker 2 -fobjc-arc \(tmpfile).o -L "\(frameworks)" -F "\(frameworks)" -rpath "\(frameworks)" -o \(tmpfile).dylib >>\(logfile) 2>&1
            """) else {
            throw evalError("Link failed, check \(tmpDir)/command.sh\n\(try! String(contentsOfFile: logfile))")
        }

由于苹果会对加载的动态库进行签名校验,所以我们下一步需要对这个动态库进行签名,使用 signer block 方法来进行签名操作,签名方法如下:

    // make available implementation of signing delegated to macOS app
    [SwiftEval sharedInstance].signer = ^BOOL(NSString *_Nonnull dylib) {
        [self writeCommand:InjectionSign withString:dylib];
        return [reader readString].boolValue;
    };

由于签名需要使用 Xcode 环境,所以客户端是无法进行的,只能通过 socket 告诉服务端来进行操作。当服务端收到 InjectionSign 签名命令后会调用 SignerService 类的 codesignDylib 来对相应的动态库进行签名操作,具体签名脚本操作如下:

服务端代码如下

case InjectionSign: {
    NSString *sockStr = [self readString];
    BOOL signedOK = [SignerService codesignDylib:sockStr];
    [self writeCommand:InjectionSigned withString: signedOK ? @"1": @"0"];
    break;
}
+ (BOOL)codesignDylib:(NSString *)dylib {
    NSString *command = [NSString stringWithFormat:@""
                         "(export CODESIGN_ALLOCATE=/Applications/Xcode.app"
                         "/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/codesign_allocate; "
                         "/usr/bin/codesign --force -s '-' \"%@\")", dylib];
    return system(command.UTF8String) >> 8 == EXIT_SUCCESS;
}

至此修改文件的重新编译、打包动态库和签名操作就全部完成了,接下来就是我们最熟悉的加载动态库进行方法替换了。

4. 加载动态库进行方法替换

  • 加载并注入动态库
    上面提到了在调用了 SwiftEval 类的 rebuildClass 方法进行编译打包动态库和签名后,会再调用SwiftInjection 类的 inject 方法来进行动态库的加载和方法的替换,让我们一起看看具体的实现步骤。在获取到改变后的新类的符号地址后就可以通过 runtime 的方式来进行方法的替换了。

  • 方法的替换
    在拿到新类的符号地址后,我们把新类里所有的类方法和实例方法都替换到对应的旧类中,使用的是SwiftInjection 的 injection 方法,具体实现如下图:

最后我们修改的代码就在不需要重启 App 重新编译的情况下生效了,当然为了执行修改过的代码,需要退出当前页面,再进来才可以看到效果。

参考文章

Injection源码深度解析
App 如何通过注入动态库的方式实现极速编译调试?
Injection:iOS热重载背后的黑魔法

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,724评论 6 479
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,104评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,142评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,086评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,076评论 5 370
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,914评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,220评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,871评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,318评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,834评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,951评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,574评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,162评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,162评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,383评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,349评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,652评论 2 343

推荐阅读更多精彩内容