iOS启动时间
背景
产品用户规模越来越大,用户的挑剔性也越来越高。点击app icon进入首页是用户对app的第一印象,也是app用户体验中非常重要的一环。
而产品承接的业务会越来越多,为了适应庞大的业务,需要对启动项做严格的把关。启动优化的第一步就是启动时间段的精确测量。
调研及实现
先说下结论。
启动过程可以粗略的分为main前t1和main后t2,总启动时间 t = t1 + t2; 更精准的一些,我们把时间段做了细分,如下:
进程创建 ----> load方法 ----> main方法 ----> didFinishLaunch ----> (AD页展示 ----> AD页结束 ---->) 首页加载开始 ----> 首页加载结束
其中,从进程创建到main函数执行,属于main前;后面的属于main后
启动模式
由苹果wwdc的ppt可以看到,启动分为冷启动、热启动、resume三种case.
其中最耗时的加载过程其实是冷启动,我们的记录和优化也针对于冷启动。
启动过程
用dyld分析main前的流程
执行demo程序,main函数打断点,可以看到只有一个start
bt查看,可知是libdyld.dylib 的start
这个信息太少,通过main之前的+load调试来看一下
+load的调用栈就比较丰富了,我们通过+load的调用栈来粗浅探究一下dyld的调用流程。
首先内核创建进程位于dyldStartup.s,该类中声明了内核最初的过程。sp就是内存加载最初的入口。
/*
* C runtime startup for interface to the dynamic linker.
* This is the same as the entry point in crt0.o with the addition of the
* address of the mach header passed as the an extra first argument.
*
* Kernel sets up stack frame to look like:
*
* | STRING AREA |
* +-------------+
* | 0 |
* +-------------+
* | apple[n] |
* +-------------+
* :
* +-------------+
* | apple[0] |
* +-------------+
* | 0 |
* +-------------+
* | env[n] |
* +-------------+
* :
* :
* +-------------+
* | env[0] |
* +-------------+
* | 0 |
* +-------------+
* | arg[argc-1] |
* +-------------+
* :
* :
* +-------------+
* | arg[0] |
* +-------------+
* | argc |
* +-------------+
* sp-> | mh | address of where the a.out's file offset 0 is in memory
* +-------------+
*
* Where arg[i] and env[i] point into the STRING AREA
*/
在汇编代码中可以看到下面关键信息
__dyld_start:
popq %rdi # param1 = mh of app
pushq $0 # push a zero for debugger end of frames marker
movq %rsp,%rbp # pointer to base of kernel frame
andq $-16,%rsp # force SSE alignment
subq $16,%rsp # room for local variables
# call dyldbootstrap::start(app_mh, argc, argv, slide, dyld_mh, &startGlue)
movl 8(%rbp),%esi # param2 = argc into %esi
leaq 16(%rbp),%rdx # param3 = &argv[0] into %rdx
movq __dyld_start_static(%rip), %r8
leaq __dyld_start(%rip), %rcx
subq %r8, %rcx # param4 = slide into %rcx
leaq ___dso_handle(%rip),%r8 # param5 = dyldsMachHeader
leaq -8(%rbp),%r9
call __ZN13dyldbootstrap5startEPK12macho_headeriPPKclS2_Pm
注释中的# call dyldbootstrap::start(app_mh, argc, argv, slide, dyld_mh, &startGlue) 解释了下面汇编代码的含义,和+load中的栈完全吻合。那就继续顺着栈跟吧~
dyldbootstrap::start(app_mh, argc, argv, slide, dyld_mh, &startGlue)位于dyldInitialization.cpp中,做用是dyld的引导程序。对dyld进行预先rebase、初始化c++静态函数,架构矫偏等等工作。然后进入主角:dyld.cpp的_main函数:dyld::_main(appsMachHeader, appsSlide, argc, argv, envp, apple, startGlue)。这个函数是整个main前的核心逻辑。非常复杂,我们先顺着+load的栈进行单线跟踪,以免淹没在汪洋大海。好,接着寻找下一个帧:initializeMainExecutable(),该函数如下:
void initializeMainExecutable()
{
// record that we've reached this step
gLinkContext.startedInitializingMainExecutable = true;
// run initialzers for any inserted dylibs
ImageLoader::InitializerTimingList initializerTimes[allImagesCount()];
initializerTimes[0].count = 0;
const size_t rootCount = sImageRoots.size();
if ( rootCount > 1 ) {
for(size_t i=1; i < rootCount; ++i) {
sImageRoots[i]->runInitializers(gLinkContext, initializerTimes[0]);
}
}
// run initializers for main executable and everything it brings up
sMainExecutable->runInitializers(gLinkContext, initializerTimes[0]);
// register cxa_atexit() handler to run static terminators in all loaded images when this process exits
if ( gLibSystemHelpers != NULL )
(*gLibSystemHelpers->cxa_atexit)(&runAllStaticTerminators, NULL, NULL);
// dump info if requested
if ( sEnv.DYLD_PRINT_STATISTICS )
ImageLoader::printStatistics((unsigned int)allImagesCount(), initializerTimes[0]);
if ( sEnv.DYLD_PRINT_STATISTICS_DETAILS )
ImageLoaderMachO::printStatisticsDetails((unsigned int)allImagesCount(), initializerTimes[0]);
}
在这个函数中,我们可以看到两个点:
一:环境变量的设置 DYLD_PRINT_STATISTICS & DYLD_PRINT_STATISTICS_DETAILS,通过设置他们,就能在程序run的阶段,看到dyld帮我们统计的各个时间点段。哇~ 那是不是我们可以把dyld统计的字段拿出来就行了,何必自己再统计呢?
马上试一下:#include
通通不支持...
理想很丰满啊! 苹果并没有对开发者公开!
回归现实,继续学习:
二:先初始化mainexcutable以外的images,最后初始化mainexcutable image,也就是我们自己的二进制。很好理解,其他的都是我们的依赖。但是,其他的images有app引入的吗?如果有,他们都是在这个阶段初始化的!mach-o view打开我们的二进制,打开load_commands
哇~!竟然一个三方库都没有!
打开demo工程
可以看到AFNetworking和PFLAPM两个库在其中。为什么呢?
因为我们的push repo全部使用了 --use-libraries也就是强制要求生成.a静态库
.a和.framework有什么区别呢?
mach-o view会帮我们解释。分别打开两个来查看
可以清楚地看到:.framework是一个标准的可执行文件,有mach header, load commands section等等,构建了各种依赖关系,依赖可以支持动态库。而.a文件则是一堆.o,每一个.o各自独立去包含自己的依赖。所以.a一般要比.framework大。
使用.a和.framework的关系是,.a有利于启动速度降低,.framework有利于包体积缩小。介于我们当前绘本只有30几兆,通通使用.a是可以的。但后续如果业务扩充,则需要权衡考虑,可以用framework减小包大小,再多framework合并,降低递归初始化和校验,节省时间。
补充个mach_header (mach-o/loader.h)
uint32_t magic; /* mach magic number identifier */
cpu_type_t cputype; /* cpu specifier */
cpu_subtype_t cpusubtype; /* machine specifier */
uint32_t filetype; /* type of file */
uint32_t ncmds; /* number of load commands */
uint32_t sizeofcmds; /* the size of all the load commands */
uint32_t flags; /* flags */
uint32_t reserved; /* reserved */
};
对应着mach-o view中的Mach64 Header,如下:
关于--use-libraries,在cocoapods的源码可以搜到定义,最简单的描述是在changelog.md中 解释如下:
* Lint as framework automatically. If needed, `--use-libraries` option
allows linting as a static library.
[Boris Bügling](https://github.com/neonichu)
[#2912](https://github.com/CocoaPods/CocoaPods/issues/2912)
继续+load的加载帧
从上文源码中可以看到,调入了ImageLoader::runInitializer,我们进入ImageLoader.cpp
ImageLoader::runInitializer -> ImageLoader::processInitializers -> ImageLoader::recursiveInitialization -> dyld::notifySingle(dyld.cpp)
然后通过一个通知runtime的回调函数进入runtime中:
(*sNotifyObjCInit)(image->getRealPath(), image->machHeader());
这个函数指针的注册位于
dyld.cpp
void registerObjCNotifiers(_dyld_objc_notify_mapped mapped, _dyld_objc_notify_init init, _dyld_objc_notify_unmapped unmapped)
{
// record functions to call
sNotifyObjCMapped = mapped;
sNotifyObjCInit = init;
sNotifyObjCUnmapped = unmapped;
//省略后面代码
}
dyldAPIs.cpp
void _dyld_objc_notify_register(_dyld_objc_notify_mapped mapped,
_dyld_objc_notify_init init,
_dyld_objc_notify_unmapped unmapped)
{
dyld::registerObjCNotifiers(mapped, init, unmapped);
}
后面的调用简写了,在runtime库中
runtime库中objc-os.mm
void _objc_init(void)
{
//省略前面代码
_dyld_objc_notify_register(&map_images, load_images, unmap_image);
}
void
load_images(const char *path __unused, const struct mach_header *mh)
{
//省略前面代码
// Discover load methods
{
mutex_locker_t lock2(runtimeLock);
prepare_load_methods((const headerType *)mh);
}
// Call +load methods (without runtimeLock - re-entrant)
call_load_methods();
}
void call_load_methods(void)
{
//省略前面代码
do {
// 1. Repeatedly call class +loads until there aren't any more
while (loadable_classes_used > 0) {
call_class_loads();
}
// 2. Call category +loads ONCE
more_categories = call_category_loads();
// 3. Run more +loads if there are classes OR more untried categories
} while (loadable_classes_used > 0 || more_categories);
//省略后面代码
}
至此,我们的load流程就通了,顺便往回追一下
context.notifySingle(dyld_image_state_dependents_initialized, this, &timingInfo);
// initialize this image
bool hasInitializers = this->doInitialization(context);
ImageLoaderMachO.cpp
bool ImageLoaderMachO::doInitialization(const LinkContext& context)
{
CRSetCrashLogMessage2(this->getPath());
// mach-o has -init and static initializers
doImageInit(context);
doModInitFunctions(context);
CRSetCrashLogMessage2(NULL);
return (fHasDashInit || fHasInitializers);
}
其中doModInitFunctions用于调用cpp构造函数。明显的,attribute(constructor)的函数,我们要慎重书写。
然后返回至dyld.cpp的调用起点 initializeMainExecutable();
#else
// run all initializers
initializeMainExecutable();
#endif
//省略代码。。。
// find entry point for main executable main函数入口!!
result = (uintptr_t)sMainExecutable->getEntryFromLC_MAIN();
if ( result != 0 ) {
// main executable uses LC_MAIN, we need to use helper in libdyld to call into main()
if ( (gLibSystemHelpers != NULL) && (gLibSystemHelpers->version >= 9) )
*startGlue = (uintptr_t)gLibSystemHelpers->startGlueToCallExit;
else
halt("libdyld.dylib support not present for LC_MAIN");
}
else {
// main executable uses LC_UNIXTHREAD, dyld needs to let "start" in program set up for main()
result = (uintptr_t)sMainExecutable->getEntryFromLC_UNIXTHREAD();
*startGlue = 0;
}
#if __has_feature(ptrauth_calls)
// start() calls the result pointer as a function pointer so we need to sign it.
result = (uintptr_t)__builtin_ptrauth_sign_unauthenticated((void*)result, 0, 0);
#endif
}
catch(const char* message) {
syncAllImages();
halt(message);
}
catch(...) {
dyld::log("dyld: launch failed\n");
}
CRSetCrashLogMessage("dyld2 mode");
if (sSkipMain) {
if (dyld3::kdebug_trace_dyld_enabled(DBG_DYLD_TIMING_LAUNCH_EXECUTABLE)) {
dyld3::kdebug_trace_dyld_duration_end(launchTraceID, DBG_DYLD_TIMING_LAUNCH_EXECUTABLE, 0, 0, 2);
}
result = (uintptr_t)&fake_main;
*startGlue = (uintptr_t)gLibSystemHelpers->startGlueToCallExit;
}
return result;
可以看到关键的一句
// find entry point for main executable main函数入口!!
result = (uintptr_t)sMainExecutable->getEntryFromLC_MAIN();
这就是从main mach-o中,拿到了main函数的入口地址。
之后的过程是一系列的偏移,修正,可以不用管。最终返回出去到dyld.start,也就是最初的main函数调用栈
由此可见,dyld贯穿了main前的整个过程,runtime是中间被启动的一个库之一,配合dyld的加载过程,main后的app程序中使用。同样被启动的库还有gcd、security等等。
iOS时间的记录方式
Foundation: NSDate 网络对齐
CoreFoundation: CFAbsoluteTimeGetCurrent 网络对齐
QuartzCore: CACurrentMediaTime() 内置 单位是秒
Mach: mach_absolute_time 内置 单位是纳秒 滴答数 无法记录
优化项
一:framework数量控制,结合.a 合并操作权衡
二:+load __attribute__(constructor)函数减少调用
三:无用类和方法
四:didfinish中耗时任务后置
五:AD加载的同时,做首屏的准备工作
六:二进制重排,把先依赖的类放到前面。
整治思路
(参照美团外卖https://tech.meituan.com/2018/12/06/waimai-ios-optimizing-startup.html)
冷启动性能问题的治理目标主要有三个:
解决存量问题:优化当前性能瓶颈点,优化启动流程,缩短冷启动时间。
管控增量问题:冷启动流程规范化,通过代码范式和文档指导后续冷启动过程代码的维护,控制时间增量。
完善监控:完善冷启动性能指标监控,收集更详细的数据,及时发现性能问题。
目前难题及后续优化
难题
其中,前三者都是依赖runtime的,理论上用于记录runtime前的不太合适,前两个又有网络对齐的操作,误差会大一些。只有mach_absolute_time最适合记录,精度也最高。但是目前我们统计进程创建的时间是利用了进程的kinfo_proc信息,从这个信息中能获取到的时间戳其实是NSDate 1970 匹配的。
+ (BOOL)processInfoForPID:(int)pid procInfo:(struct kinfo_proc*)procInfo
{
int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};
size_t size = sizeof(*procInfo);
return sysctl(cmd, sizeof(cmd)/sizeof(*cmd), procInfo, &size, NULL, 0) == 0;
}
+ (NSTimeInterval)processStartTime
{
struct kinfo_proc kProcInfo;
if ([self processInfoForPID:[[NSProcessInfo processInfo] processIdentifier] procInfo:&kProcInfo]) {
return kProcInfo.kp_proc.p_un.__p_starttime.tv_sec * 1000.0 + kProcInfo.kp_proc.p_un.__p_starttime.tv_usec / 1000.0;
} else {
#if DEBUG
NSAssert(NO, @"无法取得进程的信息");
#endif
return 0;
}
}
好在经过反复测试,用NSDate或CFAbsoluteTimeGetCurrent计算出来的时间误差和mach_absolute_time计算出来的相比,在1ms以下。对于我们当前统计启动时间,做启动项优化的需求来说,这个误差完全可以接受。
后续优化
一、时间精度上的优化。
二、和壳工程配合,做到业务完全无感知
三、二进制重排