本文屬原創,轉載請注明出處,謝謝!
這篇文章一直拖了快1個多月了,一直都找借口不去完成它。今天終于鐵了心了。開始正題。
做 iOS 開發的都知道,和 Android 開發不同,在送出 App 之後總是要等上至少一個星期的稽核時間(加急稽核除外),而如果在這等待途中發現了什麼 bug,輕的話就等 Apple 稽核完,産品上線後再送出新版本進行等待,嚴重的話可能就隻能撤下 App 重新送出,重新等待了。這個問題很困擾人。之後就有了 WaxPath, JSPath 等支援用 Lua, JavaScript 等語言進行 App 動态更新的第三方庫。另外,微軟實作的一個叫 CodePush 的庫則支援 Cordova 和 React Native 的動态部署更新。本文對這些第三方庫都不進行講解,而是通過自己的方式來實作 iOS 上 App 的動态更新。
我們知道,React Native 支援的語言是 JavaScript,在打包 App 前,需要對 JavaScript 進行打包。預設情況下,是通過下面的代碼進行
RCTRootView
的初始化的:
NSURL *jsCodeLocation;
jsCodeLocation = [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
RCTRootView *rootView = [[RCTRootView alloc] initWithBundleURL:jsCodeLocation
moduleName:@"MyProject"
initialProperties:nil
launchOptions:launchOptions];
這種是直接讀取本地檔案 URL 的方式,而在 Debug 下我們也看到這樣的讀取方式:
如果我們将這個 URL 換成遠端伺服器上的 URL,就可以動态的讀取最新的 JS Bundle 了。但是實際上這種方式是不可行的,因為遠端加載 JS Bundle 是需要時間的,我們總不可能讓使用者在那幹等着吧。于是想到另外的方式,通過進入 App 之後進行檢測,如果有新版本的 JS Bundle 的話,則進行新 Bundle 的下載下傳。而這個又可以通過兩種方式進行處理:
1、 直接告訴使用者,正在下載下傳新的資源包,并通過 loading 界面讓使用者進行等待;
2、 不讓使用者察覺,在後頭進行新版本的下載下傳,使用者下次使用 App 的時候加載新的資源包。
下面我要介紹的是第二種方法。也就是通過背景更新。為了讓使用者每次打開 App 能拿到目前最新的 JS Bundle,我們讓其從 Document 處去讀取 JS Bundle,新版本的 JS Bundle 下載下傳後也同樣存在這個目錄,類似下面代碼:
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];
上面代碼隻是進行了 Bundle 的讀取操作,由于每個 JS 包需要進行版本的控制,是以,我将版本的檢測放到了 JavaScript 裡面,在 index.ios.js 檔案開頭,定義了一個常量
const JSBundleVersion = 1.0; //JS 版本号
,每次疊代新的 JS 版本則讓其加 0.01。而如果向 APP Store 送出新版本,比如送出了 1.1 版本,則相應的将 JSBundleVersion 設定為 1.1,為什麼這樣做我後面再詳細說明。
當檢測到有新的 JS 版本時,則通知 Native 進行 JS 的下載下傳和儲存,當然也可以直接在 JS 上進行下載下傳儲存。如下:
getLatestVersion((err, version)=>{
if (err || !version) {
return;
}
let serverJSVersion = version.jsVersion;
if (serverJSVersion > JSBundleVersion) {
//通知 Native 有新的 JS 版本
NativeNotification.postNotification('HadNewJSBundleVersion');
}
});
Native 接到通知後,負責去下載下傳新的 JS bundle,下載下傳成功後并儲存到指定路徑,使用者下次打開 App 時直接加載即可。
這裡有幾個地方可以優化一下:
1. 當檢測到有新版本時,進一步判斷使用者目前網絡是否是 wifi 網絡,如果是則通知 native 下載下傳,反之不下載下傳。
2. 在 1 的條件下,添加一個網絡改變的監測,因為很多情況下使用者在非 wifi 網絡下打開了 App 但是之後 App 又沒被 kill 掉,這樣就下載下傳不到最新的 bundle 了,是以通過監測網絡的改變,如果網絡變為 wifi 并且有新版本,則下載下傳。于是代碼大概如下:
const JSBundleVersion = ;
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 代碼基本就這些,接下來看看在 native 上需要做哪些操作。
首先,要接收到下載下傳 JS bundle 的通知,當然是要先注冊為觀察者了。
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
//...
[NativeNotificationManager addObserver:self selector:@selector(hadNewJSBundleVersion:) name:@"HadNewJSBundleVersion" object:nil];
//...
}
hadNewJSBundleVersion
方法裡面根據需求下載下傳 JS bundle, 為了能保證下載下傳的包完整,我們可以同時準備一份 JS bundle 的 md5 碼,用于校驗。如下:
- (void)hadNewJSBundleVersion:(NSNotification *)notification {
//根據需求設定下載下傳位址
NSString *version = APP_VERSION;
NSString *base = [@"http://domain/" stringByAppendingString:version];
NSString *uRLStr = [base stringByAppendingString:@"/main.jsbundle"];
NSString *md5URLStr = [base stringByAppendingString:@"/mainMd5.jsbundle"];
//存儲路徑為每次打開 App 要加載 JS 的路徑
NSURL *dstURL = [self URLForCodeInDocumentsDirectory];
[self downloadCodeFrom:uRLStr md5URLString:md5URLStr toURL:dstURL completeHandler:^(BOOL result) {
NSLog(@"finish: %@", @(result));
}];
}
downloadCodeFrom: md5URLString: toURL:completeHandler
方法就指派下載下傳,檢驗和儲存操作。
(注意這句代碼:
NSString *base = [@"http://domain/" stringByAppendingString:version];
,這跟我們遠端伺服器存儲檔案的路徑有關,我會在後面進行說明)。
- (void)downloadCodeFrom:(NSString *)srcURLString
md5URLString:(NSString *)md5URLString
toURL:(NSURL *)dstURL
completeHandler:(CompletionBlock)complete {
//下載下傳MD5資料
[SLNetworkManager sendWithRequestMethor:(RequestMethodGET) URLString:md5URLString parameters:nil error:nil completionHandler:^(NSData *md5Data, NSURLResponse *response, NSError *connectionError) {
if (connectionError && md5Data.length < ) {
return;
}
//下載下傳JS
[SLNetworkManager sendWithRequestMethor:(RequestMethodGET) URLString:srcURLString parameters:nil error:nil completionHandler:^(NSData *data, NSURLResponse *response, NSError *connectionError) {
if (connectionError || data.length < ) {
return;
}
//MD5 校驗
NSString *md5String = [[NSString alloc] initWithData:md5Data encoding:NSUTF8StringEncoding];
if(checkMD5(data, md5String)) {
//校驗成功,寫入檔案
NSError *error = nil;
[data writeToURL:dstURL options:(NSDataWritingAtomic) error:&error];
if (error) {
!complete ?: complete(NO);
//寫入失敗,删除
[SLFileManager deleteFileWithURL:dstURL error:nil];
} else {
!complete ?: complete(YES);
}
}
}];
}];
}
到這裡,檢測更新,下載下傳新 bundle 的操作就算完成了。
下面,來完成檔案讀取并初始化
RCTRootView
的操作。在 AppDelegate 内我們通過調用自定義方法來獲得
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
方法負責處理一些我們需要的邏輯(如:根據是否在Debug模式下,是否在模拟器上等不同狀态初始化不同的rootView),最終傳回一個
RCTRootView
對象。
- (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 部分。上面其實已經貼出一次這段代碼,在這之前我先說下我們存放和讀取 JS 的路徑。首先在 Documents 内建立一個目錄叫 JSBundle,然後根據目前 App 的版本号再建立一個和版本号相同名字的目錄(如:1.0, 1.1),最後路徑大概這樣:…/Documents/JSBundle/1.0/main.jsbundle
下面講解下思路:首先判斷我們的目标路徑是否存在 JS bundle(使用者首次安裝或更新版本後該路徑是不存在 JS 的),如果不存在,則将項目上的 JS bundle 拷貝到該路徑下。可以看到在拷貝之前調用了
resetJSBundlePath
方法,該方法的作用是将這個路徑的其他檔案清除,這樣做的原因是:從舊版本更新到新版本(這裡指的是App釋出的新版本)後,之前舊的 JS bundle 還存在着。為了保險起見,得判斷一下檔案是否拷貝成功了,如果沒成功,則将讀取路徑設定成項目上的 JS bundle 路徑。最後,建立 bridge,建立 rootView 并傳回。
這樣,動态更新的操作就完成了。還有一件事,上面說到的代碼
NSString *base = [@"http://domain/" stringByAppendingString:version];
為什麼要這樣做呢?原因很簡單:為了相容不同版本。舉個例子:你釋出了1.0版本後,下載下傳路徑是 http://domain/1.0/main.jsbundle,過了一段時間你又釋出了1.1 版本, 這時下載下傳路徑是 http://domain/1.1/main.jsbundle,1.1版本中,你可能在 native 上添加了其他檔案,或者是更新了 react-native 的版本,這時,如果讓還是 1.0 版本的使用者下載下傳了 1.1 的 JS bundle,問題就來了,你懂得。這隻是我個人的解決方案,當然,這些其實完全可以放到伺服器端去處理的,伺服器端提供一個接口,我們可以通過傳遞目前 App 的版本号,伺服器判斷是否有新的 JS bundle 後傳回下載下傳路徑,然後前端再進行下載下傳存儲。至于用什麼方法大家覺得哪種友善就用哪種吧。
最後,說下目前我将 JS bundle 遠端存放的伺服器和版本檢測所用的方法。
1. 檔案我存放在了阿裡雲上,它會根據你存放的位置給你生成一個目标URL;
2. 版本檢測我的方法是:在遠端資料庫上建立一個表格,字段分别有
forceUpdate | newestVersion | nativeVersion | JSVersion | platform | message |
---|---|---|---|---|---|
false | 1.0 | 1.0 | 1.0 | iOS | 有新版本提示 |
根據字段名稱基本都能明白了,這裡就不啰嗦了。
說了這麼多,總結一下步驟:
- JS 端檢測是否有新的 JS bundle,有則通知 native 下載下傳
- native 下載下傳完 JS 後進行 md5 的校驗,并存儲
- 每次打開 App 檢測要讀取的路徑是否有 JS
- 有則直接讀取,沒有則進行拷貝
這裡,我寫了個Demo,可供參考,如有任何問題,歡迎大家進行讨論。
本文屬原創,轉載請注明出處,謝謝!