经过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];
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

这种是直接读取本地文件 URL 的方式,而在 Debug 下咱们也看到这样的读取方式:react

jsCodeLocation = [NSURL URLWithString:@"http://localhost:8081/index.ios.bundle?platform=ios&dev=true"];
  • 1
  • 1

若是咱们将这个 URL 换成远程服务器上的 URL,就能够动态的读取最新的 JS Bundle 了。可是实际上这种方式是不可行的,由于远程加载 JS Bundle 是须要时间的,咱们总不可能让用户在那干等着吧。因而想到另外的方式,经过进入 App 以后进行检测,若是有新版本的 JS Bundle 的话,则进行新 Bundle 的下载。而这个又能够经过两种方式进行处理: 
一、 直接告诉用户,正在下载新的资源包,并经过 loading 界面让用户进行等待; 
二、 不让用户察觉,在后头进行新版本的下载,用户下次使用 App 的时候加载新的资源包。 
下面我要介绍的是第二种方法。也就是经过后台更新。为了让用户每次打开 App 能拿到当前最新的 JS Bundle,咱们让其从 Document 处去读取 JS Bundle,新版本的 JS Bundle 下载后也一样存在这个目录,相似下面代码:ios

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];
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

上面代码只是进行了 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 上进行下载保存。以下:git

getLatestVersion((err, version)=>{
  if (err || !version) {
    return;
  }
  let serverJSVersion = version.jsVersion;
  if (serverJSVersion > JSBundleVersion) {
    //通知 Native 有新的 JS 版本
    NativeNotification.postNotification('HadNewJSBundleVersion');
  }
});
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

Native 接到通知后,负责去下载新的 JS bundle,下载成功后并保存到指定路径,用户下次打开 App 时直接加载便可。 
这里有几个地方能够优化一下: 
1. 当检测到有新版本时,进一步判断用户当前网络是不是 wifi 网络,若是是则通知 native 下载,反之不下载。 
2. 在 1 的条件下,添加一个网络改变的监测,由于不少状况下用户在非 wifi 网络下打开了 App 可是以后 App 又没被 kill 掉,这样就下载不到最新的 bundle 了,因此经过监测网络的改变,若是网络变为 wifi 而且有新版本,则下载。因而代码大概以下:github

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;
          }
        });
      }
    });
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32

JS 代码基本就这些,接下来看看在 native 上须要作哪些操做。 
首先,要接收到下载 JS bundle 的通知,固然是要先注册为观察者了。数据库

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
  //...
  [NativeNotificationManager addObserver:self selector:@selector(hadNewJSBundleVersion:) name:@"HadNewJSBundleVersion" object:nil];
  //...
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 1
  • 2
  • 3
  • 4
  • 5

hadNewJSBundleVersion 方法里面根据需求下载 JS bundle, 为了能保证下载的包完整,咱们能够同时准备一份 JS bundle 的 md5 码,用于校验。以下:react-native

- (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));
  }];
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

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 < 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 校验
      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);
        }
      }
    }];
  }];
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33

到这里,检测更新,下载新 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;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

getRootViewModuleName:launchOptions方法负责处理一些咱们须要的逻辑(如:根据是否在Debug模式下,是否在模拟器上等不一样状态初始化不一样的rootView),最终返回一个 RCTRootView 对象。app

- (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;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47

这里,咱们主要看 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,可供参考,若有任何问题,欢迎你们进行讨论。

 

原文:http://blog.csdn.net/linshaolie/article/details/50961955

相关文章
相关标签/搜索