契约 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 校验)
17 KiB
opendance-android
OpenDance Studio 阶段三: Android 辅助 App(Kotlin + CameraX + MediaPipe 端侧推理)。
用手机摄像头做端侧姿态推理,把 33 个骨骼关键点通过 WebSocket 推给 Windows 客户端, 由后者驱动 VRM 角色并 DTW 实时评分。
画面不离开设备。通过网络发送的只有 33 个关键点坐标,不含任何图像数据。
状态
阶段三已完成: T-AND-001 -> T-AND-012 全部落地(含重连状态机与 MediaPipe 崩溃修复)。
| 验收项 | 结果 |
|---|---|
| 单元测试 | OK 56 项全部通过(JVM,无需设备;新增重连策略与 MediaPipe 诊断用例) |
| 跨端金标准 | OK Kotlin 序列化器与 Web 端 TypeScript 实现逐位一致 |
./gradlew assembleRelease |
OK 生成已签名 APK(28.6 MB) |
| 签名校验 | OK apksigner 退出码 0, v2 方案,CN=OpenDance Studio Dev |
| 包信息 | OK com.opendance.studio v0.2.0, minSdk 29(Android 10+)/ targetSdk 34 |
| 模型随包分发 | OK assets/pose_landmarker_lite.task 5.7 MB(Stored 未压缩) |
| ABI 覆盖 | OK arm64-v8a + armeabi-v7a(debug 额外含 x86/x86_64) |
快速开始
export JAVA_HOME=/path/to/jdk17 # AGP 8.7.3 要求
export ANDROID_HOME=/path/to/android-sdk
bash build/gen-keystore.sh # 生成发布签名(首次;不入库,请自行备份)
bash build/verify.sh # 单元测试 -> 构建 -> 产物校验
bash build/build-apk.sh all # 仅构建 debug + release
bash build/package-release.sh # 打发布包
详细使用说明见 README.txt,工程约束见 DSH.md,
第一次上手请看下文的「初次部署(从零跑起来)」一节。
初次部署(从零跑起来)
本节面向第一次拿到本仓库、要在真机上把 App 跑起来的人。 下文出现的地址、路径、口令一律是占位符或文档示例值,请替换为你自己的值, 并确保这些值不要提交进仓库。
1. 前置条件
| 项 | 要求 | 依据 |
|---|---|---|
| JDK | 17 或更高(AGP 8.7.3 要求;build/build-apk.sh 会校验主版本 >= 17) |
gradle.properties / build/build-apk.sh |
| Gradle | 8.9,仓库自带 wrapper(./gradlew),本机无需另装 |
gradle/wrapper/gradle-wrapper.properties |
| Android SDK | compileSdk 34 / minSdk 29(Android 10+)/ targetSdk 34;需 platforms;android-34 与 build-tools;34.0.0 |
app/build.gradle.kts |
| 设备 | 需要真机(Android 10 及以上、带摄像头);模拟器只作有限调试 | app/build.gradle.kts 的 ABI 过滤 |
真机与模拟器的差别:
- release 包只打包
arm64-v8a+armeabi-v7a, 无法安装到 x86/x86_64 模拟器。 - debug 包额外含
x86/x86_64,可以装进模拟器,但模拟器相机是虚拟场景, CameraX 取到的是合成画面,MediaPipe 的姿态识别效果不能代表真机, 也不能作为「第一次跑通」的验收依据。 - 结论:姿态识别与联调验收请用真机。
权限:运行页需要相机权限(运行时授予)。另外 INTERNET、ACCESS_NETWORK_STATE、
ACCESS_WIFI_STATE 为普通权限,安装时自动授予,用于 WebSocket 推流与局域网扫描。
2. 获取代码与本地配置
git clone https://<Gitea 地址>/<owner>/opendance-android.git
cd opendance-android
local.properties 是本地文件(已在 .gitignore 中,不会入库),
只需写 Android SDK 路径:
sdk.dir=<Android SDK 路径>
build/build-apk.sh 在该文件缺失时会依据 ANDROID_HOME / ANDROID_SDK_ROOT
自动生成它;也可以手动创建。不要提交它,每台机器路径不同。
签名材料同样不入库(.gitignore 已忽略 keystore/、*.jks、*.keystore、
keystore.properties):
- 仓库里没有私钥,只有生成脚本与说明。
- 首次发布前执行
bash build/gen-keystore.sh,生成keystore/opendance-release.jks与keystore.properties(含storeFile、keyAlias与两个口令字段)。请自行备份,且不要提交。 app/build.gradle.kts的签名取值优先级:keystore.properties-> 环境变量(KEYSTORE_PATH/KEYSTORE_PASSWORD/KEY_ALIAS/KEY_PASSWORD)-> 回退 debug 签名。 回退只为让 CI 也能产出已签名 APK;正式发布必须提供自己的 keystore。
3. 首次构建
export JAVA_HOME=<JDK 17 路径>
export ANDROID_HOME=<Android SDK 路径>
# 方式一:仓库脚本(推荐,构建后自动校验产物)
bash build/build-apk.sh debug # 仅 debug
bash build/build-apk.sh release # 仅 release
bash build/build-apk.sh all # 两者都构建
# 方式二:直接调用 Gradle
./gradlew assembleDebug
./gradlew assembleRelease
产物路径与文件名(固定):
| 变体 | 产物 | 说明 |
|---|---|---|
| debug | app/build/outputs/apk/debug/app-debug.apk |
applicationId 带 .debug 后缀,含 x86/x86_64 |
| release | app/build/outputs/apk/release/app-release.apk |
仅 arm64-v8a + armeabi-v7a,开启 R8 |
发布整包(APK + THIRD_PARTY.md + README.txt + contracts/)用
bash build/package-release.sh,输出在 dist-release/。
4. 安装到手机
# 用 adb(手机需开启 USB 调试)
adb install -r app/build/outputs/apk/debug/app-debug.apk
adb install -r app/build/outputs/apk/release/app-release.apk
也可以直接把 APK 拷到手机,在系统设置中允许「安装未知来源应用」后点击安装。
首次进入运行页会弹出相机权限请求;拒绝后运行页显示 「需要相机权限才能进行姿态识别」,可点「授予相机权限」重新申请, 或到系统「设置 -> 应用 -> OpenDance -> 权限」中手动开启。
5. 配置与联通(填 Windows 客户端地址)
本 App 只连接 Windows 客户端,不访问 Web 服务端:地址由
AppSettings.webSocketUrl() 拼成 ws://<host>:<port>/pose,子协议 opendance.pose.v1。
连接页字段(对应 data/AppSettings.kt):
| 界面字段 | 设置项 | 默认值 | 说明 |
|---|---|---|---|
| IP 地址 | host |
RFC 5737 文档网段占位地址(源码为 192.0.2.100, 非真实主机) |
填 Windows 电脑的局域网 IP |
| 端口 | port |
8765 |
与 Windows 客户端监听端口一致 |
| 目标帧率(FPS) | targetFps |
30 |
取值范围 5~60 |
| 分辨率 | resolution |
640x480 |
可选 480x360 / 640x480 / 1280x720 |
| 镜像画面 | mirror |
开 | 只影响显示与 flags.bit0,发送的是未镜像坐标 |
| 仅上半身模式 | upperBodyOnly |
关 | 下半身置信度置 0,接收端插值 |
填写示例(占位符,请替换):
IP 地址:192.0.2.10 # RFC 5737 文档示例网段,实际填你电脑的局域网 IP
端口: 8765
不想手填 IP 时,可点「扫描局域网」:App 取本机 WiFi IPv4 地址后推导 /24 网段,
以 32 并发、单机 300ms 超时探测该网段内开放 8765 端口的主机(约 2~3 秒),
结果按延迟升序展示,点候选即可填入。也可以先点「测试连接」确认端口可达。
6. 第一次跑通
数据流是单向的:手机 -> Windows 客户端;手机端不连 Web 服务端。
- 手机与 Windows 电脑连到同一个局域网 / WiFi(路由器不要开 AP 隔离)。
- 在电脑上启动 OpenDance Studio Windows 客户端,确认它在监听 8765 端口。
- 手机打开 App,连接页填入电脑 IP(或用「扫描局域网」自动发现),点「测试连接」。
- 点「开始推流」,首次会申请相机权限,允许后进入运行页。
- 站到画面中央,屏幕上出现骨架叠加。
成功标志:
| 位置 | 可观察现象 |
|---|---|
| App 运行页 | 「连接状态」为推流中(streaming;短暂经过 解析地址 → 连接中 → 握手中) |
| App 运行页 | 「目标」显示 ws://<IP>:<端口>/pose;服务端返回 welcome 后出现「会话」ID 与「协商子协议」 |
| App 运行页 | 「已发送」帧数持续增长;「发送帧率」「识别帧率」大于 0;「推理耗时」为毫秒数 |
| App 运行页 | 「保活」显示心跳与静默超时(由服务端在 welcome 中声明),心跳必须小于静默超时的一半 |
| App 运行页 | 断开时「最近断开」显示 close code 与原因(如 1001 对端离开),而不是只有"未连接" |
| App 运行页 | 「人体识别」为已识别,叠加层出现骨架(黄点 = 置信度好,红点 = 置信度低) |
| Windows 客户端 | 显示已连接并收到关键点,VRM 模型随动作实时动起来 |
7. 首次部署自检清单
| # | 命令 | 预期输出 |
|---|---|---|
| 1 | ./gradlew testDebugUnitTest |
BUILD SUCCESSFUL,全部用例通过;报告在 app/build/reports/tests/testDebugUnitTest/index.html |
| 2 | bash build/build-apk.sh debug |
末尾打印 OK Debug APK: app/build/outputs/apk/debug/app-debug.apk 及其大小与签名信息 |
| 3 | bash build/build-apk.sh all |
debug 与 release 均 OK; release 显示 已签名(APK Signature Scheme v2) |
| 4 | $ANDROID_HOME/build-tools/34.0.0/apksigner verify app/build/outputs/apk/release/app-release.apk |
退出码 0(无输出即通过) |
| 5 | unzip -l app/build/outputs/apk/release/app-release.apk | grep pose_landmarker_lite.task |
列出 assets/pose_landmarker_lite.task(约 5.7 MB, Stored 未压缩) |
| 6 | ./gradlew verifyReleaseAbi |
release APK 的 ABI 恰为 [arm64-v8a, armeabi-v7a],且每个 ABI 下都有 MediaPipe native 库 |
| 7 | grep -c "^com.google.mediapipe" app/build/outputs/mapping/release/seeds.txt |
大于 0(说明 R8 没有裁掉 MediaPipe;为 0 则 release 必崩) |
| 8 | adb install -r app/build/outputs/apk/debug/app-debug.apk |
Success |
其中 KeypointFrameTest.goldenVectorMatchesWebImplementation 必须通过:
它断言本端 Kotlin 序列化器与 Web 端 TypeScript 实现产出逐位相同的 239 字节帧。
8. 常见问题(首次部署)
| 现象 | 可能原因 | 处理 |
|---|---|---|
| 相机黑屏,或一直停在「需要相机权限才能进行姿态识别」 | 相机权限被拒绝或已被永久拒绝;也可能相机被其他 App 占用 | 在运行页点「授予相机权限」;若不再弹窗,去系统「设置 -> 应用 -> OpenDance -> 权限」手动开启,并关闭占用相机的其它 App |
| 相机预览正常但「人体识别」一直是「未识别到人体」 | 光线不足、人不在画面内、或分辨率/模式不合适 | 站到画面中央并改善光照;确认分辨率不是过低;观察「推理耗时」是否异常大 |
| 「连接状态」一直「已断开 / 重连中」,「测试连接」失败 | IP 或端口填错;Windows 客户端未启动;防火墙拦截入站 8765;手机与电脑不在同一网段(AP 隔离) | 核对连接页「本机地址」与电脑 IP 是否同网段;启动客户端;在 Windows 防火墙放行 OpenDance 的 TCP 8765 入站;关闭路由器 AP 隔离 |
| 「扫描局域网」扫不到主机 | 手机未连 WiFi、路由器 AP 隔离、或客户端未监听 8765 | 确认手机连的是 WiFi 而非蜂窝;改为手动填 IP;检查客户端监听端口与防火墙 |
| 点「开始推流」立即闪退,或提示 MediaPipe 初始化失败 | MediaPipe 原生库加载 / 静态初始化失败抛出的是 Error 子类(UnsatisfiedLinkError、ExceptionInInitializerError),不是 Exception;常见诱因是 ABI 不匹配(例如把只含 arm 的 release 包装到 x86 模拟器)、extractNativeLibs=false、R8 裁掉 MediaPipe 或模型资源缺失 |
先看 App 给出的诊断:PoseDiagnostics 会把 cause 链最内层的真因、设备 ABI、APK 内 ABI 与结论一起显示在运行页(例如"APK 只包含 arm64-v8a/armeabi-v7a,而模拟器(x86_64)需要 x86_64")。真机用 release、模拟器调试用 debug;仓库已用 jniLibs.useLegacyPackaging = true 处理 native 解压,并有 -keepclasseswithmembernames + -printseeds 防止 R8 裁掉 MediaPipe |
| 画面里没有人 / 相机暂停一段时间后连接被断开 | 这是静默超时(服务端在 welcome 里声明 silentTimeoutMs),即使没有关键点,App 也会按 heartbeatIntervalMs 发应用层 ping |
正常情况下不会触发。若仍频繁断开,运行页「保活」一行会显示两端的参数;契约要求心跳小于静默超时的一半,可在 Windows 端 pose_server.gd 调整并在两端核对日志 |
| 断网恢复后不自动重连 | 契约 §7 规定:只有真正断开才重连;服务端主动关闭(1000/1001)或协议错误(1002)以及用户主动停止都不重连,避免"重连风暴" |
运行页「最近断开」会写明 close code 与原因。若原因是 1001 对端离开,说明 Windows 客户端那边重启或主动关闭了推流,需要在电脑端恢复后再点一次「开始推流」 |
| Windows 端「已连接 N 台」忽大忽小 | 同一次闪断被计成多台设备 | 契约 §3.1 要求 hello 携带稳定的 deviceId(本端取 ANDROID_ID 的哈希,不含硬件序列号),接收端按它去重。核对两端日志中的 deviceId 是否一致 |
技术栈
| 组件 | 选型 |
|---|---|
| 语言 | Kotlin 2.0.21 |
| 构建 | AGP 8.7.3 + Gradle 8.9 + JDK 17 |
| 相机 | CameraX 1.4.0 |
| 推理 | MediaPipe Tasks Vision 0.10.35 |
| 通信 | OkHttp 4.12.0 |
| UI | Jetpack Compose(BOM 2024.10.01, Material3) |
数据流
CameraX(ImageAnalysis) ──▶ MediaPipe PoseLandmarker(端侧推理)
│ 33 点归一化坐标
┌─────────────────────┴─────────────────────┐
▼ ▼
KeypointFrame(239B) ──▶ WebSocket ──▶ Windows 骨架叠加层
app/src/main/java/com/opendance/studio/
├── model/ 239 字节帧编解码 + 33 点拓扑(契约核心)
├── camera/ CameraX 采集
├── pose/ MediaPipe 端侧推理
├── net/ WebSocket 推流 + 局域网主机发现
├── data/ 设置持久化
└── ui/ 连接页 / 运行页 / 关于页
依赖的契约(opendance-web 冻结,逐项独立版本)
| 契约 | 版本 |
|---|---|
websocket-keypoints |
0.2.0(本端为发送方) |
skeleton-topology |
0.2.0 |
rest-api |
0.5.0 |
data-models |
0.7.0 |
model-formats |
0.5.0 |
database |
0.4.0 |
版本号来源统一(文档 §11.2):应用版本取 app/build.gradle.kts 的 versionName
(= project.yaml 的 version = APK 文件名 = Git tag),契约版本在构建期从
contracts/VERSION 解析后注入 BuildConfig;Kotlin 界面代码里不出现任何版本号字面量。
contracts/websocket-keypoints.md- 239 字节关键点帧(本端为发送方)contracts/skeleton-topology.md- MediaPipe 33 点索引与坐标语义contracts/open-source-credits.md- 关于页必须展示的开源声明
contracts/ 是只读快照。任何跨端契约变更必须先回到
[opendance-web](https://<Gitea 地址>//opendance-web) 修改并提升版本。
跨端一致性:金标准字节向量测试
本仓库最重要的测试不是「函数跑通了」,而是与另两端逐位一致:
KeypointFrameTest.goldenVectorMatchesWebImplementation 用 opendance-web 已通过验收的
TypeScript 实现生成 239 字节金标准向量,断言 Kotlin 实现产出完全相同的字节。
这条测试同时守护三端(TypeScript / GDScript / Kotlin),任何一端私自改字段布局都会立刻失败。
已知限制
- Godot(Windows 端)的
WebSocketPeer不暴露握手请求路径,因此无法校验 path 是否为/pose, 本端仍按契约连接到/pose,互操作不受影响。 - 未在真实 Android 设备上安装运行(本环境无设备也无模拟器)。 已验证到:构建通过、签名有效、包信息正确、模型与 4 个 ABI 的 native 库均正确打包。
- 主机发现依赖手机与电脑处于同一 WiFi;路由器开启 AP 隔离时无法互访(见 README.txt FAQ)。
- release 包只含 arm64-v8a + armeabi-v7a。x86/x86_64 仅供模拟器,会占掉约 27 MB, 对真机用户没有价值;debug 包保留全部 4 个 ABI 以便模拟器调试。
第三方开源声明
见 THIRD_PARTY.md,或应用内「关于」页面。
未使用 SMPL 模型及其衍生拓扑,统一采用 MediaPipe 33 点拓扑。