1. 程式人生 > >CodePush優化之減小更新包體積

CodePush優化之減小更新包體積

 

還有 10 天就要迎來 2019 新年,感慨 18 年過的好快,恍恍惚惚。2018 年經歷了很多,人生最重要的事情,很開心。閒餘時間瀏覽了這一年寫過的部落格,9 篇相對 17 年少了很多。時間不等人,什麼事還是要提前計劃往前做。本來是要等新年再和大家分享新的內容,回頭看看還是以整數結尾這一年,也算欣慰。

這篇部落格內容是18年的最後一篇,也是關於 React Native 的最後一篇。從17年開RN專欄,到現在整整30篇。瀏覽訪問量證實了大家對RN的認可,更多的還是對於跨平臺技術的接受與熱愛。懷著對RN以及跨平臺技術的喜愛,今年年初在微信建立react-native技術交流群,短短的時間內就由稀疏零散變成了400多人的大家庭,這不僅僅是一個討論組,更多的是大家技術生活的一部分。未來,共同進步。

很早之前,曾寫過關於熱更新的自實現解決方案 《React Native 實現熱部署、差異化增量熱更新》。其中詳細描述瞭如何實現自定義熱更新,以及增量更新。這種方案存在的缺點也是非常明顯的,不夠穩定,相容性差等。微軟出品的 react-native-code-push 幫助我們在RN中實現熱部署變得非常容易,幾句簡單的配置程式碼,就可以讓我們讚歎不已。圍繞 codepush 熱更新技術,在面試中也就成為了家常便飯。曾經群裡有個朋友和我聊起熱更新,提到 “ codepush 是差異化增量更新的嗎?”  當時心想, codepush 作為一個成熟的熱更新技術框架,差異化更新是必須的。其實,codepush 不完全是增量更新。之前的回答還是有點誤己誤人了。

分析

在 《CodePush熱更新常用命令與注意事項》中簡單介紹過關於codepush的一些常用操作及命令。在釋出更新包時,我們一般會通過如下命令:

code-push release  <appName> <platform> ./bundle-images資料夾路徑/ -d Production -t 1.1.1 --des "更新描述"

或者

code-push release-react <appName> <platform> -t 1.1.1 -d Production --des "更新描述" -m true (強制更新)

兩種方式的區別在於第二條命令會自動打包生成bundle檔案和圖片資源。

假設當前熱更新的版本為 1.1.0,內容如下:

資原始檔夾/
├── drawable-mdpi
│   ├── a.png
│   └── b.png
└── index.iOS.bundle

即將要更新的版本為1.1.1,內容如下:

資原始檔夾/
├── drawable-mdpi
│   ├── a.png
│   ├── b.png
│   └── c.png // 新增圖片 c.png
└── index.iOS.bundle

如果當前 App 處於 1.1.0版本,並且在之前沒有做過任何codepush相關的更新操作。從 1.1.0 更新到 1.1.1 時,按照我們所理解的 codepush 差異化更新的特點,在App檢測到 1.1.1 的更新後,會將 c.png 圖片和結構發生變化的 jsbundle 檔案下載到本地,並在合適的時機進行載入,渲染。但真相併非如此!

1. 在 codepush 系統檢測到有新版本,第一次做熱更新載入時,會將所有的資源全部下載到本地。

2. 如果之前使用者是1.0.0 版本通過 codepush 升級到 1.1.0 版本,再升級到 1.1.1 版本,這個時候 codepush 就會下載 diff 之後的 JSBundle 和新增的 c.png 圖片兩個檔案。

所以在第一次做熱更新操作時,是稍微消耗頻寬流量的。

RN圖片載入流程

造成這個現象的主要原因還要從 RN 框架 圖片載入的方式說起,我們來看看 RN 圖片載入流程的核心程式碼,開啟 node_modules/react-native/Libraries/Image/AssetSourceResolver.js:

  /**
   * jsBundle 檔案是否從packager服務載入
   */
  isLoadedFromServer(): boolean {
    return !!this.serverUrl;
  }

  /**
   * jsBundle 檔案是否從本地檔案目錄載入
   */
  isLoadedFromFileSystem(): boolean {
    return !!(this.jsbundleUrl && this.jsbundleUrl.startsWith('file://'));
  }

  /**
   * 圖片載入
   */
  defaultAsset(): ResolvedAssetSource {
    if (this.isLoadedFromServer()) {
      return this.assetServerURL();
    }

    if (Platform.OS === 'android') {
      return this.isLoadedFromFileSystem()
        ? this.drawableFolderInBundle()
        : this.resourceIdentifierWithoutScale();
    } else {
      return this.scaledAssetURLNearBundle();
    }
  }

  

在 defaultAsset() 方法中,首先判斷JSBundle檔案是否是從 packager 服務載入(除錯模式),如果是會直接本地服務載入。

接著對 Android、iOS 兩個不同不同處理:

(1)Android 平臺下,判斷是否是從手機本地目錄下載入,如果是,則呼叫 drawableFolderInBundle() 方法;反之呼叫 resourceIdentifierWithoutScale() 方法。

(2)iOS 平臺下直接呼叫 scaledAssetURLNearBundle() 方法。

我們首先 Android 平臺下的  drawableFolderInBundle()、resourceIdentifierWithoutScale() 兩個方法:

  /**
   * 如果jsbundle從本地檔案目錄下載入執行,則會解析相對於其位置的資產
   * E.g. 'file:///sdcard/AwesomeModule/drawable-mdpi/icon.png'
   */
  drawableFolderInBundle(): ResolvedAssetSource {
    const path = this.jsbundleUrl || 'file://';
    return this.fromSource(path + getAssetPathInDrawableFolder(this.asset));
  }

  /**
   * 與應用捆綁在一起的資產的預設位置,按資源識別符號定位
   * Android資源系統選擇正確的比例
   * E.g. 'assets_awesomemodule_icon'
   */
  resourceIdentifierWithoutScale(): ResolvedAssetSource {
    invariant(
      Platform.OS === 'android',
      'resource identifiers work on Android',
    );
    return this.fromSource(
      assetPathUtils.getAndroidResourceIdentifier(this.asset),
    );
  }

從上述原始碼我們不難發現:

(1)如果 JSBundle 檔案是從本地檔案目錄(File)載入,例如(/sdcard/com.songlcy.myapp/...)之類的目錄,不是從 assets 資源目錄載入的情況下,會從相對該目錄下的 drawable-xxx 目錄下載入圖片。

假設當前載入的 JSBundle 的檔案路徑是 /sdcard/com.songlcy.myapp/code-push/index.iOS.jsbundle,會從 /sdcard/com.songlcy.myapp/code-push/ 目錄下查詢圖片。

(2)如果是從 assets 目錄中載入的 JSBundle 檔案,這個時候就會從apk包中的 drawable-xxx 目錄中載入圖片。

iOS 平臺下並沒有做任何區分,直接呼叫了 scaledAssetURLNearBundle() 方法:

  /**
   * 直接從 JSBundle 當前目錄下查詢 assets 目錄
   * E.g. 'file:///sdcard/bundle/assets/AwesomeModule/[email protected]'
   */
  scaledAssetURLNearBundle(): ResolvedAssetSource {
    const path = this.jsbundleUrl || 'file://';
    return this.fromSource(path + getScaledAssetPath(this.asset));
  }

分析完圖片的整個載入流程,我們再回到 codepush 更新。我們都知道,在當前APP檢測到有更新時,codepush 會將伺服器上的JSBundle 及 圖片資源下載到手機本地目錄,所以 JSBundle 檔案是從手機系統檔案目錄載入,根據RN圖片載入流程,更新的圖片資源也需要放到 JSBundle 所在目錄下。因此在 codepush 第一次更新的時候,需要把所有資源全部下載下來,否則會出現找不到資源的錯誤,載入失敗。同樣這樣做也是為了方便統一管理,在第二次更新時, codepush 就會做一次 diff-patch,通過比對來實現差異化增量更新。

優化方案

瞭解了當前 codepush 在第一次更新時所帶來的更新流量開銷,那麼我們如何優化第一次更新的包體積,使其也可以做到差異化增量更新呢?通過上面的分析,我們可以修改RN圖片載入流程,通過 assets 和 本地目錄結合,在更新後,判斷當前JSBundle所在的本地目錄下是否存在更新之前的資源,如果存在直接載入,不存在,則從apk包中的 drawable-xxx 目錄中載入。此時,我們就不用上傳所有的圖片資源,只需要上傳更新的資源即可。

修改RN圖片載入流程,我們可以直接在原始碼中進行修改,也可以使用 hook 的方式進行修改,保證了專案與node_modules耦合度降低,核心程式碼如下:

import { NativeModules } from 'react-native';    
import AssetSourceResolver from "react-native/Libraries/Image/AssetSourceResolver";
import _ from 'lodash';

let iOSRelateMainBundlePath = '';
let _sourceCodeScriptURL = '';

// ios 平臺下獲取 jsbundle 預設路徑
const defaultMainBundePath = AssetsLoad.DefaultMainBundlePath;

function getSourceCodeScriptURL() {
    if (_sourceCodeScriptURL) {
        return _sourceCodeScriptURL;
    }
    // 呼叫Native module獲取 JSbundle 路徑
    // RN允許開發者在Native端自定義JS的載入路徑,在JS端可以呼叫SourceCode.scriptURL來獲取 
    // 如果開發者未指定JSbundle的路徑,則在離線環境下返回asset目錄
    let sourceCode =
        global.nativeExtensions && global.nativeExtensions.SourceCode;
    if (!sourceCode) {
        sourceCode = NativeModules && NativeModules.SourceCode;
    }
    _sourceCodeScriptURL = sourceCode.scriptURL;
    return _sourceCodeScriptURL;
}

// 獲取bundle目錄下所有drawable 圖片資源路徑
let drawablePathInfos = [];
AssetsLoad.searchDrawableFile(getSourceCodeScriptURL(),
     (retArray)=>{
      drawablePathInfos = drawablePathInfos.concat(retArray);
});
// hook defaultAsset方法,自定義圖片載入方式
AssetSourceResolver.prototype.defaultAsset = _.wrap(AssetSourceResolver.prototype.defaultAsset, function (func, ...args) {
     if (this.isLoadedFromServer()) {
         return this.assetServerURL();
     }
     if (Platform.OS === 'android') {
         if(this.isLoadedFromFileSystem()) {
             // 獲取圖片資源路徑
             let resolvedAssetSource = this.drawableFolderInBundle();
             let resPath = resolvedAssetSource.uri;
             // 獲取JSBundle檔案所在目錄下的所有drawable檔案路徑,並判斷當前圖片路徑是否存在
             // 如果存在,直接返回
             if(drawablePathInfos.includes(resPath)) {
                 return resolvedAssetSource;
             }
             // 判斷圖片資源是否存在本地檔案目錄
             let isFileExist = AssetsLoad.isFileExist(resPath);
             // 存在直接返回
             if(isFileExist) {
                 return resolvedAssetSource;
             } else {
                 // 不存在,則根據資源 Id 從apk包下的drawable目錄載入
                 return this.resourceIdentifierWithoutScale();
             }
         } else {
             // 則根據資源 Id 從apk包下的drawable目錄載入
             return this.resourceIdentifierWithoutScale();
         }

     } else {
         let iOSAsset = this.scaledAssetURLNearBundle();
         let isFileExist =  AssetsLoad.isFileExist(iOSAsset.uri);
         isFileExist = false;
         if(isFileExist) {
             return iOSAsset;
         } else {
             let oriJsBundleUrl = 'file://'+ defaultMainBundePath +'/' + iOSRelateMainBundlePath;
             iOSAsset.uri = iOSAsset.uri.replace(this.jsbundleUrl, oriJsBundleUrl);
             return iOSAsset;
         }
     }
});

實現邏輯其實很簡單:

(1)通過 hook 的方式重新定義 defaultAsset() 方法。

(2)如果是從手機系統檔案目錄載入JSBundle檔案:

         1. 獲取當前圖片資原始檔路徑,判斷當前JSBundle目錄下是否存在。如果存在,則直接返回當前資源。

         2. 判斷手機本地檔案目錄下是否存在該圖片資源,如果存在,則直接返回當前資源。不存在,則從 apk 包中根據資源 Id 來載入圖片資源。

(3)不是從手機系統檔案目錄載入JSBundle檔案,則直接從 apk 包中根據資源 Id 來載入圖片資源。

經過以上流程在 codepush 第一次更新時,實現資源的差異化增量更新。詳細程式碼可以檢視 react-native-code-push-assets