App 如何通过注入动态库的方式实现极速编译调试
Last updated
Last updated
App 如何通过注入动态库的方式实现极速编译调试
今天我们再继续聊聊,动态库链接器的实际应用,也就是编译调试的提速问题。
iOS 原生代码的编译调试,都是通过一遍又一遍地编译重启 App 来进行的。所以,项目代码量越大,编译时间就越长。虽然我们可以通过将部分代码先编译成二进制集成到工程里,来避免每次都全量编译来加快编译速度,但即使这样,每次编译都还是需要重启 App,需要再走一遍调试流程。
对于开发者来说,提高编译调试的速度就是提高生产效率。试想一下,如果上线前一天突然发现了一个严重的bug,每次编译调试都要耗费几十分钟,结果这一天的黄金时间,一晃就过去了。到最后,可能就是上线时间被延误。这个责任可不轻啊。
那么问题来了,原生代码怎样才能够实现动态极速调试,以此来大幅提高编译调试速度呢?在回答这个问题之前,我们先看看有哪些工具是这么玩儿的。了解了它们的玩法,我们也就自然清楚这个问题的答案了。
说到iOS代码动态极速调试的工具,你首先能想到的估计就是 Playground。它是 Xcode 里集成的一个能够快速、实时调试程序的工具,可以实现所见即所得的效果,如下图所示:
图1 Playground工具实时调试示例
可以看到,任何的代码修改都能够实时地在右侧反馈出来。
Flutter 是 Google 开发的一个跨平台开发框架,调试也是快速实时的。官方的效果动画如下:
图2 Flutter使用示例
可以看到,在 Flutter 编辑器中修改文字 clicked 为 tapped 后点击 reload,模拟器中的文字立刻就改变了,程序没有重启。同样地,修改按钮图标也会立刻生效。
接下来,我们先看看 Flutter 是怎么实现实时编译的。
Flutter 会在点击 reload 时去查看自上次编译以后改动过的代码,重新编译涉及到的代码库,还包括主库,以及主库的相关联库。所有这些重新编译过的库都会转换成内核文件发到 Dart VM 里,Dart VM 会重新加载新的内核文件,加载后会让 Flutter framework 触发所有的Widgets 和 Render Objects 进行重建、重布局、重绘。
Flutter 为了能够支持跨平台开发,使用了自研的 Dart 语言配合在 App 内集成 Dart VM 的方式运行 Flutter 程序。目前 Flutter 还没有达到 Cocoa 框架那样的普及程度,所以如果你不是使用 Flutter 来开发 iOS 程序的话,想要达到极速调试应该要怎么做呢?
所幸的是,John Holdsworth 开发了一个叫作 Injection 的工具可以动态地将 Swift 或 Objective-C 的代码在已运行的程序中执行,以加快调试速度,同时保证程序不用重启。John Holdsworth 也提供了动画演示效果,如下:
作者已经开源了这个工具,地址是https://github.com/johnno1962/InjectionIII 。使用方式就是 clone 下代码,构建 InjectionPluginLite/InjectionPlugin.xcodeproj ;删除方式是,在终端里运行下面这行代码:
构建完成后,我们就可以编译项目。这时添加一个新的方法:
然后在这个方法中添加一个断点,按下 ctrl + = ,接下来你会发现程序运行时会停到断点处,这样你的代码就成功地被运行中的 App 执行了。那么,Injection 是怎么做到的呢?
Injection 会监听源代码文件的变化,如果文件被改动了,Injection Server 就会执行 rebuildClass 重新进行编译、打包成动态库,也就是 .dylib 文件。编译、打包成动态库后使用 writeSting 方法通过 Socket 通知运行的 App。writeString 的代码如下:
Server 会在后台发送和监听 Socket 消息,实现逻辑在 InjectionServer.mm
的 runInBackground 方法里。Client 也会开启一个后台去发送和监听 Socket 消息,实现逻辑在 InjectionClient.mm
里的 runInBackground 方法里。
Client 接收到消息后会调用 inject(tmpfile: String) 方法,运行时进行类的动态替换。inject(tmpfile: String) 方法的具体实现代码,你可以点击这个链接查看。
inject(tmpfile: String) 方法的代码大部分都是做新类动态替换旧类。inject(tmpfile: String) 的入参 tmpfile 是动态库的文件路径,那么这个动态库是如何加载到可执行文件里的呢?具体的实现在inject(tmpfile: String) 方法开始里,如下:
你先看下 SwiftEval.instance.loadAndInject(tmpfile: tmpfile) 这个方法的代码实现:
在这段代码中,你是不是看到你所熟悉的动态库加载函数 dlopen 了呢?
如上代码所示,dlopen 会把 tmpfile 动态库文件载入运行的 App 里,返回指针 dl。接下来,dlsym 会得到 tmpfile 动态库的符号地址,然后就可以处理类的替换工作了。dlsym 调用对应代码如下:
当类的方法都被替换后,我们就可以开始重新绘制界面了。整个过程无需重新编译和重启 App,至此使用动态库方式极速调试的目的就达成了。
我把Injection的工作原理用一张图表示了出来,如下所示:
今天这篇文章,详细分享了动态库链接器的一个非常实用的应用场景:如何使用动态库加载方式进行极速调试。由此我们可以看出,类似链接器这样的底层知识是非常重要的。
当然了,这只是一个场景,还有更多的场景等待着我们去发掘。比如把 Injection 技术扩展开想,每当你修改了另一个人负责的代码就给那个人发条消息,同时将修改的代码编译、打包成动态库直接让对方看到修改的情况,这样不仅是提高了自己的效率,还提高了整个团队的沟通效率。怎么样?是不是有种想立刻尝试的感觉,心动不如行动,动手写起来吧。
所以,打好了底层知识的基础以后,我们才可以利用它们去提高开发效率,为用户提供更稳定、性能更好的 App 。
图3 Injection使用示例
图4 Injection的工作原理示意图