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 不一定是連續的。
當核心發起一個讀請求時(例如程式發起 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 上打個補丁,用來統計啟動時刻檔案行為。部分資料如下:
資料中,第一列的資料表示發生 IO 行為的檔案,第二列表示該檔案中此偏移量對應的部分發生了 IO 行為。
第一列表示發生 IO 的位置,如果為 0,則表示發生了真實的磁碟 IO;如果為 1,則表示從pagecache 快取中讀取了內容。
通過資料可以發現,Apk 中部分檔案,實際上是發生了磁碟 IO,可以嘗試將啟動階段, Apk 中所用到的檔案排布到一起,期望通過少量的 IO,就將所有的檔案全部讀到。之後的工作,需要通過解析 zip 包結構,將上述結果中,檔案偏移量對應到詳細的檔名。首先需要得到安裝包中的檔案排布情況,可以通過類似 010 Editor 的工具得到,為了工程化的考慮,也可以參考 zip 格式定義通過指令碼分析 zip 檔案實現。
然後通過解析結果和先前的統計結果對應分析,就能找到 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 中檔案調整前後的效果如下,幾個和配置相關的移到檔案頭部。
調整前
調整後
- 迴歸測試:
按照所以計劃將檔案全部調整完畢後,就到了驗證效果的環節。主要有以下幾種驗證方式和思路:
-
線下錄屏,然後拆解視訊幀,測直觀的啟動時間。
-
線下使用工具度量 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
複製程式碼
- 工程化:
所以單點能力都基本具備單點能力都具備後,需要找到一個能儘可能自動化的方案。具體流程圖如下。 後續對於 ReApk (優化Apk)流程,可以擴充套件其他的構建構建產物優化方案。
6. 結果與展望
目前整體方案,已上線支付寶錢包 Android App,該單項,啟動效能,在整體全量使用者下有 5% 左右的優化效果,低端機上效果較明顯,根據不同機型,能有10%左右的啟動效能優化效果。
Facebook 的工具鏈優化方案 Redex,對於 dex 的優化,從度量到迴歸測試,開源出了一整套解決方案,對於 zip 的重佈局,希望未來能將此整套方案,做到儘可能的“開箱即用”,賦能公司內外更多的 App。
7. 小結
通過本節內容,我們初步瞭解了支付寶在 Android 客戶端如何通過安裝包重排布來優化 IO 效能。由於篇幅限制,很多技術要點我們無法一一展開。而相應的技術核心,我們同樣應用在了 mPaaS 並對外輸出,歡迎大家上手體驗:
關於 Android 端啟動效能優化的設計思路和具體實踐,同樣期待你們的反饋,歡迎一起探討交流。
往期閱讀