HarmonyOS 7 Hvigor:Native SO提审ABI闭包与体积门禁
发布包已经能在测试机启动,提审前的最后一轮检查却把 libvision_runtime.so 标成了红色:不是代码崩溃,也不是 RPATH 找不到,而是 release 包里还留着完整调试符号,单库 18.2 MB。更麻烦的是,同目录十二个 SO 里只有这个文件的裁剪策略失效;如果只看整包大小,很难知道是哪个三方构建步骤把 Debug 产物带了进来。
这次我没有继续靠人工翻目录,而是给 ReleaseSoGate 加了一道产物门禁。任务号固定为 SOAUDIT-1312,检查包名 com.example.retaildesk、版本 2.7.0(2070000)、Product release 和 arm64-v8a。目标很具体:十二个 Native SO 的 NEEDED 闭包没有缺口、未裁剪库归零、Native 体积不超过 25.0 MB,三项同时成立才允许进入提审目录。

一、错误发生在构建末端,不该让业务页来背锅
这个问题最初暴露得很偶然。开发包安装正常,业务页也能跑;换成 release 产物后,包体突然增加十几 MB。团队第一反应是图片资源没有压缩,可资源清单没有变化。把 HAP 解包后才发现 libs/arm64-v8a 从 24.9 MB 回到了 38.6 MB,新增的 13.7 MB 都集中在 libvision_runtime.so。
我把排查拆成三条互不替代的证据。ABI 清单确认实际提交的架构,动态段里的 NEEDED 确认每个依赖是否能在同 ABI 目录或系统允许集合中找到,节区与符号表确认 release 库有没有残留调试信息。以前这三件事由不同同事手工做,结论常常停留在“看起来没问题”;门禁则要求把每条证据写进同一份 JSON 报告。
这和之前处理 SO 装载路径不是一回事。路径问题关注运行时能不能找到库;本次关注的是已经能装载的 release 产物是否完整、干净并满足体积预算。一个库可以加载成功,同时依赖闭包不完整于另一个产品变体,也可以功能正常却携带数十 MB 的调试节。门禁必须站在最终 HAP 的视角,而不是相信中间构建目录。
二、先把 ELF 输出收敛成可比较的数据
第一段脚本解决的是 llvm-readelf 输出难以直接判定的问题。脚本扫描解包目录,只保留最终 libs/arm64-v8a,分别读取动态段和节区表,再生成统一的 ElfFact。命令失败、文件名重复或输出无法解析都会让任务失败,不能把“没有读到”误判成“没有问题”。
import { execFileSync } from 'node:child_process'
import { readdirSync, statSync } from 'node:fs'
import { join } from 'node:path'
type ElfFact = {
name: string
bytes: number
needed: string[]
hasDebugSections: boolean
}
function readElf(args: string[]): string {
return execFileSync('llvm-readelf', args, { encoding: 'utf8' })
}
export function scanElfClosure(libDir: string): ElfFact[] {
return readdirSync(libDir).filter((name) => name.endsWith('.so')).sort().map((name) => {
const file = join(libDir, name)
const dynamic = readElf(['--dynamic', file])
const sections = readElf(['--sections', file])
const needed = [...dynamic.matchAll(/Shared library: \[(.+?)\]/g)].map((m) => m[1])
const hasDebugSections = /\.debug_(info|line|str)|\.symtab/.test(sections)
return { name, bytes: statSync(file).size, needed, hasDebugSections }
})
}
这里没有用模糊的 includes('debug') 去判断文件名,而是检查 ELF 节区;也没有边扫描边决定通过,因为单库的 NEEDED 必须和完整目录对照。execFileSync 只适合构建机脚本,不会进入手机运行时。扫描临时解包目录在任务结束后删除,报告则写到固定输出位置,避免下一轮误读旧数据。
scanElfClosure() 被重复调用时会重新读取最终产物,不使用内存缓存。原因是 Hvigor 的打包、三方 CMake 构建与符号裁剪可能发生在不同阶段;缓存一份中间目录清单,正是这次漏检的来源。脚本宁愿多花几百毫秒,也不能拿旧证据给新包放行。
三、闭包判定要区分系统库和随包库
拿到十二个 ElfFact 后,第二段代码才开始做规则判断。随包依赖必须在当前 ABI 目录存在;明确由系统提供的库进入 allowlist。allowlist 版本化保存在工程中,不能遇到缺库就临时加名字,否则门禁会退化成一份越来越宽的忽略列表。
type AuditReport = {
scanned: number
missingNeeded: string[]
unstripped: string[]
nativeBytes: number
state: 'AUDIT_PASS' | 'AUDIT_FAIL'
}
const SYSTEM_LIBS = new Set(['libc.so', 'libm.so', 'libdl.so', 'libhilog_ndk.z.so'])
const LIMIT = 25 * 1024 * 1024
export function evaluateReleaseGate(facts: ElfFact[]): AuditReport {
const packaged = new Set(facts.map((item) => item.name))
const missing = new Set<string>()
for (const fact of facts) {
for (const dep of fact.needed) {
if (!packaged.has(dep) && !SYSTEM_LIBS.has(dep)) missing.add(`${fact.name} -> ${dep}`)
}
}
const unstripped = facts.filter((item) => item.hasDebugSections).map((item) => item.name)
const nativeBytes = facts.reduce((sum, item) => sum + item.bytes, 0)
const state = missing.size === 0 && unstripped.length === 0 && nativeBytes <= LIMIT
? 'AUDIT_PASS' : 'AUDIT_FAIL'
return { scanned: facts.length, missingNeeded: [...missing], unstripped, nativeBytes, state }
}
这段代码刻意不替构建系统自动删除库。自动修复可能让另一个产品变体丢失依赖,门禁只给出证据并阻止晋级。体积阈值也不是应用市场的通用上限,而是这个项目给 Native 部分设置的预算;正文、页面和日志中的 25.0 MB 都是团队约束,不能误写成平台统一规定。
第一次执行时结果是 scanned=12、missingNeeded=0、unstripped=1、nativeSize=38.6MB,状态 AUDIT_FAIL。修正三方 release 构建的 strip 阶段后,同一任务重新扫描,未裁剪库变成 0,Native 体积降到 24.9 MB,节省 13.7 MB。任务 ID 不变,但 reportRevision 从 1 递增到 2,页面不会把前后两次数据混成一条。
四、Hvigor 只负责卡住晋级点
第三段代码处理执行时机。我们没有侵入每个三方库的 CMake,而是在 release HAP 生成后解包、扫描,再决定是否复制到 dist/review/。这一步只有在 product=release 时运行;日常 debug 构建仍保留符号,方便定位问题。
import { writeFileSync } from 'node:fs'
export function runReleaseAudit(product: string, libDir: string): AuditReport {
if (product !== 'release') {
throw new Error(`SO_AUDIT_WRONG_PRODUCT:${product}`)
}
const report = evaluateReleaseGate(scanElfClosure(libDir))
writeFileSync('build/reports/native-audit.json', JSON.stringify({
taskId: 'SOAUDIT-1312',
packageName: 'com.example.retaildesk',
version: '2.7.0(2070000)',
abi: 'arm64-v8a',
...report
}, null, 2))
if (report.state !== 'AUDIT_PASS') {
throw new Error(`SO_AUDIT_BLOCKED:${report.unstripped.join(',')}`)
}
return report
}
报告先落盘再抛错,这样 CI 失败时仍有可下载证据。写入采用临时文件后重命名的方式更稳妥,示例为突出判定逻辑省略了封装;实际工程还会记录 HAP 的 SHA-256,避免报告和产物错配。重复执行会覆盖同一任务的临时报告,但只有哈希匹配的 AUDIT_PASS 才能晋级。
页面 NativeAuditPage 只读取报告,不在手机上调用 llvm-readelf。它显示包名、版本、Product、ABI、扫描数量、缺失 NEEDED、未裁剪库、修复前后体积和阈值。页面离场时取消文件监听,构建任务结束后关闭解包文件句柄并删除临时目录;如果监听器未释放,连续构建会重复弹出旧失败提示。
五、目录、日志和状态必须能互相证明
工程目录里,tools/native-audit/ElfScanner.ts 负责采集,GateRules.ts 负责判定,ReleaseAudit.ts 负责构建收口,entry/src/main/ets/pages/NativeAuditPage.ets 展示报告。HiLog 固定记录:task=SOAUDIT-1312 package=com.example.retaildesk version=2.7.0(2070000)、product=release abi=arm64-v8a scanned=12、missingNeeded=0 unstripped=0、nativeBefore=38.6MB nativeAfter=24.9MB saved=13.7MB limit=25.0MB、state=AUDIT_PASS。

我特意把模拟器放在 DevEco Studio 右侧,运行页和报告使用相同字段。这样看到绿色状态时,不只是“构建成功”四个字,而是能反查这一轮到底扫描了什么。代码区则停在 evaluateReleaseGate() 的三项合取条件上,底部日志展示最终数值;如果三处有一个对不上,素材本身就不能算验收通过。
六、最终结果不是瘦身成功,而是证据闭环
修复后的任务 SOAUDIT-1312 扫描 12 个 arm64-v8a 库,缺失 NEEDED 为 0,未裁剪库为 0。Native 体积从 38.6 MB 降到 24.9 MB,低于 25.0 MB 阈值,最终状态 AUDIT_PASS。页面时间 13:12、电量 86%,与 IDE 右侧模拟器保持一致。

这套门禁没有承诺包一定能过审,它只把本项目最容易漏掉的 Native 产物问题变成可重复检查。签名、隐私、权限、截图和内容合规仍然有各自的审查链路。它也不检查运行时业务正确性:一个被正确裁剪的 SO 仍可能有逻辑缺陷,所以回归测试不能被包体扫描替代。
七、我后来补的三个反向用例
第一个用例故意删除 libimage_codec.so,确认引用它的库会生成一条 missingNeeded,而不是只看目录数量仍等于 11。第二个用例把 debug 版 libvision_runtime.so 覆盖进 release 目录,验证 .debug_info 与 .symtab 能让任务失败。第三个用例加入一个无调试节但体积异常的填充库,验证体积预算是独立规则,不会因为 unstripped=0 就放行。
我还测试了并发构建。两个 Product 同时生成报告时,如果共用一个临时目录,后完成的任务可能删除另一任务仍在读取的文件。现在临时路径包含 product、ABI 与 HAP 哈希,清理只操作自己的目录。脚本收到中断信号后也会执行收口,避免下一轮把残留目录当成正式产物。
以前提审前的“再看一眼包”更多依赖经验;这次把经验拆成 ABI 清单、动态依赖、符号节和体积预算后,问题不再需要某个熟悉 ELF 的人临场发现。对发布工程来说,最有价值的不是多一张绿色卡片,而是任何人、任何构建机面对同一份 HAP,都能得到同样的结论。
更多推荐


所有评论(0)