文章目录
React Native生产环境中的热更新涉及动态分发新代码到用户设备,而无需用户通过 App Store 或 Google Play 更新。
一、更新方式选型
1. Microsoft CodePush
• 原理:CodePush 是一个基于云的服务,可以将 JavaScript 和资源包更新直接推送到用户设备。
优点:
• 避免频繁的应用商店更新。
• 更新小型功能和 Bug 修复效率高。
缺点:
• 仅支持 JavaScript 和资源更新,不适用于原生代码(如修改原生模块需要重新发布应用)
• 微软已经正式宣布 Visual Studio App Center 将于 2025 年 3 月 31 日 停止服务
2. Expo OTA Updates
• 原理:Expo 提供了开箱即用的 OTA(Over-the-Air)更新服务,无需额外的配置。
优点:
• 无需使用原生代码即可快速发布更新。
• 与 Expo 环境深度集成。
缺点:
• 需要使用 Expo 构建工具。
3. 手动资源部署
• 将打包的资源分发到服务器,供客户端通过 HTTP 拉取。
• 避免频繁发布 App 的问题
二、步骤(手动资源部署)
**** 主要流程 ****
1. 打包更新文件(JS Bundle 和资源) → 上传到服务器
2. 服务器维护一个 JSON 文件 → 包含最新版本信息和下载链接
3. App 启动时检查更新 → 下载 ZIP 文件 → 解压并保存 → 替换本地 JS Bundle 文件
4. 动态加载新的 JS Bundle 文件
1.配置入口文件,打包 React Native Bundle 和资源
React Native 默认期望入口文件名为 index.js,并且位于项目根目录
• 检查项目根目录是否存在 index.js 文件。
• 如果入口文件使用了其他名字(如 App.js 或 src/index.js),请在package.json显式指定路径:
import { AppRegistry } from 'react-native'
import { registerRootComponent } from 'expo'
import App from './App'
AppRegistry.registerComponent('App', () => App)
registerRootComponent(App)
2. 打包 React Native Bundle 和资源
通过 React Native 提供的 React Native Hermes CLI 直接打包生成 Hermes 字节码格式的 .hbc 文件。
--entry-file是你的入口文件名称
# 终端运行:
# 打包 iOS 平台
mkdir -p ./update/assets
npx react-native bundle \
--platform ios \
--dev false \
--entry-file AppEntry.js \
--bundle-output ./update/index.ios.bundle \
--assets-dest ./update/assets \
--minify true \
--reset-cache \
--sourcemap-output ./update/index.ios.bundle.map \
--transformer node_modules/react-native-hermes-engine/src/transformer.js
cd update
zip -r update.zip index.ios.bundle assets
# 打包 Android 平台
npx react-native bundle \
--platform android \
--dev false \
--entry-file AppEntry.js \
--bundle-output ./update/index.android.bundle \
--assets-dest ./update/assets \
--minify true \
--reset-cache \
--sourcemap-output ./update/index.android.bundle.map \
--transformer node_modules/react-native-hermes-engine/src/transformer.js
cd update
zip -r update.zip index.ios.bundle assets
如果觉得以上打包命令麻烦,可以在package.json加入以下代码,直接终端运行build-both
"build-ios": "rm -rf ~/Desktop/output/ios_update && mkdir -p ~/Desktop/output/ios_update/assets && npx react-native bundle --platform ios --dev false --entry-file AppEntry.js --bundle-output ~/Desktop/output/ios_update/index.ios.bundle --assets-dest ~/Desktop/output/ios_update/assets --minify true --reset-cache --sourcemap-output ~/Desktop/output/ios_update/index.ios.bundle.map --transformer node_modules/react-native-hermes-engine/src/transformer.js && cd ~/Desktop/output/ios_update && zip -r ios_update.zip index.ios.bundle assets && mv ios_update.zip ~/Desktop/output/",
"build-android": "rm -rf ~/Desktop/output/android_update && mkdir -p ~/Desktop/output/android_update/assets && npx react-native bundle --platform android --dev false --entry-file AppEntry.js --bundle-output ~/Desktop/output/android_update/index.android.bundle --assets-dest ~/Desktop/output/android_update/assets --minify true --reset-cache --sourcemap-output ~/Desktop/output/android_update/index.android.bundle.map --transformer node_modules/react-native-hermes-engine/src/transformer.js && cd ~/Desktop/output/android_update && zip -r android_update.zip index.android.bundle assets && mv android_update.zip ~/Desktop/output/",
"build-both": "yarn build-ios && yarn build-android"
3.将打包文件上传到服务器
打包完成后,你会得到:
• index.ios.bundle(iOS 的 JS Bundle 文件)
• index.android.bundle(Android 的 JS Bundle 文件)
• assets 目录(图片、字体等资源)
将这些文件打包成一个 ZIP 文件,例如 update_v1.0.1.zip,然后上传到你的服务器
4. App 端拉取最新的 Bundle 文件
步骤:
1. 在服务器维护一个配置文件(JSON 文件),记录当前最新版本的热更新包信息(版本号、下载链接、MD5 校验值等)。
示例:https://yourserver.com/update.json
{
"version": "1.0.1",
"url": "https://yourserver.com/updates/update_v1.0.1.zip",
"md5": "a1b2c3d4e5f67890abcdef1234567890"
}
5. 动态加载新的 JS Bundle 文件
依赖的第三方库
1. react-native-zip-archive:用于解压下载的更新包
2. rn-fetch-blob:用于下载更新包。
3. react-native-md5(可选):用于校验 MD5
yarn add react-native-zip-archive rn-fetch-blob
yarn add react-native-md5
6.下载Zip文件
• 目标路径:你应该将 index.ios.bundle 和 assets 放置到应用能够访问到的位置,通常是沙盒目录下的某个文件夹。
• index.ios.bundle 应该放置到 FileSystem.documentDirectory 或其他可访问的目录。
• assets 文件夹也应放置到同样的路径下,确保资源能够被引用。
// 指定下载文件名
const downloadPath = `${fs.dirs.DocumentDir}/update.zip`
// 解压后的根目录
const extractPath = `${fs.dirs.DocumentDir}/update`
7.解压文件
// 解压更新包
const unzippedPath = await unzip(downloadPath, extractPath)
// 拼接解压后 Bundle 文件的正确路径
const bundlePath = `${extractPath}/ios/index.${Platform.OS}.bundle`
*如果解压失败,查看沙盒目录下是否存在文件:Xcode查看手机沙盒文件
8.加载新的 index.ios.bundle
// 使用 AppRegistry 启动新 Bundle
AppRegistry.runApplication('App', {
rootTag: 1, // rootTag 通常是 AppRegistry 的容器 ID,1 为默认的根标签
initialProps: {}, // 你可以传递 props 传递给新应用
})
// 强制重启应用
RNRestart.Restart()
*报错Invariant Violation: "YourAppName" has not been registered. This can happen if
检查 app.json 文件中,AppRegistry.runApplication和 AppRegistry.registerComponent 使用的名称是否一致(我的统一是是'App'
)
ERROR 加载新版本失败 [Invariant Violation: "YourAppName" has not been registered. This can happen if:
* Metro (the local dev server) is run from the wrong folder. Check if Metro is running, stop it and restart it in the current project.
* A module failed to load due to an error and AppRegistry.registerComponent wasn't called.]
*如果加载新版本的 index.ios.bundle 路径已经正确传入,热更新包也成功存在,但现在运行组件崩溃或者异常,检查热更新包是否使用Hermes,请确保正确转换字节码。
需要确保热更新的 JS Bundle 和引擎保持一致,否则会导致 加载失败 或 桥接失效 问题
1、 确认 expo.jsEngine 的值
在你的 app.json 或 app.config.js 中,检查是否配置了 Hermes 引擎:
{
"expo": {
"jsEngine": "hermes"
}
}
在 ios/Podfile 中添加 Hermes 支持
use_react_native!(
:path => config[:reactNativePath],
:hermes_enabled => true
)
9.源代码(仅供参考)
1、在 iOS 的 AppDelegate.mm 文件中增加支持动态加载自定义热更新的代码。
2、核心代码 放在 utils/UpdateUtil.ts 文件中,是检查更新、下载、解压、动态加载逻辑的核心文件。
3、checkForUpdate函数,根据业务需求来定入口。我是放在 App.tsx 的 useEffect 钩子里,确保应用启动时自动检查更新。
UpdateUtil.ts文件
/*
* @Date: 2024-12-16
* @LastEditors: min
* @LastEditTime: 2024-12-18 18:14:04
* @Description: 更新逻辑,包括检查更新、下载 ZIP 包、解压和动态加载新版本
*/
import { Platform, Alert, NativeModules } from 'react-native'
import RNFetchBlob from 'rn-fetch-blob'
import { unzip } from 'react-native-zip-archive'
import { AppRegistry } from 'react-native'
import RNRestart from 'react-native-restart'
import { version as appVersion } from '../../package.json' // 引入package.json的version字段
/**
* 检查服务器是否有新版本
*/
export const checkForUpdate = async () => {
try {
const updateInfo = {
hasUpdate: true, // 是否有更新
version: '1.5.0', // 更新的版本号
description: '修复了一些已知问题并提升了性能', // 更新描述
downloadUrl:
'https://drive.usercontent.google.com/download?id=xxx&export=download&authuser=0', // 更新包下载地址
checksum: 'abc123xyz', // 更新包的校验值,用于验证完整性
isMandatory: false, // 是否是强制更新
}
if (updateInfo.version > appVersion) {
Alert.alert('发现新版本', `更新内容:${updateInfo.description}`, [
{ text: '取消', style: 'cancel' },
{ text: '更新', onPress: () => downloadAndUpdate(updateInfo.downloadUrl) },
])
}
} catch (error) {
console.error('检查更新失败', error)
}
}
/**
* 下载新版本 ZIP 包并解压
* @param downloadUrl - 更新包下载地址
*/
const downloadAndUpdate = async (downloadUrl) => {
try {
const { fs } = RNFetchBlob
const downloadPath = `${fs.dirs.DocumentDir}/update.zip` // 指定下载文件名
const extractPath = `${fs.dirs.DocumentDir}/update` // 解压后的根目录
// 下载更新包
const res = await RNFetchBlob.config({ path: downloadPath }).fetch('GET', downloadUrl)
// 解压更新包
const unzippedPath = await unzip(downloadPath, extractPath)
// 拼接解压后 Bundle 文件的正确路径
const bundlePath = `${extractPath}/ios/index.${Platform.OS}.bundle`
console.log('加载新版本的路径:', bundlePath)
// 加载新 Bundle 文件
loadNewBundle(bundlePath)
} catch (error) {
console.error('更新失败', error)
}
}
/**
* 动态加载新的 Bundle 文件
* @param bundlePath - 本地解压后的 Bundle 文件路径
*/
const loadNewBundle = (bundlePath: string) => {
try {
// 保存新版本的 Bundle 路径到本地存储
// NativeModules.SettingsManager.settings = {
// ...NativeModules.SettingsManager.settings,
// hotUpdateBundlePath: bundlePath,
// }
// 使用 AppRegistry 启动新 Bundle
AppRegistry.runApplication('App', {
rootTag: 1, // rootTag 通常是 AppRegistry 的容器 ID,1 为默认的根标签
initialProps: {}, // 你可以传递 props 传递给新应用
})
// 强制重启应用
RNRestart.Restart()
console.log('加载新版本成功', bundlePath)
// 强制重启应用,加载新的 Bundle
RNRestart.Restart()
} catch (error) {
console.log('加载新版本失败', error, bundlePath)
}
}
AppDelegate.mm文件
#import <GoogleMaps/GoogleMaps.h>
#import "AppDelegate.h"
#import <React/RCTBundleURLProvider.h>
#import <React/RCTLinkingManager.h>
#import <React/RCTBridge.h>
#import <React/RCTBundleURLProvider.h>
#import <React/RCTRootView.h>
#import <Foundation/Foundation.h>
#import <React/RCTBridge.h>
#import <React/RCTReloadCommand.h>
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
[GMSServices provideAPIKey:@"AIzaSyAupzighBWDDbeLrwl5IC4HC4yu0CmlaNw"]; // Google Maps API Key
self.moduleName = @"main";
// You can add your custom initial props in the dictionary below.
// They will be passed down to the ViewController used by React Native.
self.initialProps = @{};
// 调用父类方法
return [super application:application didFinishLaunchingWithOptions:launchOptions];
}
- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
{
#if DEBUG
// 开发模式下使用 Metro 服务器的 JS Bundle
return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
#else
// 从传入的路径加载热更新的 Bundle 文件
NSString *hotUpdateBundlePath = [[NSUserDefaults standardUserDefaults] stringForKey:@"hotUpdateBundlePath"];
if (hotUpdateBundlePath && [[NSFileManager defaultManager] fileExistsAtPath:hotUpdateBundlePath]) {
return [NSURL fileURLWithPath:hotUpdateBundlePath];
} else {
// 回退到默认的 Bundle 文件
return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
}
#endif
}
// 自定义热更新包加载路径
- (NSURL *)bundleURL
{
#if DEBUG
// Debug 模式下加载 Metro 服务器
return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
#else
// Release 模式下加载热更新或默认 JS Bundle
NSString *hotUpdateBundlePath = [self getHotUpdateBundlePath];
if (hotUpdateBundlePath != nil && [[NSFileManager defaultManager] fileExistsAtPath:hotUpdateBundlePath]) {
NSURL *hotUpdateURL = [NSURL fileURLWithPath:hotUpdateBundlePath];
NSLog(@"加载热更新包: %@", hotUpdateURL);
return hotUpdateURL;
} else {
NSLog(@"加载默认 JS Bundle");
return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
}
#endif
}
// 获取热更新包路径
- (NSString *)getHotUpdateBundlePath
{
// 热更新包存放在沙盒中的 Documents/update 文件夹下
NSString *documentsPath = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) firstObject];
NSString *updateFolder = [documentsPath stringByAppendingPathComponent:@"update"];
NSString *bundlePath = [updateFolder stringByAppendingPathComponent:@"index.ios.bundle"];
if ([[NSFileManager defaultManager] fileExistsAtPath:bundlePath]) {
NSLog(@"热更新包路径存在: %@", bundlePath);
return bundlePath;
} else {
NSLog(@"热更新包路径不存在: %@", bundlePath);
return nil;
}
}
// 重新加载 JS Bundle
- (void)reloadJSBundle {
NSLog(@"开始重新加载 JS Bundle...");
NSURL *bundleURL = [self sourceURLForBridge:nil];
if (bundleURL == nil) {
NSLog(@"无法加载 JS Bundle,路径为空");
return;
}
RCTBridge *bridge = [[RCTBridge alloc] initWithBundleURL:bundleURL
moduleProvider:nil
launchOptions:nil];
if (bridge) {
NSLog(@"JS Bundle 重新加载成功");
[bridge reload];
} else {
NSLog(@"创建 RCTBridge 失败");
}
}
// Linking API
- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options {
return [super application:application openURL:url options:options] || [RCTLinkingManager application:application openURL:url options:options];
}
// Universal Links
- (BOOL)application:(UIApplication *)application continueUserActivity:(nonnull NSUserActivity *)userActivity restorationHandler:(nonnull void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler {
BOOL result = [RCTLinkingManager application:application continueUserActivity:userActivity restorationHandler:restorationHandler];
return [super application:application continueUserActivity:userActivity restorationHandler:restorationHandler] || result;
}
// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
{
return [super application:application didRegisterForRemoteNotificationsWithDeviceToken:deviceToken];
}
// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries
- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error
{
return [super application:application didFailToRegisterForRemoteNotificationsWithError:error];
}
// Explicitly define remote notification delegates to ensure compatibility with some third-party libraries
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler
{
return [super application:application didReceiveRemoteNotification:userInfo fetchCompletionHandler:completionHandler];
}
@end