InjectionIII 使用原理介绍

iOS 原生代码的编译调试,都是通过一遍又一遍地编译重启 APP来进行的。所以项目代码量越大,编译时间就越长。虽然我们可以将部分代码先编译成二进制集成到工程里,来避免每次都全量编译来加快编译速度,但即使这样,每次编译都还是需要重启App,需要再走一遍调试流程。

幸运的是,John Holdsworth 开发了一个叫做 InjectionIII 的工具可以动态地将 Swift 或 Objective-C 的代码在已运行的程序中执行,以加快调试速度,同时保证程序不用重启。

InjectionIII下载使用

下载InjectionIII

如果仅用于使用的话在mac appStore下载就可以了:

在项目中添加代码

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

1
2
3
4
5
6
7
8
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
...

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

}
1
2
3
4
5
//成功时控制台会打印:
💉 Injection connected 👍

//没有运行InjectionIII 控制台会打印
💉 Injection loaded but could not connect. Is InjectionIII.app running?

运行InjectionIII 并选择项目目录

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

使用InjectionIII 并测试几种场景:

你可以在在对应的VC控制器中实现 - (void)injected 编写代码,写完后,command+s 保存切执行代码,Injjection就开始编译修改过的文件为动态库,然后我们在Injected方法内做UI reload工作,即可重绘UI。

也可以直接修改你想测试的VC代码,然后退出重进即可重载VC。

测试一下四种场景:

  • 修改方法
  • 新增方法
  • 新增属性
  • 新增类

其中 修改方法/新增方法/新增类 都可以动态修改

而当动态添加属性时则会报错

我们很自然的想到因为不能向已有类动态添加属性,但是InjectionIII到底做了什么呢?让我们带着疑问来看看InjectionIII的原理

没有看到效果的问题?

  • 确认 Injection 监听的目录和 Xcode 项目目录是否一致。
  • 再看下有没有保存成功,也就是针筒的颜色由绿色变成红色。
  • 确认上面那句话有没有打印,也就是说有没有真的运行这个工具。
  • 如果修改的是 cell / item 上面的内容,需要上下滚动才能看到效果。
  • 如果修改的是一个普通页面的内容,最好是退出这个页面,再进入这个页面。
  • 确认 Xcode 的版本和启动时添加的代码是否匹配,Xcode10 需要 iOSInjection10.bundle 才能生效

InjectionIII原理

大致流程梳理

  • 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,使用动态库方式极速调试的目的就达成了。

这里有一点需要说明一下,模拟器下iOS可加载Mac任意文件,而真机设备如果加载动态库,只能加载App.content目录下的,换句话说,这个工具只支持模拟器。

编译InjectionIII工程

要了解一个工具,最好的方式当然直接看源码了。InjectionIII 的源代码链接如下:https://github.com/johnno1962/InjectionIII,可以下载下来对着源码分析。

下载/clone InjectionIII 主工程代码,子工程 remote SwiftTrace XprobePlugin 也一并点进链接下载到对应目录。

然后解决下证书问题 ,直接勾选 Automatically manage signing,同时选择一下团队,注意 InjectionIII 和 InjectionBundle 两个 target 都要选择好。就可以愉快的编译了~

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

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

源码分析

一、初始化Server和Client并通过Socket建立链接

Server端 即是InjectionIII工程中的Target-InjectionIII 初始化调用 SimpleSocket 的 startServer 方法并传入端口号 在后台运行开启服务端socket 服务用于和客户端的通讯,并运行 InjectionServer 类的 runInBackground 方法进行初始化操作,弹出选择项目目录对话框,如果之前选择过的话就不会弹出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
+ (void)startServer:(NSString *)address {
NSLog(@" %s %p ",__func__,self);
[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];
}
}

Client端 即是InjectionIII工程中的Target-InjectionBundle 会在我们的demo工程中加载,项目启动以后可以在控制台执行 image list -o -f 查看加载的动态库,可以看到 iOSInjection.bundle 确实已经以动态库的形式加载进来了。

1
2
3
image list -o -f

[377] 0x0000000102b13000 /Users/suntongsheng/Library/Developer/Xcode/DerivedData/InjectionIII-aornltecfreqqwezgayglgjnnckg/Build/Products/Debug/InjectionIII.app/Contents/Resources/iOSInjection.bundle/iOSInjection

其初始化在InjectionClient 类的 +load 方法。在 InjectionClient 类的 +load 方法里会调用其 connectTo 方法传入对应的端口号来连接服务端的 socket 服务用于通讯,并运行其runInBackground 方法进行初始化操作。

1
2
3
4
5
6
7
8
9
10
11
12
+ (void)load {
NSLog(@" %s %p ",__func__,self);
// connect to InjectionIII.app using socket
if (InjectionClient *client = [self connectTo:INJECTION_ADDRESS])
[client run];
else {
printf("💉 Injection loaded but could not connect. Is InjectionIII.app running?\n");
#ifndef __IPHONE_OS_VERSION_MIN_REQUIRED
printf("⚠️ For a macOS app you need to turn off the sandbox to connect. ⚠️\n");
#endif
}
}

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

二、监听文件修改

当我们在调试工程中修改了代码并保存后,FileWatcher 会立即收到文件改变的回调,FileWatcher 使用 Mac OS 上的 FSEvents 框架实现,FileWatcher 中通过 filesChanged 告诉回调方法即InjectionServer中的fileChangeHandler方法

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

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

文件修改后若是自动注入injectPending方法会调用 recompileAndInject方法实际上会调用 SwiftEval 单例的 rebuildClass 方法来进行修改文件的重新编译、打包动态库和签名:

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
injectionNumber += 1
let tmpfile = URL(fileURLWithPath: tmpDir)
.appendingPathComponent("eval\(injectionNumber)").path
let logfile = "\(tmpfile).log"

guard var (compileCommand, sourceFile) = try 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).
This could be due to one of the following:
1. Injection does not work with Whole Module Optimization.
2. There are restrictions on characters allowed in paths.
3. File paths in the simulator paths are case sensitive.
Try a build clean then rebuild to make logs available or
consult: "\(commandFile)".
""")
}

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

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

1
2
3
4
5
6
7
8
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文件生成相同名字的动态库,具体如下图:

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

过程中产生的产物如下:

苹果会对加载的动态库进行签名校验,所以下一步需要对这个动态库签名,由于签名需要使用 Xcode 环境,所以客户端是无法进行的,只能通过 socket 告诉服务端来进行操作。当服务端收到 InjectionSign 签名命令后会调用 SignerService 类的 codesignDylib 来对相应的动态库进行签名操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@implementation SignerService

+ (BOOL)codesignDylib:(NSString *)dylib identity:(NSString *)identity {
static NSString *adhocSign = @"-";
NSString *command = [NSString stringWithFormat:@""
"(export CODESIGN_ALLOCATE=/Applications/Xcode.app"
"/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/codesign_allocate; "
"if /usr/bin/file \"%@\" | grep ' bundle ' >/dev/null;"
"then /usr/bin/codesign --force -s \"%@\" \"%@\";"
"else exit 1; fi)",
dylib, identity ?: adhocSign, dylib];
return system(command.UTF8String) >> 8 == EXIT_SUCCESS;
}

- (void)runInBackground {
char __unused skip, buffer[1000];
buffer[read(clientSocket, buffer, sizeof buffer-1)] = '\000';
NSString *path = [[NSString stringWithUTF8String:buffer] componentsSeparatedByString:@" "][1];

if ([[self class] codesignDylib:path identity:nil]) {
snprintf(buffer, sizeof buffer, "HTTP/1.0 200 OK\r\n\r\n");
write(clientSocket, buffer, strlen(buffer));
}
}

@end

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

四、加载动态库进行方法替换

在生成动态库后 先使用dlopen方法把对应的动态库加载到当前运行的调试工程的进程中 并拿到返回的指针dl,然后使用dlsym拿到动态库的符号地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@objc func loadAndInject(tmpfile: String, oldClass: AnyClass? = nil) throws -> [AnyClass] {

...
guard let dl = dlopen("\(tmpfile).dylib", RTLD_NOW) else {
...
}

if oldClass != nil {

...
}
else {
// grep out symbols for classes being injected from object file
//正常流程会走到这里
return try extractClasses(dl: dl, tmpfile: tmpfile)
}
}

// Overridden by SwiftInjectionEval subclass for injection
@objc func extractClasses(dl: UnsafeMutableRawPointer,
tmpfile: String) throws -> [AnyClass] {
guard shell(command: """
\(xcodeDev)/Toolchains/XcodeDefault.xctoolchain/usr/bin/nm \(tmpfile).o | grep -E ' S _OBJC_CLASS_\\$_| _(_T0|\\$S|\\$s).*CN$' | awk '{print $3}' >\(tmpfile).classes
""") else {
throw evalError("Could not list class symbols")
}
guard var classSymbolNames = (try? String(contentsOfFile: "\(tmpfile).classes"))?.components(separatedBy: "\n") else {
throw evalError("Could not load class symbol list")
}
classSymbolNames.removeLast()
let result = Set(classSymbolNames.compactMap {
dlsym(dl, String($0.dropFirst())) })
.map { unsafeBitCast($0, to: AnyClass.self) }

return result;
}

如上代码所示,dlopen 会把 tmpfile 动态库文件载入运行的 App 里,返回指针 dl。回到测试工程使用 lldb image list -o -f 确实看到eval101.dylib 被加载进来

1
[408] 0x0000000111bfe000 /var/folders/tw/nl7v5_cd577bbdzfgxjy63bw0000gn/T/com.johnholdsworth.InjectionIII/eval101.dylib

接下来, dlsym 会得到 tmpfile 动态库的符号地址,然后就可以处理类的替换工作了。在拿到新类的符号地址后,我们把新类里所有的类方法和实例方法都替换到对应的旧类中,使用的是SwiftInjection 的 injection 方法,通过OC runtime 的class_replaceMethod把整个类的实现方法都替换了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static func injection(swizzle newClass: AnyClass?, onto oldClass: AnyClass?) {
var methodCount: UInt32 = 0
if let methods = class_copyMethodList(newClass, &methodCount) {
for i in 0 ..< Int(methodCount) {
let method = method_getName(methods[i])
var replacement = method_getImplementation(methods[i])
if traceInjection, let tracer = SwiftTrace
.trace(name: injectedPrefix+NSStringFromSelector(method),
objcMethod: methods[i], objcClass: newClass,
original: autoBitCast(replacement)) {
replacement = autoBitCast(tracer)
}
class_replaceMethod(oldClass, method, replacement,
method_getTypeEncoding(methods[i]))
}
free(methods)
}
}