天天看點

通過React Native動态更新iOS應用

本文屬原創,轉載請注明出處,謝謝!

這篇文章一直拖了快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,可供參考,如有任何問題,歡迎大家進行讨論。

本文屬原創,轉載請注明出處,謝謝!