一、InjectionIII使用
iOS 原生代码的编译调试,都是通过一遍又一遍地编译重启 App 来进行的。所以,项目代码量越大,编译时间就越长。虽然我们可以通过将部分代码先编译成二进制集成到工程里,来避免每次都全量编译来加快编译速度,但即使这样,每次编译都还是需要重启 App,需要再走一遍调试流程。
对于开发者来说,提高编译调试的速度就是提高生产效率。试想一下,如果上线前一天突然发现了一个严重的 bug,每次编译调试都要耗费几十分钟,结果这一天的黄金时间,一晃就过去了。到最后,可能就是上线时间被延误。这个责任可不轻啊。
那么问题来了,原生代码怎样才能够实现动态极速调试,以此来大幅提高编译调试速度呢? 所幸的是,John Holdsworth 开发了一个叫作 Injection 的工具可以动态地将 Swift 或 Objective-C 的代码在已运行的程序中执行,以加快调试速度,同时保证程序不用重启。OC运行的效果图如下所示:
作者已经开源了这个工具,地址是:https://github.com/johnno1962/InjectionIII ,也可以从 Mac App Store 获得。这里分享下Mac App Store下载安装使用的具体方法。
注意
目前只支持模拟器运行
1、Mac App Store下载
这个是 Mac 上的一款 App,可以在 Mac App Store 中搜索 Injection,那款免费的 App 就是,现在已经更新到第三个版本,点击安装
2、安装成功,打开应用
InjectionII.app期望在路径中找到您当前的Xcode /Applications/Xcode.app,适用于Swift和Objective-C可以与AppCode一起使用,但您需要首先使用Xcode构建项目,以提供用于确定如何编译项目的日志。
3、AppDelegate中注入代码
InjectionII.app
需要知道您当前的Xcode路径 /Applications/Xcode.app
,适用于Swift
和Objective-C
可以与AppCode
一起使用,但您需要首先使用Xcode构建项目,以提供用于确定如何编译项目的日志。
要使用注入,下载并运行应用程序,您只需将以下内容之一添加到应用程序代理中即可 applicationDidFinishLaunching:
#if DEBUG
// or oc
[[NSBundle bundleWithPath:@"/Applications/InjectionIII.app/Contents/Resources/iOSInjection.bundle"] load];
// or switf
Bundle(path: "/Applications/InjectionIII.app/Contents/Resources/iOSInjection.bundle")?.load()
// for tvOS:
Bundle(path: "/Applications/InjectionIII.app/Contents/Resources/tvOSInjection.bundle")?.load()
// Or for macOS:
Bundle(path: "/Applications/InjectionIII.app/Contents/Resources/macOSInjection.bundle")?.load()
#endif
4、选择监听文件目录
首次运行时,会弹出弹框,选择监听文件改变路径
选择后,控制台会打印类似日志
💉 Injection connected, watching /Users/zjh48/Desktop/ZJHInjectionIIIDemo/**
5、实现- (void)injected
方法
在对应的VC控制器中实现 - (void)injected
编写代码,写完后,command+s
保存切执行代码,Injjection就开始编译修改过的文件为动态库,然后我们在Injected方法内做UI reload工作,即可重绘UI。
6、没有看到效果的问题的总结
- 确认 Injection 监听的目录和 Xcode 项目目录是否一致。
- 再看下有没有保存成功,也就是针筒的颜色由绿色变成红色。
- 确认上面那句话有没有打印,也就是说有没有真的运行这个工具。
- 如果修改的是 cell / item 上面的内容,需要上下滚动才能看到效果。
- 如果修改的是一个普通页面的内容,最好是退出这个页面,再进入这个页面。
- 确认 Xcode 的版本和启动时添加的代码是否匹配,Xcode10 需要 iOSInjection10.bundle 才能生效
二、InjectionIII重载原理
1、流程梳理
首先我们修改一个文件,Injection工具会通过File Watcher监听观察文件改动,然后将改动的文件编译,打包,这时候Injection工具会给我们的App发个消息:“兄弟我这边ready了,你更新下代码”;我们的App收到消息后更新代码后再给Injection个反馈:“好的大佬,代码已经更新,UI也刷新了”;Injection收到反馈后,工具会变绿,完美的闭环式沟通。注意这里的过程,App要收消息,那么必须要有对应的代码,如何实现?App的代码如何更新?
我们知道如果要让既有App,执行自己的代码可以通过注入动态库,静态的注入可以使用optool工具修改MachO的Load Commands然后重签,运行时可以使用dlopen或者Bundle(path: "**.bundle").load()加载,作者也正是采用这种方式,文中AppDelegate注入代码,工具初始化,就是为了实现注入动态库。
这里有一点需要说明一下,模拟器下iOS可加载Mac任意文件,不存在沙盒的说法,而真机设备如果加载动态库,只能加载App.content目录下的,换句话说,这个工具只支持模拟器。
2、具体实现
Injection 会监听源代码文件的变化,如果文件被改动了,Injection Server 就会执行 rebuildClass 重新进行编译、打包成动态库,也就是 .dylib 文件。编译、打包成动态库后使用 writeSting
方法通过 Socket
通知运行的 App。writeString 的代码如下:
- (BOOL)writeString:(NSString *)string {
const char *utf8 = string.UTF8String;
uint32_t length = (uint32_t)strlen(utf8);
if (write(clientSocket, &length, sizeof length) != sizeof length ||
write(clientSocket, utf8, length) != length)
return FALSE;
return TRUE;
}
Server 会在后台发送和监听 Socket 消息,实现逻辑在 InjectionServer.mm
的 runInBackground 方法里。Client 也会开启一个后台去发送和监听 Socket 消息,实现逻辑在 InjectionClient.mm
里的 runInBackground 方法里。
Client 接收到消息后会调用 inject(tmpfile: String)
方法,运行时进行类的动态替换。inject(tmpfile: String) 方法的代码大部分都是做新类动态替换旧类。inject(tmpfile: String) 的 入参 tmpfile 是动态库的文件路径,那么这个动态库是如何加载到可执行文件里的呢?具体的实 现在 inject(tmpfile: String) 方法开始里,如下:
let newClasses = try SwiftEval.instance.loadAndInject(tmpfile: tmpfile)
先看下 SwiftEval.instance.loadAndInject(tmpfile: tmpfile) 这个方法的代码实现:
@objc func loadAndInject(tmpfile: String, oldClass: AnyClass? = nil) throws -> [AnyClass] {
print("💉 Loading .dylib ...")
// load patched .dylib into process with new version of class
guard let dl = dlopen("\(tmpfile).dylib", RTLD_NOW) else {
let error = String(cString: dlerror())
if error.contains("___llvm_profile_runtime") {
print("💉 Loading .dylib has failed, try turning off collection of test coverage in your scheme")
}
throw evalError("dlopen() error: \(error)")
}
print("💉 Loaded .dylib - Ignore any duplicate class warning ^")
if oldClass != nil {
// find patched version of class using symbol for existing
var info = Dl_info()
guard dladdr(unsafeBitCast(oldClass, to: UnsafeRawPointer.self), &info) != 0 else {
throw evalError("Could not locate class symbol")
}
debug(String(cString: info.dli_sname))
guard let newSymbol = dlsym(dl, info.dli_sname) else {
throw evalError("Could not locate newly loaded class symbol")
}
return [unsafeBitCast(newSymbol, to: AnyClass.self)]
}
else {
// grep out symbols for classes being injected from object file
try injectGenerics(tmpfile: tmpfile, handle: dl)
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 symbols = (try? String(contentsOfFile: "\(tmpfile).classes"))?.components(separatedBy: "\n") else {
throw evalError("Could not load class symbol list")
}
symbols.removeLast()
return Set(symbols.flatMap { dlsym(dl, String($0.dropFirst())) }).map { unsafeBitCast($0, to: AnyClass.self) }
}
}
在这段代码中,是不是看到你所熟悉的动态库加载函数 dlopen 了呢?
guard let dl = dlopen("\(tmpfile).dylib", RTLD_NOW) else {
throw evalError("dlopen() error: \(error)")
}
如上代码所示,dlopen 会把 tmpfile 动态库文件载入运行的 App 里,返回指针 dl。接下来, dlsym 会得到 tmpfile 动态库的符号地址,然后就可以处理类的替换工作了。dlsym 调用对应代 码如下
guard let newSymbol = dlsym(dl, info.dli_sname) else {
throw evalError("Could not locate newly loaded class symbol")
}
当类的方法都被替换后,我们就可以开始重新绘制界面了。整个过程无需重新编译和重启 App, 至此使用动态库方式极速调试的目的就达成了。
Injection 的工作原理图如下所示:
参考链接:
戴铭·iOS开发高手课
InjectionIII 成吨的提高iOS开发效率
InjectionIII:iOS开发必备效率神器-所见即所得
iOS 使用 InjectionIII 注入动态库实现快速调试
Injection:iOS热重载背后的黑魔法