基于 React Native 的 apk 更新和全量更新管理器
- 特性
- 安装
- 快速开始
- APK 更新支持
- [🌐 静态文件服务配置](#apk🌐 静态文件服务配置)
- Bundle 生成指南
- Android 特殊配置
- API 文档
- 故障排除
- 更新日志
- 📦 全量更新:完整包更新,确保应用完整性
- 🔐 安全校验:文件哈希验证,确保更新包完整性
- 📊 进度监控:实时更新下载和安装进度
- 💾 本地缓存:智能缓存管理,减少重复下载
- 📱 静态文件服务:无需复杂后端 API,只需静态文件服务器
- 📱 高效下载:采用 react-native-fs 原生下载,性能更高更稳定
- 📲 APK 更新:支持完整的 APK 更新,适用于需要原生功能变更的场景
由于本项目未发布到 npm,或者你需要自定义修改,请直接复制构建产物到你的项目中。
- 构建项目
由于lib目录被 git 忽略,你需要先在本地构建项目生成lib目录。
# 在 RNUpdate 根目录执行
npm install
npm run build- 复制构建产物
# 复制 lib 目录到你的项目 (例如复制到项目根目录下的 rn-update-manager 文件夹)
mkdir -p your-project/rn-update-manager
cp -r lib your-project/rn-update-manager/
# Windows 用户
xcopy /E /I "d:\path\to\RNUpdate\lib" "your-project\rn-update-manager\lib"- 安装必需依赖
你的主项目需要安装此库依赖的第三方包:
npm install react-native-fs react-native-zip-archive crypto-js/**
* Sample React Native App
* https://github.com/facebook/react-native
*
* @format
*/
import {
StatusBar,
useColorScheme,
Text,
Alert,
Button,
Platform,
} from "react-native";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { UpdateManager } from "./rn-update-manager/lib";
import { useEffect, useRef, useState } from "react";
import packageJson from "./package.json";
import RNExitApp from "react-native-exit-app";
import ApkInstaller from "./ApkInstaller";
import RNFS from "react-native-fs";
function App() {
const isDarkMode = useColorScheme() === "dark";
const [hasUpdate, setHasUpdate] = useState(false);
const [isChecking, setIsChecking] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const UpdateScreen = useRef<any>(null);
const checkForUpdate = async () => {
setIsChecking(true);
try {
const hasUpdate = await UpdateScreen.current.checkForUpdate();
setHasUpdate(hasUpdate);
if (hasUpdate) {
Alert.alert("发现新版本", "是否立即更新?");
} else {
Alert.alert("已是最新版本", "当前已是最新版本");
}
} catch (error) {
Alert.alert("检查失败", error.message);
} finally {
setIsChecking(false);
}
};
const onInstallUpdate = async () => {
setIsUpdating(true);
try {
await UpdateScreen.current.installUpdate();
} catch (error) {
Alert.alert("更新失败", error.message);
} finally {
setIsUpdating(false);
}
};
useEffect(() => {
UpdateScreen.current = new UpdateManager({
updateServerUrl: "http://xxxxxx",
appVersion: packageJson.version,
autoCheck: false,
onCheckComplete: (isTrue, updateInfo) => {
console.log("更新信息:", updateInfo);
},
onFullUpdateComplete: (updateInfo) => {
Alert.alert(
"更新完成",
`应用已成功更新到版本 ${updateInfo.version}\n\n为确保更新生效,请手动重启应用。`,
[
{ text: "稍后重启", style: "cancel" },
{
text: "立即退出",
onPress: () => {
if (Platform.OS === "android") {
RNExitApp.exitApp();
}
},
},
]
);
},
onApkDownloadComplete: (updateInfo, apkFilePath) => {
Alert.alert("更新包已准备好", "是否立即安装?", [
{ text: "稍后" },
{
text: "立即安装",
onPress: () => {
console.log("安装更新包,路径:", apkFilePath);
ApkInstaller.installApk(apkFilePath)
.then(() => {
console.log("安装已启动");
})
.catch((error) => {
console.error("安装失败:", error);
Alert.alert("安装失败", error.message);
});
},
},
]);
},
onProgress: (progress) => {
console.log("progress", progress);
},
onError: (error) => {
console.log("error", error);
Alert.alert("错误", error.message);
},
});
}, []);
return (
<SafeAreaProvider>
<StatusBar barStyle={isDarkMode ? "light-content" : "dark-content"} />
<Text>RN Update Manager 测试, 新版本{packageJson.version}111111</Text>
<Button
title={isChecking ? "检查中..." : "检查更新"}
onPress={checkForUpdate}
disabled={isChecking || isUpdating}
/>
{hasUpdate && (
<Button
title={isUpdating ? "更新中..." : "立即更新"}
onPress={onInstallUpdate}
disabled={isUpdating}
/>
)}
<Button
title="测试安装APK"
onPress={() => {
const testApkPath = `${RNFS.DocumentDirectoryPath}/app-update.apk`;
console.log(testApkPath, "testApkPath");
console.log("测试安装路径:", testApkPath);
ApkInstaller.installApk(testApkPath)
.then(() => console.log("安装请求已发送"))
.catch((err) => {
console.error("安装失败:", err);
Alert.alert("安装失败", err.message);
});
}}
/>
</SafeAreaProvider>
);
}
export default App;RN Update Manager 支持完整的APK更新功能。当应用需要添加原生依赖或进行原生功能变更时,可以通过 APK 更新来实现。
- 服务器端提供 APK 文件下载地址
- 客户端检查更新时发现需要 APK 更新
- 触发
onApkUpdateRequired回调 - 应用引导用户下载并安装新的 APK 文件
系统通过服务器返回的updateType字段来识别更新类型:
export enum UpdateType {
/** 全量更新 */
FULL = "full",
/** APK更新 */
APK_REQUIRED = "apk_required",
}当服务器返回updateType: "apk_required"时,系统会自动触发 APK 更新流程。
使用构建脚本生成包含 APK 文件的更新包:
# Windows环境 - 方法一:使用build.bat
# 可选版本号参数
build.bat 1.0.1
# Windows环境 - 方法二:使用PowerShell
$env:APP_VERSION="1.0.1"
$env:BUILD_APK="true"
$env:APK_PATH=".\android\app\build\outputs\apk\release\app-release.apk"
node scripts/build-static-update.js
# Windows环境 - 方法三:使用命令行参数
node scripts/build-static-update.js --build-apk=true**注意:**脚本现在支持自动构建 APK,会先运行 cd android && gradlew.bat assembleRelease(Windows)或 cd android && ./gradlew assembleRelease(Linux/macOS)来构建 APK。确保您的 Android 开发环境已正确配置。
APK 更新需要在清单文件中添加以下字段:
{
"versions": {
"1.0.1": {
"version": "1.0.1",
"description": "版本 1.0.1 更新",
"updateType": "apk_required", // 关键字段,指示这是一个APK更新
"full": {
"size": 1234567,
"downloadUrl": "https://your-cdn.com/app-updates/versions/1.0.1/android/full.zip",
"hash": "sha256:abcdef123456..."
},
"apk_required": {
"size": 23456789,
"downloadUrl": "https://your-cdn.com/app-updates/versions/1.0.1/apk/app-release.apk",
"hash": "sha256:fedcba987654..."
}
}
}
}关键字段说明:
updateType: 指定更新类型为"apk_required"apk_required: 包含 APK 文件的相关信息,包括文件大小、下载地址和哈希值
- 检查更新:应用启动或用户主动检查时,向服务器请求最新版本信息
- 分析清单:解析服务器返回的清单文件,获取
updateType和其他信息 - 类型判断:
- 如果
updateType === "delta"或updateType === "full",运行常规热更新流程 - 如果
updateType === "apk_required",跳转到 APK 更新流程
- 如果
- APK 更新流程:
- 触发
onApkDownloadComplete回调函数 - 发送
onApkDownloadComplete事件 - 应用显示更新提示,引导用户下载新版本
- 触发
- 用户交互:用户选择下载时,可以使用以下方式:
- 使用
react-native-fs直接下载并安装 APK(推荐)
- 使用
- 安装应用:用户安装新版本的 APK
-
没有触发 APK 更新回调
- 确保服务器清单文件中包含
updateType: "apk_required"字段 - 检查是否正确配置了
onApkDownloadComplete回调
- 确保服务器清单文件中包含
-
APK 下载失败
- 检查网络连接和下载 URL 是否正确
- 确保应用有适当的权限(存储权限、安装权限)
- 检查 Android 8.0+设备是否配置了 FileProvider
-
无法安装 APK
- 确保 APK 签名正确
- 确保应用有 REQUEST_INSTALL_PACKAGES 权限
- 检查 targetSdkVersion 是否为 Android 8.0+,以及是否正确配置了 FileProvider
-
构建脚本报错
- Windows 环境下确保正确设置环境变量
- 检查 Android 目录下是否有 gradlew.bat 文件
- 确保 APK 输出路径正确
项目采用静态文件服务方案,无需编写复杂的后台 API。只需要将更新文件按约定的目录结构放置在静态文件服务器或 CDN 上即可。
/var/www/app-updates/ # 更新根目录
├── version.json # 版本信息文件
├── versions/ # 版本目录
│ ├── 1.0.0/ # 版本号目录
│ │ ├── android/
│ │ │ ├── full.zip # Android全量更新包
│ │ │ └── full.zip.hash # 哈希文件
│ │ └── ios/
│ │ ├── full.zip
│ │ └── full.zip.hash
│ └── 1.1.0/
└── manifest/ # 清单文件目录
├── android.json # Android平台清单
└── ios.json # iOS平台清单
{
"latest": {
"android": "1.1.0",
"ios": "1.1.0"
},
"versions": {
"1.1.0": {
"releaseDate": "2024-01-15T10:00:00Z",
"description": "修复了一些bug,提升了性能",
"minSupportVersion": "1.0.0"
}
}
}# Android Bundle
npx react-native bundle \
--platform android \
--dev false \
--entry-file index.js \
--bundle-output ./bundles/android/index.android.bundle \
--assets-dest ./bundles/android/assets/ \
--reset-cache
# iOS Bundle
npx react-native bundle \
--platform ios \
--dev false \
--entry-file index.js \
--bundle-output ./bundles/ios/main.jsbundle \
--assets-dest ./bundles/ios/assets/ \
--reset-cache
(从 package.json 读取)
node scripts/build-static-update.js
为了让热更新生效,以及是否要使用热更新,index.android.bundle在你第一次热更新时就存在了,所以需要去判断版本,不然后面的apk更新会失效
路径:android/app/src/main/java/com/yourapp/MainApplication.kt(或.java)
package com.testnative
import android.app.Application
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactHost
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.facebook.react.defaults.DefaultReactNativeHost
import java.io.File
import android.util.Log
import org.json.JSONObject
class MainApplication : Application(), ReactApplication {
override val reactNativeHost: ReactNativeHost =
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
add(ApkInstallerPackage())
}
override fun getJSMainModuleName(): String = "index"
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
// 关键方法:检查是否有更新的Bundle
override fun getJSBundleFile(): String? {
return try {
val updatedBundle = checkForUpdatedBundle()
if (updatedBundle != null) {
Log.d("RNUpdate", "Using updated bundle: $updatedBundle")
updatedBundle
} else {
Log.d("RNUpdate", "Using default bundle")
super.getJSBundleFile()
}
} catch (e: Exception) {
Log.e("RNUpdate", "Error checking updated bundle", e)
super.getJSBundleFile()
}
}
}
override val reactHost: ReactHost
get() = getDefaultReactHost(applicationContext, reactNativeHost)
override fun onCreate() {
super.onCreate()
loadReactNative(this)
}
// 第一个为需要校验的版本,第二个为当前版本
fun compareVersions(version1: String, version2: String): Int {
// 将版本字符串按 "." 分割成整数列表
val v1Parts = version1.split('.').map { it.toInt() }
val v2Parts = version2.split('.').map { it.toInt() }
// 获取最长的版本号长度
val maxLength = maxOf(v1Parts.size, v2Parts.size)
// 逐个比较版本号的每个部分
for (i in 0 until maxLength) {
val v1Part = v1Parts.getOrNull(i) ?: 0
val v2Part = v2Parts.getOrNull(i) ?: 0
when {
v1Part > v2Part -> return 1
v1Part < v2Part -> return -1
}
}
return 0
}
private fun checkForUpdatedBundle(): String? {
return try {
val packageInfo = applicationContext.packageManager.getPackageInfo(applicationContext.packageName, 0)
val versionName = packageInfo.versionName
var fullVersion = "0.0.0"
Log.d("AppInfo", "app Version Name: $versionName")
val bundlePath = "${filesDir}/ota-updates/index.android.bundle"
val bundleVersionPath= "${filesDir}/ota-updates/version.json"
val bundleFile = File(bundlePath)
// 全量更新版本内容
val bundleVersionFile = File(bundleVersionPath)
if(bundleVersionFile.exists()){
val versionJson = JSONObject(bundleVersionFile.readText())
val version = versionJson.getString("version")
fullVersion = version
}
Log.d("AppInfo", "Full Version: $fullVersion")
Log.d("RNUpdate", "Checking bundle at: $bundlePath")
Log.d("RNUpdate", "Bundle exists: ${bundleFile.exists()}")
// 比较版本号
val isFull=compareVersions(fullVersion, String.format("%s", versionName))
if (bundleFile.exists() && bundleFile.length() > 0 && bundleFile.canRead() && bundleVersionFile.exists() && isFull> 0) {
Log.d("RNUpdate", "Bundle size: ${bundleFile.length()} bytes")
Log.d("RNUpdate", "Bundle readable: ${bundleFile.canRead()}")
// 设置文件权限为可读
try {
bundleFile.setReadable(true, false)
Log.d("RNUpdate", "Set bundle readable permissions")
} catch (e: Exception) {
Log.e("RNUpdate", "Failed to set permissions", e)
}
// 返回绝对路径,不使用file://前缀
bundlePath
} else {
Log.d("RNUpdate", "Bundle not found, empty, or not readable")
null
}
} catch (e: Exception) {
Log.e("RNUpdate", "Error in checkForUpdatedBundle", e)
null
}
}
}
- getJSBundleFile()方法:重写此方法来检查是否有更新的 Bundle 文件
- Bundle 路径:更新后的 Bundle 存储在
${filesDir}/ota-updates/index.android.bundle - 安全检查:验证文件存在、非空、可读
- 日志记录:便于调试,可以通过 logcat 查看加载状态
versionName跟package.json一致
//android/app/build.gradle
// 定义获取 versionName 的函数
def getVersionName() {
def versionPropsFile = file('../../package.json')
if (versionPropsFile.exists()) {
def json = new groovy.json.JsonSlurper().parseText(versionPropsFile.text)
def versionName = json.version
return versionName
} else {
throw new GradleException("package.json not found!")
}
}
def resultVersionName=getVersionName()
android {
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion
namespace "com.testnative"
defaultConfig {
applicationId "com.testnative"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
// 使用 package.json 中的版本号
versionName resultVersionName
}
//....
}
声明了应用访问外部存储(读写)、网络状态以及安装其他 APK 的权限。
AndroidManifest.xml:
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".MainApplication"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="false"
android:theme="@style/AppTheme"
android:supportsRtl="true">
//....
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>创建\android\app\src\main\res\xml\file_paths.xml,于支持安装 APK 的功能
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="external_files" path="." />
<files-path name="app_files" path="." />
<cache-path name="cache" path="." />
<external-cache-path name="external_cache" path="." />
<external-files-path name="external_files_path" path="." />
<root-path name="root" path="." />
</paths>原生模块
//ApkInstallerModule
package com.testnative
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.core.content.FileProvider
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.Promise
import java.io.File
class ApkInstallerModule(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
companion object {
private const val TAG = "ApkInstallerModule"
}
override fun getName(): String {
return "ApkInstaller"
}
@ReactMethod
fun installApk(apkPath: String, promise: Promise) {
try {
val apkFile = File(apkPath)
if (!apkFile.exists()) {
Log.e(TAG, "APK file not found: $apkPath")
promise.reject("FILE_NOT_FOUND", "APK file not found at path: $apkPath")
return
}
val intent = Intent(Intent.ACTION_VIEW)
val apkUri: Uri
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
// For Android 7.0+ we need to use FileProvider
apkUri = FileProvider.getUriForFile(
reactContext,
reactContext.packageName + ".fileprovider",
apkFile
)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
} else {
// For older versions
apkUri = Uri.fromFile(apkFile)
}
intent.setDataAndType(apkUri, "application/vnd.android.package-archive")
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
reactContext.startActivity(intent)
promise.resolve("Installation process started")
} catch (e: Exception) {
Log.e(TAG, "Error installing APK", e)
promise.reject("INSTALL_ERROR", "Failed to install APK: ${e.message}", e)
}
}
}// ApkInstallerPackage
package com.testnative
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class ApkInstallerPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
val modules = ArrayList<NativeModule>()
modules.add(ApkInstallerModule(reactContext))
return modules
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}js中使用
//ApkInstaller.js
import { NativeModules, Platform } from "react-native";
const { ApkInstaller } = NativeModules;
/**
* 安装APK文件
* @param {string} apkPath - APK文件的完整路径
* @returns {Promise<string>} - 安装过程的结果
*/
export const installApk = (apkPath) => {
if (Platform.OS !== "android") {
return Promise.reject(
new Error("This feature is only available on Android")
);
}
if (!apkPath) {
return Promise.reject(new Error("APK path cannot be empty"));
}
return ApkInstaller.installApk(apkPath);
};
export default {
installApk,
};new UpdateManager(config: UpdateConfig)
| 方法 | 描述 | 返回值 |
|---|---|---|
checkForUpdate() |
检查是否有可用更新 | Promise<boolean> |
downloadUpdate() |
下载更新包 | Promise<void> |
installUpdate() |
安装更新 | Promise<void> |
performUpdate() |
执行完整更新流程 | Promise<boolean> |
interface UpdateConfig {
// 必需配置
updateServerUrl: string; // 更新服务器URL
appVersion: string; // 当前应用版本
// 自动化配置
autoCheck?: boolean; // 自动检查更新,默认false
autoDownload?: boolean; // 自动下载更新,默认false
autoInstall?: boolean; // 自动安装更新,默认false
checkInterval?: number; // 检查间隔(毫秒),默认30分钟
// 网络配置
downloadTimeout?: number; // 下载超时时间,默认30秒
// 回调函数
onProgress?: (progress: UpdateProgress) => void;
onError?: (error: Error) => void;
onUpdateComplete?: (info: UpdateInfo) => void;
onFullUpdateComplete?: (hasUpdate: boolean, info?: UpdateInfo) => void;
onApkDownloadComplete?: (info: UpdateInfo, apkFilePath: string) => void; // APK下载完成
}- 检查网络连接:确保能访问更新服务器
- 检查服务器配置:确保 CORS 设置正确
- 检查文件权限:确保服务器文件可读
- 检查 Bundle 结构:确保 zip 包中包含正确的 Bundle 文件
- 检查文件名:Android 应为
index.android.bundle,iOS 应为main.jsbundle
- 查看错误日志:检查 logcat 或 Xcode 控制台输出
- 检查权限设置:确保应用有存储权限
- 检查依赖版本:确保所有依赖版本兼容
- 启用缓存:配置适当的 HTTP 缓存策略
- 使用 CDN:将更新文件放在 CDN 上提高下载速度
- 压缩优化:优化 Bundle 大小,减少下载时间
- 分时更新:在用户空闲时进行后台更新
欢迎提交 Issue 和 Pull Request 来帮助改进这个项目。
MIT License
注意:本项目采用静态文件服务方案,无需编写复杂的后台 API。只需要按照约定的目录结构放置文件,客户端就能自动获取更新信息并完成更新!