Files
opendance-android/README.md
T
zijunchiang 95429e8ce3 test(native): APK native 库体检——ELF 类型 / DT_NEEDED 闭包 / 16 KB 页对齐(T-AND-015)
T-AND-012 的结论是「ExceptionInInitializerError 的根因在集成配置(native 解压 +
R8 + ABI),不是 MediaPipe 本身不稳定」,但当时的核查是**手工一次性**的(unzip 看
lib/、grep seeds、strings 找类名)——下次照样可能漏,且漏了也没人发现,只会在用户
装上手机、点开相机那一刻暴露。

新增 build/check_native_libs.py(纯 Python 标准库解析 ELF,不需要 readelf / NDK),
检查三项**无设备也能查**的「一装上就崩」根因:
  * ABI 与 ELF 类型匹配(把 x86_64 的 .so 放进 lib/arm64-v8a/ → 一加载就崩);
  * DT_NEEDED 依赖闭包(非系统库必须同 ABI 目录内自带;MediaPipe 早期版本的
    libc++_shared.so 就是这么缺的);
  * PT_LOAD 页对齐 ≥ 16 KB(Android 15 起部分设备页 16 KB,4 KB 对齐会直接加载失败;
    Google Play 2025-11 起也要求)。
外加 zipalign -c -P 16 -v 4(有 build-tools 时)。已接入 verify.sh 与 accept-release.sh。

实测(已发布的 opendance-android-v0.2.0.zip 内 APK):
  双 ABI 下 6 个 .so 全部 ELF 类型匹配、依赖闭包完整(libmediapipe_tasks_jni.so 只依赖
  libdl/libjnigraphics/libEGL/libGLESv2/libm/libz/liblog/libandroid/libc,全是系统库)、
  PT_LOAD p_align=0x4000(16 KB 兼容)、zipalign -P 16 通过。
  结论:打包配置这一侧的「一装上就崩」风险已排除,剩下的只能靠真机实测。

与 readelf 交叉验证(6 个 .so 的 DT_NEEDED / p_align / e_machine 逐项一致)——
这次交叉验证抓到解析器自己的 bug:第一版程序头偏移写错(ELF64 的
p_offset/p_vaddr 之后第三个 QWORD 是 p_paddr 而非 p_filesz),导致 vaddr→offset
映射失败、DT_NEEDED 解析为空,页面照样打印「✓ 依赖闭包完整(系统库 0 项)」——
一句**假通过**,唯一破绽就是那个 0。教训:通过输出必须带可核对的数量/取值。

反向验证(三条分支都用真实负例证明会失败):
  * 注入宿主 x86_64 的 libz.so.1 到 lib/arm64-v8a/libbogus.so → 同时报 ELF 类型不符、
    缺依赖 libc.so.6、页对齐 0x1000 < 16384;
  * 把 libz.so 从系统库白名单去掉 → 报缺少依赖 libz.so(证明闭包分支是活的);
  * --expected-abis 传错 → 报 ABI 集合不符。

verify.sh 全绿;对**已发布资产**重新下载后跑完整 accept-release.sh 全部通过。
未新发版本:APK 本身未变(本任务是检查工具与文档),README 的新增项随下次发布进包。
2026-09-17 05:25:04 +08:00

330 lines
19 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.
# opendance-android
OpenDance Studio **阶段三**: Android 辅助 App(Kotlin + CameraX + MediaPipe 端侧推理)。
用手机摄像头做**端侧**姿态推理,把 33 个骨骼关键点通过 WebSocket 推给 Windows 客户端,
由后者驱动 VRM 角色并 DTW 实时评分。
> **画面不离开设备**。通过网络发送的只有 33 个关键点坐标,不含任何图像数据。
## 状态
**阶段三已完成**: T-AND-001 -> T-AND-013 全部落地(含重连状态机、MediaPipe 崩溃修复、APK 产物版本元数据校验)。
| 验收项 | 结果 |
|--|--|
| 单元测试 | 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) |
| APK 版本元数据 | OK 直接读产物 `AndroidManifest` 的 `versionName=0.2.0`/`versionCode=200`,与 `project.yaml` 一致(T-AND-013) |
| native 库体检 | OK ELF 类型匹配双 ABI、`DT_NEEDED` 闭包完整(无缺失依赖)、`PT_LOAD` 页对齐 `0x4000`(16 KB 页兼容)、`zipalign -P 16` 通过(T-AND-015) |
## 快速开始
```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`](README.txt),工程约束见 [`DSH.md`](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. 获取代码与本地配置
```bash
git clone https://gitea.kasugano.cn/zijunchiang/opendance-android.git
cd opendance-android
```
`local.properties` 是**本地文件**(已在 `.gitignore` 中,不会入库),
只需写 Android SDK 路径:
```properties
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. 首次构建
```bash
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. 安装到手机
```bash
# 用 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 库打包上,而这几项**无设备也能查**:
```bash
python3 build/check_native_libs.py dist-release/opendance-android-v0.2.0/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
bash build/accept-release.sh dist-release/opendance-android-v0.2.0.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 就踩过同类坑)。因此新增:
```bash
bash build/check-apk-version.sh app/build/outputs/apk/release/app-release.apk
# -> AndroidManifest versionName = 0.2.0 / versionCode = 200
# -> ✓ APK 版本元数据一致:0.2.0(versionCode 200)
```
`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/websocket-keypoints.md`](contracts/websocket-keypoints.md) - 239 字节关键点帧(本端为**发送方**)
- [`contracts/skeleton-topology.md`](contracts/skeleton-topology.md) - MediaPipe 33 点索引与坐标语义
- [`contracts/open-source-credits.md`](contracts/open-source-credits.md) - 关于页必须展示的开源声明
`contracts/` 是**只读快照**。任何跨端契约变更必须先回到
[`opendance-web`](https://gitea.kasugano.cn/zijunchiang/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`](THIRD_PARTY.md),或应用内「关于」页面。
未使用 SMPL 模型及其衍生拓扑,统一采用 MediaPipe 33 点拓扑。