支付寶 App 構建優化解析:經過安裝包重排布優化 Android 端啓動性能

1. 前言

本章節咱們將圍繞《支付寶 App 構建優化解析》另啓新系列,細分拆解客戶端在「代碼管理」、「證書管理」、「版本管理」、「構建打包」等維度的具體實現方案展開討論,帶領你們進一步瞭解支付寶在 App 構建模塊下的持續優化。android

本節將主要記錄經過對支付寶 Android Apk 文件的從新佈局,來改善 IO 性能的過程。算法

2. 背景

支付寶 App 在 Android 平臺上,因爲大量業務快速上線,Android 長尾機型等緣由,形成啓動階段及部分核心鏈路上,性能體驗不理想,進而影響用戶的使用的感覺。 從純業務角度,能夠經過優化 UI 佈局,優化代碼結構,優化 bundle 加載等方式,對性能體驗有所改善。做爲工程技術團隊,按照傳統思惟來看,彷佛沒法對性能優化作多少貢獻。通過一些方案調研後,咱們嘗試經過對編譯產物的優化,干預構建流程,以提高 App 性能。shell

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),就必須從磁盤中讀取數據。cookie

而後內核將讀取的數據緩存到 cache 中,這樣後續的讀請求就能夠命中 cache 了。Page 能夠只緩存一個文件部分的內容,不須要把整個文件都緩存進來。對磁盤的數據進行緩存從而提升性能主要是基於兩個因素:架構

  • 第一,磁盤訪問的速度比內存慢好幾個數量級(毫秒和納秒的差距)。app

  • 第二是被訪問過的數據,有很大機率會被再次訪問。框架

結合 Android 系統實際來看,上層 App 每次讀取磁盤時,文件系統默認會按 16 * 4k block 去磁盤讀取數據,並把數據放到 pagecache 中。若是下次讀取文件已經在 pagecache 中,則不會發生真實的磁盤 IO,而是直接從 pagecache中 讀取,大大提高讀的速度。有緩存就有回收,pagecache 的另外一個重要工做是釋放 page,從而釋放內存空間。Cache 回收的任務是選擇合適的 page 釋放,而且若是 page 是 dirty 的,須要將 page 寫回到磁盤中再釋放。ide

理想的作法是釋放距離下次訪問時間最久的 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 並對外輸出,歡迎你們上手體驗:

tech.antfin.com/docs/2/4954…

關於 Android 端啓動性能優化的設計思路和具體實踐,一樣期待大家的反饋,歡迎一塊兒探討交流。

往期閱讀

《開篇 | 模塊化與解耦式開發在螞蟻金服 mPaaS 深度實踐探討》

《支付寶移動端動態化方案實踐》

《支付寶客戶端架構解析:iOS 容器化框架初探》

《支付寶客戶端架構解析:Android 容器化框架初探》

《支付寶客戶端架構解析:Android 客戶端啓動速度優化之「垃圾回收」》

關注咱們公衆號,得到第一手 mPaaS 技術實踐乾貨

QRCode
相關文章
相關標籤/搜索