CodePush優化之減少更新包體積

很早以前,曾寫過關於熱更新的自實現解決方案 《React Native 實現熱部署、差別化增量熱更新》。其中詳細描述瞭如何實現自定義熱更新,以及增量更新。這種方案存在的缺點也是很是明顯的,不夠穩定,兼容性差等。微軟出品的 react-native-code-push 幫助咱們在RN中實現熱部署變得很是容易,幾句簡單的配置代碼,就可讓咱們讚歎不已。圍繞 codepush 熱更新技術,在面試中也就成爲了屢見不鮮。曾經羣裏有個朋友和我聊起熱更新,提到 「 codepush 是差別化增量更新的嗎?」 當時心想, codepush 做爲一個成熟的熱更新技術框架,差別化更新是必須的。其實,codepush 不徹底是增量更新。以前的回答仍是有點誤己誤人了。

分析

在 《CodePush熱更新經常使用命令與注意事項》中簡單介紹過關於codepush的一些經常使用操做及命令。在發佈更新包時,咱們通常會經過以下命令:
javascript

code-push release  <appName> <platform> ./bundle-images文件夾路徑/ -d Production -t 1.1.1 --des "更新描述"複製代碼

或者
java

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

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

假設當前熱更新的版本爲 1.1.0,內容以下:react

資源文件夾/├── drawable-mdpi│   ├── a.png│   └── b.png└── index.iOS.bundle複製代碼

即將要更新的版本爲1.1.1,內容以下:
android

資源文件夾/├── 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 文件下載到本地,並在合適的時機進行加載,渲染。但真相併不是如此!
ios

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

2. 若是以前用戶是1.0.0 版本經過 codepush 升級到 1.1.0 版本,再升級到 1.1.1 版本,這個時候 codepush 就會下載 diff 以後的 JSBundle 和新增的 c.png 圖片兩個文件。複製代碼

因此在第一次作熱更新操做時,是稍微消耗帶寬流量的。
git

RN圖片加載流程

形成這個現象的主要緣由還要從 RN 框架 圖片加載的方式提及,咱們來看看 RN 圖片加載流程的核心代碼,打開 node_modules/react-native/Libraries/Image/AssetSourceResolver.js:
github

/** * 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 目錄下加載圖片。
react-native

假設當前加載的 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/icon@2x.png'
   */
  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

相關文章
相關標籤/搜索