得物App安卓冷啟動優化-Application篇
前言
冷啟動指標是App體驗中相當重要的指標,在電商App中更是對用戶的留存意愿有著舉足輕重的影響。通常是指App進程啟動到首頁首幀出現的耗時,但是在用戶體驗的角度來看,應當是從用戶點擊App圖標,到首頁內容完全展示結束。
將啟動階段工作分配為任務并構造出有向無環圖的設計已經是現階段組件化App的啟動框架標配,但是受限于移動端的性能瓶頸,高并發度的設計使用不當往往會讓鎖競爭、磁盤IO阻塞等耗時問題頻繁出現。如何百尺竿頭更進一步,在啟動階段有限的時間里,將有限的資源最大化利用,在保障業務功能穩定的前提下盡可能壓縮主線程耗時,是本文將要探討的主題。
本文將介紹我們是如何通過對啟動階段的系統資源做統一管控,按需分配和錯峰加載等手段將得物App的線上啟動指標降低10%,線下指標降低34%,并在同類型的電商App中提升至Top3。
一、指標選擇
傳統的性能監控指標,通常是以Application的attachBaseContext回調作為起點,首頁decorView.postDraw任務執行作為結束時間點,但是這樣并不能統計到dex加載以及contentProvider初始化的耗時。
因此為了更貼近用戶真實體驗,在啟動速度監控指標的基礎上,我們添加了一個線下的用戶體感指標,通過對錄屏文件逐幀分析,找到App圖標點擊動畫開始播放(圖標變暗)作為起始幀,首頁內容出現的第一幀作為結束幀,計算出結果作為啟動耗時。
例:啟動過程為03:00 - 03:88,故啟動耗時為880ms。
圖片
圖片
二、Application優化
App在不同的業務場景下可能會落到不同的首頁(社區/交易/H5),但是Application運行的流程基本是固定的,且很少變更,因此Application優化是我們的首要選擇。
得物App的啟動框架任務在近幾年已經先后做過多輪優化,常規的抓trace尋找耗時點并異步化已經不能帶來明顯的收益,得從鎖競爭,CPU利用率的角度去挖掘優化點,這類優化可能短期收益不會特別明顯,但從長遠來看能夠提前規避很多劣化問題。
1.WebView優化
App在首次調用webview的構造方法時會拉起系統對webview的初始化流程,一般會耗時200+ms,如此耗時的任務常規思路都是直接丟到子線程去執行,但是chrome內核中加入了非常多的線程檢查,使得webview只能在構造它的線程中使用。
圖片
為了加速H5頁面的啟動,App通常會選擇在Application階段就初始化webview并緩存,但是webview的初始化涉及跨進程交互和讀文件,因此CPU時間片,磁盤資源和binder線程池中任何一種不足都會導致其耗時膨脹,而Application階段任務繁多,恰恰很容易出現以上資源短缺的情況。
圖片
因此我們將webview拆分成三個步驟,分散到啟動的不同階段來執行,這樣可以降低因為競爭資源導致的耗時膨脹問題,同時還可以大幅度降低出現ANR的幾率。
圖片
1.1 任務拆分
a. provider預加載
WebViewFactoryProvider是用于和webview渲染進程交互的接口類,webview初始化的第一步就是加載系統webview的apk文件,構建出classloader并反射創建了WebViewFactoryProvider的靜態實例,這一操作并沒有涉及線程檢查,因此我們可以直接將其交給子線程執行。
圖片
b. 初始化webview渲染進程
這一步對應著chrome內核中的WebViewChromiumAwInit.ensureChromiumStartedLocked()方法,是webview初始化最耗時的部分,但是和第三步是連續執行的。走碼分析發現WebViewFactoryProvider暴露給應用的接口中,getStatics這個方法會正好會觸發ensureChromiumStartedLocked方法。
至此,我們就可以通過執行WebSettings.getDefaultUserAgent()來達到僅初始化webview渲染進程的目的。
圖片
圖片
圖片
c. 構造webview
即new Webview()
1.2 任務分配
為了最大程度縮短主線程耗時,我們的任務安排如下:
a. provider預加載,可以異步執行,且沒有任何前置依賴,因此放在Application階段最早的時間點異步執行即可。
b. 初始化webview渲染進程,必須在主線程,因此放到首頁首幀結束之后。
c. 構造webview,必須在主線程,在第二步完成時post到主線程執行。這樣可以確保和第二步不在同一個消息中,降低ANR的幾率。
圖片
1.3 小結
盡管我們已經將webview初始化拆分為了三個部分,但是耗時占比最高的第二步在低端機或者極端情況還是可能觸達ANR的閾值,因此我們做了一些限制,例如當前設備會統計并記錄webview完整初始化的耗時,僅當耗時低于配置下發的閾值時,開啟上述的分段執行優化。
App如果是通過推送、投放等渠道打開,一般打開的頁面大概率是H5營銷頁,因此這類場景不適用于上述的分段加載,所以需要hook主線程的messageQueue,解析出啟動頁面的intent信息,再做判斷。
受限于開屏廣告功能,我們目前只能對無開屏廣告的啟動場景開啟此優化,后續將計劃利用廣告倒計時的間隙執行步驟2,來覆蓋有開屏廣告的場景。
圖片
2.ARouter優化
在當下組件化流行的時代,路由組件已經幾乎是所有大型安卓App必備的基礎組件,目前得物使用的是開源的ARouter框架。
ARouter 框架的設計是它默認會將注解中注冊path路徑中第一個路由層級 (例如 "/trade/homePage"中的trade)作為該路由信息所的Group, 相同Group路徑的路由信息會合并到最終生成的同一個類 的注冊函數中進行同步注冊。在大型項目中,對于復雜業務線同一個Group下可能包含上百個注冊信息,注冊邏輯執行過程耗時較長,以得物為例,路由最多的業務線在初始化路由上的耗時已經來到了150+ms。
圖片
路由的注冊邏輯本身是懶加載的,即對應Group之下的首個路由組件被調用時會觸發路由注冊操作。然而ARouter通過SPI(服務發現)機制來幫助業務組件對外暴露一些接口,這樣不需要依賴業務組件就可以調用一些業務層的視線,在開發這些服務時,開發者一般會習慣性的按照其所屬的組件為其設置路由path,這使得首次構造這些服務的時候也會觸發同一個Group下的路由加載。
而在Application階段肯定需要用到業務模塊的服務中的一些接口,這就會提前觸發路由注冊操作,雖然這一操作可以在異步線程執行,但是Application階段的絕大部分工作都需要訪問這些服務,所以當這些服務在首次構造的耗時增大時,整體的啟動耗時勢必會隨之增長。
2.1 ARouter Service路由分離
ARouter采用SPI設計的本意是為了解耦,Service的作用也應該只是提供接口,所以應當新增一個空實現的Service專門用于觸發路由加載,而原先的Service則需要更換一個Group,后續只用于提供接口,如此一來Application階段的其他任務就不需要等待路由加載任務的完成。
圖片
2.2 ARouter支持并發裝載路由
我們在實現了路由分離之后,發現現有的熱點路由裝載耗時總和是大于Application耗時,而為了保證在進入閃屏頁之前完成對路由的加載,主線程不得不sleep等待路由裝載完畢。
分析可知ARouter的路由裝載方法加了類鎖,因為他需要將路由裝載到倉庫類中的map,這些map是線程不安全的HashMap,相當于所有的路由裝載操作其實都是在串行執行,而且存在鎖競爭的情況,最終導致耗時累加大于Application耗時。
圖片
圖片
分析trace可知耗時主要來自頻繁調用裝載路由的loadInto操作,再分析這里鎖的作用,可知加類鎖是主要是為了確保對倉庫WareHouse中map操作的線程安全。
圖片
因此我們可以將類鎖降級對GroupMeta這個class對象加鎖(這個class是ARouter apt生成的類,對應apk中的ARouter$$Provider$$xxx類),來確保路由裝載過程中的線程安全,至于在此之前對map操作的線程安全問題,則完全可以通過將這些map替換為concurrentHashMap解決,在極端并發情況下會有一些線程安全問題,也可以按照圖中添加判空來解決。
圖片
圖片
至此,我們就實現了路由的并發裝載,隨后我們根據木桶效應對要預載的service進行合理分組,再放到協程中并發執行,確保最終整體耗時最短。
圖片
圖片
3.鎖優化
Application階段執行的任務多為基礎SDK的初始化,其運行的邏輯通常相對獨立,但是SDK之間會有依賴關系(例如埋點庫會依賴于網絡庫),且大部分都會涉及讀文件,加載so庫等操作,Application階段為了壓縮主線程的耗時,會盡可能地將耗時操作放到子線程中并發運行,充分利用CPU時間片,但是這也不可避免的會導致一些鎖競爭的問題。
3.1 Load so鎖
System.loadLibrary()方法用于加載當前apk中的so庫,這個方法對Runtime對象加了鎖,相當于一個類鎖。
基礎SDK在設計上通常會將load so的操作寫到類的靜態代碼塊中,確保在SDK初始化代碼執行之前就準備好了so庫。如果這個基礎SDK恰巧是網絡庫這類基礎庫,會被很多其他SDK調用,就會出現多個線程同時競爭這個鎖的情況。那么在最壞的情況下,此時IO資源緊張,讀so文件變慢,并且主線程是鎖等待隊列中最后一個,那么啟動耗時將遠超預期。
圖片
為此,我們需要將loadSo的操作統一管控并收斂到一個線程中執行,強制他們以串行的方式運行,這樣就可以避免以上情況的出現。值得一提的是,前面webview的provider預加載的過程中也會加載webview.apk中的so文件,因此需要確保preloadProvider的操作也放到這個線程。
so的加載操作會觸發native層的JNI_onload方法,一些so可能會在其中執行一些初始化工作,因此我們不能直接調用System.loadLibrary()方法來進行so加載,否則可能會重復初始化出現問題。
我們最終采用了類加載的方式,即將這些so加載的代碼全部挪到相關類的靜態代碼塊中,然后再去觸發這些類的加載即可,利用類加載的機制確保這些so的加載操作不會重復執行,同時這些類加載的順序也要按照這些so使用的順序來編排。
圖片
除此之外,so的加載任務不建議和其他需要IO資源的任務并發執行,在得物App中實測這兩種情況下該任務的耗時相差巨大。
4.啟動框架優化
目前常見的啟動框架設計是將啟動階段的工作分配到一組任務節點中,再由這些任務節點的依賴關系構造出一個有向無環圖,但是隨著業務迭代,一些歷史遺留的任務依賴已經沒有存在的必要,但是他會拖累整體的啟動速度。
啟動階段大部分工作都是基礎SDK的初始化,他們之間往往有著復雜的依賴關系,而我們在做啟動優化時為了壓縮主線程的耗時,通常都會找出主線程的耗時任務并丟到子線程去執行,但是在依賴關系復雜的Application階段,如果只是將其丟到異步執行未必能有預期的收益。
我們在做完webview優化之后發現啟動耗時并沒有和預期一樣直接減少了webview初始化的耗時,而是只有預期的一半左右,經分析發現我們的主線程任務依賴著子線程的任務,所以當子線程任務沒有執行完時,主線程會sleep等待。
并且webview之所以放在這個時間點初始化不是因為有依賴限制這它,而是因為這段時間主線程正好有一段比較長的sleep時間可以利用起來,但是異步的任務工作量是遠大于主線程的,即便是七個子線程并發在跑,其耗時也是大于主線程的任務。
因此想進一步擴大收益,就得對啟動框架中的任務依賴關系做優化。
圖片
圖片
以上第一張圖為優化之前得物App啟動階段任務的有向無環圖,紅框表示該任務在主線程執行。我們著重關注阻塞主線程任務執行的任務。
可以觀察到主線程任務的依賴鏈路上存在幾個出口和入口特別多的任務,出口多表明這類任務通常是非常重要的基礎庫(例如圖中的網絡庫),而入口多表明這個任務的前置依賴太多,他開始執行的時間點波動較大。這兩點結合起來就說明這個任務執行結束的時間點很不穩定,并且將直接影響到后續主線程的任務。
這類任務優化的思路主要是:
拆解任務自身,將可以提前執行或者延后執行的操作分出去,但是分出去之前要考慮到對應的時間段還有沒有時間片余量,或者會不會加重IO資源競爭的情況出現;
優化該任務的前置任務,讓該任務執行結束的時間點盡可能提早,就可以降低后續任務等待該任務的耗時;
移除非必要的依賴關系,例如埋點庫初始化只是需要注冊一個監聽器到網絡庫,并非發起網絡請求。(推薦)
可以看到我們在優化之后的第二張有向無環圖里,任務的依賴層級明顯變少,入口和出口特別多的任務也都基本不再出現。
圖片
圖片
對比優化前后的trace,也可以看到子線程的任務并發度明顯提高,但是任務并發度并不是越高越好,在時間片本身就不足的低端機上并發度越高表現可能會越差,因為更容易出鎖競爭,IO等待之類的問題,因此要適當留下一定空隙,并在中低端機上進行充分的性能測試之后再上線,或者針對高中低端機器使用不同的任務編排。
三、首頁優化
1.通用布局耗時優化
系統解析布局是通過inflate方法讀取布局xml文件并解析構建出view樹,這一過程涉及IO操作,很容易受到設備狀態影響,因此我們可以在編譯期通過apt解析布局文件生成對應的view構建類。然后在運行時提前異步執行這些類的方法來構建并組裝好view樹,這樣可以直接優化掉頁面inflate的耗時。
圖片
圖片
2.消息調度優化
在啟動階段我們通常會注冊一些ActivityLifecycleListener來監聽頁面生命周期,或者是往主線程post了一些延時任務,如果這些任務中有耗時操作,將會影響到啟動速度,因此可以通過hook主線程的消息隊列,將頁面生命周期回調和頁面繪制相關的msg移動到消息隊列的隊頭,這樣就可以加快首頁首幀內容展示的速度。
圖片
詳情可期待本系列后續內容。
四、穩定性
性能優化對App只能算作錦上添花,穩定性才是生命紅線,而啟動優化改造的又都是執行時機非常早的Application階段,穩定性風險程度非常高,因此務必要在準備好崩潰防護的前提下做優化,即便有不可避免的穩定性問題,也要將負面影響降到最低。
1.崩潰防護
由于啟動階段執行的任務都是重要的基礎庫初始化,因此發生崩潰時將異常識別并吃掉的意義不大,因為大概率會導致后續崩潰或功能異常,因此我們主要的防護工作都是發生問題之后的止血。
配置中心SDK的設計通常都是從本地文件中讀出緩存的配置使用,待接口請求成功后再刷新。所以如果當啟動階段命中了配置之后發生了crash,是拉不到新配置的。這種情況下只能清空App緩存或者卸載重裝,會造成非常嚴重的用戶流失。
圖片
- 崩潰回退
對所有改動點加上try-catch保護,捕捉到異常之后上報埋點并往MMKV中寫入崩潰標記位,這樣該設備在當前版本下都不會再開啟啟動優化相關的變更,隨后再拋出原異常讓他崩潰掉。至于native crash則是在Crash監控的native崩潰回調里執行同樣操作即可。
圖片
- 運行狀態檢測
Java Crash我們可以通過注冊unCaughtExceptionHandler來捕捉到,但是native crash則需要借助crash監控SDK來捕捉,但是crash監控未必能在啟動最早的時間點初始化,例如Webview的Provider的預加載,以及so庫的預加載都是早于crash監控,而這些操作都涉及native層的代碼。
為了規避這種場景下的崩潰風險,我們可以在Application的起始點埋入MMKV標記位,在結束點改為另一個狀態,這樣一些執行時間早于配置中心的代碼就可以通過獲取這個標記位來判斷上一次運行是否正常,如果上次啟動發生了一些未知的崩潰(例如發生在crash監控初始化之前的native崩潰),那么通過這個標記位就可以及時關閉掉啟動優化的變更。
結合崩潰之后自動重啟的操作,在用戶視角其實是觀察不到閃退的,只是會感覺到啟動的耗時約是平時的1-2倍。
圖片
- 配置有效期
線上的技改變更通常都會配置采樣率,結合隨機數實現逐漸放量,但是配置下發SDK的設計通常都是默認取上次的本地緩存,在發生線上崩潰等故障時,盡管及時回滾了配置,但是緩存的設計會導致用戶還會因為緩存遭遇至少一次的崩潰。
為此,我們可以為每一個開關配置加一個配套的過期時間戳,限制當前放量的開關只在該時間戳之前生效,這樣在遇到線上崩潰等故障時確保可以及時止血,而且時間戳的設計也可以避免線上配置生效的滯后性導致的crash。
圖片
用戶視角下,添加配置有效期前后對比:
圖片
五、總結
至此,我們已經對安卓App中比較通用的冷啟動耗時案例做了分析,但是啟動優化最大的痛點往往還是App自身的業務代碼,應當結合業務需求合理的進行任務分配,如果一味的靠預加載,延遲加載和異步加載是不能從根本上解決耗時問題的,因為耗時并沒有消失只是轉移,隨之而來的可能是低端機啟動劣化或功能異常。
做性能優化不僅需要站在用戶的視角,還要有全局觀,如果因為啟動指標算是首頁首幀結束就把耗時任務都丟到首幀之后,勢必會造成用戶后續的體驗有卡頓甚至ANR。所以在拆分任務時不僅需要考慮是否會和與其并發的任務競爭資源,還需要考慮啟動各個階段以及啟動后一段時間內的功能穩定性和性能是否會受之影響,并且需要在高中低端機器上都驗證下,至少要確保都沒有劣化的表現。
1.防劣化
啟動優化絕不是一次性的工作,它需要長時間的維護和打磨,基礎庫的一次技改可能就會讓指標一夜回到解放前,因此防劣化必須要盡早落地。
通過在關鍵點添加埋點,可以做到在發現線上指標劣化時迅速定位到劣化代碼大概位置(例如xxActivity的onCreate)并告警,這樣不僅可以幫助研發迅速定位問題,還可以避免線上特定場景指標劣化線下無法復現的情況,因為單次啟動的耗時波動范圍最高能有20%,如果直接去抓trace分析可能連劣化的大概范圍都難以定位。
例如兩次啟動做trace對比時,其中一次因為遇到IO阻塞導致某次讀文件的操作都明顯變慢,而另一次IO正常,這就會誤導開發者去分析這些正常的代碼,而實際導致劣化的代碼可能因為波動正好被掩蓋。
2.展望
對于通過點擊圖標啟動的普通場景,默認會在Application執行完整的初始化工作,但是一些層級比較深的功能,例如客服中心,編輯收貨地址這類,即使用戶以最快速度直接進入這些頁面,也是需要至少1s以上的操作時間,所以這些功能相關的初始化工作也是可以推遲到Application之后的,甚至改為懶加載,視具體功能的重要性而定。
通過投放,push來做召回/拉新的啟動場景通常占比較少,但是其業務價值要遠大于普通場景。由于目前啟動耗時主要來源于webview初始化以及一些首頁預載相關的任務,如果啟動落地頁并不需要所有基礎庫(例如H5頁面),那么這些我們就可以將它不需要的任務統統延遲加載,這樣啟動速度可以得到大幅度增長,做到真正意義上的秒開。