通過(guò)React Native動(dòng)態(tài)更新iOS應(yīng)用

本文屬原創(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)化一下:

  1. 當(dāng)檢測(cè)到有新版本時(shí)寄摆,進(jìn)一步判斷用戶當(dāng)前網(wǎng)絡(luò)是否是 wifi 網(wǎng)絡(luò),如果是則通知 native 下載修赞,反之不下載婶恼。
  2. 在 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è)所用的方法。

  1. 文件我存放在了阿里云上赋咽,它會(huì)根據(jù)你存放的位置給你生成一個(gè)目標(biāo)URL旧噪;
  2. 版本檢測(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干貨"

掃一掃發(fā)現(xiàn)更多精彩文章
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末笋鄙,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子怪瓶,更是在濱河造成了極大的恐慌萧落,老刑警劉巖,帶你破解...
    沈念sama閱讀 207,113評(píng)論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件洗贰,死亡現(xiàn)場(chǎng)離奇詭異找岖,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī)敛滋,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,644評(píng)論 2 381
  • 文/潘曉璐 我一進(jìn)店門(mén)许布,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人绎晃,你說(shuō)我怎么就攤上這事蜜唾≡忧” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 153,340評(píng)論 0 344
  • 文/不壞的土叔 我叫張陵袁余,是天一觀的道長(zhǎng)擎勘。 經(jīng)常有香客問(wèn)我,道長(zhǎng)泌霍,這世上最難降的妖魔是什么货抄? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 55,449評(píng)論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮朱转,結(jié)果婚禮上蟹地,老公的妹妹穿的比我還像新娘。我一直安慰自己藤为,他們只是感情好怪与,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,445評(píng)論 5 374
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著缅疟,像睡著了一般分别。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上存淫,一...
    開(kāi)封第一講書(shū)人閱讀 49,166評(píng)論 1 284
  • 那天耘斩,我揣著相機(jī)與錄音,去河邊找鬼桅咆。 笑死括授,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的岩饼。 我是一名探鬼主播荚虚,決...
    沈念sama閱讀 38,442評(píng)論 3 401
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼籍茧!你這毒婦竟也來(lái)了版述?” 一聲冷哼從身側(cè)響起,我...
    開(kāi)封第一講書(shū)人閱讀 37,105評(píng)論 0 261
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤寞冯,失蹤者是張志新(化名)和其女友劉穎渴析,沒(méi)想到半個(gè)月后,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體吮龄,經(jīng)...
    沈念sama閱讀 43,601評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡檬某,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,066評(píng)論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了螟蝙。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片恢恼。...
    茶點(diǎn)故事閱讀 38,161評(píng)論 1 334
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖胰默,靈堂內(nèi)的尸體忽然破棺而出场斑,到底是詐尸還是另有隱情漓踢,我是刑警寧澤,帶...
    沈念sama閱讀 33,792評(píng)論 4 323
  • 正文 年R本政府宣布漏隐,位于F島的核電站喧半,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏青责。R本人自食惡果不足惜挺据,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,351評(píng)論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望脖隶。 院中可真熱鬧扁耐,春花似錦、人聲如沸产阱。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 30,352評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)构蹬。三九已至王暗,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間庄敛,已是汗流浹背俗壹。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 31,584評(píng)論 1 261
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留藻烤,地道東北人绷雏。 一個(gè)月前我還...
    沈念sama閱讀 45,618評(píng)論 2 355
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像隐绵,于是被迫代替她去往敵國(guó)和親之众。 傳聞我的和親對(duì)象是個(gè)殘疾皇子拙毫,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,916評(píng)論 2 344

推薦閱讀更多精彩內(nèi)容