本文屬原創(chuàng)蕾殴,轉(zhuǎn)載請(qǐng)注明出處鬼店,謝謝!
這篇文章一直拖了快1個(gè)多月了屁擅,一直都找借口不去完成它犁罩。今天終于鐵了心了齐蔽。開(kāi)始正題。
做 iOS 開(kāi)發(fā)的都知道床估,和 Android 開(kāi)發(fā)不同含滴,在提交 App 之后總是要等上至少一個(gè)星期的審核時(shí)間(加急審核除外),而如果在這等待途中發(fā)現(xiàn)了什么 bug丐巫,輕的話就等 Apple 審核完谈况,產(chǎn)品上線后再提交新版本進(jìn)行等待,嚴(yán)重的話可能就只能撤下 App 重新提交递胧,重新等待了碑韵。這個(gè)問(wèn)題很困擾人。之后就有了 WaxPath缎脾, JSPath 等支持用 Lua祝闻, JavaScript 等語(yǔ)言進(jìn)行 App 動(dòng)態(tài)更新的第三方庫(kù)。另外赊锚,微軟實(shí)現(xiàn)的一個(gè)叫 CodePush 的庫(kù)則支持 Cordova 和 React Native 的動(dòng)態(tài)部署更新治筒。本文對(duì)這些第三方庫(kù)都不進(jìn)行講解,而是通過(guò)自己的方式來(lái)實(shí)現(xiàn) iOS 上 App 的動(dòng)態(tài)更新舷蒲。
我們知道耸袜,React Native 支持的語(yǔ)言是 JavaScript,在打包 App 前牲平,需要對(duì) JavaScript 進(jìn)行打包堤框。默認(rèn)情況下,是通過(guò)下面的代碼進(jìn)行 RCTRootView
的初始化的:
NSURL *jsCodeLocation;
jsCodeLocation = [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
RCTRootView *rootView = [[RCTRootView alloc] initWithBundleURL:jsCodeLocation
moduleName:@"MyProject"
initialProperties:nil
launchOptions:launchOptions];
這種是直接讀取本地文件 URL 的方式,而在 Debug 下我們也看到這樣的讀取方式:
jsCodeLocation = [NSURL URLWithString:@"http://localhost:8081/index.ios.bundle?platform=ios&dev=true"];
如果我們將這個(gè) URL 換成遠(yuǎn)程服務(wù)器上的 URL蜈抓,就可以動(dòng)態(tài)的讀取最新的 JS Bundle 了启绰。但是實(shí)際上這種方式是不可行的,因?yàn)檫h(yuǎn)程加載 JS Bundle 是需要時(shí)間的沟使,我們總不可能讓用戶在那干等著吧委可。于是想到另外的方式,通過(guò)進(jìn)入 App 之后進(jìn)行檢測(cè)腊嗡,如果有新版本的 JS Bundle 的話着倾,則進(jìn)行新 Bundle 的下載。而這個(gè)又可以通過(guò)兩種方式進(jìn)行處理:
1燕少、 直接告訴用戶卡者,正在下載新的資源包,并通過(guò) loading 界面讓用戶進(jìn)行等待客们;
2崇决、 不讓用戶察覺(jué),在后頭進(jìn)行新版本的下載底挫,用戶下次使用 App 的時(shí)候加載新的資源包恒傻。
下面我要介紹的是第二種方法。也就是通過(guò)后臺(tái)更新建邓。為了讓用戶每次打開(kāi) App 能拿到當(dāng)前最新的 JS Bundle碌冶,我們讓其從 Document 處去讀取 JS Bundle,新版本的 JS Bundle 下載后也同樣存在這個(gè)目錄涝缝,類(lèi)似下面代碼:
NSURL *jsCodeLocation;
jsCodeLocation = [self URLForCodeInDocumentsDirectory];
if (![self hasCodeInDocumentsDirectory]) {
//從 Document 上讀取 JS Bundle
BOOL copyResult = [self copyBundleFileToURL:jsCodeLocation];
if (!copyResult) {
//拷貝失敗扑庞,從 main Bundle 上讀取
jsCodeLocation = [self URLForCodeInBundle];
}
}
RCTBridge *bridge = [self createBridgeWithBundleURL:jsCodeLocation];
rootView = [self createRootViewWithBridge:bridge];
上面代碼只是進(jìn)行了 Bundle 的讀取操作,由于每個(gè) JS 包需要進(jìn)行版本的控制拒逮,所以罐氨,我將版本的檢測(cè)放到了 JavaScript 里面,在 index.ios.js 文件開(kāi)頭滩援,定義了一個(gè)常量const JSBundleVersion = 1.0; //JS 版本號(hào)
栅隐,每次迭代新的 JS 版本則讓其加 0.01。而如果向 APP Store 提交新版本玩徊,比如提交了 1.1 版本租悄,則相應(yīng)的將 JSBundleVersion 設(shè)置為 1.1,為什么這樣做我后面再詳細(xì)說(shuō)明恩袱。
當(dāng)檢測(cè)到有新的 JS 版本時(shí)泣棋,則通知 Native 進(jìn)行 JS 的下載和保存,當(dāng)然也可以直接在 JS 上進(jìn)行下載保存畔塔。如下:
getLatestVersion((err, version)=>{
if (err || !version) {
return;
}
let serverJSVersion = version.jsVersion;
if (serverJSVersion > JSBundleVersion) {
//通知 Native 有新的 JS 版本
NativeNotification.postNotification('HadNewJSBundleVersion');
}
});
Native 接到通知后潭辈,負(fù)責(zé)去下載新的 JS bundle鸯屿,下載成功后并保存到指定路徑,用戶下次打開(kāi) App 時(shí)直接加載即可把敢。
這里有幾個(gè)地方可以優(yōu)化一下:
- 當(dāng)檢測(cè)到有新版本時(shí)寄摆,進(jìn)一步判斷用戶當(dāng)前網(wǎng)絡(luò)是否是 wifi 網(wǎng)絡(luò),如果是則通知 native 下載修赞,反之不下載婶恼。
- 在 1 的條件下,添加一個(gè)網(wǎng)絡(luò)改變的監(jiān)測(cè)柏副,因?yàn)楹芏嗲闆r下用戶在非 wifi 網(wǎng)絡(luò)下打開(kāi)了 App 但是之后 App 又沒(méi)被 kill 掉熙尉,這樣就下載不到最新的 bundle 了,所以通過(guò)監(jiān)測(cè)網(wǎng)絡(luò)的改變搓扯,如果網(wǎng)絡(luò)變?yōu)?wifi 并且有新版本,則下載包归。于是代碼大概如下:
const JSBundleVersion = 1.0;
let hadDownloadJSBundle = true;
//.....
componentDidMount() {
NetInfo.addEventListener('change', (reachability) => {
if (reachability == 'wifi' && hadDownloadJSBundle == false) {
hadDownloadJSBundle = true;
NativeNotification.postNotification('HadNewJSBundleVersion');
}
});
this._checkUpdate();
}
_checkUpdate() {
getLatestVersion((err, version)=>{
if (err || !version) {
return;
}
let serverJSVersion = version.jsVersion;
if (serverJSVersion > JSBundleVersion) {
//通知 Native 有新的 JS 版本
isWifi((wifi) => {
if (wifi) {
hadDownloadJSBundle = true;
NativeNotification.postNotification('HadNewJSBundleVersion');
} else {
hadDownloadJSBundle = false;
}
});
}
});
}
JS 代碼基本就這些锨推,接下來(lái)看看在 native 上需要做哪些操作。
首先公壤,要接收到下載 JS bundle 的通知换可,當(dāng)然是要先注冊(cè)為觀察者了。
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
//...
[NativeNotificationManager addObserver:self selector:@selector(hadNewJSBundleVersion:) name:@"HadNewJSBundleVersion" object:nil];
//...
}
hadNewJSBundleVersion
方法里面根據(jù)需求下載 JS bundle厦幅, 為了能保證下載的包完整沾鳄,我們可以同時(shí)準(zhǔn)備一份 JS bundle 的 md5 碼,用于校驗(yàn)确憨。如下:
- (void)hadNewJSBundleVersion:(NSNotification *)notification {
//根據(jù)需求設(shè)置下載地址
NSString *version = APP_VERSION;
NSString *base = [@"http://domain/" stringByAppendingString:version];
NSString *uRLStr = [base stringByAppendingString:@"/main.jsbundle"];
NSString *md5URLStr = [base stringByAppendingString:@"/mainMd5.jsbundle"];
//存儲(chǔ)路徑為每次打開(kāi) App 要加載 JS 的路徑
NSURL *dstURL = [self URLForCodeInDocumentsDirectory];
[self downloadCodeFrom:uRLStr md5URLString:md5URLStr toURL:dstURL completeHandler:^(BOOL result) {
NSLog(@"finish: %@", @(result));
}];
}
downloadCodeFrom: md5URLString: toURL:completeHandler
方法就賦值下載译荞,檢驗(yàn)和保存操作。
(注意這句代碼:
NSString *base = [@"http://domain/" stringByAppendingString:version];
休弃,這跟我們遠(yuǎn)程服務(wù)器存儲(chǔ)文件的路徑有關(guān)吞歼,我會(huì)在后面進(jìn)行說(shuō)明)。
- (void)downloadCodeFrom:(NSString *)srcURLString
md5URLString:(NSString *)md5URLString
toURL:(NSURL *)dstURL
completeHandler:(CompletionBlock)complete {
//下載MD5數(shù)據(jù)
[SLNetworkManager sendWithRequestMethor:(RequestMethodGET) URLString:md5URLString parameters:nil error:nil completionHandler:^(NSData *md5Data, NSURLResponse *response, NSError *connectionError) {
if (connectionError && md5Data.length < 32) {
return;
}
//下載JS
[SLNetworkManager sendWithRequestMethor:(RequestMethodGET) URLString:srcURLString parameters:nil error:nil completionHandler:^(NSData *data, NSURLResponse *response, NSError *connectionError) {
if (connectionError || data.length < 10000) {
return;
}
//MD5 校驗(yàn)
NSString *md5String = [[NSString alloc] initWithData:md5Data encoding:NSUTF8StringEncoding];
if(checkMD5(data, md5String)) {
//校驗(yàn)成功塔猾,寫(xiě)入文件
NSError *error = nil;
[data writeToURL:dstURL options:(NSDataWritingAtomic) error:&error];
if (error) {
!complete ?: complete(NO);
//寫(xiě)入失敗篙骡,刪除
[SLFileManager deleteFileWithURL:dstURL error:nil];
} else {
!complete ?: complete(YES);
}
}
}];
}];
}
到這里,檢測(cè)更新丈甸,下載新 bundle 的操作就算完成了糯俗。
下面,來(lái)完成文件讀取并初始化 RCTRootView
的操作睦擂。在 AppDelegate 內(nèi)我們通過(guò)調(diào)用自定義方法來(lái)獲得 RCTRootView
得湘,如下:
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
RCTRootView *rootView = [self getRootViewModuleName:@"DynamicUpdateDemo" launchOptions:launchOptions];
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
UIViewController *rootViewController = [UIViewController new];
rootViewController.view = rootView;
self.window.rootViewController = rootViewController;
[self.window makeKeyAndVisible];
return YES;
}
getRootViewModuleName:launchOptions
方法負(fù)責(zé)處理一些我們需要的邏輯(如:根據(jù)是否在Debug模式下,是否在模擬器上等不同狀態(tài)初始化不同的rootView)顿仇,最終返回一個(gè) RCTRootView
對(duì)象忽刽。
- (RCTRootView *)getRootViewModuleName:(NSString *)moduleName
launchOptions:(NSDictionary *)launchOptions {
NSURL *jsCodeLocation = nil;
RCTRootView *rootView = nil;
#if DEBUG
#if TARGET_OS_SIMULATOR
//debug simulator
jsCodeLocation = [NSURL URLWithString:@"http://localhost:8081/index.ios.bundle?platform=ios&dev=true"];
#else
//debug device
NSString *serverIP = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"SERVER_IP"];
NSString *jsCodeUrlString = [NSString stringWithFormat:@"http://%@:8081/index.ios.bundle?platform=ios&dev=true", serverIP];
NSString *jsBundleUrlString = [jsCodeUrlString stringByAddingPercentEscapesUsingEncoding:NSUTF8StringEncoding];
jsCodeLocation = [NSURL URLWithString:jsBundleUrlString];
#endif
rootView = [self createRootViewWithURL:jsCodeLocation moduleName:moduleName launchOptions:launchOptions];
#else
//production
jsCodeLocation = [self URLForCodeInDocumentsDirectory];
if (![self hasCodeInDocumentsDirectory]) {
[self resetJSBundlePath];
BOOL copyResult = [self copyBundleFileToURL:jsCodeLocation];
if (!copyResult) {
jsCodeLocation = [self URLForCodeInBundle];
}
}
RCTBridge *bridge = [self createBridgeWithBundleURL:jsCodeLocation];
rootView = [self createRootViewWithModuleName:moduleName bridge:bridge];
#endif
#if 0 && DEBUG
jsCodeLocation = [self URLForCodeInDocumentsDirectory];
if (![self hasCodeInDocumentsDirectory]) {
[self resetJSBundlePath];
BOOL copyResult = [self copyBundleFileToURL:jsCodeLocation];
if (!copyResult) {
jsCodeLocation = [self URLForCodeInBundle];
}
}
RCTBridge *bridge = [self createBridgeWithBundleURL:jsCodeLocation];
rootView = [self createRootViewWithModuleName:moduleName bridge:bridge];
#endif
return rootView;
}
這里天揖,我們主要看 production 部分。上面其實(shí)已經(jīng)貼出一次這段代碼跪帝,在這之前我先說(shuō)下我們存放和讀取 JS 的路徑今膊。首先在 Documents 內(nèi)創(chuàng)建一個(gè)目錄叫 JSBundle,然后根據(jù)當(dāng)前 App 的版本號(hào)再創(chuàng)建一個(gè)和版本號(hào)相同名字的目錄(如:1.0伞剑, 1.1)斑唬,最后路徑大概這樣:.../Documents/JSBundle/1.0/main.jsbundle
下面講解下思路:首先判斷我們的目標(biāo)路徑是否存在 JS bundle(用戶首次安裝或更新版本后該路徑是不存在 JS 的),如果不存在黎泣,則將項(xiàng)目上的 JS bundle 拷貝到該路徑下恕刘。可以看到在拷貝之前調(diào)用了 resetJSBundlePath
方法抒倚,該方法的作用是將這個(gè)路徑的其他文件清除褐着,這樣做的原因是:從舊版本更新到新版本(這里指的是App發(fā)布的新版本)后,之前舊的 JS bundle 還存在著托呕。為了保險(xiǎn)起見(jiàn)含蓉,得判斷一下文件是否拷貝成功了,如果沒(méi)成功项郊,則將讀取路徑設(shè)置成項(xiàng)目上的 JS bundle 路徑馅扣。最后,創(chuàng)建 bridge着降,創(chuàng)建 rootView 并返回差油。
這樣,動(dòng)態(tài)更新的操作就完成了任洞。還有一件事蓄喇,上面說(shuō)到的代碼
NSString *base = [@"http://domain/" stringByAppendingString:version];
為什么要這樣做呢?原因很簡(jiǎn)單:為了兼容不同版本交掏。舉個(gè)例子:你發(fā)布了1.0版本后公罕,下載路徑是 http://domain/1.0/main.jsbundle,過(guò)了一段時(shí)間你又發(fā)布了1.1 版本耀销, 這時(shí)下載路徑是 http://domain/1.1/main.jsbundle楼眷,1.1版本中,你可能在 native 上添加了其他文件熊尉,或者是更新了 react-native 的版本罐柳,這時(shí),如果讓還是 1.0 版本的用戶下載了 1.1 的 JS bundle狰住,問(wèn)題就來(lái)了张吉,你懂得。這只是我個(gè)人的解決方案催植,當(dāng)然肮蛹,這些其實(shí)完全可以放到服務(wù)器端去處理的勺择,服務(wù)器端提供一個(gè)接口,我們可以通過(guò)傳遞當(dāng)前 App 的版本號(hào)伦忠,服務(wù)器判斷是否有新的 JS bundle 后返回下載路徑省核,然后前端再進(jìn)行下載存儲(chǔ)。至于用什么方法大家覺(jué)得哪種方便就用哪種吧昆码。
最后气忠,說(shuō)下目前我將 JS bundle 遠(yuǎn)程存放的服務(wù)器和版本檢測(cè)所用的方法。
- 文件我存放在了阿里云上赋咽,它會(huì)根據(jù)你存放的位置給你生成一個(gè)目標(biāo)URL旧噪;
- 版本檢測(cè)我的方法是:在遠(yuǎn)程數(shù)據(jù)庫(kù)上創(chuàng)建一個(gè)表格,字段分別有
forceUpdate | newestVersion | nativeVersion | JSVersion | platform | message |
---|---|---|---|---|---|
false | 1.0 | 1.0 | 1.0 | iOS | 有新版本提示 |
根據(jù)字段名稱基本都能明白了脓匿,這里就不啰嗦了淘钟。
說(shuō)了這么多,總結(jié)一下步驟:
- JS 端檢測(cè)是否有新的 JS bundle陪毡,有則通知 native 下載
- native 下載完 JS 后進(jìn)行 md5 的校驗(yàn)米母,并存儲(chǔ)
- 每次打開(kāi) App 檢測(cè)要讀取的路徑是否有 JS
- 有則直接讀取,沒(méi)有則進(jìn)行拷貝
這里缤骨,我寫(xiě)了個(gè)Demo,可供參考尺借,如有任何問(wèn)題绊起,歡迎大家進(jìn)行討論。
本文屬原創(chuàng)燎斩,轉(zhuǎn)載請(qǐng)注明出處虱歪,謝謝!
想獲得第一手精彩文章栅表,歡迎關(guān)注我的微信公眾號(hào):"iOS和Android干貨"