举一个基于链接优化启动速度的例子:
最开始讲解 Page In 的时候,我们提到 TEXT 段的页解密很耗时,有没有办法优化呢?
可以通过 ld 的-rename_p,把 TEXT 段中的内容,比如字符串移动到其他的段(启动路径上难免会读很多字符串),从而规避这个解密的耗时。抖音的重命名方案:
“-Wl,-rename_p,__TEXT,__cstring,__RODATA,__cstring”,
“-Wl,-rename_p,__TEXT,__const,__RODATA,__const”,
“-Wl,-rename_p,__TEXT,__gcc_except_tab,__RODATA,__gcc_except_tab”,
“-Wl,-rename_p,__TEXT,__objc_methname,__RODATA,__objc_methname”,
“-Wl,-rename_p,__TEXT,__objc_classname,__RODATA,__objc_classname”,
“-Wl,-rename_p,__TEXT,__objc_methtype,__RODATA,__objc_methtype”
裁剪
编译完 Mach-O 之后会进行裁剪(strip),是因为里面有些信息,如调试符号,是不需要带到线上去的。裁剪有多种级别,一般的配置如下:
-
All Symbols,主二进制
-
Non-Global Symbols,动态库
-
Debugging Symbols,二方静态库
为什么二方库在出静态库的时候要选择 Debugging Symbols 呢?是因为像 order_file 等链接期间的优化是基于符号的,如果把符号裁剪掉,那么这些优化也就不会生效了。
签名 & 上传
裁剪完二进制后,会和编译好的资源文件一起打包成.app 文件,接着对这个文件进行签名。签名的作用是保证文件内容不多不少,没有被篡改过。接着会把包上传到 iTunes Connect,上传后会对__TEXT
段加密,加密会减弱 IPA 的压缩效果,增加包大小,也会降低启动速度**(iOS 13 优化了加密过程,不会对包大小和启动耗时有影响)**。
dyld3 启动流程
Apple 在 iOS 13 上对第三方 App 启用了 dyld3,官方数据[3]显示,过去四年新发布的设备中有 93%的设备是 iOS 13,所以我们重点看下 dyld3 的启动流程。
Before dyld
用户点击图标之后,会发送一个系统调用 execve 到内核,内核创建进程。接着会把主二进制 mmap 进来,读取 load command 中的 LC_LOAD_DYLINKER,找到 dyld 的的路径。然后 mmap dyld 到虚拟内存,找到 dyld 的入口函数_dyld_start
,把 PC 寄存器设置成_dyld_start
,接下来启动流程交给了 dyld。
注意这个过程都是在内核态完成的,这里提到了 PC 寄存器,PC 寄存器存储了下一条指令的地址,程序的执行就是不断修改和读取 PC 寄存器来完成的。
dyld
创建启动闭包
dyld 会首先创建启动闭包,闭包是一个缓存,用来提升启动速度的。既然是缓存,那么必然不是每次启动都创建的,只有在重启手机或者更新/下载 App 的第一次启动才会创建。闭包存储在沙盒的 tmp/com.apple.dyld 目录,清理缓存的时候切记不要清理这个目录。
闭包是怎么提升启动速度的呢?我们先来看一下闭包里都有什么内容:
-
dependends,依赖动态库列表
-
fixup:bind & rebase 的地址
-
initializer-order:初始化调用顺序
-
optimizeObjc: Objective C 的元数据
-
其他:main entry, uuid…
动态库的依赖是树状的结构,初始化的调用顺序是先调用树的叶子结点,然后一层层向上,最先调用的是 libSystem,因为他是所有依赖的源头。
为什么闭包能提高启动速度呢?
因为这些信息是每次启动都需要的,把信息存储到一个缓存文件就能避免每次都解析,尤其是 Objective C 的运行时数据(Class/Method**…)解析非常****慢。**
fixup
有了闭包之后,就可以用闭包启动 App 了。这时候很多动态库还没有加载进来,会首先对这些动态库 mmap 加载到虚拟内存里。接着会对每个 Mach-O 做 fixup,包括 Rebase 和 Bind。
-
Rebase:修复内部指针。这是因为 Mach-O 在 mmap 到虚拟内存的时候,起始地址会有一个随机的偏移量 slide,需要把内部的指针指向加上这个 slide。
-
Bind:修复外部指针。这个比较好理解,因为像 printf 等外部函数,只有运行时才知道它的地址是什么,bind 就是把指针指向这个地址。
举个例子:一个 Objective C 字符串@“1234”,编译到最后的二进制的时候是会存储在两个 p 里的
-
__TEXT,__cstring
,存储实际的字符串"1234" -
__DATA,__cfstring
,存储 Objective C 字符串的元数据,每个元数据占用 32Byte,里面有两个指针:内部指针,指向__TEXT,__cstring
中字符串的位置;外部指针 isa,指向类对象的,这就是为什么可以对 Objective C 的字符串字面量发消息的原因。
如下图,编译的时候,字符串 1234 在__cstring
的 0x10 处,所以 DATA 段的指针指向 0x10。但是 mmap 之后有一个偏移量 slide=0x1000,这时候字符串在运行时的地址就是 0x1010,那么 DATA 段的指针指向就不对了。Rebase 的过程就是把指针从 0x10,加上 slide 变成 0x1010。运行时类对象的地址已经知道了,bind 就是把 isa 指向实际的内存地址。
LibSystem Initializer
Bind & Rebase 之后,首先会执行 LibSystem 的 Initializer,做一些最基本的初始化:
-
初始化 libdispatch
-
初始化 objc runtime,注册 sel,加载 category
注意这里没有初始化 objc 的类方法等信息,是因为启动闭包的缓存数据已经包含了 optimizeObjc。
Load & Static Initializer
接下来会进行 main 函数之前的一些初始化,主要包括+load 和 static initializer。这两类初始化函数都有个特点:调用顺序不确定,和对应文件的链接顺序有关系。那么就会存在一个隐藏的坑:有些注册逻辑在+load 里,对应会有一些地方读取这些注册的数据,如果在+load 中读取,很有可能读取的时候还没有注册。
那么,如何找到代码里有哪些 load 和 static initializer 呢?
在 Build Settings 里可以配置 write linkmap,这样在生成的 linkmap 文件里就可以找到有哪些文件里包含 load 或者 static initializer:
-
__mod_init_func
,static initializer -
__objc_nlclslist
,实现+load 的类 -
__objc_nlcatlist
,实现+load 的 Category
load 举例
如果+load 方法里的内容很简单,会影响启动时间么?比如这样的一个+load 方法?
+ (void)load { printf(“1234”); }
编译完了之后,这个函数会在二进制中的 TEXT 两个段存在:__text
存函数二进制,cstring
存储字符串 1234。为了执行函数,首先要访问__text
触发一次 Page In 读入物理内存,为了打印字符串,要访问__cstring
,还会触发一次 Page In。
- 为了执行这个简单的函数,系统要额外付出两次 Page In 的代价,所以 load 函数多了,page in 会成为启动性能的瓶颈。
static initializer 产生的条件
静态初始化是从哪来的呢?以下几种代码会导致静态初始化
-
__attribute__((constructor))
-
static class object
-
static object in global namespace
注意,并不是所有的 static 变量都会产生静态初始化,编译器很智能,对于在编译期间就能确定的变量是会直接 inline。
//会产生静态初始化
class Demo{
static const std::string var_1;
};
const std::string var_2 = “1234”;
static Logger logger;//不会产生静态初始化
static const int var_3 = 4;
static const char * var_4 = “1234”;
std::string 会合成 static initializer 是因为初始化的时候必须执行构造函数,这时候编译器就不知道怎么做了,只能延迟到运行时~
UIKit Init
+load 和 static initializer 执行完毕之后,dyld 会把启动流程交给 App,开始执行 main 函数。main 函数里要做的最重要的事情就是初始化 UIKit。UIKit 主要会做两个大的初始化:
-
初始化 UIApplication
-
启动主线程的 Runloop
由于主线程的 dispatch_async 是基于 runloop 的,所以在+load 里如果调用了 dispatch_async 会在这个阶段执行。
Runloop
线程在执行完代码就会退出,很明显主线程是不能退出的,那么就需要一种机制:事件来的时候执行任务,否则让线程休眠,Runloop 就是实现这个功能的。
Runloop 本质上是一个While
循环,在图中橙色部分的 mach_msg_trap
就是触发一个系统调用,让线程休眠,等待事件到来,唤醒 Runloop,继续执行这个 while
循环。
Runloop 主要处理几种任务:Source0,Source1,Timer,GCD MainQueue,Block。在循环的合适时机,会以 Observer 的方式通知外部执行到了哪里。那么,Runloop 与启动又有什么关系呢?
-
App 的 LifeCycle 方法是基于 Runloop 的 Source0 的
-
首帧渲染是基于 Runloop Block 的
Runloop 在启动上主要有几点应用:
-
精准统计启动时间
-
找到一个时机,在启动结束去执行一些预热任务
-
利用 Runloop 打散耗时的启动预热任务
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数Android工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年Android移动开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录大纲截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且后续会持续更新
如果你觉得这些内容对你有帮助,可以添加V获取:vip204888 (备注Android)
总结
开发是面向对象。我们找工作应该更多是面向面试。哪怕进大厂真的只是去宁螺丝,但你要进去得先学会面试的时候造飞机不是么?
作者13年java转Android开发,在小厂待过,也去过华为,OPPO等,去年四月份进了阿里一直到现在。等大厂待过也面试过很多人。深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长,不成体系的学习效果低效漫长且无助。
这里附上上述的技术体系图相关的几十套腾讯、头条、阿里、美团等公司的面试题,把技术点整理成了视频和PDF(实际上比预期多花了不少精力),包含知识脉络 + 诸多细节,由于篇幅有限,这里以图片的形式给大家展示一部分。
相信它会给大家带来很多收获:
资料太多,全部展示会影响篇幅,暂时就先列举这些部分截图
当程序员容易,当一个优秀的程序员是需要不断学习的,从初级程序员到高级程序员,从初级架构师到资深架构师,或者走向管理,从技术经理到技术总监,每个阶段都需要掌握不同的能力。早早确定自己的职业方向,才能在工作和能力提升中甩开同龄人。
一个人可以走的很快,但一群人才能走的更远。如果你从事以下工作或对以下感兴趣,欢迎戳这里加入程序员的圈子,让我们一起学习成长!
AI人工智能、Android移动开发、AIGC大模型、C C#、Go语言、Java、Linux运维、云计算、MySQL、PMP、网络安全、Python爬虫、UE5、UI设计、Unity3D、Web前端开发、产品经理、车载开发、大数据、鸿蒙、计算机网络、嵌入式物联网、软件测试、数据结构与算法、音视频开发、Flutter、IOS开发、PHP开发、.NET、安卓逆向、云计算
不同的能力。早早确定自己的职业方向,才能在工作和能力提升中甩开同龄人。
一个人可以走的很快,但一群人才能走的更远。如果你从事以下工作或对以下感兴趣,欢迎戳这里加入程序员的圈子,让我们一起学习成长!
AI人工智能、Android移动开发、AIGC大模型、C C#、Go语言、Java、Linux运维、云计算、MySQL、PMP、网络安全、Python爬虫、UE5、UI设计、Unity3D、Web前端开发、产品经理、车载开发、大数据、鸿蒙、计算机网络、嵌入式物联网、软件测试、数据结构与算法、音视频开发、Flutter、IOS开发、PHP开发、.NET、安卓逆向、云计算