React Native App 内更新实践:从版本策略到 APK 下载和安装

React Native App 内更新实践:从版本策略到 APK 下载和安装

React Native 应用如果只通过应用市场分发,更新问题可以交给平台处理。但在一些 Android 直装、企业设备、内部分发场景里,事情没有这么简单。

用户不一定会主动安装新版本,设备也不一定有稳定的应用市场能力。版本发布之后,如果旧包长期留在现场设备上,后续接口、权限、原生能力一变,就会变成维护问题。

这篇文章记录一种简单的 App 内更新方案:客户端请求版本服务,发现新版本后下载 APK,再调起系统安装器完成覆盖安装。

重点不是"怎么下载一个文件",而是把更新这件事拆清楚:服务端负责版本策略,客户端负责状态流转,Android 系统负责下载和安装的最后一步。

更新边界

App 内更新先不用做得太复杂。当前真正需要解决的是:应用启动后,能不能知道自己是否落后,能不能把用户引导到一个可控的新版本。

因此,这套方案只处理 Android APK 覆盖安装:

  • 客户端读取当前 version 和 build;
  • 请求版本服务,判断是否有更新;
  • 如果有更新,展示版本信息和更新说明;
  • 用户确认后下载 APK;
  • 下载完成后调起系统安装器;
  • 如果是强制更新,用户不能取消更新弹窗。

这里没有接入应用市场,也没有做静默安装。普通应用没有静默安装权限,最终仍然需要用户确认系统安装页面。

这个边界要先说清楚。否则很容易把"检查更新""下载安装""版本发布""热更新""灰度发布"混在一起,最后客户端和服务端都变得难维护。

版本策略

版本判断应该放在服务端,而不是放在客户端。

客户端当然可以拿当前版本和最新版本做比较,但它不适合承担完整策略。比如某个版本是否发布、是否废弃、是否需要强制更新、最低支持到哪个构建号,这些都应该由服务端决定。

一个版本记录通常需要这些字段:

ts 复制代码
interface AppVersion {
  version: string;
  build: number;
  minSupportedBuild: number;
  force: boolean;
  packageUrl: string;
  packageSize: number;
  releaseNotes: string[];
  publishedAt: string;
}

这里的 version 用来展示给用户,比如 1.2.0。build 用来做内部比较,应该是单调递增的整数。

Android 官方文档里也有类似区分:versionName 是展示给用户的版本号,versionCode 是系统用来判断版本新旧的内部版本号。经验上,更新判断应该优先依赖 build,不要只依赖语义化版本字符串。

服务端检查版本时,核心逻辑可以简化成这样:

ts 复制代码
function checkUpdate(current: ClientVersion, latest: AppVersion) {
  if (!latest || latest.build <= current.build) {
    return { updateAvailable: false };
  }

  return {
    updateAvailable: true,
    latestVersion: {
      ...latest,
      force: latest.force || current.build < latest.minSupportedBuild,
    },
  };
}

这里有一个细节:force 和 minSupportedBuild 不应该混成一个字段。

force 表示这次发布是否希望强制用户升级。minSupportedBuild 表示服务端还能接受的最低客户端构建号。当用户当前 build 低于这个值时,即使最新版本本身不是强制发布,也应该进入强制更新。

这两个字段最终都会影响客户端交互,但语义不一样。分开之后,后续维护会更清楚。

发布管理

只有版本检查接口还不够。服务端还需要一个发布入口,用来把 APK 和版本策略管理起来。

这个后台不需要复杂,核心是两类页面。

一类是版本列表。它按环境或渠道筛选版本,展示版本号、构建号、最低支持构建号、是否强制更新、发布状态、包体大小和发布时间。这个页面解决的是"当前哪些版本对客户端可见"。

另一类是新建版本。它负责上传 APK,并填写或解析版本信息。更推荐的做法是同时上传构建产物里的元数据文件,让后台自动解析环境、版本号、构建号等信息。人工只补充强制更新开关、最低支持构建号和更新日志。

这里的关键不是后台表单做得多完整,而是发布动作要收口。

如果只是把 APK 地址手动写进配置文件,短期也能跑。但版本一多,就会出现几个问题:

  • 不同环境的包容易混用;
  • 构建号可能手填错误;
  • 强制更新策略没有记录;
  • 已发布、已废弃、已删除这些状态没有统一入口;
  • 客户端检查更新时无法判断哪个包才是当前有效版本。

所以版本服务最好同时管理"文件"和"策略"。APK 存在哪里是存储层问题,哪个 APK 能被客户端看到,才是版本服务应该回答的问题。

接口契约

客户端和服务端之间的契约可以保持很小。

客户端请求时只需要带上当前环境、展示版本和构建号:

http 复制代码
GET /api/app-updates/check?channel=stable&version=1.2.0&build=12

服务端返回时也不需要暴露太多内部信息:

ts 复制代码
interface VersionCheckResponse {
  updateAvailable: boolean;
  latestVersion?: {
    version: string;
    build: number;
    minSupportedBuild: number;
    force: boolean;
    packageUrl: string;
    packageSize: number;
    releaseNotes: string[];
    publishedAt: string;
  };
}

这个接口的重点是:客户端只消费结果,不再重复实现版本策略。

也就是说,客户端不需要知道服务端有哪些版本状态,不需要知道某个包是否被下架,也不需要知道存储服务里真实文件路径如何拼接。客户端只关心三件事:

  • 有没有更新;
  • 是否强制更新;
  • 去哪里下载安装包。

这样做之后,版本规则后续变复杂,也主要在服务端调整。客户端的更新流程可以保持稳定。

状态流转

客户端更新流程不适合散在页面里。

如果页面里直接写检查版本、弹窗、下载进度、安装、错误处理,很快就会出现重复状态。尤其是强制更新、下载失败、用户从安装页返回这些场景,页面会变得很难判断当前到底处在哪一步。

更稳的方式是把更新流程收在一个更新管理器里,用状态描述当前阶段:

ts 复制代码
type UpdateStatus =
  | 'idle'
  | 'checking'
  | 'noUpdate'
  | 'updateAvailable'
  | 'downloading'
  | 'downloaded'
  | 'installing'
  | 'failed';

interface UpdateState {
  status: UpdateStatus;
  versionInfo?: VersionInfo;
  progress?: DownloadProgress;
  error?: string;
  localPackagePath?: string;
}

页面只订阅状态,再根据状态展示弹窗或进度。

比如发现新版本时展示版本信息和更新说明,进入下载阶段后展示进度。页面不需要知道版本策略,也不需要直接管理下载任务。

核心流程可以收敛成几个方法:

ts 复制代码
class AppUpdateManager {
  async checkUpdate() {
    this.setState({ status: 'checking' });

    const response = await requestLatestVersion();
    if (!response.updateAvailable) {
      this.setState({ status: 'noUpdate' });
      return;
    }

    this.setState({
      status: 'updateAvailable',
      versionInfo: response.latestVersion,
    });
  }

  async downloadUpdate() {
    this.setState({ status: 'downloading' });

    const localPath = await downloadPackage(this.state.versionInfo.packageUrl);

    this.setState({
      status: 'downloaded',
      localPackagePath: localPath,
    });

    await this.installUpdate();
  }
}

这段代码只保留关键路径。真正落地时,还需要补上订阅、错误处理、进度回调、取消逻辑和资源清理。

重点是状态要单向流转。页面不直接操作下载任务,也不直接判断安装权限。页面只关心:当前状态是什么,用户能点什么按钮。

APK 下载

APK 文件通常比较大,下载也可能跨越网络切换、应用切后台、用户锁屏等场景。

在 Android 上,可以优先考虑系统 DownloadManager。它本身就是为长时间 HTTP 下载设计的系统服务,能处理后台下载、通知栏展示、下载结果查询等问题。

React Native 里可以通过 react-native-blob-util 间接使用 Android DownloadManager。关键配置大致是这样:

ts 复制代码
ReactNativeBlobUtil.config({
  addAndroidDownloads: {
    useDownloadManager: true,
    notification: true,
    title: '应用更新',
    description: '正在下载新版本',
    path: filePath,
    mime: 'application/vnd.android.package-archive',
  },
}).fetch('GET', packageUrl);

这里有几个细节需要提前处理。

下载前最好检查本地是否已经存在同名文件。如果存在,先删除再下载。否则可能出现下载失败,或者安装的不是本次请求的包。

下载进度要从下载模块往更新管理器回传,而不是直接写进 UI 组件。这样进度弹窗只是状态的一种展示。

下载失败后要清理未完成文件。否则下一次更新时,本地残留文件可能干扰判断。

APK 安装

下载完成不等于更新完成。

普通 Android 应用只能调起系统安装器,真正安装仍然由系统和用户确认完成。React Native 侧通常会通过文件路径和 MIME 类型打开 APK:

ts 复制代码
await ReactNativeBlobUtil.android.actionViewIntent(
  localPackagePath,
  'application/vnd.android.package-archive',
);

Android 8.0 之后,应用如果要请求安装外部 APK,需要声明 REQUEST_INSTALL_PACKAGES。用户还需要允许当前应用安装未知来源应用。

这类权限和普通运行时权限不完全一样。它不是简单调用 PermissionsAndroid.request() 就能完成的能力。更现实的处理方式是:

  • 在 AndroidManifest.xml 声明安装权限;
  • 安装失败时识别权限问题;
  • 引导用户打开应用设置页;
  • 用户返回 App 后,重置当前安装状态。

用户从系统安装页面返回时,可以通过 AppState 监听应用是否重新回到前台:

ts 复制代码
const subscription = AppState.addEventListener('change', nextState => {
  if (nextState === 'active' && state.status === 'installing') {
    resetUpdateState();
    subscription.remove();
  }
});

这里处理的是用户取消安装或从设置页返回的情况。真正安装成功后,应用通常会被新版本覆盖并重新启动,不一定会继续执行旧进程里的后续逻辑。

强制更新

强制更新不要只做成一个弹窗按钮隐藏。

如果服务端返回 force: true,客户端至少要保证两点:

  • 更新提示不能被普通关闭;
  • 更新流程失败后仍然能重新进入检查或下载。

强制更新不是为了让交互更强硬,而是为了避免旧版本继续访问已经不兼容的服务端能力。

因此,强制更新的判断最好来自服务端。客户端不要自己写死"低于某个版本就强制更新"。否则下一次策略变化时,还要发布一个新客户端才能调整旧客户端的更新规则。

热更新

APK 覆盖安装解决的是完整应用版本问题。React Native 还有另一类更新方式:热更新。

比如 Pushy 这类方案,会先接入 react-native-update,让 Release 包在启动时优先加载热更新 bundle。后续如果只是 JS 逻辑、页面样式或部分静态资源变化,可以通过命令生成热更新包,再绑定到某个原生基准版本上。

这类方案的价值很明确:小问题修复不用重新打包,也不用等待用户安装完整 APK。Pushy 文档里也提到,发布热更新前需要先上传一个原生基准包,之后 JS 和资源变更可以通过热更新包分发。如果涉及原生代码或配置变更,则需要重新发布原生包。

所以热更新和 APK 更新不是替代关系。

更准确的划分是:

更新方式 适合解决 不适合解决
APK 覆盖安装 原生代码、权限、依赖、Manifest、完整版本升级 包体较大,需要用户确认安装
热更新 JS 逻辑、页面样式、部分静态资源、小范围问题修复 原生模块、系统权限、Gradle 配置、SDK 升级

如果项目只有 APK 更新,发布会更可控,但小问题修复成本高。如果项目接入热更新,迭代会更快,但也要维护原生基准包、热更版本和灰度策略。

这里不需要把两个方案对立起来。它们解决的是不同层级的问题:APK 更新负责应用壳的能力边界,热更新负责 JS 层的快速修复。

适用场景

这套方案适合边界比较明确的 Android 直装场景:

  • 应用不完全依赖应用市场分发;
  • 用户设备需要长期稳定运行;
  • 服务端接口和客户端能力存在版本兼容要求;
  • 需要支持普通更新和强制更新;
  • 可以接受用户确认安装新 APK。

它不适合追求静默安装的场景。除非设备处在特殊管控环境里,否则普通应用没有这个能力。

也不适合把所有更新都做成 APK 覆盖安装。如果只是 JS 层的小修复,热更新会更轻。但一旦涉及原生依赖、权限、配置、系统能力,仍然应该回到完整 APK 发布。

回头看,App 内更新的关键不在下载本身,而在边界是否清楚。

服务端决定能不能更新,以及是否必须更新。客户端决定怎么把检查、下载、安装这条链路跑顺。热更新可以补充 JS 层的发布效率,但不能替代完整应用版本。

先把这三层分开,后续再扩展灰度、校验、回滚或多渠道分发,复杂度才不会全部堆回页面里。

参考资料

相关推荐
火柴就是我8 小时前
Flutter 项目本地 Maven 引入 AAR 找不到?一次 Gradle 路径问题排查
android
火柴就是我8 小时前
Android 打包报错 25.0.3
android·前端
码上有光9 小时前
Linux进程通信——共享内存、消息队列和信号量
android·linux·运维·共享内存·通信
PYB310 小时前
【Web·JS·基础】函数的使用和展运算符...objs
前端·javascript
默_笙11 小时前
🚥 给 RAG 装一台心电监护仪:LangSmith 全链路观测(上)
前端·javascript
变与不变80613 小时前
js同步和异步难点重点详解
开发语言·javascript·ecmascript
Android打工仔13 小时前
Kotlin 协程源码解析:DispatchedContinuation 里的 Dispatcher 从哪里来?
android·kotlin
Dovis(誓平步青云)13 小时前
多个链接不等于多份证据,新闻核验看板怎样合并来源
java·服务器·前端·javascript·人工智能·pdf·电脑
打工仔折腾 AI13 小时前
从BPE到SentencePiece:Transformer分词原理与Python实战对比
android·人工智能·python·深度学习·langchain·transformer·ai agent 实战
Dovis(誓平步青云)14 小时前
几个方案来回选不定?做一个随时切换的候选推荐页
android·java·服务器·开发语言·javascript·数据库·智能化