rccoder / blog

😛 个人博客 🤐 订阅是 watch 是 watch 是 watch 是 watch
585 stars 36 forks source link

零客户端开发经验 React Native 热更新 CodePush 打包集成指北 #27

Open rccoder opened 7 years ago

rccoder commented 7 years ago

本文永久地址:github.com/rccoder/blog/issues/27,其他平台可能不是最新文章。文章评论等也希望去原文进行。

一、背景

我的毕业设计是用 React Native 写一款校园 APP,服务端采用 egg + MongoDB。

选用 React Native 一来是想借助他更加的学习巩固 React、Redux 生态系统;二来是做成 APP 而不是网站会在老师面前显得不是那么的 Low,同时借助双平台为忽悠填一份色彩;三来是 React Native 确实在性能上是优于 H5,不需要 XX 内核(如 UC、QQ等)来抹平杂乱机型的性能与兼容问题,同时还能和 H5 一样保持热更新。

为什么不用 Weex?

曾经做过简单的 Weex 开发,不使用 Weex 有以下几点:一是定题目的时候(没错,题目中就有 React Native) Weex 的上层 DSL 还只有 Vue,当时尚未出现 Rax;二来是相比于 React Native,Weex 的生态与社区还比较年轻,害怕自己跳进去爬不出来导致毕业延期。

如果感兴趣的话,代码在这里:

二、热更新原理

2.1、引言

React Native 的原理大致是上层写 React 式的代码,然后利用相关的 loader 打包成 bundle 和相关的静态文件,然后利用 Android、iOS 里的 SDK 解析 bundle,然后以 Native 的方式执行。

在大型 APP 中,针对 H5 中的静态文件会设置离线包,以达到秒出加速的目的,当然离线包的增大会导致 APP 体积的增大。针对一些场景(比如营销页面等),并不希望离线加载,这样的场景就不使用离线包进行加载。

离线包也需要进行更新,这里就需要一套比较完善的更新机制来保证(大公司自己造,小公司找有没有开源的)。

2.2、正文

热更新指的就是离线包(React Native 中的 bundle)更新的这个机制。我们可以在 APP 运行的过程中 “偷偷” 下载 bundle,然后在下次 APP 开启的时候(或者某种自定义的时机,比如:弹窗提示、直接重启等)使用新的 bundle。

三、CodePush 介绍

如何去维护这样一个比较完善的更新机制呢?Microsoft 给出了一个很好的答案 —— CodePush。幸运地,他还没被“墙“,我们可以直接的使用他的服务。

CodePush 集成了微软的一个云服务器,他相当于是一个中心发布器,APP 可以询问他是否有新的 bundle 更新,然后进行下载等操作。

CodePush 官方提供了 React Native 的集成方案,可以比较容易的集成到原有代码中(当然,也存在一些坑或者其他的地方,这也是本来出现的目的之一)。

四、实施流程

1、CodePush 服务配置

CodePush 的官网在

1.1、安装 CodePush CLI

npm install -g code-push-cli

1.2、注册 CodePush

code-push register

会自动打开浏览器弹出注册界面,注册完成之后会得到一个 token,复制后填写在 Terminal 里面即可。

1.3、注册应用

code-push app add WeHIT-Android
code-push app add WeHIT-iOS

每个 APP 都会得到 ProductionProduction 状态的两个 Key:

为什么要注册两个 APP

CodePush 在发布新 bundle 的时候目前只能一个一个平台发,为了保证你的 history 看起来不是那么的乱,建议直接分成两个 APP 处理

2、SDK 集成

SDK 的集成包含三部分,分别是 JS 源码、iOS 客户端、Android 客户端。

SDK 直接用 CodePush 官方发布的 [react-native-code-push]() 即可。

值得注意是,react-native-code-push 最近发布的 2.0-beta 版本,而改版本只支持 React Native 0.43 及其以上的版本中。如果是 0.43 以下版本的需要使用 1.17.x(最新 1.17.4-beta)。

我这里使用的 RN 版本是 0.40,所以安装 1.17.4-beta 版本的 react-native-code-push。

yarn add react-native-code-push@1.17.4-beta

在进行下面的三个之前,我们先用 React Native 使用的 rnpm(React Native Package Manage) link 以下这个包 (自动改变 ios 和 android 目录下的一些文件)。

npm install rnpm -g
rnpm link react-native-code-push

期间会提示你输入 iOS 和 Android 端的 key,这里输入你用 CodePush 注册 APP 时产生的 key,这里我们先可以都输入每个 Staging 的 key。

如果不幸你忘记了之前产生的 key,可以输入以下的命令查看:

code-push deployment ls WeHIT-Android

2.1、React Native 源码集成

在 React Native 源码中,我们需要引入 react-native-code-push,然后用 CodePush 包裹一下最外层的组件。

没有 CodePush 之前我们的代码这样写:

// index.ios[android].js
...
import { AppRegistry } from 'react-native';
import AppContainer from './src'
AppRegistry.registerComponent('WeHIT', () => AppContainer);

// src/index.js

export default function AppContainer () {
  return (
    <Navigator
      initialRoute={routeMap.home}
      configureScene={configureScene}
      renderScene={renderScene} />   
  )

现在我们只需要改动一下 src/index.js,用 CodePush 包裹以下这个组件即可:

// src/index.js

import codePush from "react-native-code-push";

class App extends Component{

  componentDidMount() {}

  render() {
    return (
      <Navigator
        initialRoute={routeMap.intro}
        configureScene={configureScene}
        renderScene={renderScene} />
    )
  }
}

/**
 * Configured with a MANUAL check frequency for easy testing. For production apps, it is recommended to configure a
 * different check frequency, such as ON_APP_START, for a 'hands-off' approach where CodePush.sync() does not
 * need to be explicitly called. All options of CodePush.sync() are also available in this decorator.
 */
let codePushOptions = { checkFrequency: codePush.CheckFrequency.ON_APP_RESUME };

const AppContainer = codePush(codePushOptions)(App);

export default AppContainer;

这里 checkFrequency 定义了何时进行 bundle 更新,这里 ON_APP_RESUME 是指在 APP 重新打开的时候进行更新替换。更加详细的情况可以参加 react-native-code-push 的 example 进行编写。目前这样仅仅是够用。

为什么这样说?

听说在 Android 端和 iOS 端对更新要求是不一样的。Android 是要求你在更新的时候提示用户更新,用户需要点击确认之后才进行更新;而 iOS 端是希望这个更新是用户无感知的(上面这种就行),默默的在后台更新好并且使用最新的即可。

2.2、 iOS SDK 集成与打包

2.2.1、rnpm 自动配置

在上面使用 rnpm link 之后,其实大部分的问题都已经基本解决了。

这里你需要打开 git diff 看一下 rnpm link 有没有做一些 ”坏事“ 可能会对你的代码造成影响。

这里我们发现他改变了下面几个文件:

重点看一下是在可辨识的范围内修改了 Info.plist,在里面加入了 key 方便调用。

除此之外还重点修改了一下 AppDelegate.m

这是指在 debug 包中继续之前的老套路远程加载 bundle,在 release 模式中托管给 CodePush 管理 bundle 的加载。

对,这和我们之前预想的完全一样。

接着,用 Xcode 打开 iOS 工程,检查一下版本号是不是三位,如果不是的话,修改为三位(比如:1.0.0):

为什么要修改为三位

CodePush 只支持三位的,不然在推包的时候无法确定推包给哪个版本。

2.2.2、忽略 Test

这部分你可以先跳过,直接看 build 部分,如果有问题再回开看。

我在 build 的时候发生了错误,log 如下:

这看起来时测试出了问题,忽略掉他即可。

commmand + shift + ,,在弹出的框里面选择 build,然后取消掉 Test 部分的勾。

然后继续 build。

2.2.3、打build 包

在之前的 AppDelegate.m 中,我们代码的意图是在 debug 模式下通过远程加载 bundle,在 release 模式下用 CodePush 来处理。这样保证了我们在开发的时候不受 CodePush 的影响,所以要测试 CodePush,就打在 release 包。

command + shift + ,,在 run 里面 Build Configuration 里面选择 release。

然后 command + R 或者点击按钮进行打包。

理论上你会注意到打了一个不需要加载远程 bundle 的包。

如何打包到物理机上

这里不做讨论,只做简单的说明:首先需要一个开发者账号,在项目工程的 Singning 里面进行配置(不想买的话可以去淘宝看看),然后插上手机之后进行 build。

和其他 iOS 工程打包没有什么区别。

2.3、Android SDK 集成与打包

2.2.1、rnpm 自动配置

和 iOS 端一样,利用 rnpm link 就能解决大部分的问题。rnpm link 之后,我们 git diff 一下看看有哪些变动:

核心是修改了上面的几个文件:

自动在 setting.gradle 里面加入了 CodePush:

include ':react-native-code-push'
project(':react-native-code-push').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-code-push/android/app')

自动在 build.gradle 加入了 CodePush 的编译依赖:

自动在 string.xml 里面加入了 key,方便 java 代码中的调用。

自动在 MainActivity.java 里面引入的 CodePush。

自动在 MainApplication.java 中重写的 getJSBundleFile 函数。

到这里,进行 run 的时候会提示找不到 bundle,出现下面的错误:

Caused by: com.microsoft.codepush.react.CodePushNotInitializedException: A CodePush instance has not been created yet. Have you added it to your app's list of ReactPackages?

模拟器也是红红的提示找不到 bundle。

这和我们猜测的一直,整个 rnpm link 之后的代码中没有找到如何使用那个 key。

参考官方 example 中的代码,最终找到要在 MainApplication.java 中使用那个 Key:

那个 id 是 string.xml 里面的 id。

这样重新进行 run,可能会发现又提示:

Could not get BatchedBridge, make sure your bundle is packaged properly” on start of app

模拟是也是大大的红色错误提示:

参考 React Native 的 issues#9336stackoverflow

不要使用 Android Studio 启动,使用:

react-native run-android
react-native start --reset-cache

很好,能跑起来了!

2.3.2、打 build 包

现在已经打了 debug 的包,那如何打一个 release 包来测试我们的 CodePush 呢?

2.3.2.1、Android Studio 生成证书 jks 文件

首先我们需要一个签名,关于这个签名,我们在开发的时候实际上是使用了 Android Studio 自带的一个 debug 签名,在打 release 包的时候,我们就需要自己签名了:

如下图,在 build 里面选择 Generator Singed Apk:

然后点击 Create New...

然后填写即将生成的 patch 路径,密码,Alias的名字,密码,还有一些公司个人相关的东西:

OK,看看你填写的证书路径里是否有一个 xx.jks 的证书了。通过这个证书,我们就可以得到 App 的 Sha1 等用于一些第三方 SDK,更多细节可以参考 获取Android SHA1 、生成jks密钥、签名Apk

2.3.2.2、Android Studio 自动打包

理论上在刚才的基础下点击下一步,选择 Release 包就能得到 Release 包了,但用:

adb install ./Android/app-release.apk

安装之后,打开会直接 crash(需要先卸载之前的 APP),解压 APK,发现 asset 里面没有和 CodePush 有关的任何东西,猜测可能是这里引起 Crash。

所以这种打包方式不能用。

部分解释参见: http://www.jianshu.com/p/1cff76e20ede

2.3.2.3、手动配置 Gradle 进行自动打包

找到之前生成的证书,复制到 app 目录下,比如我的证书叫 WeHIT.jks

接着修改 android/gradle.properties,加入证书的相关信息,方便其他文件调用:

MYAPP_RELEASE_STORE_FILE=WeHIT.jks
MYAPP_RELEASE_KEY_ALIAS=WeHITKey
MYAPP_RELEASE_STORE_PASSWORD=123456
MYAPP_RELEASE_KEY_PASSWORD=123456

然后在 app/build.gradle 里配置打包签名:

这里会用到之前保存在 android/gradle.properties 里的证书信息。

然后,切换到 andriod 目录下执行:

./gradlew assembleRelease

在 app/build/outputs/apk 里面会得到 release 包:

adb install ./WeHIT/WeHIT/android/app/build/outputs/apk/app-release.apk

注1:

ADB 可能不在环境变量中,如果你是用 Android Studio 安装的 Android SDK,先设置一下。

比如我用的是 zsh,修改 ~/.zsh.rc,加入:

export ANDROID_HOME=${HOME}/Library/Android/sdk
export PATH=${PATH}:${ANDROID_HOME}/tools
export PATH=${PATH}:${ANDROID_HOME}/platform-tools

注2:

理论上 Android Studio 集成了 gradlew 的功能,在 IDE 右侧 Gradle 中就有,但打包之后依然缺失 CodePush,所以还是用命令行打包吧.

2.3.2.4、额外小记

在运行模拟器的时候,提示:

Starting emulator for AVD 'x86_QVGA_Level10'
emulator: device fd:1044
HAX is working and emulator runs in fast virt mode
emulator: Failed to sync vcpu reg
emulator: Failed to sync HAX vcpu context

发现是 Docker 这种虚拟机在跑,关掉即可正常打开模拟器。

3、CodePush 推送新 bundle

改完代码后,我们需要更新 bundle,这样就需要我们打包然后把 bundle 推送到 CodePush 服务器上。

3.1、打包推送一步流

在项目根目录执行:

Android 推包

code-push release-react WeHIT-Android android

iOS 推包

code-push release-react WeHIT-ios ios

看到 log:

Detecting android app version:

Using the target binary version value "1.0.0" from "android/app/build.gradle".

Running "react-native bundle" command:

node node_modules/react-native/local-cli/cli.js bundle --assets-dest /var/folders/qv/3gzgsb153qj0gk8ljwl0kg0w0000gn/T/CodePush --bundle-output /var/folders/qv/3gzgsb153qj0gk8ljwl0kg0w0000gn/T/CodePush/index.android.bundle --dev false --entry-file index.android.js --platform android
[05/08/2017, 23:06:39] <START> Initializing Packager
[05/08/2017, 23:06:40] <START> Building Haste Map
[05/08/2017, 23:06:41] <END>   Building Haste Map (1632ms)
[05/08/2017, 23:06:41] <END>   Initializing Packager (2679ms)
[05/08/2017, 23:06:41] <START> Transforming files
[05/08/2017, 23:07:14] <END>   Transforming files (32488ms)
bundle: start
bundle: finish
bundle: Writing bundle output to: /var/folders/qv/3gzgsb153qj0gk8ljwl0kg0w0000gn/T/CodePush/index.android.bundle
bundle: Done writing bundle output
bundle: Copying 15 asset files
bundle: Done copying assets

Releasing update contents to CodePush:

Upload progress:[==================================================] 100% 0.0s
Successfully released an update containing the "/var/folders/qv/3gzgsb153qj0gk8ljwl0kg0w0000gn/T/CodePush" directory to the "Staging" deployment of the "WeHIT" app.

表示已经成功。

3.2、打包推送两步流

上面的一步流是把 打包和推送 结合到了一起,从 log 中也能简单看到先是执行打包,然后在 Push 的。

在 APP 的测试包中,我们可能希望能保留开发 Log 等,或者说我们想比较好的自定义 history 等。这样就需要我们手动的两次进行打包和推送了。

3.2.1、打包

首先我们创建一个 bundles 文件夹来存储打包后的 bundles

mkdir bundles

然后进行打包

// react-native bundle --platform 平台 --entry-file 启动文件 --bundle-output 打包js输出文件 --assets-dest 资源输出目录 --dev 是否调试

react-native bundle --platform android --entry-file index.android.js --bundle-output ./bundles/index.android.bundle --dev false

react-native bundle --platform ios --entry-file index.ios.js --bundle-output ./bundles/index.ios.bundle --dev false

这样就在 bundles 文件夹下面生成 bundle

3.2.2、推送

bundle 已经打好,现在需要推送到 CodePush 平台上。

// code-push release <应用名称> <Bundles所在目录> <对应的应用版本>
--deploymentName 更新环境 staging时不需要这个
--description 更新描述
--mandatory 是否强制更新 默认否

code-push release WeHIT-Android ./bundles 1.0.0 --description “Android Update”
code-push release WeHIT-iOS ./bundles 1.0.0 --description “iOS Update”

3.3、暗中观察

code-push deployment history WeHIT-Android Staging

如果不想看这么多的版本,可以用:

code-push deployment ls WeHIT-Android -k

同时,你会发现装了 release 包的客户端会自动更新。

四、后记

如果之前没有客户端的开发经验,配置 CodePush SDK 还是有点复杂,如果你在配置过程中遇到了什么问题,或者发现文章中有错误,欢迎指出。

五、参考文章

JimmyLv commented 7 years ago

热更新彻底被 Apple 禁了额,-。-

rccoder commented 7 years ago

@JimmyLv 应该属于媒体想搞大新闻吧,讲道理是没有禁止的吧

https://www.zhihu.com/question/60767945

感觉像 ReactNative 这种实际上 Bundle 就是一个描述文件,这种禁止的话 H5 离线包的是不是也要禁止了 😂 感觉不大可能

zhonggithub commented 6 years ago

sync 热更新下载成功后 重启app 报错。有遇到过这种情况吗?错误信息: 07-19 14:53:45.960 19585-19760/? E/AndroidRuntime: FATAL EXCEPTION: mqt_js Process: com.userviceapp, PID: 19585 java.lang.RuntimeException: Error calling AppRegistry.runApplication at com.facebook.react.bridge.queue.NativeRunnable.run(Native Method) at android.os.Handler.handleCallback(Handler.java:739) at android.os.Handler.dispatchMessage(Handler.java:95) at com.facebook.react.bridge.queue.MessageQueueThreadHandler.dispatchMessage(MessageQueueThreadHandler.java:29) at android.os.Looper.loop(Looper.java:135) at com.facebook.react.bridge.queue.MessageQueueThreadImpl$3.run(MessageQueueThreadImpl.java:192) at java.lang.Thread.run(Thread.java:818) Caused by: com.facebook.jni.CppException: Could not get BatchedBridge, make sure your bundle is packaged correctly at com.facebook.react.bridge.queue.NativeRunnable.run(Native Method)? at android.os.Handler.handleCallback(Handler.java:739)? at android.os.Handler.dispatchMessage(Handler.java:95)? at com.facebook.react.bridge.queue.MessageQueueThreadHandler.dispatchMessage(MessageQueueThreadHandler.java:29)? at android.os.Looper.loop(Looper.java:135)? at com.facebook.react.bridge.queue.MessageQueueThreadImpl$3.run(MessageQueueThreadImpl.java:192)? at java.lang.Thread.run(Thread.java:818)?