一、测试抓到的真实缺陷(T-AND-017)
契约 §3.6 明确:「接收端收到任何一帧都算活动,ping **无需额外应答**即可保活,
pong 只用于诊断」—— 即"合规但不回 pong"的接收端是**合法**的。
但 PoseWebSocketClient.onOpen 里写的是 lastPongAtMs = timing.nowMs(),
于是心跳循环的守卫 if (lastPongAtMs > 0 && silentFor > limit) **恒为真**:
对任何接收端都会「判定链路已死 → 主动断链 → 重连」,默认参数
(heartbeatIntervalMs=5000)下即**每 15 秒断一次**,正是用户抱怨的「频繁重连」。
为什么以前没暴露:我们自己的接收端(Windows PoseServer)**会**回 pong
(T-WIN-020 实测 9 次 ping 全部收到 pong),这条路径在自家联调里永远走不到。
修复:onOpen 复位 lastPongAtMs = 0L(0 = 本连接还没收到过 pong),
只有「曾经回过 pong、之后又不回了」才判定链路已死 —— 这正是该诊断本来的意图。
二、可注入计时 Timing(时钟 + 等待)
契约 §7.1 的退避序列 0.5s→1→2→4→8→15s 封顶若要端到端验证,真实等待约 30 秒;
「连续 3 个心跳周期没收到 pong」也要等几十秒。抽成接口后:
* 生产默认 SystemTiming = 真实时钟 + 真实 delay,行为与抽取前完全一致
(10 处 System.currentTimeMillis() 与 2 处 delay() 改走 timing);
* 测试注入「可控时钟 + 立即返回的等待」,毫秒级跑完整条序列。
三、新增 4 项 JVM 用例(PoseWebSocketBackoffTest,无需设备)
* 退避序列在 15 秒封顶且不无限增长:连续失败下断言第 1..8 次等待分别落在
500/1000/2000/4000/8000/15000/15000/15000 的 ±20% 区间内,且真实耗时 < 5 秒;
* 等待走注入实现:前三次重连真实耗时 < 1.5 秒(真实退避需 3.5 秒);
* **合规但从不回 pong 的接收端不会被判定链路已死**(修复前此处失败):
走过 6 个心跳周期仍为 streaming、无「判定链路已死」日志;
* 曾经回过 pong 之后停止回复时判定链路已死并重连:诊断仍然有效。
实测:./gradlew :app:testDebugUnitTest → 7 个测试类 / 68 项用例 / 0 失败(原 64)。
build/verify.sh 全绿(签名、APK 元数据 0.2.2/202、native 体检、ABI、R8、dex)。
版本号 0.2.1/201 → 0.2.2/202。
写测试踩的两个坑(已写入任务文档):
1. 不要在瞬时重连下读 stats.nextRetryMs(等待立即返回后已被清零,与重连循环竞争),
等待时长应取注入实现记录的事实;
2. 虚拟时钟需要一个真实让出点(FakeTiming.await 里 2ms 真实 delay),
否则心跳循环会在网络回调落地前把时钟推过阈值。
380 lines
17 KiB
Plaintext
380 lines
17 KiB
Plaintext
================================================================
|
||
OpenDance Studio -- Android 辅助 App v0.2.2 使用说明
|
||
================================================================
|
||
|
||
手机端姿态采集器:用摄像头做端侧姿态推理,把 33 个骨骼关键点通过
|
||
WebSocket 推送给 Windows 客户端,由后者驱动 VRM 角色并实时评分。
|
||
|
||
隐私:画面不会离开手机。通过网络发送的只有 33 个关键点坐标。
|
||
|
||
--
|
||
一、安装
|
||
--
|
||
|
||
方式一(推荐):直接安装随附的 app-release.apk
|
||
- 在手机上允许「安装未知来源应用」
|
||
- 点击 APK 安装
|
||
|
||
方式二:用 adb 安装
|
||
adb install -r app-release.apk
|
||
|
||
要求:Android 10(API 29)或更高版本。
|
||
|
||
--
|
||
二、初次部署(从零跑起来)
|
||
--
|
||
|
||
本节面向第一次部署的人,给出从零到跑通的最短路径。
|
||
下文地址、路径、口令一律是占位符,请替换成你自己的值,且不要提交进仓库。
|
||
|
||
1) 前置条件
|
||
|
||
JDK 17 或更高 AGP 8.7.3 要求,构建脚本会校验主版本 >= 17
|
||
Gradle 8.9 仓库自带 gradlew,本机无需另装
|
||
Android SDK platforms;android-34 与 build-tools;34.0.0
|
||
compileSdk 34 / minSdk 29(Android 10+)/ targetSdk 34
|
||
设备 需要真机(Android 10 及以上、带摄像头)
|
||
|
||
真机与模拟器:
|
||
release 包只含 arm64-v8a + armeabi-v7a,装不到 x86/x86_64 模拟器;
|
||
debug 包含 x86/x86_64,可装进模拟器,但相机是虚拟场景,姿态识别
|
||
效果不能代表真机,不能作为验收依据。联调验收请用真机。
|
||
|
||
权限:相机权限为运行时授予;INTERNET / ACCESS_NETWORK_STATE /
|
||
ACCESS_WIFI_STATE 为普通权限,安装时自动授予,用于推流与局域网扫描。
|
||
|
||
2) 获取代码与本地配置
|
||
|
||
git clone https://gitea.kasugano.cn/zijunchiang/opendance-android.git
|
||
cd opendance-android
|
||
|
||
local.properties 是本地文件(已在 .gitignore 中,不入库),内容:
|
||
sdk.dir=<Android SDK 路径>
|
||
|
||
build/build-apk.sh 在缺失时会依据 ANDROID_HOME / ANDROID_SDK_ROOT 自动生成。
|
||
|
||
签名材料不入库:仓库只有生成脚本,没有私钥。首次发布前执行
|
||
bash build/gen-keystore.sh
|
||
生成 keystore/opendance-release.jks 与 keystore.properties(含 storeFile、
|
||
keyAlias 与两个口令字段)。请自行备份,且不要提交。
|
||
取值优先级: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
|
||
|
||
产物(固定路径):
|
||
app/build/outputs/apk/debug/app-debug.apk
|
||
app/build/outputs/apk/release/app-release.apk
|
||
|
||
发布整包(APK + THIRD_PARTY.md + README.txt + contracts/):
|
||
bash build/package-release.sh -> dist-release/
|
||
|
||
4) 安装到手机
|
||
|
||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||
adb install -r app/build/outputs/apk/release/app-release.apk
|
||
|
||
也可把 APK 拷到手机,允许「安装未知来源应用」后点击安装。
|
||
首次进入运行页会申请相机权限;拒绝后可在运行页点「授予相机权限」
|
||
重新申请,或到系统设置里手动开启。
|
||
|
||
5) 配置与联通
|
||
|
||
本 App 只连接 Windows 客户端,不访问 Web 服务端。
|
||
地址来自 AppSettings.webSocketUrl(): ws://<host>:<port>/pose
|
||
子协议 opendance.pose.v1。
|
||
|
||
连接页字段与默认值(见 data/AppSettings.kt):
|
||
IP 地址 host 默认是 RFC 5737 文档网段占位地址
|
||
(源码为 192.0.2.100,非真实主机)
|
||
端口 port 8765
|
||
目标帧率 targetFps 30(5~60)
|
||
分辨率 resolution 640x480(可选 480x360 / 1280x720)
|
||
镜像画面 mirror 开
|
||
仅上半身模式 upperBodyOnly 关
|
||
|
||
填写示例(占位符,请替换成你电脑的局域网 IP):
|
||
IP 地址:192.0.2.10
|
||
端口: 8765
|
||
|
||
不想手填可点「扫描局域网」:App 由本机 WiFi IPv4 推导 /24 网段,
|
||
以 32 并发、单机 300ms 超时探测开放 8765 的主机(约 2~3 秒),
|
||
按延迟升序展示,点候选即填入。也可先点「测试连接」确认端口可达。
|
||
|
||
6) 第一次跑通
|
||
|
||
手机与电脑连同一局域网 -> 电脑启动 Windows 客户端(监听 8765)->
|
||
App 填地址并测试连接 -> 点「开始推流」并授予相机权限 -> 站到画面中央。
|
||
|
||
成功标志:
|
||
App 运行页 「连接状态」为「已连接」,「目标」显示 ws://<IP>:<端口>/pose
|
||
App 运行页 服务端返回 welcome 后出现「会话」ID
|
||
App 运行页 「已发送」持续增长,「发送帧率」「识别帧率」大于 0
|
||
App 运行页 「人体识别」为「已识别」,出现骨架叠加(黄点好、红点置信度低)
|
||
Windows 端 显示已连接并收到关键点,VRM 模型随动作实时动起来
|
||
|
||
6.5) v0.2.2 修订
|
||
|
||
1. 修一处真实缺陷(T-AND-017): 契约 §3.6 明确「ping 无需额外应答即可保活,
|
||
pong 只用于诊断」,即"合规但不回 pong"的接收端是合法的;但客户端的
|
||
onOpen 里把 lastPongAtMs 设成了当前时刻,于是"连续 3 个心跳周期没收到 pong
|
||
就判定链路已死"对任何接收端都会触发 —— 默认参数下每 15 秒主动断一次并重连,
|
||
正是"频繁重连"的症状。现改为 0(= 本连接还没收到过 pong),
|
||
只有"曾经回过 pong、之后又不回了"才判定链路已死。
|
||
2. 新增可注入计时 Timing(时钟 + 等待),生产默认与原来完全一致;
|
||
测试注入可控时钟后,契约 §7.1 的退避序列(0.5→1→2→4→8→15s 封顶)能在
|
||
毫秒级端到端验证,不再需要等 30 秒。
|
||
3. 新增 4 项 JVM 用例(无需设备):退避封顶序列逐值断言、注入实现不产生真实等待、
|
||
合规不回 pong 不被误判、回过 pong 后停止回复仍能正确诊断。
|
||
4. 单元测试 68 项(原 64)。
|
||
|
||
6.5) v0.2.1 修订
|
||
|
||
1. 重连链路的真实 WebSocket 集成测试(T-AND-016): 用 MockWebServer 的
|
||
withWebSocketUpgrade 在 JVM 上起真实 WebSocket 服务端,覆盖「该重连的」
|
||
(异常关闭码 1011 / 服务端未启动)与「不该重连的」(1000/1001/1002 /
|
||
用户主动断开)两条分支,并断言重连等待取自策略、落在 ±20% 区间、
|
||
成功后计数清零。无需设备。
|
||
2. 修一处真实缺陷: 上一次会话的关闭码会污染下一次会话 —— 第一次被 1001 关闭后
|
||
用户重新开始推流,若这一次是网络层失败,客户端仍按 1001 判定「不重连」,
|
||
于是再也不会自动重连,只能反复手点。现在 connect() 会清掉会话级关闭状态,
|
||
网络失败按「未收到关闭帧(0)」记录并按 §7 重连。
|
||
3. 单元测试 64 项(原 56 项)。
|
||
|
||
7) 自检清单
|
||
|
||
./gradlew testDebugUnitTest
|
||
预期:BUILD SUCCESSFUL,全部用例通过
|
||
报告:app/build/reports/tests/testDebugUnitTest/index.html
|
||
|
||
bash build/build-apk.sh all
|
||
预期:debug 与 release 均显示 "OK ... APK", release 显示
|
||
"已签名(APK Signature Scheme v2)"
|
||
|
||
$ANDROID_HOME/build-tools/34.0.0/apksigner verify app/build/outputs/apk/release/app-release.apk
|
||
预期:退出码 0(无输出即通过)
|
||
|
||
0a) native 库体检(T-AND-015;无设备也能查「一装上就崩」的打包问题)
|
||
python3 build/check_native_libs.py app/build/outputs/apk/release/app-release.apk
|
||
预期:「✓ native 库体检通过」——每个 ABI 下每个 .so 的 ELF 类型、
|
||
依赖闭包(DT_NEEDED,非系统库必须同 ABI 自带)、页对齐(16 KB 兼容)。
|
||
|
||
0) 发布包级验收(对**发布包**而不是本地构建;文档 §7.5)
|
||
bash build/accept-release.sh dist-release/opendance-android-v0.2.2.zip
|
||
预期末行「发布包级验收全部通过」。它会校验 sha256、解压、核对文件清单、
|
||
读包内 APK 的 AndroidManifest 版本元数据、核对 ABI 与 MediaPipe native 库、
|
||
模型资源、dex 里的 PoseLandmarker、验签,以及包内契约快照与仓库是否一致。
|
||
|
||
bash build/check-apk-version.sh app/build/outputs/apk/release/app-release.apk
|
||
预期:AndroidManifest versionName = <project.yaml 的 version>
|
||
AndroidManifest versionCode = 10000*主 + 100*次 + 修订
|
||
"APK 版本元数据一致"
|
||
说明:安装界面 / 应用信息页读的是 APK 的 AndroidManifest,而不是
|
||
build.gradle.kts 的配置。构建缓存或拷错文件会让两者不一致,
|
||
所以这里直接读产物(不需要 aapt2,只用 python3 解析二进制 AXML)。
|
||
|
||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||
预期:Success
|
||
|
||
8) 常见问题
|
||
|
||
Q: 相机黑屏 / 一直提示需要相机权限
|
||
A: 权限被拒绝或已被永久拒绝,或相机被其他应用占用。在运行页点
|
||
「授予相机权限」;不再弹窗时到系统「设置 -> 应用 -> OpenDance -> 权限」
|
||
手动开启相机,并关闭占用相机的其它 App。
|
||
|
||
Q: 连不上 Windows 客户端 / 扫描不到主机
|
||
A: 核对 IP 与端口、确认客户端已启动并监听 8765、在 Windows 防火墙放行
|
||
OpenDance 的 TCP 8765 入站、确认手机与电脑同一 WiFi 且路由器未开 AP 隔离。
|
||
|
||
Q: 点「开始推流」闪退(或提示 MediaPipe 初始化失败)
|
||
A: MediaPipe 原生库加载 / 静态初始化失败抛出的是 Error 子类
|
||
(UnsatisfiedLinkError、ExceptionInInitializerError),不是 Exception;
|
||
常见诱因是 ABI 不匹配(例如把只含 arm 的 release 包装到 x86 模拟器)
|
||
或模型资源缺失。处理:改用与设备 ABI 匹配的包(真机用 release、
|
||
模拟器调试用 debug);确认 APK 内含 assets/pose_landmarker_lite.task。
|
||
仓库已在 PoseDetector.initialize() 中 catch Throwable 兜底并把原因显示在
|
||
运行页;CrashLogger 会把未捕获堆栈写入应用私有目录,并在连接页展示,
|
||
可截图反馈,或用 adb logcat -s PoseDetector 查看。
|
||
|
||
--
|
||
三、快速开始
|
||
--
|
||
|
||
1. 在 Windows 电脑上启动 OpenDance Studio 客户端
|
||
(Windows 客户端会自动监听 8765 端口)
|
||
|
||
2. 确保手机与电脑连在同一个 WiFi
|
||
|
||
3. 打开本应用,进入「连接设置」页
|
||
|
||
4. 填写电脑的 IP 地址,或直接点「扫描局域网」自动发现
|
||
(扫描会探测局域网内开放 8765 端口的主机,约 2~3 秒)
|
||
|
||
5. 可点「测试连接」确认端口可达
|
||
|
||
6. 按需调整推流参数(见下)
|
||
|
||
7. 点「开始推流」,授予相机权限
|
||
|
||
8. 站到画面中央,屏幕上会出现骨架叠加:
|
||
- 黄色圆点 = 识别良好的关节
|
||
- 红色圆点/红色连线 = 置信度低(光线不足或被遮挡)
|
||
|
||
9. 在 Windows 客户端上确认已连接,即可开始跟练
|
||
|
||
--
|
||
四、推流参数
|
||
--
|
||
|
||
分辨率
|
||
低(480x360) 最省电,适合老机型
|
||
中(640x480) 推荐,识别精度与功耗平衡
|
||
高(1280x720) 最清晰,但推理更慢、更耗电
|
||
|
||
目标帧率
|
||
默认 30 FPS。低端机若「识别帧率」明显低于目标值,可降到 20 或 15。
|
||
|
||
镜像画面
|
||
默认开启。前置摄像头的常见用法。
|
||
注意:本应用发送的是未镜像的原始坐标,是否镜像由帧头 flags.bit0 声明,
|
||
Windows 客户端据此正确还原左右。因此无论开关如何,动作方向都不会错。
|
||
|
||
仅上半身模式
|
||
下半身关键点置信度置 0(显示为红点),Windows 端会插值。
|
||
带宽或算力不足时开启可明显降负载。
|
||
|
||
--
|
||
五、运行页状态说明
|
||
--
|
||
|
||
连接状态 未连接 / 连接中 / 已连接 / 重连中(第 N 次)/ 已断开
|
||
目标 正在连接的 WebSocket 地址
|
||
会话 服务端返回的会话 ID
|
||
已发送 累计发送帧数
|
||
已丢弃 因推理或连接跟不上而丢弃的帧数(>0 说明设备吃力)
|
||
发送帧率 实际推流帧率
|
||
识别帧率 实际推理帧率
|
||
推理耗时 单帧 MediaPipe 推理毫秒数
|
||
人体识别 已识别 / 未识别到人体
|
||
平均置信度 < 70% 时转黄,提示改善光线或减少遮挡
|
||
最近错误 连接或推理的错误原因
|
||
|
||
网络中断时会自动重连(1s -> 2s -> 4s... 最长 15s 退避),
|
||
无需手动操作。
|
||
|
||
--
|
||
六、常见问题
|
||
--
|
||
|
||
Q: 扫描不到电脑
|
||
A: 1) 确认手机与电脑在同一 WiFi(不是手机热点)
|
||
2) 确认 Windows 客户端已启动并正在监听 8765
|
||
3) 在 Windows 防火墙放行 OpenDance.exe 的 TCP 8765 入站
|
||
4) 部分路由器开启了「AP 隔离」,会阻断设备互访,需在路由器关闭
|
||
|
||
Q: 显示「已连接」但电脑端没有画面
|
||
A: 查看运行页的「已发送」是否在增长。若为 0,说明相机或推理没出帧;
|
||
若「人体识别」一直是「未识别到人体」,请站到画面中央并保证光线充足。
|
||
|
||
Q: 帧率很低 / 已丢弃帧数持续增长
|
||
A: 依次尝试:降低分辨率 -> 降低目标帧率 -> 开启「仅上半身模式」-> 关闭其他后台应用。
|
||
|
||
Q: 关键点抖动明显
|
||
A: 正常现象,Windows 端有 DTW 带状对齐与平滑处理,不要求逐帧完全一致。
|
||
改善光照可显著提高稳定性。
|
||
|
||
Q: 应用退到后台会怎样?
|
||
A: 返回键或离开运行页会立即停止推流并释放相机,不会在后台占用摄像头。
|
||
|
||
--
|
||
七、从源码构建
|
||
--
|
||
|
||
环境要求:
|
||
JDK 17 AGP 8.7.3 要求(JDK 25 未经测试)
|
||
Android SDK platforms;android-34 与 build-tools;34.0.0
|
||
Gradle 8.9 已随 gradlew 提供
|
||
|
||
构建:
|
||
|
||
export JAVA_HOME=/path/to/jdk17
|
||
export ANDROID_HOME=/path/to/android-sdk
|
||
|
||
# 生成发布签名密钥库(首次;文件不会入库,请自行备份)
|
||
bash build/gen-keystore.sh
|
||
|
||
# 构建并校验产物
|
||
bash build/build-apk.sh all
|
||
|
||
# 一键验证(单元测试 -> 构建 -> 产物校验)
|
||
bash build/verify.sh
|
||
|
||
产物:
|
||
app/build/outputs/apk/debug/app-debug.apk
|
||
app/build/outputs/apk/release/app-release.apk
|
||
|
||
Android Studio 路径:
|
||
打开本目录 -> Build -> Generate Signed Bundle / APK -> APK -> 选择 keystore -> release
|
||
|
||
--
|
||
八、测试
|
||
--
|
||
|
||
./gradlew testDebugUnitTest
|
||
|
||
56 项单元测试(JVM,无需设备)。除金标准字节向量测试外,另有两组针对
|
||
本次缺陷的用例:
|
||
- ReconnectPolicyTest(11 项):退避序列 0.5s->1->2->4->8->15s 封顶 + ±20%
|
||
抖动;契约 §7 的「只有真正断开才重连」(1000/1001/1002 与主动停止都不重连);
|
||
心跳间隔必须严格小于静默超时的一半;
|
||
- PoseDiagnosticsTest(12 项):从 ExceptionInInitializerError 的 cause 链
|
||
翻出 UnsatisfiedLinkError 真因;ABI 比对(release 包装在 x86_64 模拟器必崩);
|
||
环状 cause 不死循环。
|
||
金标准字节向量测试仍是契约一致性的硬证据:用 opendance-web 已通过验收的
|
||
TypeScript 实现产出的 239 字节向量,断言本端 Kotlin 实现逐位相同。
|
||
|
||
测试报告:app/build/reports/tests/testDebugUnitTest/index.html
|
||
|
||
--
|
||
九、目录结构
|
||
--
|
||
|
||
settings.gradle.kts / build.gradle.kts 工程与插件配置
|
||
gradle/wrapper/ Gradle 8.9 wrapper
|
||
project.yaml 任务清单与状态
|
||
DSH.md 本项目 DSH 执行约束
|
||
tasks/ 单任务说明书(T-AND-001 ~ 012)
|
||
contracts/ 只读契约快照(来源 opendance-web;六项各自独立版本)
|
||
app/src/main/java/com/opendance/studio/
|
||
MainActivity.kt 三页导航
|
||
model/ 239 字节帧编解码 + 33 点拓扑
|
||
camera/ CameraX 采集
|
||
pose/ MediaPipe 端侧推理
|
||
net/ WebSocket 推流 + 主机发现 + 重连策略
|
||
data/ 设置(含稳定 deviceId)
|
||
ui/ 连接页 / 运行页 / 关于页
|
||
util/ 崩溃日志 + 连接日志(可导出)
|
||
app/src/main/assets/pose_landmarker_lite.task MediaPipe 模型
|
||
build/ 打包与验证脚本
|
||
|
||
--
|
||
十、许可
|
||
--
|
||
|
||
本应用代码以 MIT 许可发布。第三方开源项目声明见 THIRD_PARTY.md
|
||
(应用内「关于」页同样可查看)。
|
||
未使用 SMPL 模型及其衍生拓扑,统一采用 MediaPipe 33 点拓扑。
|