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 的新增项随下次发布进包。
This commit is contained in:
2026-09-17 05:25:04 +08:00
parent c207e7dadd
commit 95429e8ce3
7 changed files with 430 additions and 0 deletions
+14
View File
@@ -21,6 +21,7 @@ OpenDance Studio **阶段三**: Android 辅助 App(Kotlin + CameraX + MediaPipe
| 模型随包分发 | OK `assets/pose_landmarker_lite.task` 5.7 MB(Stored 未压缩) | | 模型随包分发 | OK `assets/pose_landmarker_lite.task` 5.7 MB(Stored 未压缩) |
| ABI 覆盖 | OK arm64-v8a + armeabi-v7a(debug 额外含 x86/x86_64) | | ABI 覆盖 | OK arm64-v8a + armeabi-v7a(debug 额外含 x86/x86_64) |
| APK 版本元数据 | OK 直接读产物 `AndroidManifest` 的 `versionName=0.2.0`/`versionCode=200`,与 `project.yaml` 一致(T-AND-013) | | 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) |
## 快速开始 ## 快速开始
@@ -256,6 +257,19 @@ app/src/main/java/com/opendance/studio/
(= `project.yaml` 的 `version` = APK 文件名 = Git tag),契约版本在构建期从 (= `project.yaml` 的 `version` = APK 文件名 = Git tag),契约版本在构建期从
`contracts/VERSION` 解析后注入 `BuildConfig`;Kotlin 界面代码里不出现任何版本号字面量。 `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, **发布包级验收(T-AND-014)**:`build/verify.sh` 验的是本地刚构建的 APK,
而用户装的是 zip 里的 `app-release.apk`。发布前对包再跑一次: 而用户装的是 zip 里的 `app-release.apk`。发布前对包再跑一次:
+5
View File
@@ -138,6 +138,11 @@ WebSocket 推送给 Windows 客户端,由后者驱动 VRM 角色并实时评
$ANDROID_HOME/build-tools/34.0.0/apksigner verify app/build/outputs/apk/release/app-release.apk $ANDROID_HOME/build-tools/34.0.0/apksigner verify app/build/outputs/apk/release/app-release.apk
预期:退出码 0(无输出即通过) 预期:退出码 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) 0) 发布包级验收(对**发布包**而不是本地构建;文档 §7.5)
bash build/accept-release.sh dist-release/opendance-android-v0.2.0.zip bash build/accept-release.sh dist-release/opendance-android-v0.2.0.zip
预期末行「发布包级验收全部通过」。它会校验 sha256、解压、核对文件清单、 预期末行「发布包级验收全部通过」。它会校验 sha256、解压、核对文件清单、
+26
View File
@@ -118,6 +118,32 @@ for abi in arm64-v8a armeabi-v7a; do
fi fi
done 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 保留 ---- # ---- 5. 模型资源与 R8 保留 ----
log "MediaPipe 模型与 R8 保留:" log "MediaPipe 模型与 R8 保留:"
if unzip -l "$APK" | grep -q "assets/pose_landmarker_lite.task"; then if unzip -l "$APK" | grep -q "assets/pose_landmarker_lite.task"; then
+289
View File
@@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""APK native 库体检(T-AND-015):纯标准库解析 ELF,不需要 readelf / NDK。
为什么需要它(T-AND-012 的「先按 ABI / native / R8 排查」):
`ExceptionInInitializerError` / `UnsatisfiedLinkError` 这类「一装上就崩」的问题,
根因绝大多数落在 native 库上,而它们**都能在没有设备的情况下查出来**:
1. **ABI 与 ELF 机器类型不匹配** —— 把 x86_64 的 .so 放进 `lib/arm64-v8a/`,
安装能过、一加载就崩;
2. **DT_NEEDED 依赖缺失** —— 例如只打包了 `libmediapipe_jni.so` 却没带
`libc++_shared.so`(MediaPipe 早期版本的经典坑)。系统库由 Android 提供,
非系统库必须**同 ABI 目录内**自带;
3. **16 KB 页面对齐** —— Android 15 起部分设备页大小为 16 KB,`PT_LOAD` 的
`p_align` 若仍是 4 KB(0x1000),这些设备上会直接加载失败(Google Play
2025-11 起也要求 16 KB 对齐);
4. **路径/命名**:.so 必须直接位于 `lib/<abi>/` 下(子目录不会被安装)。
本脚本把这些一次查完,并给出可读结论。**它不能替代真机**(运行时行为、
GPU/驱动差异仍需设备),但能把「打包配置错误」这一类彻底挡在发布前。
用法:
python3 build/check_native_libs.py <apk> [--expected-abis arm64-v8a,armeabi-v7a]
[--min-page-align 16384] [--json]
退出码:0 通过 / 1 有问题 / 2 用法或文件问题。
"""
from __future__ import annotations
import argparse
import json
import struct
import sys
import zipfile
# Android 系统提供的库(不需要 App 自带)
SYSTEM_LIBS = {
"libc.so", "libm.so", "libdl.so", "liblog.so", "libz.so", "libstdc++.so",
"libandroid.so", "libjnigraphics.so", "libEGL.so", "libGLESv1_CM.so",
"libGLESv2.so", "libOpenSLES.so", "libOpenMAXAL.so", "libnativewindow.so",
"libvulkan.so", "libsync.so", "libcutils.so", "libutils.so", "libui.so",
"libgui.so", "libbinder.so", "libhardware.so", "libcamera2ndk.so",
"libmediandk.so", "libaaudio.so", "libneuralnetworks.so", "libOpenCL.so",
}
# ABI → 期望的 (ELF class, e_machine)
ABI_MACHINE = {
"arm64-v8a": (2, 183), # ELFCLASS64, EM_AARCH64
"armeabi-v7a": (1, 40), # ELFCLASS32, EM_ARM
"x86_64": (2, 62), # ELFCLASS64, EM_X86_64
"x86": (1, 3), # ELFCLASS32, EM_386
}
MACHINE_NAME = {183: "AArch64", 40: "ARM", 62: "x86-64", 3: "Intel 80386"}
class ElfError(Exception):
pass
# 人类可读输出;--json 模式下静音,保证 stdout 是**纯 JSON**(便于脚本消费)
EMIT = True
def emit(*parts) -> None:
if EMIT:
print(*parts)
def parse_elf(data: bytes) -> dict:
"""解析出 {elf_class, machine, is_64, needed[], soname, page_aligns[]}"""
if len(data) < 64 or data[:4] != b"\x7fELF":
raise ElfError("不是 ELF 文件(魔数 %r)" % data[:4])
elf_class = data[4] # 1=32bit, 2=64bit
data_encoding = data[5] # 1=LE, 2=BE
if data_encoding != 1:
raise ElfError("只支持小端 ELF(EI_DATA=%d)" % data_encoding)
is_64 = elf_class == 2
if not is_64 and elf_class != 1:
raise ElfError("未知的 EI_CLASS=%d" % elf_class)
machine = struct.unpack_from("<H", data, 18)[0]
if is_64:
e_phoff, = struct.unpack_from("<Q", data, 32)
e_phentsize, e_phnum = struct.unpack_from("<HH", data, 54)
e_shoff, = struct.unpack_from("<Q", data, 40)
else:
e_phoff, = struct.unpack_from("<I", data, 28)
e_phentsize, e_phnum = struct.unpack_from("<HH", data, 42)
e_shoff, = struct.unpack_from("<I", data, 32)
loads = [] # (vaddr, offset, filesz)
page_aligns = []
dynamic = None
for i in range(e_phnum):
base = e_phoff + i * e_phentsize
if base + e_phentsize > len(data):
raise ElfError("程序头越界")
p_type, = struct.unpack_from("<I", data, base)
# 程序头的字段顺序(ELF64):type@0 flags@4 offset@8 vaddr@16 paddr@24
# filesz@32 memsz@40 align@48
# 注意第三个 QWORD 是 **p_paddr** 而不是 p_filesz —— 第一版就是在这里
# 取错字段,导致 vaddr→offset 映射永远失败、DT_NEEDED 解析为空,
# 于是「依赖闭包完整」变成了一句**假通过**(靠和 readelf 交叉比对才发现)。
if is_64:
p_offset, p_vaddr = struct.unpack_from("<QQ", data, base + 8)
p_filesz, = struct.unpack_from("<Q", data, base + 32)
p_align, = struct.unpack_from("<Q", data, base + 48)
else:
# ELF32:type@0 offset@4 vaddr@8 paddr@12 filesz@16 memsz@20
# flags@24 align@28
p_offset, p_vaddr = struct.unpack_from("<II", data, base + 4)
p_filesz, = struct.unpack_from("<I", data, base + 16)
p_align, = struct.unpack_from("<I", data, base + 28)
if p_type == 1: # PT_LOAD
loads.append((p_vaddr, p_offset, p_filesz))
page_aligns.append(p_align)
elif p_type == 2: # PT_DYNAMIC
dynamic = (p_offset, p_filesz)
needed: list[str] = []
soname = ""
if dynamic is not None:
d_off, d_size = dynamic
entry_size = 16 if is_64 else 8
strtab_vaddr = strsz = None
entries = []
for off in range(d_off, min(d_off + d_size, len(data)), entry_size):
if is_64:
d_tag, d_val = struct.unpack_from("<QQ", data, off)
else:
d_tag, d_val = struct.unpack_from("<II", data, off)
if d_tag == 0:
break
entries.append((d_tag, d_val))
if d_tag == 5: # DT_STRTAB
strtab_vaddr = d_val
elif d_tag == 10: # DT_STRSZ
strsz = d_val
def vaddr_to_offset(vaddr: int) -> int | None:
for v, o, sz in loads:
if v <= vaddr < v + sz:
return o + (vaddr - v)
return None
strtab_off = vaddr_to_offset(strtab_vaddr) if strtab_vaddr is not None else None
if strtab_off is not None:
limit = strtab_off + (strsz or 0)
for d_tag, d_val in entries:
if d_tag not in (1, 14): # DT_NEEDED / DT_SONAME
continue
start = strtab_off + d_val
end = data.find(b"\0", start, limit if limit > start else len(data))
if end < 0:
continue
name = data[start:end].decode("utf-8", "replace")
if d_tag == 1:
needed.append(name)
else:
soname = name
return {
"elf_class": elf_class,
"machine": machine,
"needed": needed,
"soname": soname,
"page_aligns": page_aligns,
}
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(add_help=True)
parser.add_argument("apk")
parser.add_argument("--expected-abis", default="arm64-v8a,armeabi-v7a")
parser.add_argument("--min-page-align", type=int, default=16384)
parser.add_argument("--json", action="store_true")
args = parser.parse_args(argv[1:])
expected_abis = [a.strip() for a in args.expected_abis.split(",") if a.strip()]
global EMIT
EMIT = not args.json
try:
zf = zipfile.ZipFile(args.apk)
except (OSError, zipfile.BadZipFile) as exc:
print("✗ 无法读取 APK:%s" % exc, file=sys.stderr)
return 2
libs: dict[str, list[str]] = {}
for name in zf.namelist():
parts = name.split("/")
if len(parts) == 3 and parts[0] == "lib" and name.endswith(".so"):
libs.setdefault(parts[1], []).append(parts[2])
for abi in libs:
libs[abi].sort()
problems: list[str] = []
notes: list[str] = []
if not libs:
print("✗ APK 里没有任何 lib/<abi>/*.so", file=sys.stderr)
return 1
found_abis = sorted(libs.keys())
if found_abis != sorted(expected_abis):
problems.append("ABI 集合为 %s,期望 %s" % (found_abis, sorted(expected_abis)))
report: dict = {"abis": {}}
emit(" APK native 库体检:%s" % args.apk)
for abi in found_abis:
emit(" [%s]" % abi)
entries = []
abi_files = set(libs[abi])
for lib in libs[abi]:
raw = zf.read("lib/%s/%s" % (abi, lib))
try:
info = parse_elf(raw)
except ElfError as exc:
problems.append("%s/%s:%s" % (abi, lib, exc))
emit(" ✗ %-32s %s" % (lib, exc))
continue
want_class, want_machine = ABI_MACHINE.get(abi, (None, None))
machine_name = MACHINE_NAME.get(info["machine"], str(info["machine"]))
if want_machine is not None and (info["elf_class"], info["machine"]) != (want_class, want_machine):
problems.append(
"%s/%s:ELF class=%d machine=%s,与该 ABI 不符(该 ABI 下会加载失败)"
% (abi, lib, info["elf_class"], machine_name)
)
emit(" ✗ %-32s ELF 类型不符:class=%d machine=%s" % (lib, info["elf_class"], machine_name))
else:
emit(" ✓ %-32s ELF%s %s" % (lib, "64" if info["elf_class"] == 2 else "32", machine_name))
# 依赖闭包:非系统库必须在同 ABI 目录内自带
missing = [
dep for dep in info["needed"]
if dep not in SYSTEM_LIBS and dep not in abi_files
]
if missing:
problems.append("%s/%s:缺少依赖 %s(同 ABI 目录内没有)" % (abi, lib, ", ".join(missing)))
emit(" ✗ 缺少依赖:%s" % ", ".join(missing))
else:
extra = [d for d in info["needed"] if d not in SYSTEM_LIBS]
emit(" ✓ 依赖闭包完整(需自带 %s;系统库 %d 项)" % (
", ".join(extra) if extra else "无", len(info["needed"]) - len(extra)))
# 16 KB 页面对齐
bad_align = [a for a in info["page_aligns"] if a < args.min_page_align]
if bad_align:
problems.append(
"%s/%s:PT_LOAD p_align=%s < %d,Android 15+ 的 16 KB 页设备会加载失败"
% (abi, lib, ", ".join(hex(a) for a in bad_align), args.min_page_align)
)
emit(" ✗ 页对齐不足:%s" % ", ".join(hex(a) for a in bad_align))
else:
aligns = sorted(set(info["page_aligns"]))
emit(" ✓ 页对齐 %s(≥ %d,16 KB 页兼容)" % (
" ".join(hex(a) for a in aligns), args.min_page_align))
entries.append({"lib": lib, **{k: info[k] for k in ("machine", "elf_class", "needed", "soname")},
"page_aligns": info["page_aligns"]})
report["abis"][abi] = entries
# libc++ 共享库的经典坑:若某个 .so 需要它,必须自带
for abi, libs_here in libs.items():
if "libc++_shared.so" in libs_here:
notes.append("%s 自带 libc++_shared.so" % abi)
report["problems"] = problems
report["notes"] = notes
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2))
if problems:
print("")
for p in problems:
print(" ✗ %s" % p, file=sys.stderr)
print(" ✗ native 库体检发现 %d 个问题" % len(problems), file=sys.stderr)
return 1
emit(" ✓ native 库体检通过:%s | ELF 类型匹配、依赖闭包完整、16 KB 页对齐" % ", ".join(found_abis))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+19
View File
@@ -118,6 +118,25 @@ else
fi fi
done 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 输出) ---- # ---- T-AND-012:R8 不得裁掉 MediaPipe(seeds 由 -printseeds 输出) ----
SEEDS="app/build/outputs/mapping/release/seeds.txt" SEEDS="app/build/outputs/mapping/release/seeds.txt"
if [ -f "$SEEDS" ]; then if [ -f "$SEEDS" ]; then
+4
View File
@@ -82,6 +82,10 @@ tasks:
file: tasks/014-release-acceptance.md file: tasks/014-release-acceptance.md
scope: 发布包级端到端验收(读 zip 内 APK:版本元数据 / ABI+MediaPipe native / 模型资源 / R8 / 契约快照) scope: 发布包级端到端验收(读 zip 内 APK:版本元数据 / ABI+MediaPipe native / 模型资源 / R8 / 契约快照)
status: done status: done
- id: T-AND-015
file: tasks/015-native-libs.md
scope: APK native 库体检(ELF 类型 / DT_NEEDED 闭包 / 16 KB 页对齐),纯标准库实现
status: done
deliverable: deliverable:
artifact: opendance-android-v0.2.0.apk artifact: opendance-android-v0.2.0.apk
+73
View File
@@ -0,0 +1,73 @@
# T-AND-015:APK native 库体检(ABI / ELF 类型 / 依赖闭包 / 16 KB 页对齐)
- **状态**: done
- **依赖**: T-AND-012(MediaPipe 初始化崩溃修复)/ T-AND-014(发布包级验收)
- **文档**: v1.6 §6.6 / §7.5
- **契约**: 不涉及(打包体检)
## 为什么需要
T-AND-012 的结论是「`ExceptionInInitializerError` 的根因在**集成配置**(native 解压 + R8 + ABI),
不是 MediaPipe 本身不稳定」。但当时的核查是**手工一次性**的:`unzip` 看 `lib/`、`grep` seeds、
`strings` 找类名。手工核查的问题是**下次照样可能漏**,而且漏了也不会有人发现 ——
这类缺陷只会在用户装上手机、点开相机的那一刻暴露。
本任务把它变成**可重复、可反向验证**的产物级检查:纯 Python 标准库解析 ELF,
不依赖 readelf / NDK,因此任何装了 python3 的机器(含 CI)都能跑。
## 检查项(都在「无设备也能查」的范围内)
| 项 | 为什么它会导致「一装上就崩」 |
|--|--|
| **ABI 与 ELF 类型匹配** | 把 x86_64 的 `.so` 放进 `lib/arm64-v8a/`:安装能过,一加载就 `UnsatisfiedLinkError` |
| **`DT_NEEDED` 依赖闭包** | 只打包 `libmediapipe_jni.so` 却没带它依赖的 `libc++_shared.so` 是 MediaPipe 的经典坑;非系统库必须**同 ABI 目录内**自带(系统库由 Android 提供,见脚本内白名单) |
| **16 KB 页对齐** | Android 15 起部分设备页大小为 16 KB;`PT_LOAD` 的 `p_align` 若仍是 4 KB(`0x1000`),这些设备上直接加载失败(Google Play 2025-11 起也要求 16 KB 对齐) |
| **zip 层对齐** | `zipalign -c -P 16 -v 4`(有 build-tools 时跑) |
实现:`build/check_native_libs.py`(新增,纯标准库)。已接入
`build/accept-release.sh`(发布包)与 `build/verify.sh`(本地构建)。
## 实测(已发布的 `opendance-android-v0.2.0.zip` 内 APK)
```
[arm64-v8a]
✓ libandroidx.graphics.path.so ELF64 AArch64 | 系统库 3 项 | 页对齐 0x4000
✓ libimage_processing_util_jni.so ELF64 AArch64 | 系统库 6 项 | 页对齐 0x4000
✓ libmediapipe_tasks_jni.so ELF64 AArch64 | 系统库 9 项 | 页对齐 0x4000
[armeabi-v7a]
✓ libandroidx.graphics.path.so ELF32 ARM | 系统库 3 项 | 页对齐 0x4000
✓ libimage_processing_util_jni.so ELF32 ARM | 系统库 6 项 | 页对齐 0x4000
✓ libmediapipe_tasks_jni.so ELF32 ARM | 系统库 9 项 | 页对齐 0x4000
✓ native 库体检通过 | ✓ zipalign -c -P 16 -v 4 通过
```
结论(对当前产物):**没有缺失的 native 依赖**(`libmediapipe_tasks_jni.so` 只依赖
`libdl/libjnigraphics/libEGL/libGLESv2/libm/libz/liblog/libandroid/libc`,全部系统库),
**两个 ABI 都已是 16 KB 页对齐**,ABI 目录内 ELF 类型正确。
也就是说:打包配置这一侧的「一装上就崩」风险已被排除;剩下的只能靠真机实测。
### 与 readelf 交叉验证
解析器输出与 `LC_ALL=C readelf -d/-lW` 在 6 个 `.so` 上**逐项一致**
(`DT_NEEDED` 集合、`PT_LOAD` `p_align`、`e_machine`)。
> **这次交叉验证抓到了解析器自己的一个 bug**:第一版程序头字段偏移写错 ——
> ELF64 的 `p_offset/p_vaddr` 之后第三个 QWORD 是 **`p_paddr`** 而不是 `p_filesz`
> (ELF32 同理)。后果是 `vaddr → offset` 映射永远失败、`DT_NEEDED` 解析为空,
> 于是页面照常打印「✓ 依赖闭包完整(需自带 无;系统库 **0** 项)」——
> 一句**假通过**。`0 项` 这个数字是唯一的破绽,靠和 readelf 比对才暴露出来。
> 教训:**「通过」的输出里必须带可核对的数量/取值**,否则假通过和真通过长得一模一样。
## 反向验证(三条例都实测会失败)
| 负例 | 结果 |
|--|--|
| 把宿主 x86_64 的 `libz.so.1` 塞进 `lib/arm64-v8a/libbogus.so` | ✗ 三条同时报:`ELF class=2 machine=x86-64,与该 ABI 不符`、`缺少依赖 libc.so.6`、`PT_LOAD p_align=0x1000 … < 16384` |
| 把 `libz.so` 从系统库白名单里去掉(证明闭包这条分支是活的) | ✗ `arm64-v8a/libmediapipe_tasks_jni.so:缺少依赖 libz.so`、`armeabi-v7a/...` 同样报 |
| `--expected-abis` 传成单个 ABI | ✗ `ABI 集合为 [...],期望 [...]` |
## 仍未覆盖(诚实声明)
- **运行时行为**:库能加载 ≠ 推理能跑。GPU/驱动差异、真机内存、连续 30 分钟稳定性仍需设备;
- **设备页大小**:这里只保证「16 KB 兼容」,无法证明某台具体设备上的实际行为;
- 因此本检查**不替代**真机验收,只是把「打包配置错误」这一类彻底挡在发布前。