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 的新增项随下次发布进包。
174 lines
6.5 KiB
Bash
Executable File
174 lines
6.5 KiB
Bash
Executable File
#!/bin/bash
|
||
# ============================================================
|
||
# OpenDance Studio —— Android 辅助 App 一键验证
|
||
#
|
||
# 依次执行:单元测试 → debug 构建 → release 构建 → 产物校验
|
||
# 任一环节失败即退出非 0。
|
||
#
|
||
# 用法:
|
||
# JAVA_HOME=/path/to/jdk17 ANDROID_HOME=/path/to/sdk bash build/verify.sh
|
||
# ============================================================
|
||
set -uo pipefail
|
||
|
||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||
cd "$PROJECT_DIR"
|
||
|
||
FAILED=0
|
||
|
||
echo "============================================================"
|
||
echo " 1/3 单元测试(JVM,无需设备)"
|
||
echo "============================================================"
|
||
if ./gradlew testDebugUnitTest --console=plain -q 2>&1 | tail -20; then
|
||
echo "✓ 单元测试通过"
|
||
else
|
||
echo "✗ 单元测试失败"
|
||
FAILED=1
|
||
fi
|
||
echo " 测试报告:app/build/reports/tests/testDebugUnitTest/index.html"
|
||
|
||
echo ""
|
||
echo "============================================================"
|
||
echo " 2/3 构建 debug + release APK"
|
||
echo "============================================================"
|
||
if bash build/build-apk.sh all 2>&1 | grep -vE "^> Task|^$" | tail -20; then
|
||
:
|
||
else
|
||
FAILED=1
|
||
fi
|
||
|
||
echo ""
|
||
echo "============================================================"
|
||
echo " 3/3 产物校验(签名 / 版本元数据 / 包信息 / 模型资源 / ABI / R8)"
|
||
echo "============================================================"
|
||
SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}"
|
||
# 优先选用与 compileSdk 匹配的 build-tools,缺失时退回最新版
|
||
if [ -d "$SDK/build-tools/34.0.0" ]; then
|
||
BT="$SDK/build-tools/34.0.0"
|
||
else
|
||
BT="$(ls -d "$SDK"/build-tools/* 2>/dev/null | sort -V | tail -1)"
|
||
fi
|
||
RELEASE_APK="app/build/outputs/apk/release/app-release.apk"
|
||
|
||
if [ ! -f "$RELEASE_APK" ]; then
|
||
echo "✗ 未生成 $RELEASE_APK"
|
||
FAILED=1
|
||
elif [ -z "$BT" ]; then
|
||
echo "✗ 未找到 build-tools,无法校验"
|
||
FAILED=1
|
||
else
|
||
# 以**退出码**为准:不同 build-tools 版本的 apksigner 输出文案不一致
|
||
# (34.0.0 会打印 "Verifies",36.0.0 配 --print-certs 时只打印证书信息),
|
||
# 依赖日志字符串判断会导致误判。
|
||
"$BT/apksigner" verify "$RELEASE_APK" > /tmp/apk_verify.txt 2>&1
|
||
VERIFY_STATUS=$?
|
||
"$BT/apksigner" verify --print-certs "$RELEASE_APK" > /tmp/apk_sign.txt 2>/dev/null
|
||
|
||
if [ "$VERIFY_STATUS" -eq 0 ]; then
|
||
echo "✓ 签名校验通过(apksigner 退出码 0)"
|
||
grep -E "Signer #1 certificate DN" /tmp/apk_sign.txt | sed 's/^/ /' || true
|
||
if grep -q "APK Signature Scheme v2" /tmp/apk_verify.txt 2>/dev/null; then
|
||
echo " v2 签名方案:已启用"
|
||
fi
|
||
else
|
||
echo "✗ 签名校验失败(apksigner 退出码 $VERIFY_STATUS)"
|
||
cat /tmp/apk_verify.txt
|
||
FAILED=1
|
||
fi
|
||
|
||
"$BT/aapt2" dump badging "$RELEASE_APK" 2>/dev/null | head -1 | sed 's/^/ /'
|
||
|
||
# ---- 版本元数据必须落在**产物本身**上(文档 §11.2)----
|
||
# gradle 的 verifyReleaseVersion 只比构建配置;APK 的 AndroidManifest 才是
|
||
# 安装界面 / 应用信息页读到的版本。构建缓存或拷错文件都只会在这一层暴露。
|
||
if bash "$PROJECT_DIR/build/check-apk-version.sh" "$RELEASE_APK"; then
|
||
:
|
||
else
|
||
echo "✗ APK 的 versionName/versionCode 与 project.yaml 不一致"
|
||
FAILED=1
|
||
fi
|
||
|
||
if unzip -l "$RELEASE_APK" | grep -q "assets/pose_landmarker_lite.task"; then
|
||
echo "✓ MediaPipe 模型已随包分发"
|
||
else
|
||
echo "✗ APK 中缺少 MediaPipe 模型"
|
||
FAILED=1
|
||
fi
|
||
|
||
# ---- T-AND-012:ABI 必须恰为真机 ABI,且每个 ABI 下都有 MediaPipe native 库 ----
|
||
ABIS=$(unzip -l "$RELEASE_APK" | grep -oE "lib/[^/]+/" | sed 's#lib/##;s#/##' | sort -u)
|
||
ABIS_ONE_LINE=$(echo "$ABIS" | tr '\n' ' ')
|
||
echo " 包含 ABI:$ABIS_ONE_LINE"
|
||
EXPECTED_ABIS=$(printf 'arm64-v8a\narmeabi-v7a')
|
||
if [ "$ABIS" = "$EXPECTED_ABIS" ]; then
|
||
echo "✓ release ABI 恰为 arm64-v8a + armeabi-v7a(真机 ABI)"
|
||
else
|
||
echo "✗ release ABI 与预期不符(预期 arm64-v8a / armeabi-v7a)"
|
||
FAILED=1
|
||
fi
|
||
if echo "$ABIS" | grep -qE "x86"; then
|
||
echo "✗ release APK 含模拟器 ABI,会导致包体翻倍且与 abiFilters 不符"
|
||
FAILED=1
|
||
fi
|
||
for abi in arm64-v8a armeabi-v7a; do
|
||
if unzip -l "$RELEASE_APK" | grep -q "lib/$abi/.*mediapipe"; then
|
||
echo "✓ lib/$abi 下有 MediaPipe native 库"
|
||
else
|
||
echo "✗ lib/$abi 下缺少 MediaPipe native 库(真机安装后必然初始化失败)"
|
||
FAILED=1
|
||
fi
|
||
done
|
||
|
||
# ---- T-AND-015:native 库体检(ABI / ELF 类型 / 依赖闭包 / 16 KB 页对齐)----
|
||
NPY="${PYTHON_BIN:-python3}"
|
||
if command -v "$NPY" >/dev/null 2>&1; then
|
||
# 期望 ABI 用上面已经算好的 EXPECTED_ABIS(项目声明的真机 ABI),
|
||
# 而不是 APK 自己的 ABI 列表 —— 否则「ABI 是否符合预期」这条就自我循环了
|
||
if "$NPY" "$PROJECT_DIR/build/check_native_libs.py" "$RELEASE_APK" \
|
||
--expected-abis "$(echo "$EXPECTED_ABIS" | tr '\n' ',' | sed 's/,$//')" \
|
||
>/tmp/apk_native.txt 2>&1; then
|
||
grep -E "✓ native 库体检通过" /tmp/apk_native.txt | sed 's/^/ /'
|
||
else
|
||
echo "✗ native 库体检未通过:"
|
||
sed 's/^/ /' /tmp/apk_native.txt | tail -20
|
||
FAILED=1
|
||
fi
|
||
else
|
||
echo "✗ 未找到 $NPY,无法做 native 库体检"
|
||
FAILED=1
|
||
fi
|
||
|
||
# ---- T-AND-012:R8 不得裁掉 MediaPipe(seeds 由 -printseeds 输出) ----
|
||
SEEDS="app/build/outputs/mapping/release/seeds.txt"
|
||
if [ -f "$SEEDS" ]; then
|
||
MP_SEEDS=$(grep -c "^com.google.mediapipe" "$SEEDS" || true)
|
||
if [ "$MP_SEEDS" -gt 0 ]; then
|
||
echo "✓ R8 保留 MediaPipe 类 $MP_SEEDS 条(seeds.txt)"
|
||
else
|
||
echo "✗ R8 把 MediaPipe 整体裁掉了,release 包必然崩溃"
|
||
FAILED=1
|
||
fi
|
||
else
|
||
echo "✗ 未生成 $SEEDS(proguard-rules.pro 的 -printseeds 是否被移除?)"
|
||
FAILED=1
|
||
fi
|
||
|
||
# ---- T-AND-012:关键类必须真的在 dex 里 ----
|
||
if unzip -p "$RELEASE_APK" classes.dex 2>/dev/null | grep -qa "poselandmarker/PoseLandmarker"; then
|
||
echo "✓ dex 中存在 PoseLandmarker 类"
|
||
else
|
||
echo "✗ dex 中找不到 PoseLandmarker(R8/打包异常)"
|
||
FAILED=1
|
||
fi
|
||
fi
|
||
|
||
echo ""
|
||
if [ "$FAILED" -ne 0 ]; then
|
||
echo "============================================================"
|
||
echo " 验证失败"
|
||
echo "============================================================"
|
||
exit 1
|
||
fi
|
||
echo "============================================================"
|
||
echo " 验证全部通过"
|
||
echo "============================================================"
|