|
【GMTC·贝壳】贝壳找房 iOS 冷启动优化实践
【GMTC·贝壳】贝壳找房 iOS 冷启动优化实践
陈旭@贝壳找房
贝壳产品技术
贝壳产品技术 “贝壳产品技术公众号”作为贝壳官方产品技术号,致力打造贝壳产品、技术干货分享平台,面向互联网/O2O开发/产品从业者,每周推送优质产品技术文章、技术沙龙活动及招聘信息等。欢迎大家关注我们。 242篇内容
2021年08月06日 20:02
本基于2021年GMTC全球前端技术会"移动端性能优化"专题下[贝壳找房 iOS 冷启动优化实践]主题分享整理而来。前言随着贝壳找房在产业互联网领域不断深耕,各项业务都在持续高速发展,业务功能和复杂度不断增长的同时,带来的问题是 App 启动变慢,用户体验变差,如果没有收口处理,开发团队每过一段时间都要花时间进行优化。冷启动优化是性能优化的重要一环,其重要性毋庸置疑,启动的快慢是一个 App 给人的第一印象,对于 C 端 App 甚至会影响用户的留存。每个客户端研发对冷启动优化基本都耳熟能详,但往往知识点是零散的,不够系统和全面,以至于优化只是根据自己熟悉的部分做了几点,效果不是特别明显。我们首先要明确目标,在高目标的驱动下,传统思维方式的转变、许多近乎苛刻的优化方法和全面的防劣化手段就是题中应有之义。本文将从优化思想到技术实现的细节讲解,让你体系化地了解启动优化过程的设计和实现。值得强调的是,本文并不会面面俱到,对一些耳熟能详的优化方法,比如+load的治理、二进制重排等,网上已经有很多资料,读者可以自行查阅。这里着重介绍对较大收益的方案系统化落地、大家容易忽视的一些优化方法以及对优化成果进行防劣化治理。主要分为以下几个部分:图一主要内容1 启动优化常见误区1.1 冷启动测试标准图二 冷启动测试标准大家对于冷启动的线下测试需要注意几点: (1)重启机,并静置 2-3 分钟或杀进程后静置 10 分钟以上。 (2)打开模式或者 mock 络环境,减少网络环境的影响。 (3)关闭 iCloud,减少系统同步数据的影响。(4)线下为减少误差,可以测50次求平均值。业界对于冷启动完成的定义有不同的标准,有的以didFinishLaunching结束为准,有的以首帧开始渲染为准,我们这里以首页渲染完成也就是首页VC的viewDidAppear为准,这样更符合用户的真实感受,把首页的加载优化纳入到了冷启动优化的过程里。对于测试工具,使用Xcode的instrument以及App Launch等都可以查看耗时,但是不够直观,这里选用了开源的AppleTrace工具,可以以火焰图的形式直观地展示出启动过程中的各方法耗时。而且在代码发生变化时,通过可视化的方式可以非常直观的看出来哪块对启动时间造成了影响。1.2 冷启动优化的本质图三冷启动优化本质首先, 优化的首要目标是去除那些无用的操作,只做必要的事,不做重复计算,这点在任何优化中都是共通的。 第二,只做现在必须做的事,不提前优化,不过度优化。 第三,资源换时间,包含我们常见的缓存就是空间换时间,还有后面讲到的编译期读取I/O到内存,拿编译时间的增加换启动时间的减少,就是时间换时间。第四,算法、策略等内部优化,这个是针对每个优化项内部进行优化,举个例子,iOS里常见的KV存储NSUserDefault, 因为需要整体的序列化和反序列化,启动期间用到NSUserDefault的地方积少成多也会耗时。而微信开源的MMKV基于mmap内存映射,提供可供随时写入的内存块, 不必担心 crash 导致数据丢失, 而且增量更新,速度较NSUserDefault有约百倍提升。第五,充分利用CPU的多核性能,平衡好任务的串行和并行,用最短时间把任务完成。1.3 冷启动优化的常见误区图四冷启动优化的常见误区这里列出来一些常见的误区,给大家分享一下,避免少踩坑:首先,耗时操作简单放到子线程就可以了。举个例子,定位请求各种传感器的请求间隔通常会设置为几十毫秒,即使放在子线程也会把CPU打满,子线程的资源竞争也会影响到主线程的耗时。所以,一方面要看线程切换的成本,另一方面还要看放到子线程,后续依赖能否满足。第二,任务延迟固定时间。这种延迟1s,2s执行某项任务的操作,对于某些低端机型来讲,只是把时间转嫁到后半段,整体的启动时间并没有变化。另外延迟固定时间是特别不推荐的写法,没有明确的先后关系,会带来潜在的质量问题。 第三,延迟到首页渲染完成之后。有的同学说这样是不是就可以了,没错,冷启动的时间减少了,但是我们要保证首页渲染完成之后不卡,否则一堆任务堆积在那,展示的时间变短了,但是用户可交互时间没有变化,那就是自欺欺人。 第四,CPU 占用过高会有问题。这点可能和大家常识想悖,平时我们总是说CPU、内存、时间越少越好,但这不是绝对的。举个例子,一个是CPU占用2%,启动时间2s, 另一个是CPU占用100%,启动时间1.5s, 你会选哪个呢,当然是选第2个。CPU占用过高只是那1,2s的时间,启动完成后通过任务调度把CPU迅速降下来就没问题,而且设备的性能就是需要尽可能压榨的,以几秒钟的高CPU占用,换来启动时间的减少,收益远大于成本。2 优化思路分析2.1 iOS冷启动阶段划分对iOS冷启动进行优化,首先我们需要知道冷启动包括哪些阶段:图五iOS冷启动阶段划分iOS 13 推出了 MetricKit 框架,用于收集电量和性能数据,将启动过程分成 System Interface、Runtime Init、UIKit Init、Application Init 和 First Frame Render、Extended 等六个阶段:System Interface:App 初始化的准备工作,包含进程的创建、加载主二进制 、启动 dyld、加载动态库等,然后进行 libSystem 初始化,即一些系统底层组件的初始化。 Runtime Init:初始化Objective-C的Runtime,执行+load和static initializer初始化函数等。 UIKit Init:实例化UIApplication和UIApplicationDelegate,开始事件处理,与系统交互。 Application Init:处理UIApplicationDelegate的各种生命周期回调。Initial Frame Render:首帧渲染,主要是创建、布局和绘制视图等工作,并把准备好的第一帧提交给Render Server进程去渲染。 Extended:首帧之后的工作,如异步获取数据、视图更新、用户交互等。2.2 优化可行性分析图六优化可行性分析上图针对冷启动的每个阶段,进行了优化可行性分析,这里不再赘述。2.3 贝壳 iOS 冷启动阶段划分图七贝壳 iOS 冷启动阶段划分我们将启动过程拆分成 T1、T2、T3 和 T4 四个阶段:T1 阶段:从创建进程到 main 函数执行,对应 MetricKit 的 System Interface 和 Runtime Init,通过系统函数sysctl可以获取进程创建的时间戳。 T2 阶段:main 函数开始到 AppDelegate类中 didFinishLaunchingWithOptions:执行结束,对应 MetricKit 的 UIKit Init 和 Application Init。 T3 阶段:didFinishLaunchingWithOptions执行结束到首页viewDidAppear,更接近用户的视觉感受。 T4 阶段:viewDidAppear到启动15s, 主要是为了保证启动后的流畅度,这一阶段主要关注CPU、内存、线程等指标。2.4 优化成果图八优化成果3 主要优化方案3.1 整体思路图九整体思路3.2 现状图十现状有了优化的整体思路,我们还要立足现状,这里之所以引出贝壳 B 端 App 架构,因为 B 端平台支撑了多个 App,需要平台统一管控启动项,同时优化方案需要抽象,不局限于特定 App。3.3 主要方案图十一主要方案3.4 框架优化-最小集图十二最小集我们梳理最小集的时候,标准可以严苛一点,比如crash监控最开始启动,这个应该没有异义,否则就是自欺欺人,rootViewController要attach window, 首页的展示和底部的 tabbar 的创建,其他的基本都可以延迟或懒加载。许多同学可能说abtest也重要啊,热修复也重要啊,可能有非常多的理由,如果标准不严苛,都觉得自己重要,那优化的空间就太小了。业务关心的其实只是自己的功能正常不正常,我们可以这样想,如果最小集足够小和稳定,就像 hello world 一样,那所谓重要的代码放在 hello world 前和放在 hello world 后又有多大的区别呢?而且标准不是一成不变的,如果真的有那种优先级高的,有足够的理由或者不可调和的矛盾,我们可以再挪回来,但目前看还没有。我们梳理了 App 里的启动项,以正常流程为例,之前有将近30项,梳理完最小集,只保留了5项左右。3.4.1 框架优化-生命周期延迟图十三生命周期延迟传统的方式大多是针对启动期间的任务项做并发、闲时等处理,但是任务项本身耗时、并发对不齐、加上线程切换、任务调度都会耗时。什么都不干肯定不会耗时,这是一个特别简单的道理,但是很多人做不到,仍然按照传统的思维一点一点往后挪。举个例子,就像一个毛线团绕在一起,你要一根一根拽出来,每根线都会受到其他的限制,但是如果我们换个思路,只留下几根线,剩下的一把拽出去,是不是速度会快很多呢。图十四生命周期类图核心类介绍:LJAppLauncher: 启动期间必须项的管理器,分为头部任务、尾部任务和并行任务,这里梳理完最小集后,启动项已经很少,只有crash监控、http参数配置、window创建、tabbar创建、首页创建,如果有依赖关系,使用addInOrder,如果无依赖关系可以并行使用addNoOrder,如果优先级较低可以使用addToTail, 这里以crash为例。CrashInitialization: 遵循InitializationProtocol协议,头部和尾部串行任务实现方法setupWithOptions,异步任务实现asyncWithOption方法。LJAppLaunchManager:首页渲染完成后的生命周期管理,接收到首页渲染完成的通知后,再进行生命周期的分发,任务项分为高优先级、低优先级、闲时任务、并行任务。LJAppLaunchTaskService:遵循LJAppLaunchLifecycleProtocol, 可以通过launchPriority指定优先级,默认低优先级,根据是否需要在主线程串行执行、异步执行、闲时执行等特性,把相关代码填入相应方法。CrashInitialization启动期间最小集的一个示例,LJAppLaunchTaskService是冷启动后任务项的一个示例,拆分成细粒度后的任务项都可以类似LJAppLaunchTaskService。3.4.2 框架优化-启动器图十五启动器我们的启动器也分成了两个阶段:第一个阶段是对最小集统一管理。第二个阶段是对延迟的任务分散生命周期,动态注册。由于最小集的原因,冷启动加载的任务极少,速度必然会加快。另一方面,冷启动的数据优化了,体验也要跟着优化,不能把延迟过来的任务项都堆到一起引起卡顿,那样就没有意义了。这就需要梳理清楚任务项的依赖、能否在子线程执行、在子线程哪些串行哪些并行、能否闲时处理,充分利用设备性能,集中力量用最短的时间把这些事做完。3.4.3 框架优化-任务编排图十六任务编排这里采用了面向阶段调度的方法,首先根据优先级和功能属性划分阶段,先集中力量把阶段一完成,再完成阶段二,最终把串行的任务变为有向无环图,原本要靠锁来保证状态同步,现在启动框架严格按照有向无环图的顺序执各项 SDK 的初始化,实现了无锁化。接着根据框架的动态注册能力,把任务项打散,分阶段填充。3.4.4 框架优化-线程管理图十七线程管理我们根据优先级和分类创建了 3 个线程队列:主线程队列,管控所有UI操作及必须在主线程执行的高优和低优任务后台线程队列,管控可以并行执行的任务项闲时线程队列,管控可以闲时执行的任务自己创建的queue也是基于GCD封装的,比单独调用系统的GCD好处在于: 如果只是调用系统的GCD,会出现各自为政,难以统一管控,而从线程管理器中获取队列,可以进一步进行优化和统一管控。系统的GCD优先级和顺序难以统一控制,不利于整体调优。自己创建的queue可以利用runloop的闲时,对queue里的每个任务不管主线程还是异步都在压缩CPU的时间片,让方法分布更紧凑。3.5 首页渲染优化图十八首页渲染优化首先技术选型也很重要,首页不使用Flutter,就可以避免flutter引擎的初始化,节省了这部分时间。像首页、底部tab等都会用到图片,低端机上imageNamed:读取图片耗时明显,可以在main函数之后,提前异步加载,或者更极端一点,可以将图片用base64存进代码,并预先读入内存。 手动读取vc.view触发viewDidLoad。大家习惯于整体刷新,就像tableView,直接reload很简单,但许多时候我们可以把功能拆细粒度,微小变化时局部刷新,这样肯定比全部刷新要节省时间。也是大家容易忽略的一点,有些视图只在if条件里才会展示,大家经常在if外面进行创建,这也是没有做到按需。3.6 动态库懒加载图十九动态库简介动态库的使用很常见,大家可以用 otool 命令查看一下 App里用到了哪些动态库,由于动态库是在 App 启动的时候通过 dyld 根据依赖关系递归的加载到内存中,如果动态库数量多了,会大大的拖慢应用的启动速度。图二十动态库使用动态库运行时手动加载有两种方式:一种是 [NSBundle loadAndReturnError], 另一种是dlopen, 这里推荐第一种苹果封装的 API,优点是可以访问 bundle 里的资源,而且也是基于dlopen实现的,增加了验签过程。图二十一动态库注意事项为了避免编译期编译符号找不到,需要添加-undefined dynamic_lookup这个指令。主工程的配置也需要跟着修改:Build Settings-Strip Style 修改为Non-Global Symbols,将外部引用的符号保留。动态库依赖的静态库符号缺失会导致动态库load失败或者运行时crash,这就需要我们能提前发现问题并解决,比如可以通过脚本进行扫描。nm –um 命令获取动态库依赖的外部符号,nm –gm 获取 APP 依赖的所有符号,前者不在后者中,则说明缺失。3.7 编译期消除I/O图二十二编译期消除I/O具体的做法:提前在工程根目录下放置空的.h和.m, .h的接口可以提前写进去,编译时python提前把Plist文件中数据拿出来转成OC的数据后,转成了OC的字面量都是字符串的形式,这时就可以写入.m生成出来实现方法。等到业务方调用某个接口时,之前调用OC的系统方法牵扯到I/O耗时,现在变成了 - (NSDictionary *) readPlist { return @{key:value} }; 这种就是纯方法调用的耗时了。好处:读取本地配置文件 I/O 耗时较多,一个文件要耗费几ms, 随着业务增加当启动期间的 I/O 文件多时,这部分时间就很可观了,可能达到几十ms甚至更多, 而我们在编译期写入内存,业务只是方法调用,不到1ms, 而且不管业务怎么变化,都是不到1ms, 这部分价值就大了。几十个研发同学每次编译增加了不到1s,而几十万经纪人每次启动都能减少几十ms, 这个时间换时间就非常值得,日活千万、上亿的 App 收益会更大。3.8 static initializer治理图二十三static initializer治理3.8.1 背景C++代码需要静态链接,必须保证在main函数之前,全局常量初始化完毕, 会增加main函数之前的执行时间,随着全局常量的增多,这部分耗时也很有必要优化。3.8.2 哪些可以产生initializer(1) 执行C函数 inttest(){return1;}intx=test();(2) 结构体的编译时常量CGRectrect=CGRectZero;(3) \__attribute((constructor))static__attribute__((constructor))voidljshl_MessageCellIdentifiers(void){kLJSHLMessageCellIdentifiers=@[@"LJSHLMessageBotCell",@"LJSHLMessageSystemCell",@"LJSHLMessageUserCell",@"LJSHLMessageSelfCell",@"LJSHLMessageVIPCell",@"LJSHLMessageSystemWelcomeCell"];}(4) C++全局类对象初始化classtestGlobalVar{public:testGlobalVar(){std::cout<<"testGlobalVar"<
|
|