Files
opendance-android/README.md
T
zijunchiang c0d0937023 fix(reconnect): 合规接收端被周期性误判「链路已死」(v0.2.2)+ 可注入计时覆盖退避封顶
一、测试抓到的真实缺陷(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),
     否则心跳循环会在网络回调落地前把时钟推过阈值。
2026-09-18 02:34:54 +08:00

22 KiB
Raw Blame History

opendance-android

OpenDance Studio 阶段三: Android 辅助 App(Kotlin + CameraX + MediaPipe 端侧推理)。

用手机摄像头做端侧姿态推理,把 33 个骨骼关键点通过 WebSocket 推给 Windows 客户端, 由后者驱动 VRM 角色并 DTW 实时评分。

画面不离开设备。通过网络发送的只有 33 个关键点坐标,不含任何图像数据。

状态

阶段三已完成: T-AND-001 -> T-AND-017 全部落地(含重连状态机与真实 WebSocket 重连链路测试、MediaPipe 崩溃修复、APK 产物版本元数据校验、native 库体检)。

验收项 结果
单元测试 OK 68 项全部通过(JVM,无需设备;含 MockWebServer 真实 WebSocket 的重连链路集成测试)
跨端金标准 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.2 (versionCode 202), 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)
APK 版本元数据 OK 直接读产物 AndroidManifest 的 versionName=0.2.2/versionCode=202,与 project.yaml 一致(T-AND-013)
native 库体检 OK ELF 类型匹配双 ABI、DT_NEEDED 闭包完整(无缺失依赖)、PT_LOAD 页对齐 0x4000(16 KB 页兼容)、zipalign -P 16 通过(T-AND-015)

v0.2.2 修订(退避封顶端到端验证 + 修「合规接收端被周期性误判链路已死」)

缺陷(T-AND-017):契约 §3.6 明确「接收端收到任何一帧都算活动,ping 无需额外应答 即可保活,pong 只用于诊断」—— 即"合规但不回 pong"的接收端是合法的。 但 onOpen 里写的是 lastPongAtMs = now,于是心跳循环的守卫 if (lastPongAtMs > 0 && silentFor > limit) 恒为真:对任何接收端都会 「判定链路已死 → 主动断链 → 重连」,默认参数下每 15 秒一次 —— 正是"频繁重连"的症状。 为什么以前没暴露:我们自己的 Windows 接收端会回 pong,这条路径在自家联调里走不到。 修复:onOpen 复位 lastPongAtMs = 0L(0 = 本连接还没收到过 pong), 只有"曾经回过 pong、之后又不回了"才判定链路已死。

新增可注入计时 Timing(时钟 + 等待,生产默认与原来完全一致): 契约 §7.1 的退避序列真实等待约 30 秒,写在测试里既慢又易 flaky。 注入可控时钟后,PoseWebSocketBackoffTest(4 项,无需设备)可以在毫秒级断言 0.5→1→2→4→8→15→15s(各 ±20% 抖动区间)并确认没有真实等待; 另有两条用例分别锁住「合规不回 pong 不被误判」与「回过 pong 后停止回复仍能正确诊断」。

$ ./gradlew :app:testDebugUnitTest
测试类 7 个,用例 68 项,失败 0        # 原 64 项

v0.2.1 修订(重连链路的真实 WebSocket 集成测试 + 一处真实缺陷)

缺陷:上一次会话的关闭码会污染下一次会话,导致重新开始推流后失去自动重连能力。 复现路径:第一次连接被对端以 1001 关闭(按契约 §7 不重连,正确)→ 用户再次点 「开始推流」→ 这一次是网络层失败(服务端还没起来),按 §7 应当重连 → 但 connect() 只重置了重连计数与 pong 时间,没有清 lastCloseCode, scheduleReconnect() 仍按 1001 判定 → 直接 stopped,再也不会自动重连。

修复:connect() 清掉会话级关闭状态(lastCloseCode = 0、lastCloseReason = ""); onFailure() 记录 recordClose(0, …)(0 = 未收到关闭帧 → 应重连), 顺带让界面「最近一次断开原因」显示本次失败而不是旧值。

为什么之前没发现:ReconnectPolicyTest(11 项)验的是纯函数「给定关闭码怎么退避」, 而缺陷在调用点(关闭码没被正确重置)。新增 app/src/test/java/com/opendance/studio/PoseWebSocketReconnectTest.kt(8 项): 用 MockWebServer.withWebSocketUpgrade 在 JVM 上起真实 WebSocket 服务端, 覆盖「该重连的」(1011 / 服务器未启动)与「不该重连的」(1000/1001/1002 / 主动断开)两条分支, 并断言重连等待取自策略且落在 ±20% 区间、成功后计数清零。


## 快速开始

```bash
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.kasugano.cn/zijunchiang/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 服务端。

  1. 手机与 Windows 电脑连到同一个局域网 / WiFi(路由器不要开 AP 隔离)。
  2. 在电脑上启动 OpenDance Studio Windows 客户端,确认它在监听 8765 端口。
  3. 手机打开 App,连接页填入电脑 IP(或用「扫描局域网」自动发现),点「测试连接」。
  4. 点「开始推流」,首次会申请相机权限,允许后进入运行页。
  5. 站到画面中央,屏幕上出现骨架叠加。

成功标志:

位置 可观察现象
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 界面代码里不出现任何版本号字面量。

native 库体检(T-AND-015):ExceptionInInitializerError 这类「一装上就崩」的 问题大多出在 native 库打包上,而这几项无设备也能查:

python3 build/check_native_libs.py dist-release/opendance-android-v0.2.1/app-release.apk
#   -> 每个 ABI 下每个 .so 的 ELF 类型 / DT_NEEDED 闭包 / PT_LOAD 页对齐
#   -> ✓ native 库体检通过:arm64-v8a, armeabi-v7a | ELF 类型匹配、依赖闭包完整、16 KB 页对齐

纯 Python 标准库实现(不需要 readelf / NDK),已接入 verify.sh 与 accept-release.sh。 与 readelf 在 6 个 .so 上逐项交叉验证一致;三条检测分支都用真实负例反向验证过 (错 ABI 的 .so、缺失依赖、页对齐不足)。它不替代真机,只把「打包配置错误」挡在发布前。

发布包级验收(T-AND-014):build/verify.sh 验的是本地刚构建的 APK, 而用户装的是 zip 里的 app-release.apk。发布前对包再跑一次:

bash build/accept-release.sh dist-release/opendance-android-v0.2.2.zip
#   -> sha256 / 文件清单 / AndroidManifest versionName+versionCode / ABI+MediaPipe native
#      / 模型资源 / dex 中的 PoseLandmarker / 验签 / 包内契约快照与仓库一致
#   -> 发布包级验收全部通过

已对已发布的 v0.2.0 资产(重新下载)跑通,并用「篡改包内 contracts/VERSION」 与「期望版本写错」两个负例反向验证过。

产物侧复核(T-AND-013):versionName 是构建配置,而安装界面 / 应用信息页 / dumpsys package 读的是 APK 的 AndroidManifest。构建缓存、复用旧 APK、 打包时拷错文件都会造成「配置全对、产物是旧版」,配置层校验抓不到(Windows 客户端 的 v0.2.1 就踩过同类坑:配置文件全一致,exe 属性页显示 0.1.0)。因此新增:

bash build/check-apk-version.sh app/build/outputs/apk/release/app-release.apk
#   -> AndroidManifest versionName = 0.2.1 / versionCode = 201
#   -> ✓ APK 版本元数据一致:0.2.1(versionCode 201)

build/check_apk_version.py 直接解析二进制 AXML,不需要 Android SDK / aapt2 (已与 aapt2 dump badging 在 v0.1.0 / v0.2.0 两个真实 APK 上交叉验证逐项一致)。 该检查已接入 build/verify.sh(步骤 3/3)与 build/package-release.sh(打包前 die)。

contracts/ 是只读快照。任何跨端契约变更必须先回到 opendance-web 修改并提升版本。

跨端一致性:金标准字节向量测试

本仓库最重要的测试不是「函数跑通了」,而是与另两端逐位一致:

KeypointFrameTest.goldenVectorMatchesWebImplementation 用 opendance-web 已通过验收的 TypeScript 实现生成 239 字节金标准向量,断言 Kotlin 实现产出完全相同的字节。 这条测试同时守护三端(TypeScript / GDScript / Kotlin),任何一端私自改字段布局都会立刻失败。

已知限制

  1. Godot(Windows 端)的 WebSocketPeer 不暴露握手请求路径,因此无法校验 path 是否为 /pose, 本端仍按契约连接到 /pose,互操作不受影响。
  2. 未在真实 Android 设备上安装运行(本环境无设备也无模拟器)。 已验证到:构建通过、签名有效、包信息正确、模型与 4 个 ABI 的 native 库均正确打包。
  3. 主机发现依赖手机与电脑处于同一 WiFi;路由器开启 AP 隔离时无法互访(见 README.txt FAQ)。
  4. release 包只含 arm64-v8a + armeabi-v7a。x86/x86_64 仅供模拟器,会占掉约 27 MB, 对真机用户没有价值;debug 包保留全部 4 个 ABI 以便模拟器调试。

第三方开源声明

见 THIRD_PARTY.md,或应用内「关于」页面。 未使用 SMPL 模型及其衍生拓扑,统一采用 MediaPipe 33 点拓扑。