西瓜Android子進程優化治理實踐

2024年2月6日 20点热度 0人点赞

背景

App在啟動時會通過Zygote fork出對應的應用進程,來運行應用上的Activity/Service等Android組件。通常,一個簡單的App就是一個單獨的進程;不過較為大型的App會設計成多進程的模式,將其他復雜的子功能抽離,放在子進程中運行,與主進程保持隔離,避免影響主進程的穩定性。

西瓜視頻對啟動階段的子進程做了較為全面的治理,取得了不錯的收益,接下來將結合原理與案例為大傢介紹下我們所做的優化。

進程啟動介紹

創建進程是個復雜的流程,在講解具體的優化思路之前,先簡單講解下進程的啟動過程,以 Android 10.0 為例,整體流程分為三個部分,如下圖所示:

  1. system_server進程調用 Process.start() 方法,通過socket向zygote進程發送創建新進程的請求;
Process.start
-> ZYGOTE_PROCESS.start
// 該過程生成argsForZygote參數數組,保存進程的uid、gid、runtime-flags等一系列參數
-> ZygoteProcess.startViaZygote 
// 根據abi選擇合適的ZygoteState來通信
-> ZygoteProcess.openZygoteSocketIfNeeded
-> attemptConnectionToPrimaryZygote |
-> attemptConnectionToSecondaryZygote
-> ZygoteProcess.zygoteSendArgsAndGetResult
// usap介紹:https://juejin.cn/post/6922704248195153927
-> attemptUsapSendArgsAndGetResult |
// 通過socket向Zygote進程發送參數列表並進入阻塞等待狀態,直到接受到新創建的進程pid
-> attemptZygoteSendArgsAndGetResult
  1. zygote進程執行 ZygoteInit.main() 後便進入 ZygoteServer.runSelectLoop() 循環體內,當有客戶端連接時便會執行ZygoteConnection.runOnce()方法,再經過層層調用後fork出新的應用進程;
ZygoteInit.main
-> ZygoteServer.runSelectLoop
// 接收客戶端connect()請求,Zygote服務端執行accept()操作
-> ZygoteServer.acceptCommandPeer
// 處理客戶端的一次請求,讀取其中的參數列表
-> ZygoteConnection.processOneCommand
-> Zygote.forkAndSpecialize
-> nativeForkAndSpecialize
-> com_android_internal_os_Zygote_nativeForkAndSpecialize
-> ForkCommon
// fork出子進程
-> fork
-> SpecializeCommon
// 子進程處理一些特殊特殊邏輯:比如設置隨機數
-> CallStaticVoidMethod(..., gCallPostForkChildHooks, ...)
-> Zygote.callPostForkChildHooks
-> ZygoteHooks.postForkChild
-> nativePostForChild
-> ZygoteHooks_nativePostForkChild
  1. 在Zygote fork出新進程後,即pid=0,子進程開始運行,執行 ZygoteConnection.handleChildProc 方法;
ZygoteConnection.processOneCommand
-> ZygoteConnection.handleChildProc
-> ZygoteInit.zygoteInit
-> ZygoteInit.nativeZygoteInit
-> com_android_internal_os_ZygoteInit_nativeZygoteInit
-> AppRuntime.onZygoteInit
// 啟動binder線程
-> ProcessState.startThreadPool
-> RuntimeInit.applicationInit
-> findStaticMain
// 返回的是 Runnable
-> return new MethodAndArgsCaller
// 此時會回到 ZygoteInit.main 裡
-> MethodAndArgsCaller.run
// 通過反射調用到 ActivityThread.main
-> ActivityThread.main

根據上面的描述,我們對創建進程有了直觀了解,可以看到相關邏輯是比較復雜的,並且進程作為系統分配資源的單位,多一個進程就會多占用一部分內存資源,運行時擠占CPU的運行時間,增加系統壓力從而影響到應用性能。

那為什麼我們需要關註啟動階段的進程情況?因為應用在啟動階段,主進程執行的任務較重,如果再去啟動其他子進程,那對應用的啟動速度,以及1min流暢性上都會造成明顯影響,容易影響到用戶體驗,等啟動階段結束,再啟動其他子進程,資源競爭的情況會好很多,對性能的影響就比較小。

不過我們雖然知道進程會占用系統資源,但是並沒有量化的手段來確定子進程對主進程的影響,因為這種影響會在運行期間動態變化,在本地看單個機器波動較大,所以需要通過後續的實驗來看實際收益。

其他啟動進程情況

除上面常見的啟動進程方式,還有其他的方式:

  • 通過Runtime.exec運行shell命令啟動進程:
Runtime.exec
-> ProcessBuilder.start
-> ProcessImpl.start
-> UNIXProcess.<init>
-> UNIXProcess.forkAndExec
-> [UNIXProcess_md.c] UNIXProcess_forkAndExec
-> [UNIXProcess_md.c] startChild
  • 通過native代碼直接fork進程。

這兩種方式隻要了解即可,本文主要關註業務代碼中通過Android組件啟動的進程,不過後面會對 Runtime.exec 啟動的進程做個簡單介紹。

治理思路

按照解決問題的一般思路,我們對問題進行具體分析,確定問題原因後再著手解決;對於進程治理也是一樣的,在治理前我們先了解下業務側添加新進程的過程,確定進程的啟動情況:

  • 在AndroidManifest.xml中增加組件,並配置android:process屬性;
  • 使用的時候通過啟動對應的組件來創建進程;

通過AS的界面窗口,我們可以在本地清楚地看到西瓜在啟動階段會啟動:push 進程、小程序進程、downloader進程、sandboxed進程這四個常見的子進程,也可以通過 ps 命令來查看進程信息。

確定要優化的進程後,我們可以通過 manifest 中的組件申明,枚舉出來所有會拉起對應進程的組件,也可以通過 adb logcat | grep {進程名},過濾進程的啟動日志就能看到對應的首個組件。

找到對應的組件後,接下來就要對組件進行處理,對於子進程的治理有三個通用思路:

  • 按需加載:
    • 在使用的時候才去加載對應的功能模塊,避免在早期進行多餘的調用;
    • 對於進程的代碼實現來說,我們最好在使用進程組件時,能有個統一的入口,方便管理和維護;
  • 延遲加載:
    • 按需加載一般來說是更優先的選擇,但要有確定的加載時機;如果在時機不能確定,或者進程本身需要通過提前加載來優化後續體驗時,可以考慮延遲加載;
    • 延遲到什麼時候是個需要考慮的問題,結合應用使用時長和進程功能的特點,一般來說有兩個較為常見的選擇點:1min之後,或者退後臺,兩個時機的出發點是一致的,都為了盡量不影響用戶的使用體驗;
  • 合並到主進程:
    • 將進程中的邏輯合並到主進程中,避免創建子進程;
    • 但需要對功能進行完整的驗證,確保在單進程模式下功能的可用性和穩定性;

上面的思路都比較溫和,不是一味地去除功能,我們希望在保證功能穩定可用的前提下,盡量降低進程啟動帶來的影響,結合自身應用的特點進行選擇,采用合適的方案。

治理過程

Push進程

西瓜push的主要工作可以分為兩個部分:

  • push SDK初始化:進行配置與監控操作,在Application.onCreate階段進行,這部分不涉及進程啟動,不用幹預;
  • push進程啟動:用於處理保活、紅點、長連接的建立;

從業務功能的角度來說應用處於前臺時不需要接收 push,而應用在啟動階段基本上是處於前臺的,並且啟動時間較短,這段時間不需要接收push相關的通知,可以考慮將push進程延遲處理。

那接下來的問題就是如何在啟動階段抑制 push 進程?為了解決這個問題,先後產生了三個方案:

手動方案

最早做的方案主要有兩個部分:

  • 其中push的必要邏輯,比如長連接邏輯,可以合並到主進程;
  • 另外一部分非必要邏輯,則通過阻止相應的 service 啟動來避免push進程創建;

長鏈接合並到主進程方案最終使用的是後面講到的SDK方案,直接將長鏈接服務關聯到主進程,因為單進程版本改動不是很大,且不影響業務功能,並且由SDK同學提供支持,整體方案比較穩妥;

而阻止相應的 service 啟動,需要尋找對應 service,在啟動階段添加判斷條件,避免觸發啟動,等退後臺時再手動調用 service 來啟動 push 進程;但這種方式比較花費人力,而且 push 進程的啟動時機比較復雜,對業務不了解的情況下難以做到全面的梳理,有可能遺漏某些場景,導致 push 進程抑制失敗。

SDK方案

上面的第一種方案修改成本較高,維護性不友好,後續push SDK有功能邏輯的修改,需要再次進行排查;考慮後決定在 SDK 層面去做會是個更好的方案,所以和相關同學進行溝通,讓他幫忙支持。

通過溝通了解到,Push會通過 settings 開關配置平臺下發對應的開關,判斷是否需要延遲處理,如果需要就不啟動對應的進程,等到退後臺再啟動;功能邏輯上也比較完備,考慮了在不啟動子進程的情況下,對原有邏輯進行補充:比如註冊廠商通道,新pull接口、拉活邏輯放在主進程等。

使用 SDK方案做了幾次實驗,均取得了較大的性能收益,但並沒有取得人均活躍天數的業務收益,按照過往的經驗,這種幅度的性能收益是能帶來對應業務收益的,因此懷疑該方案可能攔截並不徹底。

同時得知抖音也做了 push 進程延遲的相關實驗,取得了較大收益,進行溝通後,了解到他們是通過攔截 service 來實現的,所以參考抖音產生了第三個方案。

攔截方案

和第一種手動方案的思路是類似的,都是對push進程的組件進行攔截,然後退後臺再啟動,隻是修改方案不同:該方案直接對 startService、bindService 方法進行插樁處理,判斷啟動的是 push 組件,就通過 runnable 保存下來,等退後臺再執行,從而成功抑制push進程的啟動,不用像第一種方案對每個 service 進行手動處理:

  1. 從 packageInfo 中收集在 manifest 申明過的 push 進程組件信息,目前push的相關進程是 push、pushservice,收集這兩個進程的組件即可;
  2. 在 startService 和 bindService 的插樁方法添加過濾邏輯,判斷是否是push進程組件,如果是保存在 runnable 中;
  3. 利用 andoridx 工具庫的 LifecycleObserver 監聽退後臺,西瓜封裝成 ActivityStack.OnAppBackGroundListener,退後臺時將之前保留的 Runnable 執行一遍,同時移除監聽。

在攔截方案的同時加了 push 進程的監控邏輯,方便實驗中的問題排查,這部分會在後續進程啟動監控部分中具體講解。

攔截方案和 SDK 方案對比,

  1. 攔截方案和SDK方案的性能效果接近,證明SDK方案的攔截沒有問題,但產品指標不如SDK方案;
  2. SDK 方案考慮全面,不啟動進程的同時補充其他的功能邏輯,更加嚴謹,不容易出問題,並且SDK同學會繼續幫忙維護,節省後續的開發維護成本。

綜合考慮最後上線的是 SDK 方案。

小程序進程

方案實現

相較於 push 進程,小程序進程的優化方案選擇就簡單很多,一開始想使用 service 攔截方案,直接在 startService 和 bindService 處攔截小程序進程組件的啟動。

但這種方案對小程序進程不太可行,因為小程序功能邏輯主要在小程序插件裡的,需要在插件中對相關代碼進行插樁處理,比較麻煩,而更大的問題是可能會影響到業務功能。

因為與 push 進程啟動相比,小程序進程並不存在退後臺啟動的邏輯,在使用過程中就可能會使用到小程序,並沒有合適的時機進行啟動,所以需要結合業務邏輯去分析,看看業務上延遲小程序進程啟動是否可行。

西瓜小程序入口邏輯都在小程序服務 MiniAppService 這個類上,通過對入口方法進行梳理,發現有合適的處理方案:小程序進程啟動大都是由於預加載任務 PreloadMiniAppTask 導致的,去除這個任務就可以完成小程序進程按需的邏輯;修改完成後經過測試,小程序功能沒有問題,符合預期。

後續在代碼上梳理了小程序的加載邏輯,發現在視頻廣告(包括長視頻、中視頻等多題材),遊戲中心等場景會使用到小程序功能,這部分功能的加載邏輯是按需的,因此我們沒有進行改動,保證原先功能可以正常使用。

指標異常排查

不過實驗期間遇到個問題,實驗的性能和產品指標都不錯,但核心指標中使用時長有明顯的劣化,花費了較多的時間進行排查:

首先想到的是影響了推薦質量,因為使用時長能體現用戶的使用意願,可能是推薦質量不太行,導致用戶不願意繼續使用,不過與其他指標不太符合,其他核心指標是顯著正向的,單一指標劣化說不通;並且從代碼改動上看,隻涉及到小程序預加載任務,改動小,理論上不會對Feed請求造成影響;

接著猜測子進程可能會上報使用時長,抑制小程序進程後,導致上報數據變少。但排查日志上報的初始化邏輯後,發現埋點上報的工具類並沒有在子進程初始化,也就是說除主進程外,子進程並不會上報埋點,並且實驗組的使用時長次數反而更多一些,說明上報的數據量並沒有變少,排查過程一度陷入困境。

多次實驗後,每次使用時長都穩定保持在相近的劣化幅度,所以仍然懷疑是埋點導致的問題,打算朝這個方向再努力下,找到DA同學溝通後,了解到使用時長是通過相關埋點的會話時長這個參數進行上報的,而時長是根據頁面的 onResume、onPause 計算出來的,查看代碼並過濾相關埋點日志,發現在優化未開啟時,每次頁面 onPause 後會多上報一次 1s 的使用時長,那接下來需要排查:

  1. 為什麼每次頁面切換會多上報一次的使用時長?
  2. 為什麼每次多餘的上報時長是1s?

檢查小程序相關代碼,發現小程序插件在加載後,會在主進程添加ActivityCallback,並調用埋點上報的 onResume、onPause邏輯,即加載插件後,隻要頁面發生切換,會調用一次使用時長的埋點記錄。

而西瓜宿主原先在自己的ActivityCallback就有onPause調用,加上小程序插件裡面的ActivityCallback的調用,一次onPause會觸發連續兩次埋點上報;並且埋點記錄有使用時長的保護邏輯,前後時間戳差值在 <=0s 的時長數據會被記為 1s。

因此在原先沒有延後優化的時候,會錯誤地多上報1s的使用時長,導致優化後使用時長反而有所下降。這是歷史問題遺留的問題,知道原因後和DA同學溝通,不影響實驗的上線。

downloader進程

接下來要處理 downloader 下載進程,經過梳理發現下載進程在小程序進程啟動後才會啟動,小程序進程抑制後,下載進程便不會提前啟動;同時下載庫支持單進程,並不會影響西瓜的下載功能,所以下載進程並不需要特殊處理,我們來看看具體的排查過程:

  1. 通過對進程組件進行攔截,根據調用堆棧查看代碼,發現downloader進程啟動代碼在小程序進程的調用堆棧裡;
  2. 跟 downloader SDK同學溝通確認多進程啟動邏輯,並在西瓜業務代碼中查看,隻有小程序插件調用了多進程下載的能力,沒有其他地方調用;
  3. 查看 downloader 庫下載任務的調用鏈路,發現下載庫考慮到了單進程和多進程的問題,內部實際執行下載的Handler 會進行判斷,並且本地測試可以確認單進程對下載功能本身沒有影響;

由於小程序進程延遲的影響,下載進程的啟動次數偏少,優先級不高,後續可以嘗試將小程序進程和下載進程解綁,統一使用下載庫的單進程,從而完全解決下載進程的啟動問題。

Sandboxed進程

方案實現

有了 push 進程和小程序進程的經驗,那針對webview渲染進程,也就是 Sandboxed進程能不能做下延遲優化呢?

首先要明確應用適不適合延遲webview的渲染進程,如果本身強依賴webview功能,並且在啟動階段就需要使用到,就不適合對webview做多餘的改造;經過梳理,西瓜這邊對webview的使用集中在廣告的落地頁和一些活動頻道,整體上對webview的依賴比較小,可以嘗試進行延遲優化,後續實驗中關註下廣告、活動的相關指標即可。

在具體的實現方案上,我們先看看webview渲染進程是由誰來啟動的,一方面了解大致的啟動過程,另一方面看看能否通過上層業務進行攔截,而不去改動底層SDK的邏輯。

通過本地的打印日志,確定對應的進程啟動是在字節自研webView的初始化上,而具體的代碼邏輯在下載的內核中,業務層沒有能力做延遲處理,不過存在兩個相關的多進程開關:多進程啟用 RENDER_PROCESS、多進程預熱 WRAMUP。

RENDER_PROCESS 表示是否啟用多進程,線下測試發現,對應的開關返回false,就能實現不啟動渲染進程,並且webview功能依然可以正常使用,驗證成功;不過考慮到系統webview也是多進程的,為了主進程的穩定性考慮,中臺同學不建議取消多進程。

多進程預熱延遲

在對齊無法直接關閉webview多進程能力後,我們開始研究能否按需創建多進程,即用到webview能力時才創建Sandboxed進程,這時我們關註到另外一個配置:WRAMUP,表示是否啟用進程預熱,那關閉這個能力後是否就可以延遲創建Sandboxed進程呢?

接著和中臺同學進行溝通,確認了關閉多進程預熱能力後可以實現Sandboxed進程的延遲創建,因此我們制定了兩個優化策略:

  1. 延遲預熱:仍會啟用webview渲染預熱能力,由SDK提供延遲預熱的時間配置;西瓜這邊實驗配置是 1min,那 SDK就在App冷啟後延遲1min創建Sandboxed進程;
  2. 按需處理:通過關閉渲染預熱達到按需的目的,每次使用到webview功能時再創建Sandboxed進程。

UserAgent緩存

在延遲1min或者按需開啟渲染預熱能力的開關配置後,我們線下測試發現啟動階段仍然會創建 Sandboxed進程,再次梳理後發現雖然我們延遲了webview渲染預熱,但是在啟動過程中使用到webview的相關功能時仍然會創建 Sandboxed進程。

因此我們開始排查西瓜在冷啟過程中使用到webview的業務場景,通過相關代碼發現冷啟過程中有獲取UserAgent相關邏輯,這部分邏輯會觸發webview的初始化。

針對這塊我們做了UserAgent緩存邏輯優化,具體邏輯如下:

  1. 通過統一的方法入口進行獲取:有緩存直接返回;沒有緩存,獲取到緩存後,保存到sp並返回;
  2. 退後臺進行一次讀取,刷新下UserAgent緩存。

在後續的實驗過程中,按需處理的性能收益會更大一些,但兩者的產品收益差距並不大,考慮到西瓜的默認行為和延遲處理的後續邏輯是一致的,風險更小,所以最後上線的是延遲1min方案。

Exec進程

除上述通過組件申明啟動的進程外,還有一類通過 Runtime.exec 啟動的進程,業務開發中不算常見,但一些工具方法會使用相關命令獲取一些系統參數;

我們先來看看西瓜中常見的使用方式,一般用 getprop 命令獲取 brand 系統品牌或者版本,以 brand 舉例,一般有三種寫法:

  1. 使用 Build.BRAND 獲取;
  2. 反射調用 SystemProperties.get("ro.product.brand") 獲取;
  3. 通過 Runtime.getRuntime().exec 執行 "getprop ro.product.brand" 獲取;

其實,Build.BRAND 內部也是使用 SystemProperties.get 來獲取的,實際上就兩種方案:SystemProperties.get 和 getprop 命令獲取,所以我們需要知道這兩種方案拿到的結果是不是一致的。

繼續以 Android 10.0 為例,我們來看下 SystemProperties.get 源碼實現:

Build.BRAND
-> getString("ro.product.brand")
  -> SystemProperties.get
    -> native_get
      -> SystemProperties_getSS
        -> android::base::GetProperty
          -> __system_property_find
          -> __system_property_read_callback

接下來我們看看 getprop 命令的實現,這個鏈路會短不少:

getprop_main
-> PrintProperty
  -> android::base::GetProperty
    -> __system_property_find
    -> __system_property_read_callback

從代碼實現看,最終獲取的值都是從同一個地方取的,以防萬一我們通過獨立灰進行了驗證,線上這兩個方法取得值也是一致的,所以根據上述的結果,我們采用的方案是:

  1. brand 直接通過 Build.BRAND 獲取;
  2. 其他沒有暴露出來的信息,通過反射走 SystemProperties.get。

我們全局梳理後對這類錯誤的使用情況進行了插樁替換處理,目前該優化還在實驗中。

除 getprop 外,還有一些不必要的Shell命令調用,比如 cat 命令,這種完全可以直接讀取對應的文件來替換實現。

進程啟動監控

最後講一下進程啟動監控的邏輯,一開始是為了攔截push進程的組件啟動,後面則是為了防止進程啟動優化失效,最終的方案在 push 攔截監控的基礎上做了一些修改,也都是常見的技術方案:

  1. 首先從 PackageInfo 中收集各個進程的組件信息,這一步和攔截方案是相同的:
int flags = PackageManager.GET_ACTIVITIES | PackageManager.GET_RECEIVERS
| PackageManager.GET_SERVICES | PackageManager.GET_PROVIDERS;
PackageInfo packageInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), flags);
ActivityInfo[] activities = packageInfo.activities;
if (activities != null) {
for (ActivityInfo activityInfo : activities) {
if (activityInfo.processName.contains(processSuffix)) {
components.add(activityInfo.name);
}
}
}
...// 處理 receiver、service、provider
  1. 對 ActivityManager 中接口對象 IActivityManager 進行代理,這樣可以在主進程啟動組件時進行判斷:
Class activityManagerClass;
Field gDefaultField;
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
activityManagerClass = Class.forName("android.app.ActivityManagerNative");
gDefaultField = activityManagerClass.getDeclaredField("gDefault");
} else {
activityManagerClass = Class.forName("android.app.ActivityManager");
gDefaultField = activityManagerClass.getDeclaredField("IActivityManagerSingleton");
}
...
Object proxy = Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(),
new Class<?>[]{iActivityManagerInterface},
new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
return proxyMethodInvoke(iActivityManagerObject, method, args);
}
});
  1. 對各個啟動的組件進行解析,過濾後符合條件的進行上報;
private Object proxyMethodInvoke(Object proxy, Method method, Object[] args) throws InvocationTargetException, IllegalAccessException {
try {
ComponentStartInfo startInfo = parseComponentStartInfo(method.getName(), args);
if (startInfo != null) {
reportProcessStartInfo(startInfo, method);
}
} catch (Exception ignore) {
} finally {
return method.invoke(proxy, args);
}
}

除在運行期間通過 PackageInfo 獲取的方式外,也可以通過編譯期對 manifest 文件進行掃描,同樣能夠知道有哪些申明進程的組件。

優化收益

經過大半年時間的治理,西瓜視頻對上述的子進程都進行了優化,取得了較好的收益:

優化進程核心業務收益品質收益push進程有效播放次數顯著 0.189%冷啟耗時-140ms,大盤幀率 0.5%,丟幀次數-1%~2%小程序進程(含downloader進程)人均活躍天數顯著 0.0892%,有效播放次數顯著 0.207%啟動後1min幀率 0.405%,人均 anr -26.223%Sandboxed進程人均活躍天數顯著 0.0712%,有效播放次數顯著 0.168%冷啟耗時-120ms,首幀耗時-200ms,啟動後1min幀率 0.1,人均anr -6.7%

作者:阿藍

來源:微信公眾號:西瓜技術團隊

出處
:https://mp.weixin.qq.com/s/v23jEhF9kzRm3TQXijFKvw