Bundle 版 Android 热更新(前端 libapp.so + 后端 libgojni.so)
本文描述 Bundle 本地模式下 songloft-player 的 Android 自托管热更新:无基线——任何非最新 dev 更新到最新 dev、任何非最新 stable 更新到最新 stable。每次发版自动把最新补丁挂到 Release,客户端启动检查、一次下载、只重启一次:一次真进程冷启同时让前端 libapp.so(flutter_patcher)与后端 libgojni.so(gomobile)生效。
中英双语并存,改一版需同步
docs/en/backend_hotupdate.md。
核心模型:无基线 + 自动发布 + 工具链兼容键
- 无基线:客户端查本渠道最新——dev→滚动 tag
dev;stable→GitHub/releases/latest(dev 是 prerelease,latest 天然返回最新正式版)。由lib/core/updater/channel_release_resolver.dart解析,复用FrontendVersionApi思路。 - 自动发布:
release.yml的build-bundled-androidjob 每次发版自动产出并上传:前端patch-<abi>.zip+manifest-<abi>.json、后端libgojni-<abi>.so+backend-manifest-<abi>.json(arm64-v8a / armeabi-v7a / x86_64,gomobile bind 含 android/amd64 目标)。无手动 workflow、无 versionCode 绑定。 - 兼容键取代 versionCode(自动、非手改):
- 前端 libapp.so:flutter_patcher 天然按 versionCode 绑定(libapp.so ↔ 引擎)。本项目 pubspec 的
+N恒定(CI 不随构建 bump),故所有 dev/stable 构建共用同一 versionCode,自然绑定即可跨版本热更——versionCode 是自动的兼容代理,不是手挑的基线。客户端额外比对AppConfig.flutterBinding(= CIFLUTTER_VERSION)与 manifest 的flutterBinding:不同 → 不热更(防同 versionCode 但换了 Flutter 引擎导致崩溃),交「整包不兼容」分支下 APK。仅当有意 bump versionCode(通常伴随引擎/原生变更)时前端才走整包。 - 后端 libgojni.so:兼容边界是 gomobile 导出面(
mobile/export_surface.txt+release.yml导出面守卫,自动)。去掉 versionCode,靠「导出面冻结 + 崩溃回滚黑名单」保证任意老包热更到最新。
- 前端 libapp.so:flutter_patcher 天然按 versionCode 绑定(libapp.so ↔ 引擎)。本项目 pubspec 的
- 比较规则:dev 比 git commit hash;stable 比版本号(semver,
lib/core/updater/version_compare.dart)。已应用同补丁(flutter_patcher.currentVersion == patchLabel/ 后端 confirmed)跳过。
能力边界(诚实)
| 场景 | 前端 libapp.so | 后端 libgojni.so |
|---|---|---|
| dev → 最新 dev | ✓(dev 共用 versionCode/引擎) | ✓ |
| stable → 最新 stable(引擎未变) | ✓(引擎键相同,跨 versionCode) | ✓(无 versionCode) |
| stable 且 Flutter 引擎升级 | ✗ → 走整包 APK(本就是新引擎新包) | ✓(与 gomobile 导出面无关) |
| 改了 mobile.go 导出面 / 加原生插件 | ✗ 整包 | ✗ 整包(导出面守卫拦截) |
- 仅 Android;仅 Bundle 版(
hasEmbeddedBackend)+ local 模式后端在运行时才检查后端补丁。iOS 静态 xcframework + Apple 政策 → 不支持。
可行性根基(原生机制)
libgojni.so由 gomobile 的go.Seq静态块System.loadLibrary("gojni")在首次触碰任意mobile.*类时懒加载。SongloftApplication.onCreate()(早于任何mobile.*)System.load("<filesDir>/backend_patch/active/libgojni.so")预加载补丁版;bionic 按 soname 去重,后续loadLibrary("gojni")复用补丁版。gomobile 产物无 DT_SONAME(正常),bionic(minSdk 24 ≥ API 23)回退用文件 basename 作 soname;客户端落地文件名固定为libgojni.so,故去重仍生效。release.yml 只校验「soname 为空或恰为 libgojni.so」(非空且不同才失败)。- W^X:targetSdk 29+ 从私有目录
System.load()下载的 .so 允许(限制的是 execve 与含 text-reloc 的 .so)。 - 必须冷重启进程生效(Go runtime 单进程只初始化一次);
SystemNavigator.pop()只关 Activity,不够 → 用ProcessRestarter(AlarmManager + killProcess)真重启。
客户端流程(统一入口)
首页 initState 每会话调一次 PatchUpdateDialog.maybeShow(lib/core/updater/):
- 并行检查前端(
PatchUpdateService.checkPatch)+ 后端(BackendPatchService.checkPatch,仅hasEmbeddedBackend && Android && local && 后端运行)本渠道最新补丁,各自过滤「忽略此版本」。 - 任一有更新 → 弹一个对话框列出待更新组件 + GitHub 代理选择器(复用
GithubProxySelectionMixin),按钮 [忽略此版本] [稍后] [下载并更新]。 - 「下载并更新」一起下载(前端
flutter_patcher.applyPatchstage libapp.so、后端downloadAndStage下 .so + md5 + 交原生stageBackendPatch)。 - 完成 → 「立即重启」一次
EmbeddedBackendService.restartProcess()(真进程冷启),提示「应用将重启,可能中断当前播放」。「稍后」保留 staged,下次冷启一并生效。 - 前端补丁引擎不兼容(新 stable 换了 Flutter)→ checkPatch 返回 null → 落入「整包不兼容」分支跳设置页下 APK。
崩溃回滚 + 黑名单(原生 BackendPatchManager)
状态存纯文件 filesDir/backend_patch/state.json(需在 Dart 引擎前可读)。preloadIfStaged:无 active / 在黑名单 → 不预加载(回滚随包版);confirmed → 直接 System.load;staged/pending → bootAttempts++,超阈值(>1)判定启动即崩 → 拉黑(gitCommit+md5)+ 清 active + 回滚;System.load 抛异常 → 立即拉黑回滚,绝不让进程崩。confirm 时机:新进程后端健康后(startup_gate 冷启 / backend_lifecycle resume)BackendPatchService.confirmIfHealthy() 校验 /api/v1/version git_commit 一致 → confirmBackendPatch()。
Manifest 约定(父仓库 Release 资产,按 ABI)
- 前端
manifest-<abi>.json:{hasUpdate, patch:{version(patchLabel), semanticVersion, gitCommit, flutterBinding, patchUrl, md5}} - 后端
backend-manifest-<abi>.json:{hasUpdate, backend:{abi, version, gitCommit, buildTime, soUrl, md5, size}}(无 targetVersionCode) - 都随
release.yml的 release(tag=dev / v<x.y.z>)自动上传;客户端按渠道解析最新。
发布纪律
- 每次发版自动带补丁,无需额外操作;导出面守卫(
go doc ./mobile比对mobile/export_surface.txt)在release.yml里,导出面漂移即 fail(须整包)。 - 改 Flutter 版本 → 前端老包自动走整包(引擎键不匹配);改 mobile.go 导出面 / 加原生插件 → 整包。
验证
- dev 任意→最新:老 dev 包 → 弹一个对话框列前端+后端 → 一次下载 → 单次重启 →
/api/v1/versiongit_commit 变最新 + 前端改动生效 → confirm。 - stable 任意→最新(引擎未变):老 stable 包 → 后端按版本号更新到最新 stable;前端引擎键相同 → 也更新。
- stable 引擎已变:前端引擎键不匹配 → 前端走整包;后端仍可热更。
- 崩溃回滚:坏 .so → System.load/Init 崩 → bootAttempts 超阈值拉黑回滚,不再下发。
- 无 versionCode 依赖:后端全程不比 versionCode;CI
readelf+ 导出面守卫为唯一后端门禁。
注意
- Google Play 等渠道可能限制动态下发
.so,本项目走自控/侧载分发。 - 标准版(非 bundle)也是无基线:player 仓库
build-and-release.yml的build-android每次发版自动产出前端patch-<abi>.zip+manifest(无后端);手动patch-release.yml已删除。客户端逻辑对老式 manifest 仍向后兼容(无新字段时退回 hasUpdate + versionCode 绑定旧行为)。
