日常开发中,有人抱怨Xcode编译慢、有人吐槽App启动慢,有人想尝试热重载....,其实,这些事情背后都离不开编译和链接。
一、概念
1、LLVM
-
何为编译?简单解释是 把源代码翻译成可执行的代码(机器码);这样,程序在运行时,执行效率快,速度快;而解释器却是在程序运行时,将源码一行行解析成目标代码(字节码),然后再执行;效率自然就差一些;
-
iOS使用LLVM编译器;平常所说的LLVM有狭义和广义之分;
-
广义的LLVM: LLVM编译器架构,包括编译前端(Frontend)、优化器(Optimizer)和编译后端(Backend)三大部分;
-
狭义的LLVM:LLVM架构的编译后端,主要负责,代码优化、目标代码生成等;
LLVM项目已经成长为一个十分庞大的项目,已经从最初的Low Level Virtual Machine(低级虚拟机)发展成了The LLVM Compiler Infrastructure(编译器基础设施)。
-
2、ld和dyld
-
链接分静态链接和动态链接;
-
静态链接:静态链接器(ld64)将编译产生的一个或多个目标文件组合生成一个可执行文件(Mach-O);
-
动态链接:程序运行时(或首次启动,或第一次使用到),通过动态链接器(dyld)将链接需要的动态库;
-
使用dyld加载动态库有两个时机:
- 少部分动态库在程序启动加载时,通过dyld链接。
- 大部分动态库的第一次使用时,才通过dyld链接(减少启动耗时)。
对应的,在Mach-O文件的
__DATA.__nl_symbol_ptr
(非懒加载符号表),存储了non-lazily绑定
的符号,这些符号在Mach-O加载时绑定;
__DATA.__la_symbol_ptr
(懒加载符号表)存储了lazy绑定的符号,这些方法在第一调用时,由dyld_stub_binder
来绑定;每个Mach-O的non-lazily
绑定符号都有dyld_stub_binder
。
3、JIT和AOT
- 根据是够在运行期编译,编译方式分AOT(Ahead Of Time,运行前编译) 和 JIT(Just-in-time,即时编译);Android N引入混合编译模式(AOT和JIT);
- 跨端UI框架Flutter同样支持AOT和JIT;在Debug 模式下,采用 JIT即时编译,Release 模式下采用的是 AOT 静态编译。其中,JIT将 Dart 代码编译成可以运行在 Dart VM 上的 Dart Kernel,而 Dart Kernel 是可以动态更新的;
二、LLVM简介
1、LLVM架构
-
LLVM架构采用经典的编译器架构(三段式)设计:
- 前端 (Frontend) :对源码做词法分析、语法分析、语义分析、生成中间代码
- 优化器 (Optimizer) :用于中间代码优化
- 后端 (Backend) :根据中间代码,用于生成对应的 CPU架构的机器码
-
LLVM架构示意如下:
-
Objective C/C/C++语言使用的编译器前端是
Clang
,Swift
是 Swift 语言的编译器,完整的表示是:Swift Compiler -
从LLVM架构示意图,也能明白这种三段式架构的优势:
- 增加新的语言(如Swift),只需要实现新的 Frontend即可,Optimizer 和 Backend 可以重用;
- 新增新的 CPU 架构时(如ARM),也只需要实现新的 Backend即可;
2、LLVM编译链接过程
编译源码有四步:预处理 -> 编译 -> 汇编 -> 链接
,细分过程:预处理 -> 词法分析 -> 语法分析 -> 生成AST -> 中间代码(LR)生成 -> IR优化 -> 生成汇编代码 -> 汇编 -> 链接 -> 生成Mach-O文件
-
编译前端(Clang)处理
- 预处理(preprocessor):替进行头文件引入,宏替换,注释处理,条件编译(#ifdef)等操作
- 词法分析(lexical anaysis):词法分析器读入源文件的字符流,将他们组织称有意义的词素(lexeme)序列,对于每个词素,此法分析器产生词法单元(token)作为输出。
- 语法分析(semantic analysis):词法分析产生的词法单元Token流会被解析成一颗抽象语法树(abstract syntax tree - AST);有了抽象语法树,Clang就可以对这个树进行分析,找出代码中的错误。比如类型不匹配,亦或Objective-C中向target发送了一个未实现的消息。
- CodeGen(中间代码生成):遍历语法树,生成LLVM IR(中间)代码。LLVM IR是编译前端的输出,编译后端的输入。
-
IR处理
-
LLVM会对生成的IR进行优化,优化会调用相应的Pass进行处理;
-
Pass由多个节点组成,都是
Pass
类的子类,每个节点负责做特定的优化;补充:Pass相关可以见Writing an LLVM Pass和开发和调试第一个 LLVM Pass
-
-
编译后端处理
- 生成汇编代码:LLVM对IR进行优化后,会针对不同CPU架构生成不同的目标代码,最后以汇编代码的格式输出;
- 汇编:汇编器以汇编代码作为输入,将汇编代码转换为机器代码,最后输出目标文件(Object File),
-
链接:链接静态库,
.o
文件,生成一个Mach-O格式的可执行文件,Mach-O文件的更多了解可见: Mach-O文件周边二三事
3、优秀的Clang
-
Clang比GCC有更好的性能(更快速度、更省内存),主要有:
- 编译速度快,占用内存小,并且兼容 GCC。
- Clang可以发现显示出问题所在的行和具体位置,并且可以确切地说明出现这个问题的原因,并指出错误的类型是什么
- 模块化设计:Clang采用基于库的模块化设计,易于IDE集成及其他用途的重用
- 诊断信息可读性强:在编译过程中,Clang创建并保留了大量详细的元数据(metadata),有利于调试和错误报告;
- 设计清晰简单,容易理解,易于扩展增强。
-
Clang提供的能力
- LibClang:LibClang 可以访问 Clang 的上层高级抽象的能力,比如获取所有 Token、遍历语法树、代码补全等。
- Clang Plugin:允许在AST 上做些操作,这些操作可以被集成到编译中,成为编译的一部分(影响编译过程)。
- LibTooling:可以完全控制 Clang AST,通过 LibTooling 能够编写独立运行的语言分析和检查工具;
-
基于 Clang 库的静态分析工具
- OCLint:默认支持70+检查规则,可定制、能够帮助发现问题
- Clang Static Analyzer(Clang 静态分析器): C++ 开发的,分析 C、C++ 和 Objective-C 的开源工具
- Infer:Facebook 开源的静态分析工具,可以检查出 C、Java 和 Objective-C 空指针访问、资源泄露以及内存泄露等问题。
-
Clang和代码混淆
- 可以利用Clang的
LibTooling
来实现代码混淆,通过ATS接口,实现对类名、方法名、字符串的混淆; - 但是之前做代码混淆,还是通过写脚本,结合宏定义、AES加密等手段实现对类名、方法名和字符串的混淆;了解更多可见浅谈代码混淆
- 可以利用Clang的
三、静态链接器&动态链接器
1、静态链接器ld64
-
ld64将链接目标文件和静态库等;当链接静态库时,ld64会将库里的目标文件和项目中的目标文件组合生成一个可执行文件;静态库多了,可执行文件体积就大了;
-
一般,静态库生成的二进制中不裁剪调试符号信息,如果安装包选择裁剪符号,最终的安装包中不会包含静态库的调试符号,这一举措可以减少安装包体积;(
调试符号可以合并在 dSYM 文件
);
2、ld64对优化的启示
-
优化编译速度:合并静态库,意味着链接过程中,更少的
Page in
和Page Fault
。 -
优化启动速度:修改符号在二进制文件中链接顺序,优化启动性能(二进制重排);
- 背景:当进程访问一个虚拟内存页,而对应的物理内存却不存在时,会触发一次Page Fault(缺页中断),将需要的数据 or 指令从磁盘加载到物理内存页中,建立映射关系,然后再恢复现场;
- 二进制重排原理:利用
Xcode
的Build Setting
的Linking->Order File
选项可以指定链接符号顺序,减少程序加载过程中的缺页中断数。 - 二进制重排难点:或静态扫描、或运行时trace、或llvm 插桩 或 静态库插桩 找到启动时候函数的执行顺序,函数含括Objective-C函数、C/C++函数等;
说明1:App Store渠道分发的App,Page Fault在iOS 13前还会进行签名验证,但是iOS 13后签名验证取消了,相对来说,iOS 13后一次Page Fault耗时少了;
说明2:如果指定的符号不存在,ld会忽略;如果提供了link选项
-order_file_statistics
,会以warning的形式把这些没找到的符号打印在日志里。
3、动态链接器dyld
- iOS 13系统中,全面采用dyld 3,替代旧版本dyld 2;
- dyld2从2004年发布以来,历经很多版本迭代;dyld2引入了保障安全的ASLR(地址空间布局随机化)、Code Sign和优化系统库载入性能的shared cache技术;dyld3进一步优化启动时间;
- dyld会使用共享缓存,共享缓存在
/var/db/dyld/
;当加载 Mach-O 文件时,dyld会先检查是否有共享缓存,每个进程都会在自己的地址空间映射这些共享缓存,这样做可以起到优化 App 启动速度的作用。
4、dyld加载过程及启示
-
过程大概如下:(三步静态调整(fix-up) + 一步动态调整)
-
Load dylibs image:加载Mach-O需要的(部分)动态库镜像;
-
Rebase/Bind image:Rebase修复的是指向当前镜像内部的资源指针; bind指向的是镜像外部的资源指针;
-
Objc setup:注册Objc类 (class registration)、把Category的定义插入方法列表 (category registration)、保证每一个selector唯一 (selctor uniquing)
-
initializers:
- Objc的
+load()
函数 - C++的构造函数属性函数 形如
attribute((constructor)) void DoSomeInitializationWork()
- 非基本类型的C++静态全局变量的创建(通常是类或结构体)(non-trivial initializer) 比如一个全局静态结构体的构建,如果在构造函数中有繁重的工作,那么会拖慢启动速度。
- Objc的
-
-
优化启示:load函数中尽量少的操作,能延迟的尽量延迟;无用的代码尽早清除掉(
主要是为了减少Objective-C类个数,减少selector
);很多人提到 减少系统库依赖帮助优化启动速度,目前实践和收益评估比较难;两方面原因:
- 启动时候,加载的系统库只是一部分,大部分还是在第一次使用才会加载(懒加载);
- dyld加载动态库,是会使用共享缓存的,这对启动速度提升是正向的;
四、编译链接优化
1、查看Xcode编译时间
- 关闭Xcode
- 打开终端,输入如下命令后,
defaults write com.apple.dt.Xcode ShowBuildOperationDuration YES
- 重启Xcode,Build,可以查看编译时间
2、Xcode编译过程
- 创建
app_name.app
的文件夹; - 把
Entitlements.plist
写入到DerivedData
里,处理打包的时候需要的信息(如bundle-identifier); - 创建一些辅助文件,比如各种.hmap,这是headermap文件;
- 执行CocoaPods的编译前脚本:检查Manifest.lock文件;
- 编译.m文件,生成.o文件。
- 链接动态库,o文件,生成一个Mach-O格式的可执行文件。
- 编译assets,编译storyboard,链接storyboard
- 拷贝动态库Logger.framework,并且对其签名
- 执行CocoaPods编译后脚本:拷贝CocoaPods Target生成的Framework
- 对app_name.App签名,并验证
- 生成
app_name.app
3、编译的常规优化
-
使用
forward declaration
(前向声明):即用@class class_name
,而不是#import "class_name.h"
,这样能减少编译时间; -
常用头文件放到预编译文件里:Xcode的pch文件是预编译文件,这里的内容在执行Xcode Build之前就已经被预编译,并且引入到每一个.m文件里了。
-
Debug时
Build Active Architecture Only
设置为YES:只编译出支持当前CPU架构的安装包; -
提高编译线程数:Xcode默认的编译线程数,就是CPU的内核数,可适当增加编译线程数来提高编译速度
# 获取当前内核数: sysctl -n hw.ncpu # 设置编译线程数: defaults write com.apple.dt.Xcode IDEBuildOperationMaxNumberOfConcurrentCompileTasks 12 # 获取编译线程数: defaults read com.apple.dt.Xcode IDEBuildOperationMaxNumberOfConcurrentCompileTasks
-
Debug时,
Debug Information Format
改为DWARF
,不需要dSYM
文件,有一定的效果; -
Debug时,Link-Time Optimization设置为NO:不使用LTO特性
-
Enable Index-While-Building Functionality设置为NO: 默认开启,Xcode 编译时会建立代码索引,影响编译速度;关闭后,在编译时就不会进行索引,而是在空闲时间建立代码索引(自动补全Code、查找定义)
4、Link-Time Optimization(LTO)
-
LTO主要是对链接过程的一个优化,开启LTO有三个好处:
-
多余代码去除(Dead code elimination):LTO在link时候,发现跨多文件的无用代码;
-
跨过程优化(Interprocedural analysis and optimization):最终不会执行的代码,在二进制结果文件中不出现;
-
内联优化(Inlining optimization):汇编中不使用 “
call func_name
” 语句,直接将外部方法内的语句“复制”到调用者的代码段内。好处是不用进行调用函数前的压栈、调用函数后的出栈操作,提高运行效率与栈空间利用率。
-
-
LTO有
Monilithic
和Incremental
两个方式选择,Release下,建议选择Incremental
,Debug禁用LTO,因为LTO对符号剥离有点影响,可能会影响断点的单步执行; -
选择
Incremental
这个优化方式,会有link cache,使二次编译的速度更快(只编译和链接少数修改过的文件),另一方面它还可能减小Code Size
;而Monilithic
方式并不支持多线程和增量链接 -
开启
Incremental
的选项后,如果出现duplicate symbols
错误,可以看下代码,有可能是全局变量使用不规范,应该:在定义时,必须使用static
关键字或者单独在.m
文件中定义,.h
文件中只能声明变量,而不应该定义变量;
5、总结
- 了解Xcode的编译链接过程,使用常规的优化手段,对提升编译速度有一定的效果;
- 升级硬件设备也是办法之一;体验过:
13.3英寸Mac Pro
->15.4英寸Mac Pro
带来的编译提升体验; - 如果要进一步优化编译链接速度,做的事情就很多了
- 模块/组件拆分,沉库,组件集成和发版
- Debug模式下,相关的用源码,无关的用二进制;
- 多个静态库合并;
- .....
五、热重载方案
1、Flutter的热重载
-
Flutter 是跨平台UI框架,使用自研的 Dart 语言配合在 App 内集成 Dart VM 的方式运行 Flutter 程序。
-
Flutter在Debug模型下,支持JIT(Just-in-time,即时编译)模式,可以实现实时编译,热重载,极大提高了调试效率;
-
Flutter实现热重载的原理:JIT将 上次编译后改动过的代码 及其关联到的代码库编译成Dart Kernel,发送到
Dart VM
里,Dart VM 会重新加载新的Dart Kernel
,加载后会让 Flutter framework 触发所有的Widgets
(控件)和Render Objects
进行重建、重布局、重绘。运行在 Dart VM 上的 Dart Kernel是可以动态更新的
2、Swift UI的“热重载”
- SwiftUI是Apple在2019年WWDC大会推出的基于 Swift 语言构建的全新 UI 框架 ,完全抛弃了Storyboard 和Autolayout,采用声明式的界面语言(DSL) 和 实时预览功能,开发体验有了很大的提升;
- 其中,实时预览分静态预览和动态预览,默认情况下是静态预览,它的速度快,而且支持代码和可视化两种编写方式,不过它没有任何响应事件,无法滚动和跳转页面。而动态预览类似于Flutter的热重载,但是依然还有差距(需要编译一段时间,有一些限制)
- SwiftUI还需要💪;此外,静态 UI 调试其实完全可以通过 StoryBoard,而真正实用的动态预览又有一定的局限性。
3、Injection for Xcode
-
John Holdsworth 开发的 Injection 的工具可以动态地将 Swift 或 Objective-C 的代码在已运行的程序中执行,到达热重载的效果;
-
Injection原理:
-
监听源代码文件的变化后,重新编译对应的类,打包成动态库(.dylib 文件),然后通过Socket通知给运行的App;
-
Injection将动态库通过
dlopen
将动态库载入到运行的 App 里,返回handler(句柄); -
dlsym
结合handler + 符号
得到动态库的符号地址后,就可以处理类的替换工作了; -
当替换工作完成,就重新绘制界面;整个过程无需重新编译和重启 App。
dlopen
可以打开指定的动态库文件,并返回句柄;dlsym根据句柄和符号获得符号地址;dlclose可以卸载/关闭对应的动态库;
-
-
Injection for Xcode是利用注入动态库方式实现热重载,用到了dyld的API
dlopen
和dlsym
(线下是OK的);但是提审的App中不能带这两个API,否则会被拒的;注意:Apple拒绝线上App有动态库注入的能力;
参考文章
Getting Started with the LLVM System