所謂熱更新就是在不重新安裝的前提下進(jìn)行代碼和資源的更新,相信在整個(gè)宇宙中還不存在覺(jué)得熱更新不重要的程序猿。
增量熱更新就更牛逼了,只需要把修改過(guò)和新增的代碼和資源推送給用戶下載即可,增量部分的代碼和資源都比較小,所以整個(gè)熱更新流程可以在用戶無(wú)感的情況下完成,我已經(jīng)想不到更好的更新方式可以讓我裝更大的逼了。
一.實(shí)現(xiàn)腳本的熱更新
1.為什么可以熱更新
簡(jiǎn)單地說(shuō),因?yàn)镽N是使用腳本語(yǔ)言來(lái)編寫的,所謂腳本語(yǔ)言就是不需要編譯就可以運(yùn)行的語(yǔ)言,也就是“即讀即運(yùn)行”。我們?cè)凇白x”之前將之替換成新版本的腳本,運(yùn)行時(shí)執(zhí)行的便是新的邏輯了,稍微抽象一下,圖片資源是不是也是“即讀即運(yùn)行”?所以腳本本質(zhì)上和圖片資源一樣,都是可以進(jìn)行熱更新的。
2.RN加載腳本的機(jī)制
要實(shí)現(xiàn)RN的腳本熱更新,我們要搞明白R(shí)N是如何去加載腳本的。
在編寫業(yè)務(wù)邏輯的時(shí)候,我們會(huì)有許多個(gè)js文件,打包的時(shí)候RN會(huì)將這些個(gè)js文件打包成一個(gè)叫index.android.bundle(ios的是index.ios.bundle)的文件,所有的js代碼(包括rn源代碼、第三方庫(kù)、業(yè)務(wù)邏輯的代碼)都在這一個(gè)文件里,啟動(dòng)App時(shí)會(huì)第一時(shí)間加載bundle文件,所以腳本熱更新要做的事情就是替換掉這個(gè)bundle文件。
3.生成bundle文件
我們?cè)赗N項(xiàng)目根目執(zhí)行以下命令來(lái)得到bundle文件和圖片資源:
react-native bundle --entry-file index.android.js --bundle-output ./bundle/index.android.bundle --platform android --assets-dest ./bundle --dev false
其中--entry是入口js文件,android系統(tǒng)就是index.android.js,ios系統(tǒng)就是index.ios.js,--bundle-output就是生成的bundle文件路徑,--platform是平臺(tái),--assets-dest是圖片資源的輸出目錄,這個(gè)在后面的圖片增量更新中會(huì)用到,--dev表示是否是開(kāi)發(fā)版本,打正式版的安裝包時(shí)我們將其賦值為false。
生成的bundle文件體積還是不小的,空項(xiàng)目的話恐怕至少也有900K,所以我們將其打成zip包并放到web服務(wù)器上以供客戶端去下載。
4.下載bundle文件
下載文件可以使用原生語(yǔ)言來(lái)寫,也可以使用js實(shí)現(xiàn),我個(gè)人推薦使用React Native FileTransfer來(lái)實(shí)現(xiàn)下載功能。
實(shí)現(xiàn)方法很簡(jiǎn)單:
import FileTransfer from 'react-native-file-transfer';
let fileTransfer = new FileTransfer();
fileTransfer.onprogress = (progress) => {
console.log(parseInt(progress.loaded * 100 / progress.total))
};
// url:新版本bundle的zip的url地址
// bundlePath:存在新版本bundle的路徑
// unzipJSZipFile:下載完成后執(zhí)行的回調(diào)方法,這里是解壓縮zip
fileTransfer.download(url, bundlePath, unzipJSZipFile, (err) => {
console.log(err);
}, true
);
解壓縮的工作我們可以使用react-native-zip來(lái)完成。
import Zip from 'react-native-zip';
function unzipJSZipFile() {
// zipPath:zip的路徑
// documentPath:解壓到的目錄
Zip.unzip(zipPath, documentPath, (err)=>{
if (err) {
// 解壓失敗
} else {
// 解壓成功,將zip刪除
fs.unlink(zipPath).then(() => {
// 通過(guò)解壓得到的補(bǔ)丁文件生成最新版的jsBundle
});
}
});
}
解壓成功后,我們使用react-native-fs來(lái)將zip刪除。
5.替換bundle文件
安裝包中的bundle文件是在asset目錄下的,而asset目錄我們是沒(méi)有寫權(quán)限的,所以我們不能修改安裝包中的bundle文件。好在RN中提供了修改讀取bundle路徑的方法。以android為例(ios的類似),在ReactActivity類中有這么一個(gè)方法:
/**
* Returns a custom path of the bundle file. This is used in cases the bundle should be loaded
* from a custom path. By default it is loaded from Android assets, from a path specified
* by {@link getBundleAssetName}.
* e.g. "file://sdcard/myapp_cache/index.android.bundle"
*/
protected @Nullable String getJSBundleFile() {
return null;
}
該方法返回了一個(gè)自定義的bundle文件路徑,如果返回默認(rèn)值null,RN會(huì)讀取asset里的bundle。我們?cè)贛ainActivity類中重寫這個(gè)方法,返回可寫目錄一下的bundle文件路徑:
@Override
protected @Nullable String getJSBundleFile() {
String jsBundleFile = getFilesDir().getAbsolutePath() + "/index.android.bundle";
File file = new File(jsBundleFile);
return file != null && file.exists() ? jsBundleFile : null;
}
如果可寫目錄下沒(méi)有bundle文件,還是返回null,RN依然讀取的是asset中的bundle,如果可寫目錄下存在bundle,RN就會(huì)讀取可寫目錄下的bundle文件。
我們將下載好的zip解壓到getFilesDir().getAbsolutePath()目錄下,再次啟動(dòng)App時(shí)便會(huì)讀取該目錄下的bundle文件了,以后再有新版本的bundle文件,依然是下載、解壓并覆蓋掉這個(gè)bundler文件,至此,我們便完成了代碼的熱更新工作。
6.圖片不見(jiàn)了
當(dāng)我們使用可寫目錄下的bundle文件時(shí)會(huì)出現(xiàn)一個(gè)很嚴(yán)重的問(wèn)題:所有的本地圖片資源都無(wú)法顯示了。
我們的圖片資源都是通過(guò)require來(lái)獲取的:
<Image source={require('./imgs/test.png')} />
為了找到圖片消失的原因,我們打開(kāi)image.android.js或者image.ios.js,找到渲染圖片的方法:
render: function() {
var source = resolveAssetSource(this.props.source);
var loadingIndicatorSource = resolveAssetSource(this.props.loadingIndicatorSource);
// ...
}
原來(lái)是通過(guò)resolveAssetSource方法來(lái)獲取資源,那么找到resolveAssetSource方法:
function resolveAssetSource(source: any): ?ResolvedAssetSource {
if (typeof source === 'object') {
return source;
}
var asset = AssetRegistry.getAssetByID(source);
if (asset) {
return assetToImageSource(asset);
}
return null;
}
function assetToImageSource(asset): ResolvedAssetSource {
var devServerURL = getDevServerURL();
return {
__packager_asset: true,
width: asset.width,
height: asset.height,
uri: devServerURL ? getPathOnDevserver(devServerURL, asset) : getPathInArchive(asset),
scale: pickScale(asset.scales, PixelRatio.get()),
};
}
又發(fā)現(xiàn)是通過(guò)getPathInArchive方法來(lái)獲取資源的,那么繼續(xù)找到getPathInArchive方法:
/**
* Returns the path at which the asset can be found in the archive
*/
function getPathInArchive(asset) {
var offlinePath = getOfflinePath();
if (Platform.OS === 'android') {
if (offlinePath) {
// E.g. 'file:///sdcard/AwesomeModule/drawable-mdpi/icon.png'
return 'file://' + offlinePath + getAssetPathInDrawableFolder(asset);
}
// E.g. 'assets_awesomemodule_icon'
// The Android resource system picks the correct scale.
return assetPathUtils.getAndroidResourceIdentifier(asset);
} else {
// E.g. '/assets/AwesomeModule/icon@2x.png'
return offlinePath + getScaledAssetPath(asset);
}
}
該方法的邏輯是如果有離線腳本,那么就從該腳本所在目錄里尋找圖片資源,否則就從asset中讀取圖片資源,所謂離線腳本就是我們剛剛下載并解壓的bundle文件,而我們并沒(méi)有將圖片資源放在這個(gè)目錄下,所以所有的圖片都不見(jiàn)了。
找到原因就好辦了,我們?cè)谑褂胋undle命令生成bundle文件的時(shí)候也將圖片資源輸出出來(lái)了,那打包bundle文件的時(shí)候我們將所有圖片也一并打包進(jìn)zip,客戶端下載zip并解壓縮后,客戶端可寫目錄下也就有了所有的圖片資源,這樣就即實(shí)現(xiàn)了腳本的熱更新又實(shí)現(xiàn)了圖片的熱更新。
二.減小更新包體積
將一個(gè)完整bundle文件和所有圖片都打成zip,zip的體積讓人不敢直視。
1.增量更新圖片
每一次的版本更新我們都將所有圖片裝進(jìn)zip包未免有點(diǎn)太任性了,其實(shí)我們只需要將修改過(guò)和新增的圖片資源放進(jìn)zip就行了。
我們修改一下獲取圖片資源的方法里的邏輯:
/**
* Returns the path at which the asset can be found in the archive
*/
function getPathInArchive(asset) {
var offlinePath = getOfflinePath();
if (Platform.OS === 'android') {
if (offlinePath) {
// 熱更新修改 開(kāi)始
if(global.patchList){
let picName = `${asset.name}.${asset.type}`;
for (let i = 0; i < global.patchList.length; i++) {
if(global.patchList[i].endsWith(picName)){
return 'file://' + offlinePath + getAssetPathInDrawableFolder(asset);
}
}
}
// 熱更新修改 結(jié)束
// E.g. 'file:///sdcard/AwesomeModule/drawable-mdpi/icon.png'
// return 'file://' + offlinePath + getAssetPathInDrawableFolder(asset);
}
// E.g. 'assets_awesomemodule_icon'
// The Android resource system picks the correct scale.
return assetPathUtils.getAndroidResourceIdentifier(asset);
} else {
// E.g. '/assets/AwesomeModule/icon@2x.png'
return offlinePath + getScaledAssetPath(asset);
}
}
其中g(shù)lobal.patchList是一個(gè)數(shù)組,里面放的是自安裝包版本以來(lái)所有修改過(guò)和新增的圖片名,如果訪問(wèn)的圖片名在這個(gè)數(shù)組中就從離線腳本所在目錄里尋找圖片資源,否則還是從asset中尋找圖片資源。
我們?cè)诖虬鼁ip的時(shí)候,就只裝修改過(guò)和新增的圖片,并將這些圖片名記錄在更新配置文件里,客戶端去讀取更新配置文件時(shí)將配置中的圖片名讀取到并生成global.patchList,這樣我們的更新包就小了許多了。
這么做的缺點(diǎn)就是每次更新RN版本的時(shí)候,都需要修改下RN的源碼,不過(guò)我覺(jué)得這點(diǎn)小麻煩還是可以接受的,畢竟已上線的產(chǎn)品,我們還是以穩(wěn)定為主,能不升級(jí)RN就不升級(jí)RN。
2.增量更新腳本
bundle文件的體積,我們也得想想辦法去減少它。
有兩種思路:
分離bundle。bundle里存放了RN源碼、第三方庫(kù)代碼和業(yè)務(wù)邏輯代碼,其中頻繁更新的就只有業(yè)務(wù)邏輯代碼,所以我們將RN源碼和第三方庫(kù)代碼打包成一個(gè)bundle,業(yè)務(wù)邏輯打包成一個(gè)bundle,熱更新的時(shí)候就只更新業(yè)務(wù)邏輯的bundle即可。
打包補(bǔ)丁文件。我們可以使用bsdiff對(duì)比兩個(gè)版本的bundle文件得到差異文件,也就是“補(bǔ)丁”,客戶端下載好補(bǔ)丁文件,將其與本地的bundle進(jìn)行融合從而得到最新版本的bundle文件。
這里重點(diǎn)講解第二個(gè)思路的做法。
- 生成補(bǔ)丁。
我們從bsdiff官網(wǎng)上下載到最新的源碼,然后進(jìn)行編譯就得到可執(zhí)行的二進(jìn)制文件了。
如果是win系統(tǒng),可以直接到我的百度網(wǎng)盤下載,下載密碼:zq1x。解壓下載好的zip,使用命令行進(jìn)入到bsdiff的目錄,輸入命令:
bsdiff a.txt b.txt c.pat
上面的命令就是生成a.txt、b.txt兩個(gè)文件的補(bǔ)丁c.pat。
如果是linux系統(tǒng),可以依次執(zhí)行以下命令:
yum install bzip2-devel
wget http://www.daemonology.net/bsdiff/bsdiff-4.3.tar.gz
tar zxvf bsdiff-4.3.tar.gz
cd bsdiff-4.3
編譯完成后,會(huì)在目錄下生成2個(gè)二進(jìn)制文件:bsdiff、bspatch,這2個(gè)二進(jìn)制文件可以直接使用,不過(guò)推薦拷貝到/usr/local/sbin/下:
cp bsdiff /usr/local/sbin/
cp bspatch /usr/local/sbin/
這樣就可以在命令行中直接使用了:
bsdiff a.txt b.txt c.pat
- 使用補(bǔ)丁。
得到了補(bǔ)丁文件,下一步就會(huì)使用補(bǔ)丁了,拿上面的a.txt、b.txt、c.pat做測(cè)試:
bspatch a.txt d.txt c.pat
得到文件d.txt,將其開(kāi)打看看是否和b.txt一樣,如果一樣,說(shuō)明測(cè)試成功。
- 在RN中使用bsdiff。
待續(xù)。。。
三.制作一鍵熱更新工具
待續(xù)。。。