通过React Native动态更新iOS应用

前端之家收集整理的这篇文章主要介绍了通过React Native动态更新iOS应用前端之家小编觉得挺不错的,现在分享给大家,也给大家做个参考。

本文属原创,转载请注明出处,谢谢!
这篇文章一直拖了快1个多月了,一直都找借口不去完成它。今天终于铁了心了。开始正题。
iOS开发的都知道,和Android开发不同,在提交 App 之后总是要等上至少一个星期的审核时间(加急审核除外),而如果在这等待途中发现了什么 bug,轻的话就等 Apple 审核完,产品上线后再提交新版本进行等待,严重的话可能就只能撤下 App 重新提交,重新等待了。这个问题很困扰人。之后就有了 WaxPath, JSPath 等支持用 Lua,JavaScript等语言进行 App 动态更新的第三方库。另外,微软实现的一个叫 CodePush 的库则支持 Cordova 和React Native的动态部署更新。本文对这些第三方库都不进行讲解,而是通过自己的方式来实现 iOS 上 App 的动态更新。
我们知道,ReactNative 支持的语言是 JavaScript,在打包 App 前,需要对 JavaScript 进行打包。默认情况下,是通过下面的代码进行RCTRootView的初始化的:

 
 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
NSURL *jsCodeLocation; jsCodeLocation = [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]; RCTRootView *rootView = [[RCTRootView alloc] initWithBundleURL:jsCodeLocation moduleName:@"MyProject" initialProperties:nil launchOptions:launchOptions];

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

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

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

      
      
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    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 上进行下载保存。如下:

      
      
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    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 并且有新版本,则下载。于是代码大概如下:

      
      
  • 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
    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//通知 Native 有新的 JS 版本 isWifi((wifi) => { if (wifi) { hadDownloadJSBundle = 'HadNewJSBundleVersion'); } else { hadDownloadJSBundle = false; } }); } }); }

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

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

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

      
      
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • - (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];,这跟我们远程服务器存储文件的路径有关,我会在后面进行说明)。

      
      
  • 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
    - (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 completionHandler:^(NSData *data,102); Box-sizing: border-Box;">NSError *connectionError) { if (connectionError || data10000) { 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,如下:

      
      
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    - (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; .window.rootViewController = rootViewController; [.window makeKeyAndVisible]; return YES; }

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

      
      
  • 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
    - (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]; //production jsCodeLocation = [self hasCodeInDocumentsDirectory]) { [self resetJSBundlePath]; if (!copyResult) { jsCodeLocation = [self createRootViewWithModuleName:moduleName bridge:bridge]; #endif #if 0 && DEBUG jsCodeLocation = [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 iOS 有新版本提示

    根据字段名称基本都能明白了,这里就不啰嗦了。

    说了这么多,总结一下步骤:
    - JS 端检测是否有新的 JS bundle,有则通知 native 下载
    - native 下载完 JS 后进行 md5 的校验,并存储
    - 每次打开 App 检测要读取的路径是否有 JS
    - 有则直接读取,没有则进行拷贝

    这里,我写了个Demo,可供参考,如有任何问题,欢迎大家进行讨论。

    本文属原创,转载请注明出处,谢谢!

    猜你在找的React相关文章