03讲:iOS InjectionIII工具的使用及重载原理
一、InjectionIII使用
iOS 原生代码的编译调试,都是通过一遍又一遍地编译重启 App 来进行的。所以,项目代码量越大,编译时间就越长。虽然我们可以通过将部分代码先编译成二进制集成到工程里,来避免每次都全量编译来加快编译速度,但即使这样,每次编译都还是需要重启 App,需要再走一遍调试流程。
对于开发者来说,提高编译调试的速度就是提高生产效率。试想一下,如果上线前一天突然发现了一个严重的 bug,每次编译调试都要耗费几十分钟,结果这一天的黄金时间,一晃就过去了。到最后,可能就是上线时间被延误。这个责任可不轻啊。
那么问题来了,原生代码怎样才能够实现动态极速调试,以此来大幅提高编译调试速度呢? 所幸的是,John Holdsworth 开发了一个叫作 Injection 的工具可以动态地将 Swift 或 Objective-C 的代码在已运行的程序中执行,以加快调试速度,同时保证程序不用重启。OC运行的效果图如下所示:
![](https://img.haomeiwen.com/i2120486/1f422f0e957845a4.gif)
作者已经开源了这个工具,地址是:https://github.com/johnno1962/InjectionIII,也可以从Mac App Store 获得。这里分享下Mac App Store下载安装使用的具体方法。
注意
目前只支持模拟器运行
1、Mac App Store下载
这个是 Mac 上的一款 App,可以在 Mac App Store 中搜索 Injection,那款免费的 App 就是,现在已经更新到第三个版本,点击安装
![](https://img.haomeiwen.com/i2120486/a48015fa69e8188e.png)
Mac App Store下载.png
2、安装成功,打开应用
InjectionII.app期望在路径中找到您当前的Xcode /Applications/Xcode.app,适用于Swift和Objective-C可以与AppCode一起使用,但您需要首先使用Xcode构建项目,以提供用于确定如何编译项目的日志。
![](https://img.haomeiwen.com/i2120486/64696875699b665d.png)
启动app.png
3、AppDelegate中注入代码
InjectionII.app需要知道您当前的Xcode路径/Applications/Xcode.app,适用于Swift和Objective-C可以与AppCode一起使用,但您需要首先使用Xcode构建项目,以提供用于确定如何编译项目的日志。
要使用注入,下载并运行应用程序,您只需将以下内容之一添加到应用程序代理中即可applicationDidFinishLaunching:
#ifDEBUG// or oc[[NSBundlebundleWithPath:@"/Applications/InjectionIII.app/Contents/Resources/iOSInjection.bundle"]load];// or switfBundle(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
![](https://img.haomeiwen.com/i2120486/d5efbc8bd7d0ee3d.png)
AppDelegate中注入代码.png
4、选择监听文件目录
首次运行时,会弹出弹框,选择监听文件改变路径
![](https://img.haomeiwen.com/i2120486/cbc0ebc26528b497.png)
选择监听文件目录.png
选择后,控制台会打印类似日志
💉 Injection connected, watching /Users/zjh48/Desktop/ZJHInjectionIIIDemo/**
5、实现- (void)injected方法
在对应的VC控制器中实现- (void)injected编写代码,写完后,command+s保存切执行代码,Injjection就开始编译修改过的文件为动态库,然后我们在Injected方法内做UI reload工作,即可重绘UI。
![](https://img.haomeiwen.com/i2120486/db5f65667029b532.png)
实现injected方法.png
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{constchar*utf8=string.UTF8String;uint32_tlength=(uint32_t)strlen(utf8);if(write(clientSocket,&length,sizeoflength)!=sizeoflength||write(clientSocket,utf8,length)!=length)returnFALSE;returnTRUE;}
Server 会在后台发送和监听 Socket 消息,实现逻辑在InjectionServer.mm的 runInBackground 方法里。Client 也会开启一个后台去发送和监听 Socket 消息,实现逻辑在InjectionClient.mm里的 runInBackground 方法里。
Client 接收到消息后会调用inject(tmpfile: String)方法,运行时进行类的动态替换。inject(tmpfile: String) 方法的代码大部分都是做新类动态替换旧类。inject(tmpfile: String) 的 入参 tmpfile 是动态库的文件路径,那么这个动态库是如何加载到可执行文件里的呢?具体的实 现在 inject(tmpfile: String) 方法开始里,如下:
letnewClasses=trySwiftEval.instance.loadAndInject(tmpfile:tmpfile)
先看下 SwiftEval.instance.loadAndInject(tmpfile: tmpfile) 这个方法的代码实现:
@objcfuncloadAndInject(tmpfile:String,oldClass:AnyClass?=nil)throws->[AnyClass]{print("💉 Loading .dylib ...")// load patched .dylib into process with new version of classguardletdl=dlopen("\(tmpfile).dylib",RTLD_NOW)else{leterror=String(cString:dlerror())iferror.contains("___llvm_profile_runtime"){print("💉 Loading .dylib has failed, try turning off collection of test coverage in your scheme")}throwevalError("dlopen() error:\(error)")}print("💉 Loaded .dylib - Ignore any duplicate class warning ^")ifoldClass!=nil{// find patched version of class using symbol for existingvarinfo=Dl_info()guarddladdr(unsafeBitCast(oldClass,to:UnsafeRawPointer.self),&info)!=0else{throwevalError("Could not locate class symbol")}debug(String(cString:info.dli_sname))guardletnewSymbol=dlsym(dl,info.dli_sname)else{throwevalError("Could not locate newly loaded class symbol")}return[unsafeBitCast(newSymbol,to:AnyClass.self)]}else{// grep out symbols for classes being injected from object filetryinjectGenerics(tmpfile:tmpfile,handle:dl)guardshell(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{throwevalError("Could not list class symbols")}guardvarsymbols=(try?String(contentsOfFile:"\(tmpfile).classes"))?.components(separatedBy:"\n")else{throwevalError("Could not load class symbol list")}symbols.removeLast()returnSet(symbols.flatMap{dlsym(dl,String($0.dropFirst()))}).map{unsafeBitCast($0,to:AnyClass.self)}}}
在这段代码中,是不是看到你所熟悉的动态库加载函数 dlopen 了呢?
guardletdl=dlopen("\(tmpfile).dylib",RTLD_NOW)else{throwevalError("dlopen() error:\(error)")}
如上代码所示,dlopen 会把 tmpfile 动态库文件载入运行的 App 里,返回指针 dl。接下来, dlsym 会得到 tmpfile 动态库的符号地址,然后就可以处理类的替换工作了。dlsym 调用对应代 码如下
guardletnewSymbol=dlsym(dl,info.dli_sname)else{throwevalError("Could not locate newly loaded class symbol")}
当类的方法都被替换后,我们就可以开始重新绘制界面了。整个过程无需重新编译和重启 App, 至此使用动态库方式极速调试的目的就达成了。
Injection 的工作原理图如下所示:
![](https://img.haomeiwen.com/i2120486/d9973b9cb5e48df2.png)
作者:张聪_2048
链接:https://www.jianshu.com/p/0489c654657d
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。