通过 Runloop 源码分析和线下调试,我们发现 CA::Transaction::commit()
,CFRunLoopPerformBlock
,kCFRunLoopBeforeTimers
这三个时机的顺序从早到晚依次是:
可以通过在 didFinishLaunch 中向 Runloop 注册 block 或者 BeforeTimer 的 Observer 来获取上图中两个时间点的回调,代码如下:
//注册block
CFRunLoopRef mainRunloop = [[NSRunLoop mainRunLoop] getCFRunLoop];
CFRunLoopPerformBlock(mainRunloop,NSDefaultRunLoopMode,^(){
NSTimeInterval stamp = [[NSDate date] timeIntervalSince1970];
NSLog(@“runloop block launch end:%f”,stamp);
});
//注册kCFRunLoopBeforeTimers回调
CFRunLoopRef mainRunloop = [[NSRunLoop mainRunLoop] getCFRunLoop];
CFRunLoopActivity activities = kCFRunLoopAllActivities;
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, activities, YES, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
if (activity == kCFRunLoopBeforeTimers) {
NSTimeInterval stamp = [[NSDate date] timeIntervalSince1970];
NSLog(@“runloop beforetimers launch end:%f”,stamp);
CFRunLoopRemoveObserver(mainRunloop, observer, kCFRunLoopCommonModes);
}
});
CFRunLoopAddObserver(mainRunloop, observer, kCFRunLoopCommonModes);
经过实测,我们最后选择的无侵入获取首屏渲染方案是:
-
iOS13(含)以上的系统采用
runloop
中注册一个kCFRunLoopBeforeTimers
的回调获取到的 App 首屏渲染完成的时机更准确。 -
iOS13 以下的系统采用
CFRunLoopPerformBlock
方法注入 block 获取到的 App 首屏渲染完成的时机更准确。
监控周期
App 的生命周期可以分为三个阶段:研发,灰度和线上,不同阶段监控的目的和方式都不一样。
研发阶段
研发阶段的监控主要目的是防止劣化,对应着会有线下的自动化监控,通过实际的启动性能测试来尽早地发现和解决问题,抖音的线下自动化监控流程图如下:
由定时任务触发,先 release 模式下打包,接着跑一次自动化测试,测试完毕后会上报测试结果,方便通过看板来跟踪整体的变化趋势。
如果发现有劣化,会先发一条报警信息,接着通过二分查找的方式找到对应的劣化 MR,然后自动跑火焰图和 Instrument 来辅助定位问题。
那么如何保证测试的结果是稳定可靠的呢?
答案就是控制变量:
-
关闭 iCloud & 不登录 AppleID & 飞行模式
-
风扇降温,且用 MFI 认证数据线
-
重启手机和开始下一次测试之前静置一段时间
-
多次测量取平均值 & 计算方差
-
Mock 启动过程中的 AB 变量
实践下来,我们发现 iPhone 8 的稳定性最好,其次是 iPhone X,iPhone 6 的稳定性很差。
除了自动化测试,在研发流程上还可以加一些准入,来防止启动劣化,这些准入包括
-
新增动态库
-
新增 +load 和静态初始化
-
新增启动任务 Code Review
不建议做细粒度的 Code Review,除非对相关业务很了解,否则一般肉眼看不出会不会有劣化。
线上 & 灰度
灰度和线上的策略是相似的,主要看的是大盘数据和配置报警,大盘监控和报警和公司的基建有很大关系,如果没有对应基建 Xcode MetricKit 本身也可以看到启动耗时:打开 Xcode -> Window -> Origanizer -> Launch Time
大盘数据本身是统计学的,会有些统计学的规律:
-
发版本的前几天启动速度比较慢,这是因为 iOS 13 后更新 App 的第一次启动要创建启动闭包,这个过程是比较慢的
-
新版本发布会导致老版本 pct50 变慢,因为性能差的设备升级速度慢,导致老版本性能差设备比例变高
-
采样率调整会影响 pct50,比如某些地区的 iPhone 6 比例较高,如果这些地区采样率提高会导致大盘性能差的设备比例提高。
基于这些背景,我们一般会通过控制变量的方式:拆地区,机型,版本,有时候甚至要根据时间看启动耗时的趋势。
工具
==
完成了监控之后,我们需要找到一些可以优化的点,就需要用到工具。主要包括两大类:Instrument 和自研。
Time Profiler
Time Profiler 是大家日常性能分析中用的比较多的工具,通常会选择一个时间段,然后聚合分析调用栈的耗时。但Time Profiler 其实只适合粗粒度的分析,为什么这么说呢?我们来看下它的实现原理:
默认 Time Profiler 会 1ms 采样一次,只采集在运行线程的调用栈,最后以统计学的方式汇总。比如下图中的 5 次采样中,method3 都没有采样到,所以最后聚合到的栈里就看不到 method3。所以 Time Profiler 中的看到的时间,并不是代码实际执行的时间,而是栈在采样统计中出现的时间。
Time Profiler 支持一些额外的配置,如果统计出来的时间和实际的时间相差比较多,可以尝试开启:
-
High Frequency,降低采样的时间间隔
-
Record Kernel Callstacks,记录内核的调用栈
-
Record Waiting Thread,记录被 block 的线程
System Trace
既然 Time Profiler 支持粗粒度的分析,那么有没有什么精细化的分析工具呢?答案就是 System Trace。
既然要精细化分析,那么我们就需要标记出一小段时间,可以用 Point of interest 来标记。除此之外,System Trace 分析虚拟内存和线程状态都很管用:
-
Virtual Memory:主要关注 Page In这个事件,因为启动路径上有很多次 Page In,且相对耗时
-
Thread State:主要关注挂起和抢占两个状态,记住主线程不是一直在运行的
-
System Load 线程有优先级,高优先级的线程不应该超过系统核心数量
os_signpost
os_signpost 是 iOS 12 推出的用于在 instruments 里标记时间段的 API,性能非常高,可以认为对启动无影响。结合最开始讲的分阶段监控,我们可以在 Instrument 把启动划分成多个阶段,和其他模板一起分析具体问题:
结合 swizzle,os_signpost 可以发挥出意想不到的效果,比如 hook 所有的 load 方法,来分析对应耗时,又比如 hook UIImage 对应方法,来统计启动路径上用到的图片加载耗时。
其他 Instrument 模板
除了这些,还有几个模板是比较常用的:
-
Static Initializer:分析 C++ 静态初始化
-
App Launch:Xcode 11 后新出的模板,可以认为是 Time Profiler + System Trace
-
Custom Instrument:自定义 Instrument,最简单是用 os_signpost 作为模板的数据源,自己做一些简单的定制化展示,具体可参考 WWDC 的相关 Session。
火焰图
火焰图用来分析时间相关的性能瓶颈非常有用,可以直接把业务代码的耗时绘制出来。此外,火焰图可以自动化生成然后 diff,所以可以用于自动化归因。
火焰图有两种常见实现方式
-
hook objc_msgSend
-
编译期插桩
本质上都是在方法的开始和末尾打两个点,就知道这个方法的耗时,然后转换成 Chrome 的标准的 json 格式就可以分析了。注意就算用 mmap 来写文件,仍然会有一些误差,所以找到的问题并不一定是问题,需要二次确认。
最佳实践
====
整体思路
优化的整体思路其实就四步:
-
删掉启动项,最直接
-
如果不能删除,尝试延迟,延迟包括第一次访问以及启动结束后找个合适的时间预热
-
不能延迟的可以尝试并发,利用好多核多线程
-
如果并发也不行,可以尝试让代码执行更快
这块会以 Main 函数做分界线,看下 Main 函数前后的优化方案;接着介绍如何优化 Page In;最后讲解一些非常规的优化方案,这些方案对架构的要求比较高。
Main 之前
Main 函数之前的启动流程如下:
-
加载 dyld
-
创建启动闭包(更新 App/重启手机需要)
-
加载动态库
-
Bind & Rebase & Runtime 初始化
-
+load 和静态初始化
动态库
减少动态库数量可以加减少启动闭包创建和加载动态库阶段的耗时,官方建议动态库数量小于 6 个。
推荐的方式是动态库转静态库,因为还能额外减少包大小。另外一个方式是合并动态库,但实践下来可操作性不大。最后一点要提的是,不要链接那些用不到的库(包括系统),因为会拖慢创建闭包的速度。
下线代码
下线代码可以减少 Rebase & Bind & Runtime 初始化的耗时。那么如何找到用不到的代码,然后把这些代码下线呢?可以分为静态扫描和线上统计两种方式。
最简单的静态扫描是基于 AppCode,但是项目大了之后 AppCode 的索引速度非常慢,另外的一种静态扫描是基于 Mach-O 的:
-
_objc_selrefs
和_objc_classrefs
存储了引用到的 sel 和 class -
__objc_classlist
存储了所有的 sel 和 class
二者做个差集就知道那些类/sel 用不到,但objc 支持运行时调用,删除之前还要在二次确认。
还有一种统计无用代码的方式是用线上的数据统计,主流的方案有三种:
-
ViewConteroller 渗透率,hook 对应的声明周期方法即可统计
-
Class 渗透率,遍历运行时的所有类,通过 Objective C Runtime 的标志位判断类是否被访问
-
行级渗透率,需要用编译期插桩,对包大小和执行速度均有损。
前两种是 ROI 较高的方案,绝大多数时候 Class 级别的渗透率足够了。
+load 迁移
+load 除了方法本身的耗时,还会引起大量 Page In,另外 +load 的存在对 App 稳定性也是冲击,因为 Crash 了捕获不到。
举个例子,很多 DI 的容器需要把协议绑定到类,所以需要在启动的早期(+load)里注册:
+ (void)load
{
[DICenter bindClass:IMPClass toProtocol:@protocol(SomeProcotol)]
}
本质上只要知道协议和类的对应关系即可,利用 clang attribute,这个过程可以迁移到编译期:
typedef struct{
const char * cls;
const char * protocol;
}_di_pair;
#if DEBUG
#define DI_SERVICE(PROTOCOL_NAME,CLASS_NAME)\
__used static Class<PROTOCOL_NAME> _DI_VALID_METHOD(void){\
return [CLASS_NAME class];\
}\
__attribute((used, p(_DI_SEGMENT “,” _DI_SECTION ))) static _di_pair _DI_UNIQUE_VAR = \
{\
_TO_STRING(CLASS_NAME),\
_TO_STRING(PROTOCOL_NAME),\
};\
#else
__attribute((used, p(_DI_SEGMENT “,” _DI_SECTION ))) static _di_pair _DI_UNIQUE_VAR = \
{\
_TO_STRING(CLASS_NAME),\
_TO_STRING(PROTOCOL_NAME),\
};\
#endif
原理很简单:宏提供接口,编译期把类名和协议名写到二进制的指定段里,运行时把这个关系读出来就知道协议是绑定到哪个类了。
有同学会注意到有个无用的方法_DI_VALID_METHOD
,这个方法只在 debug 模式下存在,为了让编译器保证类型安全。
静态初始化迁移
静态初始化和 +load 方法一样也会引起大量 Page In,一般来自 C++代码,比如网络或者特效的库。另外有些静态初始化是通过头文件引入进来的,可以通过预处理来确认。
几个典型的迁移思路:
-
std:string 转换成 const char *
-
静态变量移动到方法内部,因为方法内部的静态变量会在方法第一次调用的时候初始化
//Bad
namespace {
static const std::string bucket[] = {“apples”, “pears”, “meerkats”};
}
const std::string GetBucketThing(int i) {
return bucket[i];
}
//Good
std::string GetBucketThing(int i) {
static const std::string bucket[] = {“apples”, “pears”, “meerkats”};
return bucket[i];
}
Main 之后
启动器
启动是需要一个框架来管控的,抖音采用了轻量级的中心式方案:
-
有个启动任务的配置仓,里面只包含启动任务的顺序和线程
-
业务仓实现协议 BootTask,表明这是个启动任务
启动任务的执行流程如下:
为什么需要启动器呢?
-
全局并发调度:比如 AB 任务并发,C 任务等待 AB 执行完毕,框架调度还能减少线程数量和控制优先级
-
延迟执行:提供一些时机,业务可以做预热性质的初始化
-
精细化监控:所有任务的耗时都能监控到,线下自动化监控也能受益
-
管控:启动任务的顺序调整,新增/删除都能通过 Code Review 管控
三方 SDK
有些三方 SDK 的启动耗时很高,比如 Fabric,抖音下线了 Fabric 后启动速度 pct50 快了 70ms 左右。
除了下线,很多 SDK 是可以延迟的,比如分享和登录的 SDK。此外,在接入 SDK 之前可以先评估下对启动性能的影响,如果影响较大是可以反馈给 SDK 的提供方去修改的,尤其是付费的 SDK,他们其实很愿意配合做一些修改。
高频次方法
有些方法的单个耗时不高,但是在启动路径上会调用很多次的,这种累计起来的耗时也不低,比如读 Info.plist 里面的配置:
+ (NSString *)plistChannel
{
return [[[NSBundle mainBundle] infoDictionary] objectForKey:@“CHANNEL_NAME”];
}
修改的方式很简单,加一层内存缓存即可,这种问题在 TimeProfiler 里时间段选长一些往往就能看出来。
锁
锁之所以会影响启动时间,是因为有时候子线程先持有了锁,主线程就需要等待子线程锁释放。还要警惕系统会有很多隐藏的全局锁,比如 dyld 和 Runtime。举个例子:
下图是 UIImage imageNamed
引起的主线程 block:
通过右侧的堆栈能看到,imageNamed 触发了 dlopen,dlopen 会等待 dyld 的全局锁。通过 System Trace 的 Thread State Event,可以找到线程被 blocked 的下一个事件,这个事件表明了线程重新可以运行,原因就是其他线程释放了锁:
接下来通过分析后台线程这个时间在做什么,就知道为什么会持有锁,如何优化了。
线程数量
线程的数量和优先级都会影响启动时间。可以通过设置 QoS 来配置优先级,两个高优的 QoS 是 User Interactive/Initiated,启动的时候,需要主线程等待的子线程任务都应该设置成高优的。
高优的线程数量不应该多于 CPU 核心数量,可以通过 System Trace 的 System Load 来分析这种情况。
/GCD
dispatch_queue_attr_t attr = dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_UTILITY, -1);
dispatch_queue_t queue = dispatch_queue_create(“com.custom.utility.queue”, attr);
//NSOperationQueue
operationQueue.qualityOfService = NSQualityOfServiceUtility
线程的数量也会影响启动时间,但 iOS 中是不太好全局管控线程的,比如二/三方库要起后台线程就不太好管控,不过业务上的线程可以通过启动任务管控。
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数初中级Android工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则近万的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年Android移动开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!
如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:Android)
![](https://img-blog.csdnimg.cn/img_convert/21e5324834db5ae9f179c8fab932811b.jpeg)
最后
有任何问题,欢迎广大网友一起来交流,分享高阶Android学习视频资料和面试资料包~
偷偷说一句:群里高手如云,欢迎大家加群和大佬们一起交流讨论啊!
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》,点击传送门即可获取!
因此收集整理了一份《2024年Android移动开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
[外链图片转存中…(img-SeJvQkB5-1711989441188)]
[外链图片转存中…(img-l2KfxwKu-1711989441189)]
[外链图片转存中…(img-Hnn0qXvU-1711989441189)]
[外链图片转存中…(img-DmqZjYXU-1711989441189)]
[外链图片转存中…(img-u9n5UuCm-1711989441189)]
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!
如果你觉得这些内容对你有帮助,可以扫码获取!!(备注:Android)
![](https://img-blog.csdnimg.cn/img_convert/21e5324834db5ae9f179c8fab932811b.jpeg)
最后
有任何问题,欢迎广大网友一起来交流,分享高阶Android学习视频资料和面试资料包~
偷偷说一句:群里高手如云,欢迎大家加群和大佬们一起交流讨论啊!
[外链图片转存中…(img-iapz8icb-1711989441190)]