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>-<commit>.zip+manifest-<abi>.json、后端libgojni-<abi>-<commit>.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 ↔ 引擎)。
--split-per-abi下 gradle 会把各 ABI APK 的 versionCode 改写为「ABI偏移×1000+pubspec基础值」(如 arm64-v8a=2001),CI 用 aapt 从分 ABI APK 读真实值写入 manifest/补丁;本项目 pubspec 的+N恒定(CI 不随构建 bump),同 ABI 的新旧构建 versionCode 相同,自然绑定即可跨版本热更——versionCode 是自动的兼容代理,不是手挑的基线。客户端额外比对AppConfig.flutterBinding(= CIFLUTTER_VERSION)与 manifest 的flutterBinding:不同 → 不热更(防同 versionCode 但换了 Flutter 引擎导致崩溃),交「整包不兼容」分支下 APK。仅当有意 bump pubspec+N(通常伴随引擎/原生变更)时前端才走整包。 - 后端 libgojni.so:兼容边界是 gomobile 导出面(
mobile/export_surface.txt+release.yml导出面守卫,自动)。去掉 versionCode,靠「导出面冻结 + 崩溃回滚黑名单」保证任意老包热更到最新。
- 前端 libapp.so:flutter_patcher 天然按 versionCode 绑定(libapp.so ↔ 引擎)。
- 比较规则:dev 比 git commit hash;stable 比版本号(semver,
lib/core/updater/version_compare.dart)。已应用同补丁(flutter_patcher.currentVersion == patchLabel/ 后端 confirmed)跳过。
原生契约哈希闸(Dart↔原生 / Go 导出面运行时校验)
热更只换 native .so,Kotlin/Java 永不热更(只随整包 APK 走)。versionCode 与 flutterBinding 两闸抓不到 Dart↔原生 MethodChannel 契约漂移:若某次发版给某 com.songloft/* channel 加了方法(Kotlin 加 + Dart 调),既不动引擎也不动 versionCode → 现有闸全放行 → 老包热更到新 libapp.so,但设备旧 Kotlin 没这方法 → 运行时 MissingPluginException。dev 渠道尤甚:只有一个滚动 release,git commit 每次都不同,无法据此区分「正常新版本」与「契约不兼容版本」。标准版无后端桥但同样热更 libapp.so,风险面更大(自定义 channel + 大量原生插件)。
唯一能忠实反映「本地 APK 原生层支持什么」的信号只能运行时从不被热更的 Kotlin 读取(Dart/Go 侧常量都会被补丁覆盖)。为此引入原生契约哈希闸:
- 两个哈希,由原生 channel
com.songloft/contract的getHash一次返回{"dart","go"}:dart= Dart↔原生契约 = {所有com.songloft/*channel 名 + 方法名集合 +GeneratedPluginRegistrant插件集 +.flutter-plugins-dependencies里 android 原生插件 name+version}。门控前端 libapp.so(标准版 + bundle 都用)。go=sha256(mobile/export_surface.txt)。门控后端 libgojni.so(仅 bundle),补上「老 APK Kotlin vs 新 libgojni 运行时错配」——这是 CI 导出面冻结守卫看不到的维度。
- 值的来源:CI 用
scripts/compute_native_contract.sh(确定性)在flutter build apk前算出,同时写进 APK assetandroid/app/src/main/assets/native_contract.json(Kotlin 读它返回)与热更 manifest(patch.contractHash/backend.contractHash),同源同值。 - 比对:
checkPatch里contractHashBlocks(manifestHash, deviceHash)——两端非空且不同 → 返回 null(落整包)。任一为空(老宿主无此 channel / 本地开发无 asset / 老式 manifest 无字段)→ 视为未知、不拦截(降级),同flutterBinding闸。 - iOS 不参与:热更 Android-only,
NativeContractService的_isAndroid守卫使 iOS 永不触发。 - 残余风险(诚实):Kotlin 方法集为启发式解析(
call.method == "x"/when分支"x" ->),动态拼接的方法名可能漏掉;插件「同版本号但内部原生实现变化」也不捕获。宁滥勿缺(误触发只是多走整包=安全侧),换来零手维护清单。
能力边界(诚实)
| 场景 | 前端 libapp.so | 后端 libgojni.so |
|---|---|---|
| dev → 最新 dev | ✓(dev 共用 versionCode/引擎) | ✓ |
| stable → 最新 stable(引擎未变) | ✓(引擎键相同,跨 versionCode) | ✓(无 versionCode) |
| stable 且 Flutter 引擎升级 | ✗ → 走整包 APK(本就是新引擎新包) | ✓(与 gomobile 导出面无关) |
改了 com.songloft/* channel 方法集 / 增删原生插件 | ✗ 整包(dart 契约哈希不匹配) | —— |
| 改了 mobile.go 导出面 | ✗ 整包(前端不受影响则仍可热更) | ✗ 整包(导出面守卫 + go 契约哈希双拦) |
- 仅 Android;仅 Bundle 版(
hasEmbeddedBackend)+ local 模式后端在运行时才检查后端补丁。iOS 静态 xcframework + Apple 政策 → 不支持。
Kotlin 层冻结策略
classes.dex(Kotlin/Java 代码)不可热更,但可通过冻结 Kotlin 方法接口让契约哈希保持不变,使日常迭代不阻断 libapp.so 热更。策略:
- 参数扩展优先:新样式/行为走现有方法(
show/updateConfig)的参数 Map 加 key,Kotlin 侧call.argument缺失时取默认值。方法名集合不变 → 哈希不变。 exec逃逸方法:FloatingLyricPlugin预埋exec方法,新原生能力通过exec+cmd参数实现。关键:子命令分发用if/else(不用when),因为compute_native_contract.sh的 grep 会捕获"x" ->模式但不捕获cmd == "x"比较。Dart 侧exec返回 null 表示当前 APK 不支持(优雅降级,不崩溃)。- 纯 Dart 插件优先:增删带原生代码的 Flutter 插件会改变
GeneratedPluginRegistrant→ 哈希变。能用 Dart 实现则不引入原生插件。
冻结后的能力边界变化:
| 场景 | 是否可热更 |
|---|---|
| FloatingLyric 新样式参数(字号/颜色/对齐) | ✓ 走 updateConfig 参数 Map |
| FloatingLyric 新原生能力(exec 子命令) | ✓ APK 含该子命令时生效,不含时降级 |
| 新增 Kotlin→Dart 反向回调 | ✓ channel.invokeMethod 不被哈希脚本捕获 |
| Widget 新数据字段 | ✓ SharedPreferences key 不影响哈希 |
| 增删带原生代码的 Flutter 插件 | ✗ 仍需整包 |
详见 songloft-player/AGENTS.md「Kotlin 层冻结规则」。
可行性根基(原生机制)
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)真重启。
客户端流程(统一入口)
ShellLayout.initState 每会话调一次 PatchUpdateDialog.maybeShow(lib/core/updater/)。放在 Shell 而不是首页:Shell 全会话常驻(延迟期间不会被 dispose),且 HomePage 与 TvHomePage 都在它下面,TV 模式一并覆盖。
启动路径的三道闸(按序,任一拦住即静默早退、一个网络请求都不发):
- 开关:「设置 → 关于与更新 → 启动时自动检查更新」(
autoUpdateCheckProvider,prefsauto_update_check_enabled,缺省开)。 - 节流:距上次检查不足
kPatchCheckThrottle(6h,prefslast_patch_check_at)则跳过。时间戳刻意在发起检查之前就写 ——checkPatch把网络异常都吞成 null,调用方分不清「没补丁」与「查失败」;宁可一次失败等一个窗口,也不要弱网环境每次冷启都白跑一轮,逃生门是手动入口。 - 延迟 + 超时:首帧后延
kPatchCheckStartupDelay(4s)才发起,让首页歌单/电台请求先落地;检查阶段套 20s 整体超时。
闸门顺序是「先等认证就绪、再等延迟」,不要退回只判
mounted。 未登录时/login不在 ShellRoute 下,但 Shell 会在认证状态定型前先挂载一次再被重定向销毁。只判mounted是竞态而非因果:认证解析慢于延迟时(Web 的 secure_storage 走 IndexedDB、冷 Keychain、慢设备),那次短命挂载会吃掉本会话唯一的检查名额、写掉节流窗口,还带着无 token 的 dio 去查后端/settings/github-proxy。因此改为ref.listenManual(authStateProvider)等到AuthStatus.authenticated才排延迟(与同类_scheduleAutoEnterLyrics等播放状态恢复同形),延迟用可取消的Timer而不是Future.delayed—— 有句柄才能在dispose里取消,否则短命挂载会把已销毁的 State 子树多钉住一个延迟时长,且未来任何 pump 出 Shell 的 widget test 都会撞上「A Timer is still pending」。
手动入口:「设置 → 关于与更新 → 检查客户端更新」(与整包更新合并为一个 tile)走 maybeShow(manual: true) —— 跳过开关与节流、绕过「忽略此版本」名单(只是本次不过滤,并不清除 ignored_* 记录)、不写节流时间戳;无补丁时不做整包检查,由调用方落回 FrontendUpgradeDialog(它自带「正在检查 / 已是最新 / 失败」三态,故无需额外 snackbar)。闸门语义由 test/core/updater/patch_update_dialog_gates_test.dart 覆盖。
查到补丁但弹不出来时要回滚节流时间戳:context.mounted 为 false(用户在检查途中登出 / 离开设置页)时,这次发现只能丢,但必须把时间戳回滚回旧值 —— 否则一次「恰好卸载」会把一个真实可用的补丁压满一个节流窗口。该分支单独打日志,不要复用「无可热更补丁」那条,否则排查时误导人。
已知缺口(改动前即如此,不是本次引入):手动入口所在的 tile 在 AppConfig.isEmbedded 守卫之内,而开关在守卫之外。所以 embedded 构建里用户能关掉自动检查、却没有手动入口 —— 开关文案因此刻意不承诺「关闭后改为手动检查」。另外某版本被忽略后,手动入口会一直先弹该补丁对话框,因而够不到整包版本信息。
放行后的流程:
- 并行检查前端(
PatchUpdateService.checkPatch)+ 后端(BackendPatchService.checkPatch,仅hasEmbeddedBackend && Android && local && 后端运行)本渠道最新补丁,各自过滤「忽略此版本」(manual时不过滤)。 - 任一有更新 → 弹一个对话框列出待更新组件 + 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 导出面 / 加原生插件 → 整包。
- 原生契约哈希全自动:
scripts/compute_native_contract.sh每次构建重算 asset + manifest,无需手维护版本号或清单。改com.songloft/*channel 方法集 / 增删原生插件 → dart 哈希自动变,老包读到不匹配自动走整包;导出面变 → go 哈希自动变。无需 bump 任何常量。
验证
- 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>-<commit>.zip+manifest(无后端);手动patch-release.yml已删除。客户端逻辑对老式 manifest 仍向后兼容(无新字段时退回 hasUpdate + versionCode 绑定旧行为)。
