Files
opendance-android/tasks/012-mediapipe-crash.md
T
zijunchiang 7ebbaef631 feat(net,pose): 连接状态机与保活对齐 + MediaPipe 崩溃修复(T-AND-010/011/012)
契约 websocket-keypoints 0.1.0 -> 0.2.0(向后兼容,子协议名与帧 version 不变;
先在 opendance-web 修改并提升版本,再同步三端快照)。

T-AND-011:连接状态异常、频繁重连(查实 3 个根因)
1. 保活用错了层:Windows 端有 5s 静默超时(只看应用层收到的帧),而本端只有
   OkHttp 的协议层 ping —— 它由底层库自动应答、**不暴露给应用层**,根本不能续命。
   画面里没人时 5s 必被踢、再重连,循环往复。
   - 契约新增 §1.1(welcome/config 声明 silentTimeoutMs 与 heartbeatIntervalMs,
     且心跳必须 < 静默超时的一半)与 §3.6(应用层 ping/pong);
   - Windows 端超时 5s -> 15s、期望心跳 5s,并在 welcome/config 中下发;
   - 本端按声明发 ping,连续 3 个周期无 pong 才判定链路已死并主动重连。
2. 不该重连时也重连:契约新增 §7 关闭码策略,1000/1001/1002 与主动停止一律不重连。
3. frame_id 恒为 0:PoseDetector.detect() 算了 safeTimestamp 却忘了写回
   lastTimestampMs,接收端的丢包检测因此完全失效。已修。

其他对齐:
- 契约新增 §8 连接状态机 idle -> resolving -> connecting -> handshaking ->
  streaming -> degraded -> reconnecting -> stopped,两端同名便于对照日志;
  resolving 只对主机名生效,局域网直连 IP 直接进 connecting;
- 退避与抖动按 §7.1 冻结:0.5s -> 1 -> 2 -> 4 -> 8 -> 15s 封顶 + ±20% 抖动;
- 契约新增 §5 设备去重:hello 携带稳定 deviceId(ANDROID_ID 的 SHA-256 前 16 位,
  不含硬件序列号),Windows 端据此去重、旧连接以 1000 关闭;
- 新增 util/ConnectionLog(环形缓冲,最近 500 条:状态迁移 / close code /
  子协议协商 / 首帧时间 / 保活参数),运行页可一键导出(FileProvider)。

T-AND-012:MediaPipe 初始化崩溃(ExceptionInInitializerError: null)
- 诊断:新增 pose/PoseDiagnostics 展开 cause 链取真因(通常 UnsatisfiedLinkError)、
  区分 native 加载失败、比对设备 ABI 与 APK 内 ABI 并给出可执行结论;
- native 解压:packaging { jniLibs { useLegacyPackaging = true } }(AGP 8 release
  默认 extractNativeLibs=false,MediaPipe JNI 对它不兼容);
- R8:补 -keepclasseswithmembernames 的 native 方法保留规则,并 -printseeds 输出
  seeds 供发布前核对(实测保留 20169 条 MediaPipe 条目);
- 构建期新增 verifyReleaseAbi:release ABI 必须恰为 arm64-v8a/armeabi-v7a、
  每个 ABI 下必须有 MediaPipe native 库、模型资源必须存在;
- 未换库:诊断指向集成配置而非 MediaPipe 本身,保留 33 点契约零成本。

T-AND-010:版本号展示与单一来源
- 版本唯一来源 versionName(= project.yaml 的 version);versionCode 按
  10000*主+100*次+修订 递增(0.2.0 -> 200);
- 契约版本改为构建期解析 contracts/VERSION 后注入 BuildConfig(六项各自独立
  递增,原先的单个 CONTRACT_VERSION 常量已失真);界面不再手写任何版本号;
- 新增 checkReleaseConsistency 并挂到 preBuild,三者不一致直接 BUILD FAILED。

README/DSH/THIRD_PARTY/tasks 同步更新(含「导出连接日志」「保活参数」
「最近断开原因」等新增可观察项与排查表)。

测试:56 项全部通过(新增 ReconnectPolicyTest 11 项、PoseDiagnosticsTest 12 项);
build/verify.sh 全通过(单元测试 -> 构建 -> 签名/ABI/native/seeds/dex 校验)
2026-09-17 01:10:06 +08:00

63 lines
4.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# T-AND-012 MediaPipe 初始化崩溃修复
- **状态**: done
- **依赖**: T-AND-003
- **文档**: v1.6 §6.6
## 问题
真机上报 `mediapipe 初始化失败:ExceptionInInitializerError: null`。
`ExceptionInInitializerError` 的 message 为 `null` 时**没有任何信息量** ——
它的真因一定在 `cause` 链里,通常是 `UnsatisfiedLinkError`(native 库加载失败)。
## 诊断结论
按文档 §6.6 的五步逐条核对,本项目实际命中 **三处**:
| # | 项 | 结论 |
|--|--|--|
| 1 | 拿到真因 | ❌ 原实现只把 `t.message` 拼进提示,`null` 就此丢失。**已修**:`PoseDiagnostics.causeChain()` 展开完整因果链,`rootCause()` 取最内层,并把"缺少的具体库"一并显示 |
| 2 | ABI 核对 | ⚠️ release 只打包 `arm64-v8a` / `armeabi-v7a`(正确),但**没有任何机制阻止把 release 装到 x86_64 模拟器**上 —— 那必然缺 native 库。**已加**:`PoseDiagnostics.abiMismatchHint()` 会比较 `Build.SUPPORTED_ABIS` 与 APK 内 `lib/<abi>/` 实际目录,直接给出"APK 只包含 X,而模拟器(x86_64)需要 Y"的结论;构建期另有 `verifyReleaseAbi` 任务核对 |
| 3 | native 解压方式 | ❌ 未设置。AGP 8 的 release 默认 `extractNativeLibs=false`,MediaPipe 的 JNI 加载对它有历史兼容问题。**已修**:`packaging { jniLibs { useLegacyPackaging = true } }` |
| 4 | R8 细化 | ❌ 只有 `-keep class com.google.mediapipe.**`,缺少 **native 方法**的保留规则(R8 仍可能裁剪方法名导致 JNI 注册失败)。**已修**:补 `-keepclasseswithmembernames class com.google.mediapipe.** { native <methods>; }`,并用 `-printseeds` 输出 seeds 供发布前核对(实测保留 **20169** 条 MediaPipe 条目) |
| 5 | 版本与 minSdk | ✅ `minSdk = 29 ≥ 24`,`tasks-vision 0.10.35` 兼容 |
**未换库**:诊断指向的是集成配置(native 解压 + R8 + ABI),不是 MediaPipe 本身不稳定,
因此保留 MediaPipe(33 点契约零成本),备选方案(ML Kit Pose Detection 等)
仅在后续实测仍不稳定时启用。文档 §6.6 的备选 A/B/C/D 已在任务说明中记录取舍。
## 改动清单
| 文件 | 改动 |
|--|--|
| `app/build.gradle.kts` | `jniLibs.useLegacyPackaging = true`;新增 `verifyReleaseAbi` 任务(ABI 集合必须恰为 `[arm64-v8a, armeabi-v7a]`,每个 ABI 下必须有 MediaPipe native 库,模型资源必须存在) |
| `app/proguard-rules.pro` | 补 native 方法保留规则;输出 `seeds.txt` / `usage.txt` 供发布前核对 |
| `pose/PoseDiagnostics.kt` | **新增**:纯函数诊断(cause 链 / 真因 / native 判定 / ABI 比对 / 汇总报告),可在 JVM 单测逐条核对 |
| `pose/PoseDetector.kt` | 读自身 APK 的 `lib/` 目录得到实际 ABI;失败时输出完整诊断并写入 `ConnectionLog`;顺手修复 `lastTimestampMs` 未回写导致 `frame_id` 恒为 0 的缺陷 |
| `util/ConnectionLog.kt` | **新增**:连接与初始化诊断日志(可导出) |
| `AndroidManifest.xml` + `res/xml/file_paths.xml` | FileProvider(导出日志用) |
## 验收(文档 §6.6)
| 验收项 | 结果 |
|--|--|
| release 包在真机(arm64)上初始化成功率 100%,连续 30 分钟无崩溃 | ⚠️ 需真机实测(本环境无设备)。构建期已强制 ABI 与 native 库齐备,R8 保留已用 seeds 核对 |
| 崩溃时 `CrashLogger` 能给出 `Caused by` 真因(含 ABI / 库名) | ✅ `PoseDiagnostics` 输出「真因(cause 链最内层)」「缺少的具体库」「设备 ABI / APK 内 ABI」与判定结论;`PoseDiagnosticsTest` 12 项逐条断言 |
| 若换库:仍输出 MediaPipe 33 点顺序 | ✅ 未换库,33 点契约不变(`KeypointFrameTest` 金标准向量仍通过) |
| 相机权限被拒 / 模型缺失 / 无 Play 服务时给出可读提示,不白屏不崩溃 | ✅ 权限分支已有独立界面与 Robolectric 用例;初始化失败 `catch Throwable` 并显示诊断;`ScreenCompositionTest` 覆盖组合稳定性 |
## 发布前必做(三条命令)
```bash
./gradlew verifyReleaseAbi
# -> [abi] release APK 包含 ABI:[arm64-v8a, armeabi-v7a]
# -> [abi] arm64-v8a / armeabi-v7a 下 MediaPipe native 库存在
grep -c "^com.google.mediapipe" app/build/outputs/mapping/release/seeds.txt
# -> 必须 > 0(实测 20169);为 0 说明 MediaPipe 被整体裁掉,release 必崩
unzip -p app/build/outputs/apk/release/app-release.apk classes.dex | grep -c poselandmarker/PoseLandmarker
# -> 必须 > 0,确认关键类仍在 dex 中
```