天天看點

通過安裝包重排布優化 Android 端啟動性能

1. 前言

本章節我們将圍繞《支付寶 App 建構優化解析》另啟新系列,細分拆解用戶端在“代碼管理”、“證書管理”、“版本管理”、“建構打包”等次元的具體實作方案展開讨論,帶領大家進一步了解支付寶在 App 構模組化塊下的持續優化。

本節将主要記錄通過對支付寶 Android Apk 檔案的重新布局,來改善 IO 性能的過程。

2. 背景

支付寶 App 在 Android 平台上,由于大量業務快速上線,Android 長尾機型等原因,造成啟動階段及部分核心鍊路上,性能體驗不理想,進而影響使用者的使用的感受。

從純業務角度,可以通過優化 UI 布局,優化代碼結構,優化 bundle 加載等方式,對性能體驗有所改善。作為工程技術團隊,按照傳統思維來看,似乎無法對性能優化做多少貢獻。經過一些方案調研後,我們嘗試通過對編譯産物的優化,幹預建構流程,以提升 App 性能。

3. 原理

布局前後,Apk 中實際的檔案并沒有本質改變,隻有位置發生了變化。那麼為什麼這樣的調整會有性能造成影響?這個原理要追溯到 Linux 的檔案系統機制。

如下圖所示,Linux 底層檔案系統中 VFS 上次 App 程序之間,存在一層 pagecache,pagecache 由記憶體中的實體 page 組成,其内容對應磁盤上的 block。Pagecache 的大小是動态變化的,可以擴大,也可以在記憶體不足時縮小。Cache 緩存的儲存設備被稱為後備存儲(backing store),一個 page 通常包含多個 block,這些 block 不一定是連續的。

通過安裝包重排布優化 Android 端啟動性能

當核心發起一個讀請求時(例如程序發起 read() 請求),首先會檢查請求的資料是否緩存到了 pagecache 中。如果有,那麼直接從記憶體中讀取,不需要通路磁盤,這被稱為 cache命中(cache hit)。如果 cache 中沒有請求的資料,即 cache 未命中(cache miss),就必須從磁盤中讀取資料。

然後核心将讀取的資料緩存到 cache 中,這樣後續的讀請求就可以命中 cache 了。Page 可以隻緩存一個檔案部分的内容,不需要把整個檔案都緩存進來。對磁盤的資料進行緩存進而提高性能主要是基于兩個因素:

 ●  第一,磁盤通路的速度比記憶體慢好幾個數量級(毫秒和納秒的差距)。

 ●  第二是被通路過的資料,有很大機率會被再次通路。

結合 Android 系統實際來看,上層 App 每次讀取磁盤時,檔案系統預設會按 16 * 4k block 去磁盤讀取資料,并把資料放到 pagecache 中。如果下次讀取檔案已經在 pagecache 中,則不會發生真實的磁盤 IO,而是直接從 pagecache中 讀取,大大提升讀的速度。有緩存就有回收,pagecache 的另一個重要工作是釋放 page,進而釋放記憶體空間。Cache 回收的任務是選擇合适的 page 釋放,并且如果 page 是 dirty 的,需要将 page 寫回到磁盤中再釋放。

理想的做法是釋放距離下次通路時間最久的 page,但是很明顯,這是不現實的。基于 LRU改進的 Two-List 是 Linux 使用的政策。這個回收政策非常類似業務開發領域,常見的圖檔加載的緩存政策。LRU 算法是選擇最近一次通路時間最靠前的 page,即幹掉最近沒被光顧過的 page。原始 LRU 算法存在的問題是,有些檔案隻會被通路一次,但是按照 LRU 的算法,即使這些檔案以後再也不會被通路了,但是如果它們是剛剛被通路的,就不會被選中。

Two-List 政策維護了兩個list,active list 和 inactive list。在 active list 上的 page 被認為是 hot 的,不能釋放。隻有 inactive list 上的 page 可以被釋放的。首次緩存的資料的 page 會被加入到 inactive list 中,已經在 inactive list 中的 page 如果再次被通路,就會移入 active list 中。兩個連結清單都使用了僞 LRU 算法維護,新的 page 從尾部加入,移除時從頭部移除,就像隊列一樣。

如果 active list 中 page 的數量遠大于 inactive list,那麼 active list 頭部的頁面會被移入 inactive list 中,進而維持兩個表的平衡。簡單的說,通過檔案重布局的目的,就是将啟動階段需要用到的檔案在 APK 檔案中排布在一起,盡可能的利用 pagecache 機制,用最少的磁盤 IO 次數,讀取盡可能多的啟動階段需要的檔案,減少 IO 開銷,進而達到提升啟動性能的目的。

4. 落地方案

在了解原理之後,就需要考慮怎麼用工程化的方案在支付寶 App 上落地,主要從以下三個流程來設計方案并落地。

 ●  度量:

重布局的前提必須是精确的度量,定位到那些可以調整,需要調整的檔案。這個過程需要足夠的準确,否則會導緻重布局之後的效果不佳。

度量的最終目的是要,統計到支付寶啟動階段,哪些檔案加載了,并且是發生真實的磁盤IO,還是命中了 pagecache 緩存。我們提供了一個度量工具,通過修改 kernel 源碼,dump 出檔案系統的 IO 行為,在特定的 Android ROM 上打個更新檔,用來統計啟動時刻檔案行為。部分資料如下:

通過安裝包重排布優化 Android 端啟動性能

資料中,第一列的資料表示發生 IO 行為的檔案,第二清單示該檔案中此偏移量對應的部分發生了 IO 行為。

第一清單示發生 IO 的位置,如果為 0,則表示發生了真實的磁盤 IO;如果為 1,則表示從pagecache 緩存中讀取了内容。

通過資料可以發現,Apk 中部分檔案,實際上是發生了磁盤 IO,可以嘗試将啟動階段, Apk 中所用到的檔案排布到一起,期望通過少量的 IO,就将所有的檔案全部讀到。之後的工作,需要通過解析 zip 包結構,将上述結果中,檔案偏移量對應到詳細的檔案名。首先需要得到安裝包中的檔案排布情況,可以通過類似 010 Editor 的工具得到,為了工程化的考慮,也可以參考 zip 格式定義通過腳本分析 zip 檔案實作。

通過安裝包重排布優化 Android 端啟動性能

然後通過解析結果和先前的統計結果對應分析,就能找到 zip 中哪些檔案,在啟動階段被讀到,為重布局提供資料支撐。

 ●  重布局:

在得到一個啟動階段的檔案清單後,第二步工作,就是根據這個檔案清單,在建構打包階段,在 Apk 中把這部分檔案排布在一起。這裡需要修改 7z 壓縮工具的源碼。支付寶建構流程,為了提升壓縮效率,減少包大小,使用 7z 工具進行最後壓縮出 Apk 的過程。這裡在簡單闡述下,重排布的原因,無論是那種壓縮工具,zip 中檔案順序是檔案系統的預設順序,即按照阿拉伯數字和字母順序。如果想指定檔案排在一起,必然要打破這種規則。

修改 7z 源碼的過程,簡單思路如下,擴充一個指令行參數,我們使用了上箭頭'^'(表意性強,提前的意思),可以傳入 list.txt,然後 7z 執行輸出檔案流時候,按照 list 中的檔案順序,改變最後的輸出順序,進而達到重排布的目的。例如如下指令,就是将 source 目錄中,所有檔案壓縮,并且把 list 中指定檔案排布在 zip 包的開始位置。

7z a -tzip archive.zip source* ^list.txt

通過這種方式,就實作了檔案重排布的簡單過程,當然在支付寶的建構流程中,較為複雜,中間還涉及到重打包,重簽名等一系列流程。後續内容會提到。

這裡有一個小插曲,在剛開始調整檔案順序時,我們通過測量發現效果并不好。後來發現了原因,原先我們調整的檔案清單,隻是度量階段發現,所有發生磁盤 IO 的檔案,把他們排布到一起,錯誤的認為,隻要他們調整了,整體 IO 情況就會改善。可是忽略了“此消彼長”的問題,如果隻調整這些檔案,那麼原先排布在這些檔案後面,利用預讀機制進緩存 cache 的檔案,如果在啟動階段用到,可能會發生新的磁盤 IO。正确的調整方式,應該能精确按時間順序統計啟動階段的所有檔案,排布在一起,這樣發生少量 IO,就能全部讀到 cache 中。

簡單看下某一次實驗主 Apk 中檔案調整前後的效果如下,幾個和配置相關的移到檔案頭部。

調整前

通過安裝包重排布優化 Android 端啟動性能

調整後

通過安裝包重排布優化 Android 端啟動性能

 ●  回歸測試:

按照是以計劃将檔案全部調整完畢後,就到了驗證效果的環節。主要有以下幾種驗證方式和思路:

 ●  線下錄屏,然後拆解視訊幀,測直覺的啟動時間。

 ●  線下使用工具度量 IO 情況,觀察啟動階段磁盤 IO 數量是否減少,量化一個“cache miss 率”的概念。

 ●  線下通過埋點的方案,通過腳本,多次模拟冷啟動,取平均值測量,消除可能誤差,觀察趨勢。

 ●  線上灰階在其他優化和代碼類似情況下,隻通過調整 IO,比較兩個版本的啟動時間變化。 在重布局方案實驗階段,使用一二兩種方案較多,後續工程化落地和常态化優化時,應采用三四種方案。

5. 演進

通過上述落地方案,線上下以及某些線上灰階版本中完成初步實驗後,我們考慮工程化,常态化的進行這件事情。在工程化之前,先對度量流程進行了擴充,探索出了一種較為簡單的度量手段。

 ●  度量優化:

原先的度量方案,具備較深的技術含量,在這個方案中,需要對 Linux 底層檔案系統非要熟悉和了解,并且還需具備修改源碼的能力,此方案是由其他資深專家指導下實作,短期内,團隊暫時無法獨立這個方案。

為了讓整體方案可控,我們想到了直接在 Android 源碼的資源加載流程中記錄日志,然後通過日志直接分析,這樣啟動階段檔案加載一目了然,當然缺陷也很明顯,無法通過判斷檔案讀取是通過磁盤 IO 還是 pagecache 緩存。

幹預資源加載記錄,要不通過 hook 方式,要不就是直接改 framework,刷個 ROM,考慮到工程化自動化測試的因素,采用了修改 framework 的方式,友善後續有測試平台,直接使用特定手機跑腳本執行即可。

以 Android 7.0 版本為例,主要修改 drawable 相關流程和 xml 相關流程。其他版本如果做測試度量機型的化,修改方式類似。

 ●  xml 加載流程修改,在解析 xml 檔案流程,直接打日志。

/**

* Loads an XML parser for the specified file.

*

* @param file the path for the XML file to parse

* @param id the resource identifier for the file

* @param assetCookie the asset cookie for the file

* @param type the type of resource (used for logging)

* @return a parser for the specified XML file

* @throws NotFoundException if the file could not be loaded

*/

@NonNull

XmlResourceParser loadXmlResourceParser(@NonNull String file, @AnyRes int id, int assetCookie,

@NonNull String type)

throws NotFoundException {

if (id != 0) {

try {

synchronized (mCachedXmlBlocks) {

if (!getResourcePackageName(id).equalsIgnoreCase("android")) {

Log.i("AlipayRes", "ResourceId: " + Integer.toHexString(id) + " ResourcePackage name: " + getResourcePackageName(id) + " Loading xml: " + file);

}

final int[] cachedXmlBlockCookies = mCachedXmlBlockCookies;

final String[] cachedXmlBlockFiles = mCachedXmlBlockFiles;

final XmlBlock[] cachedXmlBlocks = mCachedXmlBlocks;

// First see if this block is in our cache.

final int num = cachedXmlBlockFiles.length;

for (int i = 0; i < num; i++) {

if (cachedXmlBlockCookies[i] == assetCookie && cachedXmlBlockFiles[i] != null

&& cachedXmlBlockFiles[i].equals(file)) {

return cachedXmlBlocks[i].newParser();

}

}

……

……

}

  • drawable 修改

/**

* Loads a drawable from XML or resources stream.

*/

private Drawable loadDrawableForCookie(Resources wrapper, TypedValue value, int id,

Resources.Theme theme) {

if (value.string == null) {

throw new NotFoundException("Resource \"" + getResourceName(id) + "\" ("

+ Integer.toHexString(id) + ") is not a Drawable (color or path): " + value);

}

final String file = value.string.toString();

if (TRACE_FOR_MISS_PRELOAD) {

// Log only framework resources

if ((id >>> 24) == 0x1) {

final String name = getResourceName(id);

if (name != null) {

Log.d(TAG, "Loading framework drawable #" + Integer.toHexString(id)

+ ": " + name + " at " + file);

}

}

}

if (DEBUG_LOAD) {

Log.v(TAG, "Loading drawable for cookie " + value.assetCookie + ": " + file);

}

if (!getResourcePackageName(id).equalsIgnoreCase("android")) {

Log.i("AlipayRes", "ResourceId: " + Integer.toHexString(id) + " ResourcePackage name: " + getResourcePackageName(id) + " Loading drawable: " + file);

}

……

……

}

刷入 ROM,替換修改後 framework 後,冷啟動支付寶,清楚緩存,通過日志過濾即可得到完整啟動檔案加載清單。

adb shell am force-stop com.eg.android.AlipayGphone

adb shell

echo 1 > /proc/sys/vm/drop_caches

通過安裝包重排布優化 Android 端啟動性能

 ●  工程化:

是以單點能力都基本具備單點能力都具備後,需要找到一個能盡可能自動化的方案。具體流程圖如下。

後續對于 ReApk (優化Apk)流程,可以擴充其他的建構建構産物優化方案。

通過安裝包重排布優化 Android 端啟動性能

6. 結果與展望

目前整體方案,已上線支付寶錢包 Android App,該單項,啟動性能,在整體全量使用者下有 5% 左右的優化效果,低端機上效果較明顯,根據不同機型,能有10%左右的啟動性能優化效果。

Facebook 的工具鍊優化方案 Redex,對于 dex 的優化,從度量到回歸測試,開源出了一整套解決方案,對于 zip 的重布局,希望未來能将此整套方案,做到盡可能的“開箱即用”,賦能公司内外更多的 App。

原文釋出時間為:2018-11-29

本文作者:瑞涵

本文來自雲栖社群合作夥伴“

安卓巴士Android開發者門戶

”,了解相關資訊可以關注“

”。

繼續閱讀