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