iOS App 如何通过注入动态库的方式实现极速编译调试

APP编译的流程

解释器 & 编译器

你是不是经常会好奇自己参与的这么些项目,为什么有的编译起来很快,有的却很慢;编译完成后,有的启动得很快,有的却很慢。其实,在理解了编译和启动时链接器所做的事儿之后,你就可以从根儿上找到这些问题的答案了。

  • 解释器:运行时才去解析代码

    • 解释器会在运行时解释执行代码,获取一段代码后就会将其翻译成目标代码(就是字节码(Bytecode)),然后一句一句地执行目标代码。解释器,是在运行时才去解析代码,这样就比在运行之前通过编译器生成一份完整的机器码再去执行的效率要低。
    • 解释器可以在运行时去执行代码,说明它具有动态性,程序运行后能够随时通过增加和更新代码来改变程序的逻辑。

  • 编译器:编译时,编译器把代码编译成机器码,然后直接在 CPU 上执行机器码的

    • 链接器:最主要的作用,就是将符号绑定到地址上

那么,使用编译器和解释器执行代码的特点,我们就可以概括如下

  • 采用编译器生成机器码执行的好处是效率高,缺点是调试周期长
  • 解释器执行的好处是编写调试方便,缺点是执行效率低。

iOS 开发使用的到底是什么编译器

现在苹果公司使用的编译器是 LLVM,相比于 Xcode 5 版本前使用的 GCC,编译速度提高了 3 倍

LLVM 是编译器工具链技术的一个集合。而其中的 lldb 项目,就是内置链接器。

编译器会对每个文件进行编译,生成 Mach-O(可执行文件);链接器会将项目中的多个 Mach-O 文件合并成一个。

我先简单为你总结下编译的几个主要过程:

  • 1、你写好代码后,LLVM 会预处理你的代码,比如把宏嵌入到对应的位置
  • 2、预处理完后,LLVM 会对代码进行词法分析和语法分析,生成 AST 。AST 是抽象语法树,结构上比代码更精简,遍历起来更快,所以使用 AST 能够更快速地进行静态检查,同时还能更快地生成 IR(中间表示)
  • 3、最后 AST 会生成 IR,IR 是一种更接近机器码的语言,区别在于和平台无关,通过 IR 可以生成多份适合不同平台的机器码。对于 iOS 系统,IR 生成的可执行文件就是 Mach-O。

编译时链接器做了什么?

Mach-O 文件里面的内容,主要就是代码和数据

  • 代码是函数的定义
  • 数据是全局变量的定义,包括全局变量的初始值

不管是代码还是数据,它们的实例都需要由符号将其关联起来。

因为 Mach-O 文件里的那些代码,比如 if、for、while 生成的机器指令序列,要操作的数据会存储在某个地方,变量符号就需要绑定到数据的存储地址。你写的代码还会引用其他的代码,引用的函数符号也需要绑定到该函数的地址上

而链接器的作用,就是完成变量、函数符号和其地址绑定这样的任务。而这里我们所说的符号,就可以理解为变量名和函数名

那为什么要让链接器做符号和地址绑定这样一件事儿呢?不绑定的话,又会有什么问题?

如果地址和符号不做绑定的话,要让机器知道你在操作什么内存地址,你就需要在写代码时给每个指令设好内存地址。写这样的代码的过程,就像你直接在和不同平台的机器沟通,连编译生成 AST 和 IR 的步骤都省掉了,甚至优化平台相关的代码都需要你自己编写。

这件事儿看起来挺酷,但可读性和可维护性都会很差,比如修改代码后对地址的维护就会让你崩溃。而这种“崩溃”的罪魁祸首就是代码和内存地址绑定得太早

另外,绑定得太早除了可读性和可维护性差之外,还会有更多的重复工作。因为,你需要针对不同的平台写多份代码,而这些代码本可以通过高级语言一次编译成多份。既然这样,那我们应该怎么办呢?

我们首先想到的就是,用汇编语言来让这种绑定滞后。随着编程语言的进化,我们很快就发现,采用任何一种高级编程语言,都可以解决代码和内存绑定过早产生的问题,同时还能扫掉使用汇编写程序的烦恼。

链接器为什么还要把项目中的多个 Mach-O 文件合并成一个

你肯定不希望一个项目是在一个文件里从头写到尾的吧。项目中文件之间的变量和接口函数都是相互依赖的,所以这时我们就需要通过链接器将项目中生成的多个 Mach-O 文件的符号和地址绑定起来。

没有这个绑定过程的话,单个文件生成的 Mach-O 文件是无法正常运行起来的。因为,如果运行时碰到调用在其他文件中实现的函数的情况时,就会找不到这个调用函数的地址,从而无法继续执行。

链接器在链接多个目标文件的过程中,会创建一个符号表,用于记录所有已定义的和所有未定义的符号。链接时如果出现相同符号的情况,就会出现“ld: dumplicate symbols”的错误信息;如果在其他目标文件里没有找到符号,就会提示“Undefined symbols”的错误信息。

链接器对代码主要做了哪几件事儿。

  • 1、去项目文件里查找目标代码文件里没有定义的变量。
  • 2、扫描项目中的不同文件,将所有符号定义和引用地址收集起来,并放到全局符号表中。
  • 3、计算合并后长度及位置,生成同类型的段进行合并,建立绑定
  • 4、对项目中不同文件里的变量进行地址重定位

你在项目里为某项需求写了一些功能函数,但随着业务的发展,一些功能被下掉了或者被其他负责的同事在另一个文件里用其他函数更新了功能。那么这时,你以前写的那些函数就没有用武之地了。日长月久,无用的函数越来越多,生成的 Mach-O 文件也就越来越大。

这时,链接器在整理函数的符号调用关系时,就可以帮你理清有哪些函数是没被调用的,并自动去除掉。那这是怎么实现的呢?

链接器在整理函数的调用关系时,会以 main 函数为源头,跟随每个引用,并将其标记为 live。跟随完成后,那些未被标记 live 的函数,就是无用函数。然后,链接器可以通过打开 Dead code stripping 开关,来开启自动去除无用代码的功能。并且,这个开关是默认开启的。

动态库链接

在真实的 iOS 开发中,你会发现很多功能都是现成可用的,不光你能够用,其他 App 也在用,比如 GUI 框架、I/O、网络等。链接这些共享库到你的 Mach-O 文件,也是通过链接器来完成的

链接的共用库分为静态库和动态库

  • 1、静态库是编译时链接的库

    • 需要链接进你的 Mach-O 文件里,如果需要更新就要重新编译一次,无法动态加载和更新

  • 2、动态库是运行时链接的库,使用 dyld 就可以实现动态加载。

Mach-O 文件是编译后的产物,而动态库在运行时才会被链接,并没参与 Mach-O 文件的编译和链接。

所以 Mach-O 文件中并没有包含动态库里的符号定义。也就是说,这些符号会显示为“未定义”,但它们的名字和对应的库的路径会被记录下来。运行时通过 dlopen 和 dlsym 导入动态库时,先根据记录的库路径找到对应的库,再通过记录的名字符号找到绑定的地址。

dlopen 会把共享库载入运行进程的地址空间,载入的共享库也会有未定义的符号,这样会触发更多的共享库被载入。dlopen 也可以选择是立刻解析所有引用还是滞后去做。dlopen 打开动态库后返回的是引用的指针,dlsym 的作用就是通过 dlopen 返回的动态库指针和函数符号,得到函数的地址然后使用。

使用 dyld 加载动态库,有两种方式

  • 1、程序启动加载时绑定
  • 2、符号第一次被用到时绑定

为了减少启动时间,大部分动态库使用的都是符号第一次被用到时再绑定的方式。

加载过程开始会修正地址偏移,iOS 会用 ASLR 来做地址偏移避免攻击,确定 Non-Lazy Pointer 地址进行符号地址绑定,加载所有类,最后执行 load 方法和 Clang Attribute 的 constructor 修饰函数。每个函数、全局变量和类都是通过符号的形式定义和使用的,当把目标文件链接成一个 Mach-O 文件时,链接器在目标文件和动态库之间对符号做解析处理

这里系统上的动态链接器会使用共享缓存,共享缓存在 /var/db/dyld/。当加载 Mach-O 文件时,动态链接器会先检查是否有共享缓存。每个进程都会在自己的地址空间映射这些共享缓存,这样做可以起到优化 App 启动速度的作用

简单来说, dyld 做了这么几件事儿:

  • 1、先执行 Mach-O 文件,根据 Mach-O 文件里 undefined 的符号加载对应的动态库,系统会设置一个共享缓存来解决加载的递归依赖问题
  • 2、加载后,将 undefined 的符号绑定到动态库里对应的地址上;
  • 3、最后再处理 +load 方法,main 函数返回后运行 static terminator。

Injection使用

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

1、使用

Injection 是我们需要用到个一个工具,不要因为要用一个工具而厌烦这个方案,它很简单。 它是免费的,app store 搜索:InjectionIII,Icon是 一个针筒。 也是开源的,

  • GitHub链接: github.com/johnno1962/…

  • App Store链接: itunes.apple.com/cn/app/inje…

2、配置路径

打开InjectionIII工具,选择Open Project,选择你的代码所在的路径,然后点击Select Project Directory保存

3、导入配置文件

1.设置AppDelegate.m 打开你的源码,在AppDelegate.m的didFinishLaunchingWithOptions方法添加一行代码

#if DEBUG

// iOS

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

#endif

2.设置ViewController 在需要修改界面的ViewController添加方法- (void)injected,或者给ViewController类扩展添加方法- (void)injected。 所有修改控件的代码都写在这里面

- (void)injected

{

[self viewDidLoad];

}

- (void)viewDidLoad {

[super viewDidLoad];

// Do any additional setup after loading the view.

UIView *red = [[UIView alloc]initWithFrame:CGRectMake(100, 100, 100, 100)];

red.backgroundColor = [UIColor redColor];

[self.view addSubview:red];

}

4、启动项目,修改验证

在Xcode Command+R运行项目 ,看到Injection connected 提示即表示配置成功。

💉 Injection connected 👍

💉 Have you remembered to add "-Xlinker -interposable" to your project's "Other Linker Flags"?

💉 Watching /Users/yunna/Desktop/a/**

在需要修改的页面,修改控件UI,然后Command+S保存一下代码,立刻就在模拟器上显示修改的信息了。

5、每个VC要使用的话,还需要去写injected,有点烦人,但是我们有方案

用runtime 给每个VC加个方法class_addMethod

依托InjectionIII的iOS热部署配置文件,无侵害,导入即用。

@implementation InjectionIIIHelper

#if DEBUG

/**

InjectionIII 热部署会调用的一个方法,

runtime给VC绑定上之后,每次部署完就重新viewDidLoad

*/

void injected (id self, SEL _cmd) {

//重新加载view

[self loadView];

[self viewDidLoad];

[self viewWillLayoutSubviews];

[self viewWillAppear:NO];

}

+ (void)load

{

//注册项目启动监听

__block id observer =

[[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidFinishLaunchingNotification object:nil queue:nil usingBlock:^(NSNotification * _Nonnull note) {

//更改bundlePath

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

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

[[NSNotificationCenter defaultCenter] removeObserver:observer];

}];

//给UIViewController 注册injected 方法

class_addMethod([UIViewController class], NSSelectorFromString(@"injected"), (IMP)injected, "v@:");

}

#endif

@end

6、Injection 是怎么做到的呢

Injection 会监听源代码文件的变化,如果文件被改动了,Injection Server 就会执行 rebuildClass 重新进行编译、打包成动态库,也就是 .dylib 文件。编译、打包成动态库后使用 writeSting 方法通过 Socket 通知运行的 App

- (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) 方法的代码大部分都是做新类动态替换旧类。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 - Ignore any duplicate class warning...")

// load patched .dylib into process with new version of class

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

throw evalError("dlopen() error: \(String(cString: dlerror()))")

}

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).*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: \(String(cString: dlerror()))")

}

如上代码所示,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 的工作原理用一张图表示了出来,如下所示:

文章转载自:

  • 1、iOS开发高手课:05 | 链接器:符号是怎么绑定到地址上的?
  • 2、iOS开发高手课:App 如何通过注入动态库的方式实现极速编译调试?
  • 3、iOS教你如何像RN一样实时编译

以上是 iOS App 如何通过注入动态库的方式实现极速编译调试 的全部内容, 来源链接: utcz.com/a/34253.html

回到顶部