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 的新增项随下次发布进包。
216 lines
8.7 KiB
Bash
Executable File
216 lines
8.7 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# ============================================================
|
||
# OpenDance Studio Android 辅助 App —— **发布包级端到端验收**(T-AND-014)
|
||
#
|
||
# 为什么需要它:`build/verify.sh` 验的是 `app/build/outputs/` 里**刚构建**的 APK,
|
||
# 而交付给用户的是 zip 里的 `app-release.apk`。两者之间隔着打包与分发;
|
||
# 装到手机上的是后者。本项目在另两端都因此翻过车(Web v0.7.4 的实体注册、
|
||
# Windows v0.2.1 的 PE 版本元数据),所以三端统一要求:
|
||
# **每次发布都要有一条读产物本身的验收**(文档 §7.5)。
|
||
#
|
||
# 本脚本对一个 zip(或已解压目录)跑完:
|
||
# 1. sha256 校验(有 SHA256SUMS 时);
|
||
# 2. 解压 + 文件清单(apk / README / THIRD_PARTY / contracts);
|
||
# 3. APK 的 **AndroidManifest versionName/versionCode** = 本次发布版本
|
||
# (build/check-apk-version.sh;安装界面与应用信息页读的就是它);
|
||
# 4. ABI 恰为 arm64-v8a + armeabi-v7a(无模拟器 ABI),且两个 ABI 下都有
|
||
# MediaPipe native 库(T-AND-012 的崩溃根因之一);
|
||
# 5. MediaPipe 模型资源随包分发、dex 里仍有 PoseLandmarker(R8 没裁掉);
|
||
# 6. 有 apksigner 时顺带验签(可选,不影响结论);
|
||
# 7. 包内 contracts/VERSION 与仓库 contracts/VERSION 逐项一致
|
||
# (抓「APK 用旧契约构建、快照却拷了新的」)。
|
||
#
|
||
# 用法:
|
||
# bash build/accept-release.sh dist-release/opendance-android-v0.2.0.zip
|
||
# bash build/accept-release.sh <zip 或已解压目录> 0.2.0 # 显式期望版本
|
||
#
|
||
# 可选环境变量:
|
||
# ANDROID_HOME / ANDROID_SDK_ROOT 找 apksigner 用(找不到就跳过验签)
|
||
# WORK_DIR 解压目录(默认 mktemp -d)
|
||
# ============================================================
|
||
set -uo pipefail
|
||
|
||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||
cd "$PROJECT_DIR"
|
||
|
||
TARGET="${1:-}"
|
||
YAML_VERSION="$(sed -n 's/^version:[[:space:]]*//p' project.yaml | head -1)"
|
||
EXPECTED="${2:-$YAML_VERSION}"
|
||
WORK_DIR="${WORK_DIR:-$(mktemp -d)}"
|
||
|
||
FAILED=0
|
||
log() { printf '\033[1;34m[accept]\033[0m %s\n' "$*"; }
|
||
fail() { printf '\033[1;31m[accept] ✗ %s\033[0m\n' "$*" >&2; FAILED=1; }
|
||
|
||
if [ -z "$TARGET" ] || [ ! -e "$TARGET" ]; then
|
||
echo "用法:bash build/accept-release.sh <发布包 zip 或已解压目录> [期望版本]" >&2
|
||
exit 2
|
||
fi
|
||
if [ -z "$EXPECTED" ]; then
|
||
echo "✗ 无法确定期望版本(project.yaml 里没有 version,也没有显式传入)" >&2
|
||
exit 2
|
||
fi
|
||
|
||
log "目标:$TARGET"
|
||
log "期望版本:$EXPECTED"
|
||
log "工作目录:$WORK_DIR"
|
||
mkdir -p "$WORK_DIR"
|
||
|
||
# ---- 1. sha256 + 解压 ----
|
||
if [ -f "$TARGET" ]; then
|
||
# SHA256SUMS 里是裸文件名,校验时 cwd 必须切到包所在目录;sums 文件用绝对路径
|
||
TARGET_DIR_ABS="$(cd "$(dirname "$TARGET")" && pwd)"
|
||
SUMS="$TARGET_DIR_ABS/SHA256SUMS"
|
||
if [ -f "$SUMS" ]; then
|
||
log "sha256 校验($SUMS):"
|
||
( cd "$TARGET_DIR_ABS" && sha256sum -c "$SUMS" --ignore-missing ) || fail "sha256 校验失败"
|
||
else
|
||
log "未找到 $SUMS,跳过 sha256 校验"
|
||
fi
|
||
|
||
log "解压…"
|
||
unzip -qq -o "$TARGET" -d "$WORK_DIR" || { fail "解压失败"; exit 1; }
|
||
PACKAGE_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)"
|
||
else
|
||
PACKAGE_DIR="$TARGET"
|
||
fi
|
||
[ -n "$PACKAGE_DIR" ] && [ -d "$PACKAGE_DIR" ] || { fail "找不到解压后的包目录"; exit 1; }
|
||
log "包目录:$PACKAGE_DIR"
|
||
|
||
# ---- 2. 文件清单 ----
|
||
log "文件清单:"
|
||
for required in app-release.apk README.txt THIRD_PARTY.md contracts/VERSION; do
|
||
if [ -f "$PACKAGE_DIR/$required" ]; then
|
||
printf ' ✓ %s\n' "$required"
|
||
else
|
||
fail "缺少 $required"
|
||
fi
|
||
done
|
||
APK="$PACKAGE_DIR/app-release.apk"
|
||
if [ ! -f "$APK" ]; then
|
||
echo "(没有 APK,后续检查无法进行)" >&2
|
||
exit 1
|
||
fi
|
||
|
||
# ---- 3. APK 版本元数据 ----
|
||
log "APK 版本元数据(AndroidManifest):"
|
||
bash "$PROJECT_DIR/build/check-apk-version.sh" "$APK" "$EXPECTED" \
|
||
|| fail "APK 的 versionName/versionCode 不是 v$EXPECTED"
|
||
|
||
# ---- 4. ABI 与 MediaPipe native 库 ----
|
||
log "ABI 与 native 库:"
|
||
ABIS="$(unzip -l "$APK" | grep -oE 'lib/[^/]+/' | sed 's#lib/##;s#/##' | sort -u)"
|
||
ABIS_ONE_LINE="$(echo "$ABIS" | tr '\n' ' ')"
|
||
echo " 包含 ABI:$ABIS_ONE_LINE"
|
||
if [ "$ABIS" = "$(printf 'arm64-v8a\narmeabi-v7a')" ]; then
|
||
echo " ✓ ABI 恰为 arm64-v8a + armeabi-v7a(真机 ABI)"
|
||
else
|
||
fail "ABI 与预期不符(预期 arm64-v8a / armeabi-v7a)"
|
||
fi
|
||
if echo "$ABIS" | grep -qE "x86"; then
|
||
fail "release APK 含模拟器 ABI(包体翻倍且与 abiFilters 不符)"
|
||
fi
|
||
for abi in arm64-v8a armeabi-v7a; do
|
||
if unzip -l "$APK" | grep -q "lib/$abi/.*mediapipe"; then
|
||
echo " ✓ lib/$abi 下有 MediaPipe native 库"
|
||
else
|
||
fail "lib/$abi 下缺少 MediaPipe native 库(真机安装后必然初始化失败)"
|
||
fi
|
||
done
|
||
|
||
# ---- 4b. native 库体检(T-AND-015:ABI / ELF 类型 / 依赖闭包 / 16 KB 页对齐)----
|
||
log "native 库体检(ELF 类型 / DT_NEEDED 闭包 / 16 KB 页对齐):"
|
||
NPY="${PYTHON_BIN:-}"
|
||
if [ -z "$NPY" ]; then
|
||
for candidate in python3 python; do command -v "$candidate" >/dev/null 2>&1 && { NPY="$candidate"; break; }; done
|
||
fi
|
||
if [ -n "$NPY" ]; then
|
||
"$NPY" "$PROJECT_DIR/build/check_native_libs.py" "$APK" \
|
||
--expected-abis "$(echo "$ABIS" | tr '\n' ',')" || fail "native 库体检未通过"
|
||
else
|
||
fail "未找到 python3,无法做 native 库体检(apt-get install -y python3)"
|
||
fi
|
||
|
||
# 16 KB 页对齐的 zip 层检查(Android 15 起部分设备页 16 KB)
|
||
SDK_FOR_ZIPALIGN="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}"
|
||
ZIPALIGN="$(ls -d "$SDK_FOR_ZIPALIGN"/build-tools/*/zipalign 2>/dev/null | sort -V | tail -1)"
|
||
if [ -n "$ZIPALIGN" ]; then
|
||
if "$ZIPALIGN" -c -P 16 -v 4 "$APK" >"$WORK_DIR/zipalign.txt" 2>&1; then
|
||
echo " ✓ zipalign -c -P 16 -v 4 通过(4 字节对齐 + 16 KB 页对齐)"
|
||
else
|
||
fail "zipalign -P 16 校验失败(见 $WORK_DIR/zipalign.txt)"
|
||
fi
|
||
else
|
||
log "未找到 zipalign,跳过 zip 对齐检查(不影响结论)"
|
||
fi
|
||
|
||
# ---- 5. 模型资源与 R8 保留 ----
|
||
log "MediaPipe 模型与 R8 保留:"
|
||
if unzip -l "$APK" | grep -q "assets/pose_landmarker_lite.task"; then
|
||
echo " ✓ MediaPipe 模型资源随包分发(assets/pose_landmarker_lite.task)"
|
||
else
|
||
fail "APK 缺少 MediaPipe 模型资源"
|
||
fi
|
||
DEX_HIT=0
|
||
for entry in $(unzip -Z1 "$APK" | grep -E '^classes[0-9]*\.dex$'); do
|
||
if unzip -p "$APK" "$entry" | grep -qa "poselandmarker/PoseLandmarker"; then
|
||
DEX_HIT=$((DEX_HIT + 1))
|
||
fi
|
||
done
|
||
if [ "$DEX_HIT" -gt 0 ]; then
|
||
echo " ✓ dex 中存在 poselandmarker/PoseLandmarker(R8 未整体裁掉 MediaPipe;命中 $DEX_HIT 个 dex)"
|
||
else
|
||
fail "dex 里找不到 PoseLandmarker(R8 把 MediaPipe 裁掉了,release 必崩)"
|
||
fi
|
||
|
||
# ---- 6. 验签(可选)----
|
||
SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}"
|
||
APKSIGNER="$(ls -d "$SDK"/build-tools/*/apksigner 2>/dev/null | sort -V | tail -1)"
|
||
if [ -n "$APKSIGNER" ]; then
|
||
log "签名校验($APKSIGNER):"
|
||
if "$APKSIGNER" verify "$APK" >"$WORK_DIR/apksigner.txt" 2>&1; then
|
||
echo " ✓ apksigner 验签通过(退出码 0)"
|
||
else
|
||
fail "apksigner 验签失败"
|
||
sed 's/^/ /' "$WORK_DIR/apksigner.txt" | tail -5
|
||
fi
|
||
else
|
||
log "未找到 apksigner,跳过验签(不影响结论)"
|
||
fi
|
||
|
||
# ---- 7. 契约快照与仓库一致 ----
|
||
log "包内契约快照:"
|
||
snapshot_ok=1
|
||
if [ -f "$PACKAGE_DIR/contracts/VERSION" ]; then
|
||
sed 's/^/ /' "$PACKAGE_DIR/contracts/VERSION"
|
||
# 与仓库当前的 contracts/VERSION 逐项比对:抓「APK 用旧契约构建、快照拷了新的」
|
||
while IFS= read -r line; do
|
||
case "$line" in ''|'#'*) continue ;; esac
|
||
key="${line%%:*}"; value="${line#*:}"
|
||
key="$(printf '%s' "$key" | tr -d '[:space:]')"
|
||
value="$(printf '%s' "$value" | tr -d '[:space:]')"
|
||
repo_value="$(sed -n "s/^${key}:[[:space:]]*//p" contracts/VERSION | head -1)"
|
||
if [ -z "$repo_value" ]; then
|
||
fail "仓库 contracts/VERSION 缺少 $key"
|
||
snapshot_ok=0
|
||
elif [ "$repo_value" != "$value" ]; then
|
||
fail "$key:包内 $value,仓库 $repo_value"
|
||
snapshot_ok=0
|
||
fi
|
||
done <"$PACKAGE_DIR/contracts/VERSION"
|
||
[ "$snapshot_ok" = 1 ] && echo " ✓ 包内契约快照与仓库 contracts/VERSION 一致"
|
||
else
|
||
fail "包内缺少 contracts/VERSION"
|
||
fi
|
||
|
||
echo ""
|
||
if [ "$FAILED" -ne 0 ]; then
|
||
echo "============================================================"
|
||
echo " 发布包级验收失败(工作目录:$WORK_DIR)"
|
||
echo "============================================================"
|
||
exit 1
|
||
fi
|
||
echo "============================================================"
|
||
echo " 发布包级验收全部通过(工作目录:$WORK_DIR)"
|
||
echo "============================================================"
|