在世人的印象当中程序员的工作状态就是在加班中度过,做为程序员的我们决定要改变这种异样的偏见。为此我们用了很多方法和工具来提高开发效率,然而随着业务的扩展 项目越来越大,从而导致每次编译运行测试的时间逐渐增多,有时修改代码仅用几十秒,但是重新运行测试却需要十几分钟,这样的开发效率是我们不能忍受的。
为此我们把代码按功能进行分离使用CocoaPods进行管理,并对每个Pod进行单独编译形成对应的静态库,在开发时仅对需要修改的Pod使用源码,其他一律使用静态库,这样就大幅度提高了编译运行的速度。除此之外我们还可以使用运行时脚本来控制再次运行不拷贝资源文件,这种操作对编译运行的提速也是相当的明显。
虽然有了上面的提速方案,但是我们在实际开发中每次修改完代码后还是需要重新编译运行才能进行测试,于是我们考虑是否可以把这部分时间再省下来呢?事在人为,一位叫做John Holdsworth 的大神开发了一个Injection的工具可以动态地将 Swift 或Objective-C 的代码在已运行的程序中执行,以加快调试速度,同时保证程序不用重启。
网上有部分介绍Injection使用方式的文章,介绍原理的文章只有一年前老峰写的一篇,而这边文章只是大概描述了一下原理概要,并且最新的Injection的相关逻辑已经做了较大的改动,为此我们在这篇文章中详细介绍一下最新Injection的原理,并对代码进行深度解析,方便我们掌握其中的技术关键点并为己用。
2、使用方法
Injection使用方法步骤如下:
1)、下载Injection APP打开或者直接运行源码工程(直接运行源码工程需要对一些脚本工程路径和工程签名等做修改),目录选择打开想要调试的Xcode工程;
2)、在AppDelegate.m的启动方法中加入代码(如果是直接运行源码需要找到对应bundle的物理路径进行加载):
[[NSBundle bundleWithPath:@"/Applications/InjectionIII.app/Contents/Resources/iOSInjection10.bundle"] load];
3)、在需要修改界面的ViewController添加方法- (void)injected,所有修改控件的代码都写在这里面,也可以把相关代码放到 监听通知的方法里。当然不新增方法也是可以的,因为Injection是直接把类里的方法进行了动态替换,所以我们可以重新进入当前页面或触发相关逻辑来查看改动效果。
4)、运行Xcode项目,连接成功后,就可以在上面添加的injected方法里面修改或添加控件了,修改完毕Command+S保存一下代码,立刻就能显示修改的信息了。如果不开启自动执行,控制台会提示使用ctrl-=进行手动执行,这样可以方便我们更好的控制注入时机。
3、原理及流程概述
Injection的原理及流程概述如下:
1)、Injection Server 会监听源代码文件的变化,如果文件被保存了,Injection Server 就会将改动的文件使用Xcode命令重新进行编译并打包成动态库,然后进行动态库的签名,并通知客户端进行代码注入。
2)、Client 接收到消息后会先把对应动态库加载到当前进程中,并获取对应类的符号地址,然后进行类方法和实例方法的替换。
这里按Injection运行流程来对源码进行深度分析,具体如下:
1、Injection初始化
1)、服务端初始化配置
在Injection启动时调用InjectionServer的startServer方法并传入端口号 在后台运行开启服务端socket服务用于和客户端的通讯,并运行InjectionServer类的runInBackground方法进行初始化操作。
当然在Injection启动时还会初始化一些工具条属性状态,方便用户进行相关操作,工具条操作主要包括“打开调试工程”和“开启关闭自动执行注入”。
2)、客户端初始化配置
在Injection启动后,通过工具条选择打开需要调试的Xcode工程,而这个Xcode工程必须在其App启动方法里加载Injection目录下对应的bundle动态库。此时运行需要调试的Xcode工程,App会加载bundle动态库,并执行动态库里InjectionClient类的+load方法。
在InjectionClient类的+load方法里会调用其connectTo方法传入对应的端口号来连接服务端的socket服务用于通讯,并运行其runInBackground方法进行初始化操作
3)、Injection初始化详细步骤
首先服务端和客户端会读取一些数据传给对方 保存在SwiftEval单例中方便后期进行代码注入,传送的数据包括:Injection App的沙盒目录、调试Xcode工程的物理路径、目标App芯片类型和沙盒路径、Xcode App物理路径 和 调试工程的build物理路径 等。
接下来服务端会通过FileWatcher开启调试工程目录下文件改变的监听,当文件发生改变后会执行传入的injector block方法来进行代码注入。
最后客户端和服务端都会通过socket的readInt来持续获取交互命令来执行对应的操作。
2、重新编译、打包动态库和签名
当我们在调试工程中修改了代码并保存后,FileWatcher会立即收到文件改变的回调,并执行如下图的injector block方法。
在该方法中会判断是否为自动注入,如果是则执行injectPending方法通过socket对客户端下发InjectionInject代码注入命令并传入需要代码注入的文件名物理路径。如果不是自动注入那么就在控制台输出“xx文件已保存,输入ctrl-=进行注入”告诉我们手动注入的触发方式。
当客户端收到代码注入命令后会调用SwiftInjection类的injectWithOldClass: classNameOrFile:方法进行代码注入,如下图:
这个方法分为两步,第一步是调用SwiftEval单例的rebuildClass方法来进行修改文件的重新编译、打包动态库和签名,第二步是加载对应的动态库进行方法的替换。这里我们先看第一步的操作步骤。
首先根据修改的类文件名在Injection App的沙盒路径生成对应的编译脚本,脚本命名为eval+数字,数字以100为基数,每次递增1。脚本生成调用方法如下图:
其中findCompileCommand为生成sh脚本的具体方法,主要是针对当前修改类设置对应的编译脚本命令。由于脚本太长,这里就不贴上来了,有兴趣的同学可以自行查看。
使用改动类的编译脚本可以生成其.o文件,具体如下图:
接下来就是生成动态库的时间了,首先进行生成动态库的脚本配置,具体如下图:
这里针对模拟器环境进行脚本配置,配置完成后使用clang命令把对应的.o文件生成相同名字的动态库,具体如下图:
由于苹果会对加载的动态库进行签名校验,所以我们下一步需要对这个动态库进行签名,使用signer block方法来进行签名操作,签名方法如下:
由于签名需要使用Xcode环境,所以客户端是无法进行的,只能通过socket告诉服务端来进行操作。当服务端收到InjectionSign签名命令后会调用SignerService类的codesignDylib来对相应的动态库进行签名操作,具体签名脚本操作如下:
至此修改文件的重新编译、打包动态库和签名操作就全部完成了,接下来就是我们最熟悉的加载动态库进行方法替换了。
3、加载动态库进行方法替换
上面提到了在调用了SwiftEval类的rebuildClass方法进行编译打包动态库和签名后,会再调用SwiftInjection类的inject方法来进行动态库的加载和方法的替换,让我们一起看看具体的实现步骤。
1)、加载并注入动态库
在inject方法中先调用了SwiftEval单例的loadAndInject方法进行动态库的加载,具体操作为先使用dlopen方法把对应的动态库加载到当前运行的调试工程的进程中 并拿到返回的指针dl,然后使用dlsym拿到动态库的符号地址。
在获取到改变后的新类的符号地址后就可以通过runtime的方式来进行方法的替换了。
2)、方法的替换
在拿到新类的符号地址后,我们把新类里所有的类方法和实例方法都替换到对应的旧类中,使用的是SwiftInjection的injection方法,具体实现如下图:
至此我们新修改的代码就在不编译运行重启App的情况下生效了,为了更方便的进行调试,在此时Injection会主动调用我们实现的-injected方法并发出名为
"INJECTION_BUNDLE_NOTIFICATION"的通知。
Injection开源地址:
https://github.com/johnno1962/InjectionIII
转自一篇文章:http://www.sohu.com/a/322177371_781946