本篇是iOS篇,关于Android优化的部分在《提升APP冷启动速度-Android篇》
最近Ham的冷启动速度真的是越来越慢了,慢到令人发指。从手指点击APP Icon到首个页面出现,居然需要3.5秒,是时候要好好优化下了!
理论知识#
工具#
Xcode贴心地为我们准备好了耗时排查工具App Launch
我们选择好要测量的APP和超时时间后,就可以点击左上角的按钮开始抓trace啦〜
测量#
设备信息:
-
iPhone 13
-
系统 iOS 26.0.2
启动过程:强杀APP并打开多个其他APP,避免dyld缓存优化启动速度。
细看Progress部分,APP的启动过程可以分为三个部分:
初始化阶段T1#
包含:
Progress Creation系统创建进程System Interface Initialization系统接口初始化,此时dyld会解析动态符号
启动阶段T2#
包含:
UIKit Initialization- UIKit初始化,不可规避willFinishLaunchingWithOptions()-AppDelegate里的委托方法didFinishLaunchingWithOptions()-AppDelegate里的委托方法sceneWillConnectTo()sceneWillEnterForeground()Initial Frame Rendering- 首帧渲染
前台阶段T3#
APP初始化完成,用户看到APP第一帧。
当然,由系统的特性我们可以知道,因为第一帧是主线程绘制的,要优化冷启动时间,就必须要让主线程干更少的活。于是,我们看trace的时候,可以把目光瞄准在主线程上。
分析#
通过分析trace,我们可以知道,占APP启动时间大头,并且可优化的是:
-
Initial Frame Rendering- 2.99s -
System Interface Initialization- 757.31 ms -
willFinishLaunchingWithOptions()- 115.47ms -
didFinishLaunchingWithOptions()- 94.85ms
Initial Frame Rendering优化#
我们观察下这段trace:
😯,居然有View在主线程开Database
日程卡优化#
先来说下背景。Ham启动成功后,会进入"状态"tab,里面展示很多实时通知的状态卡片,比如天气、课程、校车、日程等:
Ham里带日程功能,日程数据存在Realm数据库里。 StatusScheduleCard 是一张状态卡片,用来展示用户的日程情况(见上图)。StatusScheduleCard为什么会卡主线程?我们看看代码:
importSwiftUIimportRealmSwiftstructStatusScheduleCard: View {@ObservedResults(ScheduleItemModel.self,where: {let now =Date()return (0ドル.end==nil&&0ドル.begin >= now) ||(0ドル.end!=nil&&0ドル.end>= now)},sortDescriptor:SortDescriptor(keyPath: "begin", ascending: true)) var scheduleItemList...var body: some View {...ForEach(1..<5) { i inif i < scheduleList.count {let scheduleModel = scheduleList[i]scheduleItemView(scheduleItem: scheduleModel)}}...}结合trace,这下我们明白了:
APP在渲染第一帧时会创造根View的struct,而因为日程卡要立刻上屏,因此也会初始化StatusScheduleCard。StatusScheduleCard 里有一个属性scheduleItemList,被ObservedResults wrap了。
StatusScheduleCard上屏时需要获取前五项日程数据,此时访问scheduleList[i],实际会触发Realm数据库的初始化。在渲染View时初始化数据库,当然会卡啦😭
当然,这个问题不能怪开发者,只能说明是框架本身的缺陷:开发者也不知道这个@ObservedResults的lazy fetch会在主线程读取数据库啊😭😭
怎么解决呢:
- 延时加载日程卡。在T3阶段后再加载+展示。
- 放弃使用
@ObservedResults,使用传统方式+线程切换打开Realm获取数据。
structStatusScheduleCard: View {@ObservedObjectvar vm: StatusScheduleCardViewModel@ViewBuildervar body: some View {if vm.inited {StatusScheduleCardInner(vm: vm)}}}@MainActorclassStatusScheduleCardViewModel: ObservableObject, StatusCardViewModel {@Publishedvar inited =false...funconInit() {inited =trueinitUpdateTask()}privatefuncinitUpdateTask() {Task {while!Task.isCancelled {updateData()do {tryawait Task.sleep(nanoseconds: 10_000_000_000)} catch {break}}}}privatefuncupdateData() {currentTime =Date()let currentWeekInfo =getCurrentWeekInfo(date: currentTime)Task.detached(priority: .background) { [currentWeekInfo, currentTime] inlet realm =try!Realm(queue: nil)let results = realm.objects(ScheduleItemModel.self).where {(0ドル.end==nil&&0ドル.begin >= currentTime) ||(0ドル.end!=nil&&0ドル.end>= currentTime)}.freeze().sorted(byKeyPath: "begin", ascending: true)let weekScheduleListResult = results.where {return (0ドル.end==nil&&0ドル.begin <= currentWeekInfo.end) || (0ドル.end!=nil&&0ドル.end<= currentWeekInfo.end)}.freeze()await MainActor.run {let snapshot =Array(results)let weekScheduleList =Array(weekScheduleListResult)withAnimation {self.scheduleList = snapshotself.weekScheduleList = weekScheduleList}}}}...校巴卡优化#
除了日程卡,校巴卡也占据了很多时间,初始化的时候居然在创建WebView!
这里再说下背景。BusView是校巴的二级H5页。按道理来说这里不应该直接初始化才对?🤔
真正原因其实在CommonStatusCard上:
structCommonStatusCard<Content: View>: View {let icon: Stringlet title: Stringlet color: Colorlet padding: CGFloatvar navDest: AnyView?=nillet content: () -> Contentinit<NavView: View>(icon: String,title: String,color: Color,padding: Int=12,navDest: () -> NavView,content: @escaping () -> Content) {self.icon = iconself.title = titleself.color= colorself.navDest =AnyView(navDest())self.content = contentself.padding =CGFloat(padding)}CommonStatusView在初始化时会直接执行navDest,并保存在AnyView里。所以,CommonStatusView初始化时,会初始化二级页里的内容。本质上说,是CommonStatusView编码不合理引起了这个问题。
最佳的解决方法是,CommonStatusView里就不该使用AnyView,应使用ViewBuilder去存View。后期Ham的导航架构从NavigationView迁移至NavigationStack,这样navDest里就不用真传一个ViewBuilder进来,只用传一个Route就好了,也就没有了改造的烦恼。
ViewModel初始化优化#
一般来说,每个Card下面都会有一个ViewModel:
structStatusCourseCard: View {@ObservedObjectlet vm: StatusCourseCardViewModel...structStatusBusCard: View {@ObservedObjectvar vm: StatusBusCardViewModel...这些卡片的ViewModel,被外层StatusView的ViewModel创建并持有。外层的ViewModel在初始化时就会去创建这些卡片的ViewModel:
@MainActorclassStatusContentViewModel: ObservableObject, @MainActorStatusCardController {let weatherCardVM =StatusWeatherCardViewModel()let libraryCardVM =StatusLibraryCardViewModel()let courseCardVM =StatusCourseCardViewModel()let busCardVM =StatusBusCardViewModel()let sportCardVM =StatusSportCardVM()let scheduleCardVM =StatusScheduleCardViewModel()let casAlertCardVM =StatusCasAlertCardViewModel()...init() {onInit()}...funconInit() {getAllVM().forEach { 0ドル.onInit() }}...这些卡片的ViewModel在创建时:
StatusBusCardViewModel:初始化数据并开始定位
classStatusBusCardViewModel: ObservableObject, StatusCardViewModel, LocationListener {...funconInit() {LocationManager.shared.registerListener(self)update()casContext.isLoginFlow.sink { [weakself] _inself?.update()}.store(in: &cancellables)initTimer()}...StatusCourseCardViewModel 从sqlite数据库拉数据
classStatusCourseCardViewModel: ObservableObject, StatusCardViewModel {...funconInit() {NotificationCenter.default.addObserver(self, selector: #selector(onCourseListUpdated), name: .byKey(.ham_courseUpdated), object: nil)updateCourseList()}...这些操作都是需要耗时的,真的有那么需要在T2时刻就要做吗?能否挪到T3再开始做呢?
当然可以!但首先有个问题,T3时刻什么时候开始呢?好在,Apple提供了一个通知didBecomeActiveNotification。外层的ViewModel接收到该通知后,再调用每张卡片的ViewModel初始化即可。但是注意,这个didBecomeActiveNotification可能会触发多次,我们在代码层面上需要去重。
structStatusView: View {var body: some View {....onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _invm.contentVM.doInit()}}...冷启动后第一帧时,因为没有数据,屏幕上不会展示任何卡片了,这显然不是我们想要的。接下来的缓存章节,就是为了解决这一点。
缓存#
最近在用小红书,发现小红书冷启动时一个很有意思的点:
APP冷启动时,会先展示上次的数据,再执行刷新步骤。
「执行刷新步骤」其实很好理解,这和我们的优化方案一致,在T3时刻再执行重逻辑。但是,「展示上次的数据」是怎么做到的?
或许可以...直接打开db获取数据?但这不才在日程卡优化补的坑嘛,冷启动阶段尽量不能操作重逻辑的IO。
答案如下:把要展示的首帧数据,保存在UserDefault里。
但是:
UserDefault的原理也是读文件做IO啊,首次访问也会非常慢。
不过,这也总比打开数据库要强。
以天气卡为例:
structStatusWeatherForecastInfo: SmartCodable {var day: String=""var weather: String=""var dayTemp: String=""var nightTemp: String=""}structStatusWeatherDisplayInfo: SmartCodable {var temp: String=""var weather: String=""var description: String=""var forecastInfo: [StatusWeatherForecastInfo] = []}@MainActorclassStatusWeatherCardViewModel: ObservableObject, LocationListener, StatusCardViewModel {privatelet TAG ="StatusWeatherCardViewModel"@Publishedvar loadState = LoadStatus.unload@Publishedvar weatherInfo: StatusWeatherDisplayInfo?@Publishedvar errorMessage: String=""weakvar controller: StatusCardController?privatevar lastUpdateWeatherTimestamp: Int64=0privatevar placemark: CLPlacemark?=nilprivatevar weatherObj: Weather?=nil@Publishedvar inited =false// MARK: - Initinit() {// 初始化时读取数据let displayInfo = StatusWeatherDisplayInfo.deserialize(from: LocalStorageHelper.shared.getStringValue(.weatherCache))_weatherInfo = .init(initialValue: displayInfo)}...privatefuncupdateWeather(location: CLLocation) {...// 天气获取成功时storeWeatherInfo(weatherInfo: weatherInfo)}privatefuncstoreWeatherInfo(weatherInfo: StatusWeatherDisplayInfo) {// 存数据LocalStorageHelper.shared.set(.weatherCache, value: weatherInfo.toJSONString())}...这样,就能保证在冷启动第一帧,看到上次的天气数据。
其他优化#
背景图片#
状态卡的背景图片,是需要从网络上拉取的。冷启动时还没有拉取图片时,怎么办?
首先,这里的背景图片使用SDWebImage 组件,因为它支持图片硬盘缓存。也就意味着,冷启动时只要传入上次展示图片的链接,图片会从本地取出并加载。
structStatusBackgroundView: View {@ObservedObjectvar vm: StatusBackgroundViewModellet onGetImage: (UIImage) ->Voidvar body: some View {Group {iflet url =URL(string: vm.picURL) {WebImage(url: url).resizable().onFailure { error inLog.e("StatusBackgroundView", "load pic error", error)vm.loadState = .loadedWithError}.onSuccess { image, data, cacheType inLog.i("StatusBackgroundView", "load pic success")vm.loadState = .loadedvm.savePicCache()onGetImage(image as UIImage)}.scaledToFill().transition(.fade(duration: 0.5))}}.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _invm.doInit()}}}在T3时刻更新图片链接数据。如果返回的图片链接列表里,存在当前展示的图片链接,就不需要更新缓存了。
classStatusBackgroundViewModel: ObservableObject {...funcfetchPicUrl() {if!inited {return}loadState = .loadingHamRequestHelper.shared.doRequest(DailyPicRequest()) { [weakself] response iniflet error = response.error {Log.e(TAG, "fetchPicUrl - error", error)self?.loadState = .loadedWithErrorreturn}guardlet data = response.data as? DailyPicResponse else {self?.loadState = .loadedWithErrorreturn}if data.picUrlList.isEmpty {Log.i(TAG, "fetchPicUrl - data is empty")self?.loadState = .loadedWithErrorreturn}Task { @MainActoringuardletselfelse { return }let firstLoad =self.firstLoadself.firstLoad =falseif firstLoad {if!self.picURL.isEmpty {for picUrlData in data.picUrlList {if picUrlData.url==self.picURL {return}}}}let url = data.picUrlList[ Int.random(in: data.picUrlList.indices) ].urlifself.picURL != url {if firstLoad &&!self.picURL.isEmpty {try?await Task.sleep(for: .seconds(10))}withAnimation {self.picURL = data.picUrlList[ Int.random(in: data.picUrlList.indices) ].url}}Log.i(TAG, "fetchPicUrl - success => \(self.picURL)")}}}...}React Native模块初始化#
为什么会牵涉到React Native?虽然在二级页里才会用到React Native,但是APP初始化阶段更新bundle是有必要的。不过,这个更新并不紧急,没有必要因为这个占据冷启动时间。
首先,在ContentView上overlay一个RNView(老版本也是如此):
structContentView: View {var body: some View {....overlay(alignment: .topLeading) {RNCommonView().frame(width: 0.5, height: 0.5)}...接着,在T3时刻再加载RNView。
structRNCommonView: View {@Statevar show =falsevar body: some View {ZStack {if show {RNContainerAsyncView(moduleName: "RNCommon")}}.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _inshow =true}}}RNContainerAsyncView其实就是跑了个task去初始化RNView,为了不占渲染队列。而且因为React Native不支持在子线程初始化,还不能把task放在子线程里。
structRNContainerView: UIViewRepresentable {privatelet moduleName: Stringinit(moduleName: String) {self.moduleName = moduleName}funcmakeUIView(context: Context) -> UIView {RNViewManager.shared.getView(moduleName: moduleName)}funcupdateUIView(_ uiView: UIView, context: Context) {}}structRNContainerAsyncView: View {let moduleName: String@Statevar rnView: UIView?=nilvar body: some View {ZStack {iflet rnView = rnView {RNContainerInnerView(view: rnView)}}.task {if rnView !=nil {return}let view = RNViewManager.shared.getView(moduleName: moduleName)await MainActor.run {rnView = view}}}}React Native的代码,可参考:
优化后#
该阶段骤降至152ms。
System Interface Initialization优化#
看看该项的trace,发现有一堆的Map image:
说明APP里有一堆动态库:
那为什么不能把这些动态库打包进APP里,这样不就节省了dyld解析符号的时间吗?🤔
没错,cocoapods提供了一个选项,支持将包以静态库的方式引入:
platform :ios, '15.1'use_frameworks!use_frameworks! :linkage => :static但是,将Pods改成静态链接引入会导致包体积增大。不过,用少部分包体积增长换来更快的冷启动速度,是一个值得的trade-off。
完工后:
- 动态库数量急剧减少
- 该阶段启动市场优化至 350.04 ms,立省350ms
启动队列#
其实,之前就做过一版启动队列优化,有效果但是不大:
classColdStartManager {staticlet shared =ColdStartManager()var privacyAgree: Bool {set {LocalStorageHelper.shared.set(.app_isReadPrivacy, value: newValue)}get {LocalStorageHelper.shared.getBool(.app_isReadPrivacy) ??false}}privateinit() {}privateweakvar appDelegate: AppDelegate?=nilprivatelet prepareLaunchCodeStartTask =PrepareLaunchCodeStartTask()privatelet primaryColdStartTask =PrimaryColdStartTask()privatelet secondaryAsyncColdStartTask =SecondaryAsyncColdStartTask()funconPrepareLaunch(delegate: AppDelegate) {logTime("prepareLaunch") { prepareLaunchCodeStartTask.action(delegate: delegate, privacyAgreed: privacyAgree) }}funcdoColdStart(delegate: AppDelegate) {appDelegate = delegatelogTime("primaryColdStartTask.action") { primaryColdStartTask.action(delegate: delegate, privacyAgreed: privacyAgree) }logTime("secondaryAsyncColdStartTask.action") { secondaryAsyncColdStartTask.action(delegate: delegate, privacyAgreed: privacyAgree) }}funcsetPrivacyAgree() {guardlet appDelegate = appDelegate else {return}LocalStorageHelper.shared.set(.app_isReadPrivacy, value: true)logTime("primaryColdStartTask.onPrivacyAgree") { primaryColdStartTask.onPrivacyAgree(delegate: appDelegate) }logTime("secondaryAsyncColdStartTask.onPrivacyAgree") { secondaryAsyncColdStartTask.onPrivacyAgree(delegate: appDelegate) }NotificationCenter.default.post(name: .byKey(.ham_privacyRead), object: nil)}}protocolColdStartActionTask {funcaction(delegate: AppDelegate, privacyAgreed: Bool)funconPrivacyAgree(delegate: AppDelegate)}classPrepareLaunchCodeStartTask: ColdStartActionTask {privatelet actionList: [ColdStartAction] = [...]funcaction(delegate: AppDelegate, privacyAgreed: Bool) {actionList.forEach { action inaction.action(delegate: delegate, privacyAgreed: privacyAgreed)}}funconPrivacyAgree(delegate: AppDelegate) {actionList.forEach { action inaction.onPrivacyAgree(delegate: delegate)}}}classPrimaryColdStartTask: ColdStartActionTask {privatelet actionList: [ColdStartAction] = [...]funcaction(delegate: AppDelegate, privacyAgreed: Bool) {actionList.forEach { action inaction.action(delegate: delegate, privacyAgreed: privacyAgreed)}}funconPrivacyAgree(delegate: AppDelegate) {actionList.forEach { action inaction.onPrivacyAgree(delegate: delegate)}}}classSecondaryAsyncColdStartTask: ColdStartActionTask {privatelet actionList: [ColdStartAction] = [...]funcaction(delegate: AppDelegate, privacyAgreed: Bool) {actionList.forEach { action inTask {action.action(delegate: delegate, privacyAgreed: privacyAgreed)}}}funconPrivacyAgree(delegate: AppDelegate) {actionList.forEach { action inTask {action.onPrivacyAgree(delegate: delegate)}}}}onPrepareLaunch 对应 willFinishLaunchingWithOptions,而doColdStart 对应didFinishLaunchingWithOptions。
为什么效果不大?因为所有的task几乎都是在主线程上跑的。包括SecondaryAsyncColdStartTask,因为在主线程域开的Task,也是由主线程调度。
子任务Task改Detach#
将任务调度里的
Task {...}改成
Task.detached(name: "ColdStartTask", priority: .background) {...}这样可以不继承MainActor的上下文,减缓主线程压力。
但是,detach是有风险的,你必须要确保任务能detach才能这么做。
添加idle队列#
idle阶段在APP首帧展示时触发。
structMainView: View {...var body: some View {....onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _inColdStartManager.shared.onIdle()}}}完工后的ColdStartManager#
classColdStartManager {staticlet shared =ColdStartManager()var privacyAgree: Bool {set {LocalStorageHelper.shared.set(.app_isReadPrivacy, value: newValue)}get {LocalStorageHelper.shared.getBool(.app_isReadPrivacy) ??false}}privateinit() {}privateweakvar appDelegate: AppDelegate?=nilprivatelet prepareLaunchCodeStartTask =PrepareLaunchCodeStartTask()privatelet primaryMainColdStartTask =PrimaryMainColdStartTask()privatelet primaryAsyncColdStartTask =PrimaryAsyncColdStartTask()privatelet secondaryAsyncColdStartTask =SecondaryAsyncColdStartTask()privatelet idleMainColdStartTask =IdleMainColdStartTask()privatelet idleAsyncColdStartTask =IdleAsyncColdStartTask()privatevar idleInited =falsefunconPrepareLaunch(delegate: AppDelegate) {logTime("prepareLaunch") { prepareLaunchCodeStartTask.action(delegate: delegate, privacyAgreed: privacyAgree) }}funconDidFinishLaunching(delegate: AppDelegate) {appDelegate = delegatelogTime("primaryMainColdStartTask.action") {primaryMainColdStartTask.action(delegate: delegate,privacyAgreed: privacyAgree)}Task.detached(name: "ColdStartTask", priority: .background) { [primaryAsyncColdStartTask,secondaryAsyncColdStartTask,privacyAgree] inlogTime("primaryColdStartTask.action") {primaryAsyncColdStartTask.action(delegate: delegate,privacyAgreed: privacyAgree)}logTime("secondaryAsyncColdStartTask.action") {secondaryAsyncColdStartTask.action(delegate: delegate,privacyAgreed: privacyAgree)}}}funconPrivacyAgreed() {guardlet appDelegate = appDelegate else {return}LocalStorageHelper.shared.set(.app_isReadPrivacy, value: true)NotificationCenter.default.post(name: .byKey(.ham_privacyRead), object: nil)logTime("primaryMainColdStartTask.onPrivacyAgree") {primaryMainColdStartTask.onPrivacyAgree(delegate: appDelegate)}Task.detached(name: "ColdStartTask - After privacy read", priority: .background) { [primaryAsyncColdStartTask, secondaryAsyncColdStartTask, appDelegate] inlogTime("primaryColdStartTask.onPrivacyAgree") {primaryAsyncColdStartTask.onPrivacyAgree(delegate: appDelegate)}logTime("secondaryAsyncColdStartTask.onPrivacyAgree") {secondaryAsyncColdStartTask.onPrivacyAgree(delegate: appDelegate)}}}funconIdle() {guardlet appDelegate = appDelegate, !idleInited else {return}idleInited =trueTask { @MainActorinlogTime("idleMainColdStartTask.action") {idleMainColdStartTask.action(delegate: appDelegate,privacyAgreed: privacyAgree)}}Task.detached(name: "ColdStartTask - onIdle", priority: .background) { [idleAsyncColdStartTask, appDelegate, privacyAgree] inlogTime("idleAsyncColdStartTask.action") {idleAsyncColdStartTask.action(delegate: appDelegate, privacyAgreed: privacyAgree)}}}}目前的启动队列:
- 必须启动前完成(主线程/同步依赖)
- 可异步但需尽快(后台异步)
- idle 触发(完全可延迟)
然后,我们把各冷启动Task按照需要放进不同的队列里。
举个例子,像Firebase这种监测崩溃的SDK,需要在启动时就初始化,因此放在PrimaryMainColdStartTask里;
而像QQ SDK这种不急于初始化的操作,就可以放在IdleAsyncColdStartTask 里。
成果与反思#
优化前:
优化后:
优化后,APP的启动时间骤降至平均500ms,最快仅需300ms。
当然,这个过程也充满了各种坑。比如我把Firebase的SDK初始化过程放到idle阶段,会导致APP启动即崩溃的上报失效。还有,我把Xlog的初始化放在子线程,导致部分日志丢失。本质上,启动任务队列本身就是一个不断权衡(trade-off)的过程。
好在,经过大量反复测试与调整,我最终拿到了一个稳定可运行的启动队列。