深入解读 expo-updates 内部运行流程:启动、二进制更新、资源下载与容错机制

深入解读 expo-updates 内部运行流程:启动、二进制更新、资源下载与容错机制 深入解读 expo-updates 内部运行流程启动、二进制更新、资源下载与容错机制【免费下载链接】expoAn open-source framework for making universal native apps with React. Expo runs on Android, iOS, and the web.项目地址: https://gitcode.com/GitHub_Trending/ex/expo本文基于 expo-updates 官方指南 guides/examples.md 展开逐层剖析 expo-updates 在真实设备上的完整运行时序冷启动时的内嵌更新装载、远程更新检查与下载、应用商店二进制升级后的更新选代逻辑、资源缺失时的自救路径以及 Android 多倍图multi-scale资源引发的特殊处理。读完本文你将理解 expo-updates 为什么在每次启动时都要反复校验内嵌更新也能掌握launchWaitMs、SelectionPolicy、Reaper、DatabaseLauncher、RemoteLoader等核心组件在关键场景下的行为为排查 OTA 更新问题、设计自托管更新服务提供源码级的依据。一、背景expo-updates 在启动时的总体职责expo-updates 是 Expo 开源的 OTAOver-The-Air更新模块核心目标是在不发布新原生包的前提下把新版本的 JS Bundle 与资源推送到已安装的 App 中。为了做到这一点它需要在每次冷启动时回答三个问题本地含应用包内有哪些可启动的更新——由DatabaseLauncher结合 SQLite 数据库与SelectionPolicy选出。远端是否有更新的版本——由RemoteLoader请求更新服务器上的 manifest 并下载新资源。哪些旧数据可以清理——由Reaper在后台回收不再需要的更新与资源。从源码结构看Android 侧的核心入口类位于 loader/LoaderTask.kt其类注释明确指出它负责每次冷启动时复杂的控制逻辑iOS 侧则有对应的EXUpdatesAppLauncherWithDatabase、EXUpdatesLoader等实现。文档描述的两个平台类名UpdatesPackageAndroid与ExpoUpdatesReactDelegateHandleriOS分别负责模块的初始化与启动挂接而 expo-updates 本身则通过 expo-modules-core 被集成进 App。二、应用启动的完整流程release 构建官方指南用 9 个步骤概括了一次普通 release 构建的启动时序下面结合源码逐一展开。1. 模块初始化与配置装配expo-updates 通过 expo-modules-core 被初始化。Android 侧由UpdatesPackage负责注册模块iOS 侧由ExpoUpdatesReactDelegateHandler负责挂接 React 生命周期。随后原生构建配置被读取并转换为UpdatesConfigurationiOS 对应EXUpdatesConfig对象同时初始化SQLite 数据库UpdatesDatabase记录更新与资源的元数据文件系统引用updatesDirectory即.expo-internal目录错误恢复处理器error recovery handler。以 Android 为例配置读取的核心逻辑在 UpdatesConfiguration.kt包括launchWaitMs启动等待毫秒数默认值为0、checkOnLaunch启动时是否自动检查更新、hasEmbeddedUpdate应用包内是否包含内嵌更新等关键项。2. 启动LoaderTask并启动等待计时器LoaderTask持有配置、数据库、目录、文件下载器、SelectionPolicy与回调是本次启动的控制中枢。在start()方法中LoaderTask.kt它首先调用UpdatesUtils.shouldCheckForUpdateOnLaunch判断本次启动是否需要检查更新若需要且launchWaitMs 0则在一个专用HandlerThread(expo-updates-timer)上启动计时器val shouldCheckForUpdate UpdatesUtils.shouldCheckForUpdateOnLaunch(configuration, logger, context) val delay configuration.launchWaitMs if (delay 0 shouldCheckForUpdate) { handlerThread.start() Handler(handlerThread.looper).postDelayed({ timeout() }, delay.toLong()) } else { timeoutFinished true }launchWaitMs的意义在于给远程更新的下载留出窗口期。如果下载在这个窗口内完成本次启动直接使用最新更新否则先用本地已就绪的更新兜底启动下载完成后再通知 JS 侧。从 FileDownloader.kt 可以看到网络请求的连接与读取超时也与launchWaitMs联动至少 10 秒保证下载请求不会无限期阻塞启动。3. 每次启动都读取内嵌 manifest 并决定是否装载内嵌更新紧接着LoaderTask调用launchFallbackUpdateFromDisk()LoaderTask.kt。这里有一个关键设计即使数据库里已经有更新也必须在每次启动时重新读取内嵌 manifest并用SelectionPolicy.shouldLoadNewUpdate判断内嵌更新是否比当前可启动更新更新val embeddedUpdate EmbeddedManifestUtils.getEmbeddedUpdate(context, configuration)!!.updateEntity val launchableUpdate launcher.getLaunchableUpdate(database) if (selectionPolicy.shouldLoadNewUpdate(embeddedUpdate, launchableUpdate, manifestFilters)) { val embeddedLoader EmbeddedLoader(context, configuration, logger, database, directory, scope) embeddedLoader.load { ... } }原因正如指南所述应用二进制随时可能通过 App Store 等渠道更新可能携带新的内嵌更新而 expo-updates 无法预知这一变化所以必须在每次冷启动时对比内嵌更新的时间戳必要时用EmbeddedLoader把它装载进 SQLite。4. 提前准备好一个绝对安全的启动候选LoaderTask在装载内嵌更新之后、做任何远程操作之前会先创建一个DatabaseLauncher实例从 SQLite 中选出并准备一个可以立即启动的更新校验所有资源是否在磁盘上存在、获取它们的路径。这样即使后续计时器超时或远程下载失败也总有一个安全可启动的更新兜底。源码中的maybeFinish()/finish()LoaderTask.kt保证了已就绪 计时器结束两个条件同时满足时才回调启动两者缺一不可。5. 后台启动RemoteLoader检查并下载远程更新如果配置允许检查更新LoaderTask会在后台线程启动RemoteLoaderRemoteLoader.kt流程为向更新 URL 发起请求获取 manifest通过FileDownloader.downloadRemoteUpdate用SelectionPolicy.shouldLoadNewUpdate判断是否值得装载这个 manifest/更新进 SQLite若值得则逐个下载 SQLite 中不存在的资源。RemoteLoader还会通过progressFlow向上层汇报资源下载进度成功数/失败数/总数这些数据最终会反映到 JS 侧的下载进度事件中。6. 计时器超时与下载完成后的两条分支RemoteLoader完成后回调LoaderTask此时分两种情况计时器尚未超时LoaderTask会新建一个候选DatabaseLauncher让它选中并准备刚下载好的新更新然后委托UpdatesController启动新更新对应源码中launchRemoteUpdateInBackground里newLauncher.launch(database)并更新candidateLauncher的逻辑LoaderTask.kt计时器已经超时第 4 步选中的兜底更新已被启动。此时LoaderTask不会强行切换而是向正在运行的 JS 实例发送新更新可用事件。如果 App 监听了 Updates 事件就可以在此刻调用reloadAsync立即加载新更新。7. 收尾Reaper清理旧数据所有流程结束后LoaderTask在后台运行Reaperdb/Reaper.kt。Reaper.reapUnusedUpdates通过SelectionPolicy.selectUpdatesToDelete决定删除哪些更新原则是保留当前正在运行的更新、任何比它更新的更新以及比它旧但最近一个的更新作为回滚保险。随后deleteUnusedAssets清理不再被任何更新引用的资源文件并对删除失败的文件做一次重试。三、应用二进制更新后再次启动两个关键场景指南特别强调第 5 步每次启动都装载内嵌更新的必要性并用两个场景说明为什么必须保证总是启动最新更新。这里完整保留原始场景设定假设所有更新与所有构建版本都兼容。场景 A内嵌更新比已下载更新更新时间线用户下载 build 1内含内嵌更新 A开发者发布更新 B用户下载并运行更新 B开发者通过 App Store 发布 build 2内含内嵌更新 C用户升级到 build 2 并启动LoaderTask启动读取内嵌 manifest更新 C发现其createdAt比更新 B 新于是用EmbeddedLoader将其装载进 SQLiteLoaderTask创建DatabaseLauncher选中更新 C 并准备启动校验所有资源可用LoaderTask创建RemoteLoader检查远程更新。此时最新发布的仍是 BRemoteLoader下载到 B 的 manifest 后发现它比 C 旧因此不下载LoaderTask将更新 C 委托给UpdatesController启动。结论用户升级原生包后会立即切换到新包携带的更新 C而不是继续使用旧的 OTA 更新 B——这正是每次启动都检查内嵌更新的价值所在。场景 B已下载更新比新二进制内嵌更新更新时间线用户下载 build 1内含内嵌更新 X开发者发布 build 2内嵌更新 Y但用户尚未下载开发者随后发布更新 Z与 build 1、build 2 均兼容用户下载并运行更新 Z用户最终升级到 build 2 并启动LoaderTask启动读取内嵌 manifest更新 Y发现它比用户已下载的更新 Z 旧因此对 Y 不做任何操作LoaderTask创建DatabaseLauncher选中更新 Z 并准备启动LoaderTask创建RemoteLoader检查远程更新。最新发布的仍是 ZRemoteLoader下载到 Z 的 manifest 后发现它已在 SQLite 中LoaderTask将更新 Z 委托给UpdatesController启动。结论用户升级原生包后依然运行最新的 OTA 更新 Z不会被二进制携带的旧内嵌更新 Y 覆盖。两个场景共同印证了内嵌更新装载逻辑的对称性——它同时防止旧的 OTA 覆盖新的内嵌和旧的内嵌覆盖新的 OTA。四、更新下载的细化流程Loader基类视角EmbeddedLoader与RemoteLoader都继承自抽象基类 loader/Loader.kt。指南从Loader视角描述了更新装载到磁盘无论来自远程还是应用包的完整流程源码load()方法Loader.kt与之一一对应加载 manifest调用子类实现的loadRemoteUpdate——RemoteLoader从 URL 下载EmbeddedLoader从应用包内读取EmbeddedLoader.kt读到的是assets://或 apk 内的app.bundle/index.android.bundle去重判断RemoteLoader检查数据库是否已有该更新。若已有且状态为READY立即触发成功回调不再做任何事逐资源处理否则遍历 manifest 中的资源。对每个资源检查 (a) 是否已在数据库(b) 是否已在磁盘约定文件名相同的资源视为同一资源。若磁盘不存在无论数据库中是否有对应行都发起下载写库所有下载完成后对磁盘上有但数据库中没有的资源补充 SQLite 行。对于磁盘上有但不在 SQLite的资源正常情况下不该发生但可能在破坏性数据库迁移后出现按刚下载过的方式填充记录收尾判定若没有错误且更新所需的全部资源都已就位于磁盘与 SQLite则把更新标记为READY并触发成功回调否则触发错误回调。Loader基类还维护progressFlow与assetLoadProgressListener对每个资源的下载进度进行累加进而向调用方汇报整体进度assetLoadProgressBlock。此外RemoteLoader的 companion 对象中processSuccessLoaderResultRemoteLoader.kt还会处理服务端下发的回滚到内嵌指令RollBackToEmbeddedUpdateDirective若当前构建确实有内嵌更新且指令通过选择策略就用指令中的commitTime更新内嵌更新的提交时间并写入数据库从而让未来的更新能够覆盖这次回滚。五、资源意外缺失时的容错路径正常情况下 expo-updates 的资源存储在操作系统不应清理的位置.expo-internal目录但如果代码有 bug、存储被意外删除或损坏启动时仍可能遇到数据库说资源存在、磁盘上却没有的情况。Android 侧的处理逻辑在 DatabaseLauncher.kt 的launch()与ensureAssetExists()DatabaseLauncher.kt中选中要启动的更新后DatabaseLauncher首先断言启动资源launch asset在 SQLite 中非空遍历组成该更新的每个资源含启动资源检查 SQLite 记录的路径在磁盘上是否有对应文件若没有尝试找回资源先读取内嵌 manifest若其中包含该资源则尝试从应用包内嵌位置复制若内嵌中没有、或复制失败则尝试根据 SQLite 中记录的资源 URL 重新下载若最终仍然失败当缺失的是启动资源时触发失败回调否则仍触发成功回调寄希望于缺失的非启动资源不影响运行。需要特别指出的是DatabaseLauncher.getLaunchableUpdateDatabaseLauncher.kt中的两个防御性检查EMBEDDED状态校验只有当数据库中标为EMBEDDED的更新确实等于当前二进制内嵌的更新时才可启动因为用户升级构建后数据库里可能残留着上一个二进制的内嵌记录无启动资源的更新不可启动没有 launch asset 的更新行会被跳过让 Loader 重新运行并修复该行而不是每次冷启动都失败。此外DatabaseLauncher的类注释强调了两个重要不变量它不对数据库做大的修改主要职责是读库 校验数据库与文件系统的一致性必须先选好要启动的更新再做资源校验以保持更旧更新绝不比更新更新更晚启动的不变量。六、Android 多倍图内嵌更新的特殊处理EMBEDDED状态指南还记录了一个 Android 特有的诡异怪癖涉及EmbeddedLoader与多处代码在 React Native 中require(./image.png)会根据设备屏幕密度映射到image.png、image2x.png或image3x.png在 Expo 侧这些文件被当作相互独立的资源一个更新要算作READY必须全部下载齐全但在 Android 上内嵌更新中的这些文件被放置到res目录下不同的dpi子目录Android 系统级的按密度划分资源的机制。运行时Android 的ResourcesAPI 只允许访问与当前设备密度匹配的资源因此EmbeddedLoader无法把面向其他屏幕密度的资源从应用包复制到 expo-updates 的资源存储中。这带来的连锁处理是理论上仍可以复制当前设备所需的全部资源、安全启动但 expo-updates 采取了更稳妥的策略——给这类更新在 SQLite 中标记特殊的EMBEDDED状态直接从应用包启动而不是从.expo-internal目录且不做资源覆盖asset overrides让 RN 直接从应用包读取资源。这是为数不多的、内嵌更新被区别对待的情况之一。正因如此启动一个EMBEDDED状态的更新时必须小心要校验这个内嵌更新仍然是我们预期的那个getLaunchableUpdate中update.status UpdateStatus.EMBEDDED embeddedUpdate?.updateEntity?.id ! update.id的检查就是为此而设因为用户更新构建后它可能已经变了。七、理解关键配置项与选择策略launchWaitMs控制启动等待窗口远程更新下载必须在launchWaitMs毫秒内完成才能赶在这次启动直接使用否则先启动兜底更新下载完成后通过事件通知 JS 侧。默认值为0UpdatesConfiguration.kt可通过expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS元数据配置。它同时影响网络请求的超时设置max(launchWaitMs, 10_000L)毫秒见 FileDownloader.kt。checkOnLaunch决定启动时是否自动检查更新ALWAYS/NEVER/WIFI_ONLY等由UpdatesUtils.shouldCheckForUpdateOnLaunch消费。若配置为不检查更新LoaderTask将跳过RemoteLoader只完成内嵌装载与本地启动。SelectionPolicy选择策略是 expo-updates 的裁判由三个可插拔的子策略组成selectionpolicy/SelectionPolicy.ktLauncherSelectionPolicy从一批更新中选出要启动的那个selectUpdateToLaunchLoaderSelectionPolicy判断新下载的更新是否值得装载进数据库shouldLoadNewUpdate以及回滚指令是否应被应用shouldLoadRollBackToEmbeddedDirectiveReaperSelectionPolicy判断哪些更新应该被清理selectUpdatesToDelete。默认实现基于commitTime提交时间排序并为 EAS Update 分支做了兼容同时针对 release 构建与开发客户端提供了不同的 Reaper 策略实现对应 iOS 的ReaperSelectionPolicyFilterAware与ReaperSelectionPolicyDevelopmentClient。类注释强调了一个非平凡的事实expo-updates 必须在完全不与服务器通信的情况下做出所有这些判断因为内嵌更新可能随时因新构建被安装而变化且没有机会先询问更新服务器。八、总结一套面向可靠性的启动协议把以上流程串起来可以看到 expo-updates 的启动协议围绕四个可靠性目标设计永不启动更旧的更新通过每次冷启动重读内嵌 manifest SelectionPolicy比较commitTime保证永不因远程失败而无法启动通过先准备兜底更新 计时器窗口保证容忍资源损坏通过DatabaseLauncher的内嵌复制 → 远程重下 → 失败回调三级容错保证数据库不无限膨胀通过Reaper保留当前 更新 最近一个旧版本的清理策略保证。指南文件 guides/examples.md 是理解这一协议的最佳入口配合 loader/LoaderTask.kt、launcher/DatabaseLauncher.kt、loader/RemoteLoader.kt、db/Reaper.kt 以及对应的测试用例如RemoteLoaderTest、DatabaseLauncherTest、ReaperTest阅读可以获得从指南描述到实现细节的完整闭环。如果关心迁移与发布方面的配套知识还可以继续阅读同目录下的 guides/general.md、guides/migrations.md 与 guides/releasing.md。【免费下载链接】expoAn open-source framework for making universal native apps with React. Expo runs on Android, iOS, and the web.项目地址: https://gitcode.com/GitHub_Trending/ex/expo创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考