【React Native 自定义热更新(iOS端)】


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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值