React Native拆包及熱更新方案
隨著 React Native 的不斷發展完善,越來越多的公司選擇使用 React Native 替代 iOS/Android 進行部分業務線的開發,也有不少使用 Hybrid 技術的公司轉向了 React Native 。要說 React Native 最能吸引開發者的地方那就是其擁有前端的開發速度以及原生的體驗。
1、概述
今天要跟大家探討的是 React Native 的拆包及熱更新方案,官方并沒有很好的支持這一企業十分看中的熱更新能力,因此也催生了第三方的熱更新方案,如 CodePush 、 react-native-pushy 。由于公司內部有不同的業務線,所以在采用第三方的熱更新方案靈活度不夠,在調研的初期,我們參考了攜程的提到的 jsbundle 拆分和加載優化方案 ,但這個方案需要改變 React Native 的打包代碼及 Runtime 代碼,實施難度上非常大,暫無精力深入研究,但這個方案對加載速度提升也是顯而易見的。我們暫時放棄了攜程的方案,我們前期需要一套相對簡單穩定且可行度高的方案,在經過調研及討論后定下了這樣一套熱更方案,今天我們就來聊聊這個方案。
2、流程梳理
整體流程圖其實非常簡單,不過內部一些細節規則需要仔細推敲。
3、熱更新模塊的實現方案
當下選擇使用 React Native 的項目大都是基于原有項目的基礎上進行接入,所以要達到上線的項目的狀態自然要各方面都準備就緒,熱更新就作為基建工程之一。
2.1 jsbundle 的拆分
對 React Native 的代碼打包編譯后會生成一個 bundle 文件,這里要說明一下, jsbundle 的拆分是基于生成的 bundle 文件可以看成兩部分構成(如下圖):一是 React Native 包含的的基礎類庫,一是開發的業務代碼。
了解了這一點,我們就可以基于此將完整的 bundle 文件進行拆分:
首先需要做的就是生成 common.bundle ,新建一個 blank.android.js 文件,在文件中僅引入 react 及 react native :
import React from 'react';
import {} from 'react-native';
通過打包命令編譯成 common.bundle :
react-native bundle --entry-file blank.android.js --bundle-output ~/Desktop/common.bundle --platform android --dev false
其次,打包完整的 jsbundle ,這將會包含所有的基礎類庫及業務代碼。提醒一句保持 import 的公共模塊一致:
import React from 'react';
import { AppRegistry } from 'react-native';
//其他導入
...
最后根據 diff 算法將兩個文件進行 diff 拆分,由此會生成一個 index.diff 的二進制文件。如有多個業務代碼,相應的生成多個 diff 文件即可。
記得有幾篇文章中推薦 google-diff-match-patch ,雖然 Google 這個開源版本包含多種語言的實現,但由于是基于純文本的 diff 所以在當下這個場景下并不十分合適,我還是推薦大家使用基于二進制的 diff ,再此也推薦另一種 java 版本的 bsdiff 的實現: jbdiff 。
2.2 bundle 文件的拷貝及合成
在完成拆分以后,我們需要將 common.bundle 及拆分的 *.diff 文件進行 zip 壓縮,放入 assets 目錄下,為了方便版本管理,我們將其文件名中寫入版本號 jsbundle_<版本號>.zip ,例如: jsbundle_1.zip ,每次改 zip 文件包跟隨發版時更新,并自動升級版本號。
接下來我們要做的就是將內置于 assets 目錄下的 jsbundle_*.zip 拷貝至內部存儲,這里推薦使用應用內部存儲。
在拷貝過程中根據歷史記錄的版本號,進行判斷是否需要執行拷貝,拷貝完成后將 common.bundle 及 *.diff 文件進行 patch 合并,合并后的文件即為一個完整的 bundle 文件,文件名規定為 *.diff.bundle ,例如: index.diff.bundle ,在加載時根據模塊名進行加載即可。
2.3 diff 文件的更新
說到熱更新,反而在關于 *.diff 文件的更新本身并沒有什么復雜度,簡單來說就是下載替換 *.diff 文件,并合成新的完整 bundle 文件,其他需要注意的則是關于 diff 文件版本的控制。
其他主要工作量在于 diff 文件的生成及上傳,這部分是我們編寫 shell 腳本自動完成的,以下摘錄部分 packer.sh 的打包代碼。
if [ $platform == "android" ]; then
react-native bundle \
--entry-file $commonFile.js \
--bundle-output $androidModuleDir/common.bundle \
--platform android \
--dev false
echo "common.bundle packed!!!"
react-native bundle \
--entry-file $module.js \
--bundle-output $androidModuleDir/$module.android.bundle \
--platform android \
--dev false
echo "$module.android.bundle packed!!!"
# 對 jbdiff 打成的 jar 執行文件
chmod +x dmp.jar
echo "diff start =========>>>"
java -jar ./dmp.jar $androidModuleDir/common.bundle \
$androidModuleDir/$module.android.bundle $androidModuleDir/$module.diff
# 進行二次 zip 壓縮
zip -j $androidModuleDir/$module.diff.zip $androidModuleDir/$module.diff
elfi ...
2.4 對于容器 Activity 的改造
由于對于 React Native 的 bundle 文件加載做了更改,我們就不能直接使用 sdk 提供的 ReactActivity 了,對此我們需要對容器 Activity 進行改造。
而改造的最終落腳點其實是 ReactInstanceManager 的構建,由于我們需要按業務模塊加載,所以最終將其進行了部分改造:
public class MyReactNativeHost extends ReactNativeHost{
...
protected MyReactNativeHost(Application application, String moduleName) {
super(application);
mApplication = application;
mModuleName = moduleName;
}
...
@Override
protected ReactInstanceManager createReactInstanceManager() {
if(getUseDeveloperSupport()){ //為了保留 debug 的能力
return super.createReactInstanceManager();
}
String path = JSBundleManager.getJSBundleDirPath(mApplication)
.concat(mModuleName).concat(".diff.bundle");
ReactInstanceManager.Builder builder = ReactInstanceManager.builder()
.setApplication(mApplication)
.setJSBundleLoader(JSBundleLoader.createFileLoader(path))
.setUseDeveloperSupport(false)
.setInitialLifecycleState(LifecycleState.BEFORE_RESUME);
...
return builder.build();
}
...
}
將改造后的 Activity 容器也要接入原有項目的路由框架(如果項目本身有的話),至此,整個更新加載就可以串起來了。
4、熱更新改造的后遺癥
由于采用加載文件系統下的 bundle 文件的形式,在測試過程中發現通過此形式加載的 bundle 文件,圖片加載時不能讀取到 res 目錄下的資源文件,帶著這個問題看了相關的 js 源碼,發現了一個有意思的地方:
...
class AssetSourceResolver {
isLoadedFromFileSystem(): boolean {
return !!this.bundlePath;
}
defaultAsset(): ResolvedAssetSource {
if (this.isLoadedFromServer()) { //如果是從服務器下發的bundle,資源從服務器讀取,對應debug模式
return this.assetServerURL();
}
if (Platform.OS === 'android') { //在android平臺
return this.isLoadedFromFileSystem() ?
this.drawableFolderInBundle() ://如果是從文件系統讀取的bundle則從文件系統取資源
this.resourceIdentifierWithoutScale();//否則從res讀取資源
} else {
return this.scaledAssetPathInBundle();
}
}
...
resourceIdentifierWithoutScale(): ResolvedAssetSource {
invariant(Platform.OS === 'android', 'resource identifiers work on Android');
return this.fromSource(assetPathUtils.getAndroidResourceIdentifier(this.asset));
}
drawableFolderInBundle(): ResolvedAssetSource {
const path = this.bundlePath || '';
return this.fromSource(
'file://' + path + getAssetPathInDrawableFolder(this.asset)
);
}
}
看到這里就明白了,源碼中對資源的加載保持了跟 bundle 文件同源。要解決這個問題有兩個方案:1、將 js 源碼中的邏輯進行修改,都從 res 中讀取資源;2、將 React Native 使用到的資源打包到本地,跟隨 jsbundle_*.zip 發布。我個人比較傾向于第二個方案,我主要考慮兩點:一是后續 React Native 版本升級的成本,一是對于 React Native 的資源單獨管理,同時也意外的獲得了一個 React Native 資源熱更的能力。
最后,吐槽下 React Native 的一個坑,目前最新的 0.41 版的 Android 端的占位圖通過 <Image /> 的 loadingIndicatorSource 屬性來指定無效,15 年的一個 issues #5017 到現在沒有修復,實在匪夷所思,感覺我是用了假的 RN !
來自:http://solart.cc/2017/02/22/react-native-jsbundle_patch/