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