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:
@@ -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`。发布前对包再跑一次:
|
||||||
|
|
||||||
|
|||||||
@@ -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、解压、核对文件清单、
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 兼容」,无法证明某台具体设备上的实际行为;
|
||||||
|
- 因此本检查**不替代**真机验收,只是把「打包配置错误」这一类彻底挡在发布前。
|
||||||
Reference in New Issue
Block a user