Skip to content

lzt-T/RNUpdate

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

18 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

RN Update Manager

基于 React Native 的 apk 更新和全量更新管理器

📋 目录

🚀 特性

  • 📦 全量更新:完整包更新,确保应用完整性
  • 🔐 安全校验:文件哈希验证,确保更新包完整性
  • 📊 进度监控:实时更新下载和安装进度
  • 💾 本地缓存:智能缓存管理,减少重复下载
  • 📱 静态文件服务:无需复杂后端 API,只需静态文件服务器
  • 📱 高效下载:采用 react-native-fs 原生下载,性能更高更稳定
  • 📲 APK 更新:支持完整的 APK 更新,适用于需要原生功能变更的场景

💻 安装

安装步骤

由于本项目未发布到 npm,或者你需要自定义修改,请直接复制构建产物到你的项目中。

  1. 构建项目

由于lib目录被 git 忽略,你需要先在本地构建项目生成lib目录。

# 在 RNUpdate 根目录执行
npm install
npm run build
  1. 复制构建产物
# 复制 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"
  1. 安装必需依赖

你的主项目需要安装此库依赖的第三方包:

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;

📲 APK 更新支持

RN Update Manager 支持完整的APK更新功能。当应用需要添加原生依赖或进行原生功能变更时,可以通过 APK 更新来实现。

工作原理

  1. 服务器端提供 APK 文件下载地址
  2. 客户端检查更新时发现需要 APK 更新
  3. 触发onApkUpdateRequired回调
  4. 应用引导用户下载并安装新的 APK 文件

更新类型识别机制

系统通过服务器返回的updateType字段来识别更新类型:

export enum UpdateType {
  /** 全量更新 */
  FULL = "full",
  /** APK更新 */
  APK_REQUIRED = "apk_required",
}

当服务器返回updateType: "apk_required"时,系统会自动触发 APK 更新流程。

构建 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 文件的相关信息,包括文件大小、下载地址和哈希值

更新流程详解

  1. 检查更新:应用启动或用户主动检查时,向服务器请求最新版本信息
  2. 分析清单:解析服务器返回的清单文件,获取updateType和其他信息
  3. 类型判断
    • 如果updateType === "delta"updateType === "full",运行常规热更新流程
    • 如果updateType === "apk_required",跳转到 APK 更新流程
  4. APK 更新流程
    • 触发onApkDownloadComplete回调函数
    • 发送onApkDownloadComplete事件
    • 应用显示更新提示,引导用户下载新版本
  5. 用户交互:用户选择下载时,可以使用以下方式:
    • 使用react-native-fs直接下载并安装 APK(推荐)
  6. 安装应用:用户安装新版本的 APK

常见问题解决

  1. 没有触发 APK 更新回调

    • 确保服务器清单文件中包含updateType: "apk_required"字段
    • 检查是否正确配置了onApkDownloadComplete回调
  2. APK 下载失败

    • 检查网络连接和下载 URL 是否正确
    • 确保应用有适当的权限(存储权限、安装权限)
    • 检查 Android 8.0+设备是否配置了 FileProvider
  3. 无法安装 APK

    • 确保 APK 签名正确
    • 确保应用有 REQUEST_INSTALL_PACKAGES 权限
    • 检查 targetSdkVersion 是否为 Android 8.0+,以及是否正确配置了 FileProvider
  4. 构建脚本报错

    • 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平台清单

version.json 示例

{
  "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"
    }
  }
}

📦 Bundle 生成指南

使用官方命令

# 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

📱 Android 特殊配置

MainApplication 配置(必需)

为了让热更新生效,以及是否要使用热更新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
    }
  }
}

关键说明

  1. getJSBundleFile()方法:重写此方法来检查是否有更新的 Bundle 文件
  2. Bundle 路径:更新后的 Bundle 存储在 ${filesDir}/ota-updates/index.android.bundle
  3. 安全检查:验证文件存在、非空、可读
  4. 日志记录:便于调试,可以通过 logcat 查看加载状态

设置 versionName

versionNamepackage.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>

使用原生模块安装 apk

原生模块

//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,
};

API 文档

UpdateManager

构造函数

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下载完成
}

🐛 故障排除

常见问题

1. 更新包下载失败

  • 检查网络连接:确保能访问更新服务器
  • 检查服务器配置:确保 CORS 设置正确
  • 检查文件权限:确保服务器文件可读

2. Bundle 文件未找到

  • 检查 Bundle 结构:确保 zip 包中包含正确的 Bundle 文件
  • 检查文件名:Android 应为index.android.bundle,iOS 应为main.jsbundle

3. 应用崩溃

  • 查看错误日志:检查 logcat 或 Xcode 控制台输出
  • 检查权限设置:确保应用有存储权限
  • 检查依赖版本:确保所有依赖版本兼容

性能优化建议

  1. 启用缓存:配置适当的 HTTP 缓存策略
  2. 使用 CDN:将更新文件放在 CDN 上提高下载速度
  3. 压缩优化:优化 Bundle 大小,减少下载时间
  4. 分时更新:在用户空闲时进行后台更新

🤝 贡献

欢迎提交 Issue 和 Pull Request 来帮助改进这个项目。

📄 许可证

MIT License

🔗 相关资源


注意:本项目采用静态文件服务方案,无需编写复杂的后台 API。只需要按照约定的目录结构放置文件,客户端就能自动获取更新信息并完成更新!

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors