HarmonyOS 应用包体积优化与上架自检实战:从 76.2MB 到 3.6MB
一个应用从"功能写完"到"用户能下载",中间隔着一道很多人临时抱佛脚的关卡:包体积与上架检测。包体积直接影响下载转化——应用市场对超体积包有明确的限速与提醒策略,用户在流量环境下看到一个 80MB 的安装包,放弃率肉眼可见地涨;而上架检测又是另一道硬门槛,权限声明不全、隐私政策缺失、调试符号裸奔,任何一条都可能让提审被驳回,一次来回就是三五天。
下面我们用一个真实的工程当标本:一个演示应用,直接构建出来的安装包有 79,929,450 字节(76.2 MiB)。我们把它的体积一路压到 3,811,860 字节(3.6 MiB),总降幅 95.2%,每一步都有实测数据。压完体积,再做一个"上架冲刺工作台"页面:运行时真实读取自己声明的权限、逐条给出审核风险分级,配一份可交互的提审自检清单——把"提审前通宵检查"变成"打开页面过一遍"。
我们的思路只有一句话:先看构成,再下刀。不看构成就优化,很容易在只占 4% 的部分上花掉 90% 的力气。
一、HAP 里到底装了什么
优化之前先回答一个问题:HAP 文件里面装的是什么?
HAP 的本体是一个 zip 包。这个认知很值钱——意味着不需要任何特殊工具,用 unzip -l 就能把包的构成看得一清二楚:
unzip -l entry-default-signed.hap
对标本工程的 76.2 MiB 基线包,按目录汇总未压缩体积,构成是这样的:
| 构成 | 字节 | 体积 | 占比 |
|---|---|---|---|
| resources/(其中 rawfile 里 8 个 mp3 共 71,129,040) | 71,743,040 | 68.4 MB | 89.8% |
| ets/modules.abc(方舟字节码) | 5,818,320 | 5.5 MB | 7.3% |
| ets/sourceMaps.map(调试符号映射) | 2,252,580 | 2.1 MB | 2.8% |
| libs/(native 库) | 0 | 0 | 0% |
| 签名与描述文件等 | 7,032 | — | <0.1% |

这张表把优化顺序直接拍在桌上:
- 九成体积是资源,而且是 8 个完全相同的 8.5MB 白噪音 mp3——音乐播放器页面的内置素材。这是第一刀。
- 字节码只有 5.5MB,而且 debug 包还额外背着 2.1MB 的 sourceMaps.map。构建模式切到 release 就能同时解决这两个问题。这是第二刀。
- 字节码本身还有压缩空间,靠 ArkGuard 混淆。这是第三刀。
很多人拿到"包体积大"的需求第一反应是"开混淆",但在这个工程里,混淆能作用的上限就是那 7.3% 的字节码部分——就算压掉一半,整个包也只小 3.6%。而把 8 个 mp3 挪出包,一刀就是 -89%。先看构成再动手,这一步 awk 汇总值五分钟,省掉的是方向错误的整个下午。
顺带一提 libs/ 目录:如果工程带了 native 库(.so),这里还会多出一块,而且 so 是按 ABI 分目录存放的(arm64-v8a、x86_64……),砍掉不需要的 ABI 也是一刀——这个工程没有 native 库,跳过。
二、第一刀:把不该进包的资源请出去
rawfile 目录有一个特点:放进去什么,包里就原样背什么。不会压缩、不会裁剪、不会因为你只在某个页面用到它就单独加载——mp3 本身已经是压缩格式,zip 再压一遍也无利可图。8 个 8.5MB 的音频素材放在 rawfile,等于给每一个用户都塞了一份 68MB 的白噪音合集,哪怕他从来没打开过音乐播放器页面。
正确做法是区分资源的使用时机:
- 启动必需、高频、小体积(图标、字体、必要图片):留在包里,resources 或 rawfile 都行;
- 大体积、低频、可延迟(音频、视频、大图集):出包,首次使用时从服务端下载,落到应用沙箱缓存目录,之后读本地。
动手验证这一刀的收益,只需要三步:把 8 个 mp3 移出 rawfile 目录、重新构建、看产物大小:
mv entry/src/main/resources/rawfile/*.mp3 /tmp/media-exile/
# 构建 debug 包(与基线同模式,确保变量只有资源一项)
hvigorw --mode module -p module=entry@default -p buildMode=debug assembleHap
构建产物从 79,929,450 字节变成 8,797,717 字节(8.4 MiB),降幅 89.0%。同时 unzip -l 显示 ets/modules.abc 纹丝不动(仍是 5,818,320)——资源和代码的体积是完全解耦的,这一刀干干净净。
当然,资源出包不是删掉就完事,代码侧要配套三件事:
第一,读取路径要换。原来 $rawfile('rain.mp3') 直接读包内资源,改成读沙箱缓存目录(getContext().filesDir 或 cacheDir),用 fs.open 拿到 fd 再交给播放器。包内资源和沙箱文件走的是两套 API,这是改造的主要工作量。
第二,首次使用要有下载态。下载中给进度、失败给重试入口、成功后落盘一个标记(首选项里记一版本号即可)。用户第一次点"播放雨声"时多等两秒,换来安装包小 68MB,这笔账怎么算都划算。
第三,来源要可靠。下载地址固定、支持断点续传、校验文件大小或摘要,避免弱网下截断的音频被缓存成"永久损坏"。
// 出包资源的典型读取路径:包内 rawfile → 沙箱缓存
const cacheFile = `${this.getContext().cacheDir}/rain.mp3`
if (!fs.accessSync(cacheFile)) {
// 首次使用:下载并落盘
await downloadTo('https://cdn.example.com/audio/rain.mp3', cacheFile)
}
// 之后统一从沙箱读 fd 交给播放器
const file = fs.openSync(cacheFile, fs.OpenMode.READ_ONLY)
avPlayer.fdSrc = { fd: file.fd, offset: 0, length: fs.statSync(cacheFile).size }
这一刀的原则可以推广到所有资源类型:内置的字体、Lottie 动画 json、大图集、离线模型文件,都值得过一遍同样的追问——启动真的需要它吗?做不到"是",就请它出包。
三、第二刀:切到 release 构建
日常开发用的是 debug 构建,它会贴心地帮你保留调试能力:完整的符号信息、ets/sourceMaps.map(2.1MB)、未做深度优化的字节码。这些在开发期是刚需,在上架包里就是纯浪费。
切换的方式有两种。图形界面里选 Build → Build Hap(s)/APP 时把 Build Mode 设为 release;命令行则直接传参:
hvigorw --mode module -p module=entry@default -p buildMode=release assembleHap
在 rawfile 全量保留的前提下,release 构建的产物是 75,012,507 字节(71.5 MiB),比 debug 基线少约 4.7MB。这 4.7MB 的构成值得看清,因为它解释了 release 便宜在哪:
ets/modules.abc从 5,818,320 压到 3,175,480,降 45.5%——release 编译器做了优化,字节码本身变小;ets/sourceMaps.map整个消失——省 2.2MB,调试映射不进上架包;- resources 部分一字节没动——印证第二刀只作用于代码侧。
有个容易踩的细节:debug 和 release 的产物路径完全相同(都在 entry/build/default/outputs/default/ 下),连续构建不同模式时,拿到手的到底是哪个包要以构建日志为准,不要凭时间戳猜。做体积对比实验时,每次构建前清一次产物(或记下精确字节数)是防自欺的最好办法——这篇文章里的每个数字都是清产物后重新构建记录的。
四、第三刀:ArkGuard 混淆,开对了才有收益
混淆是三刀里唯一可能"开了比不开更糟"的一刀,所以放在最后讲。ArkGuard 的配置由两处组成:entry/build-profile.json5 里 release 构建集的开关,和规则文件本身:
// entry/build-profile.json5(节选)
"buildOptionSet": [
{
"name": "release",
"arkOptions": {
"obfuscation": {
"ruleOptions": {
"enable": true,
"files": [ "./obfuscation-rules.txt" ]
}
}
}
}
]
先说一个真实存在的陷阱:obfuscation-rules.txt 这个文件不需要你创建——DevEco Studio 建项目时就已经生成了,而且模板内容里赫然写着四个开关全开:
-enable-property-obfuscation
-enable-toplevel-obfuscation
-enable-filename-obfuscation
-enable-export-obfuscation
只是因为 build-profile 里 enable: false,它一直没生效。这意味着:只要有人顺手把开关改成 true、没有打开规则文件看一眼,四个混淆能力就会全部砸下来——包括两个高风险项。正确的姿势是先读懂每个开关管什么,再决定给工程开哪几个:
| 选项 | 作用 | 风险面 |
|---|---|---|
-enable-toplevel-obfuscation |
混淆顶层作用域的符号名 | 低:不改变结构,只改名 |
-compact |
压缩空白与换行 | 低 |
-remove-log |
移除 console.* 语句 |
低(hilog 不受影响) |
-enable-property-obfuscation |
混淆属性名 | 高:JSON 反序列化、反射场景字段错位 |
-enable-filename-obfuscation |
混淆文件名 | 高:import 'libxxx.so' 等按文件名寻址的场景 |
-enable-export-obfuscation |
混淆导出名 | 高:跨 HAR/HSP 的名字匹配 |
这个工程最终采用的规则文件长这样(完整可用):
# ---- 上架冲刺配置(保守起步)----
-enable-toplevel-obfuscation
-compact
-remove-log
-print-namecache
# ---- 刻意关掉的选项与原因 ----
# -enable-property-obfuscation 未开:
# 工程网络层存在 JSON.parse(x) as T 泛型 DTO,
# 属性名混淆会导致运行时字段错位。
# -enable-filename-obfuscation / -enable-export-obfuscation 未开:
# 涉及 import 'libxxx.so' 与跨包导出名匹配,风险面大。
# ---- 保留名单(开启 property 混淆时必须补充)----
# -keep-property-name:
# phase
# remainSeconds
**为什么不开 property 混淆?**因为这个工程的网络层有一段泛型反序列化:
// http/engine/OhosHttpEngine.ets
data = JSON.parse(response.result) as T
属性名混淆会把代码里声明的 DTO 字段名改掉,但服务端返回的 JSON 字符串里的字段名不会跟着改——运行时 weather.temperature 读到的就是 undefined,而且不报错,页面只是安静地显示一片空白。这类问题在测试环境一切正常、混淆包一上线就炸,排查全靠猜。要安全开启 property 混淆,就得把所有参与序列化的字段名补进 -keep-property-name 名单并长期维护——对这个工程来说,收益配不上这份心智负担。如果你的工程没有动态 JSON 与反射,可以放心开,然后把卡片数据、推送 payload 这类跨进程对象的字段名补进保留名单。
开启混淆后的 release 构建,产物是 74,911,716 字节(71.4 MiB),modules.abc 从 3,175,480 压到 3,083,772,降 2.9%。数字不大,符合预期:混淆只作用于代码,而这个包九成是资源。但混淆的价值不止体积——
-print-namecache 会在 entry/build/default/outputs/default/symbol/release/ 下产出 entry-nameCache.json,里面是完整的改名映射:
"entry/src/main/ets/calendar/HmCalendar.ets": {
"IdentifierCache": {
"#curves": "a",
"#WEEKDAY_LABELS": "b",
...
}
}
线上崩溃栈里的 a、b 要靠这份文件还原成 curves、WEEKDAY_LABELS。namecache 要跟版本归档:每次发版把它和 HAP 一起存下来,否则混淆包的崩溃日志就是天书。这一条进了后面的提审清单。
obfuscation-rules.txt:
build-profile.json5:
entry-nameCache.json:
五、四刀全开:76.2MB 到 3.6MB
三刀各自的效果都实测完了,最后把三个变量同时打开——release 构建 + 混淆 + 资源出包——构建出的终态包是 3,811,860 字节(3.6 MiB)。完整的数据表:
| 阶段 | 体积(字节) | MiB | modules.abc | 说明 |
|---|---|---|---|---|
| E0 debug 基线 | 79,929,450 | 76.2 | 5,818,320 | rawfile 全量、未混淆、含 sourceMaps.map |
| E1 移除内置音频 | 8,797,717 | 8.4 | 5,818,320 | 资源出包(仍为 debug) |
| E2 release 构建 | 75,012,507 | 71.5 | 3,175,480 | 字节码 -45.5%,调试映射消失 |
| E3 release + 混淆 | 74,911,716 | 71.4 | 3,083,772 | toplevel + compact + remove-log |
| E4 全开终态 | 3,811,860 | 3.6 | 3,109,236 | 三刀齐下,总降 95.2% |
从这张表能读出两个容易被忽略的事实。其一,每一刀都只作用于它自己的势力范围:资源刀对 abc 零影响,构建刀对 resources 零影响,混淆刀在 E2 的基础上只再省 0.1%——刀与刀之间不叠加、不冲突,顺序无所谓,但少一刀就少一块。其二,占比决定收益:E2 和 E3 在 rawfile 全量的前提下只挪动了 4MB 出头,不是这两刀不行,是这个包里没有它们的地盘。
顺手立一个可以复用的决策树——任何资源进包前过一遍:启动首屏要用吗?不用→出包;要用,体积小于 100KB 吗?是→进包;否→再问:变化频率高吗?高→出包走配置下发;低→压缩后进包或随 HSP 按需加载。
六、上架冲刺工作台:运行时自检权限
体积压完,包能提审了吗?还差一关:权限与合规自检。审核团队看你的应用,第一个动作就是拉出 module.json5 的 requestPermissions 逐条问:这个权限对应哪个功能?弹窗时机对不对?理由文案和隐私政策一致吗?
这些问题可以在提审前自己先问一遍。而且不用靠人肉翻配置文件——应用在运行时就能读到自己的权限声明。这要靠 BundleKit 的一个接口:
import { bundleManager } from '@kit.AbilityKit'
const flags: number = bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION |
bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_REQUESTED_PERMISSION
const info = bundleManager.getBundleInfoForSelfSync(flags)
getBundleInfoForSelf 系列接口通过 flag 控制返回内容的粒度,GET_BUNDLE_INFO_WITH_REQUESTED_PERMISSION(值 0x10)要求把权限声明信息带回来。返回的 BundleInfo 上有两个关键字段:
reqPermissionDetails:数组,每项对应一条requestPermissions声明,含name(权限名)、reason(申请理由)、usedScene(使用场景,含abilities与when——inuse使用时 /always始终);permissionGrantStates:与上面数组一一对应的授权状态数组,0是已授权(PERMISSION_GRANTED),-1是未授权(PERMISSION_DENIED)。
这两个字段组合起来,恰好就是审核视角的完整画像:你声明了什么、为什么、什么时候用、现在授权状态如何。基于它做一个"上架冲刺工作台"页面,配合前面的体积数据,提审前的自检一处完成。页面的数据层先定义好模型:
export interface RawPermissionInfo {
name: string
reason: string
whenText: string
grantState: number
}
export interface PermissionCard {
name: string
shortName: string
grantType: string // user_grant / system_grant
riskLevel: string // 风险分级文案
riskColor: string // 徽标颜色
reason: string
whenText: string
grantedText: string
advice: string // 最小化建议
}
页面加载时把系统返回的原始数据转成自检卡片:
loadBundleInfo(): void {
try {
const flags: number = bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION |
bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_REQUESTED_PERMISSION
const info = bundleManager.getBundleInfoForSelfSync(flags)
this.versionText = `${info.versionName}(build ${info.versionCode})`
const infos: RawPermissionInfo[] = []
const details = info.reqPermissionDetails
for (let i = 0; i < details.length; i++) {
const d = details[i]
const state: number = i < info.permissionGrantStates.length
? info.permissionGrantStates[i] : -1
const whenText: string = d.usedScene ? d.usedScene.when : ''
const raw: RawPermissionInfo = {
name: d.name, reason: d.reason, whenText: whenText, grantState: state
}
infos.push(raw)
}
this.permCards = buildPermissionCards(infos)
} catch (err) {
this.permError = `读取失败:${JSON.stringify(err)}`
}
}
两处防御值得说。usedScene 在 module.json5 里是可省略的字段(比如 INTERNET 就不需要),运行时拿到的对象上它可能不存在,直接 .when 会炸,所以先判空再取。permissionGrantStates 与 reqPermissionDetails 按文档一一对应,但仍按下标做越界保护——接口行为在版本间有微调的可能,自检工具自己先不能崩。
风险分级是数据层的纯函数,用一个工程里真实存在的四条权限举例:
| 权限 | 类型 | 分级 | 自检要点 |
|---|---|---|---|
INTERNET |
system_grant | 普通 | 无需弹窗,确认无违规联网场景 |
KEEP_BACKGROUND_RUNNING |
system_grant | 关注 | 必须与长时任务类型匹配,无后台场景就删声明 |
MICROPHONE |
user_grant | 敏感 | reason 必填且说人话;隐私政策列明采集与删除途径;拒绝授权后主流程可用 |
USE_FLOAT_BALL |
user_grant | 敏感 | 写清何时出现、如何关闭;被拒时主功能完整 |
分级的依据是授权方式与审核关注度,不是权限名长度:system_grant 的权限装上即生效,风险在"你声明了却用不上";user_grant 的权限要弹窗向用户伸手,风险在"文案含糊、时机粗暴"。审核被拒的高发区几乎都在第二类。
reason 为空这件事在卡片上直接标红——user_grant 权限缺 reason 是编译能过、提审必挂的典型问题。事实上 MICROPHONE 这条的真实返回值是"用于语音识别:采集您说的话并转换为文字",这种把功能与数据去向说清楚的文案,就是审核想看的样子;对比"获取麦克风权限"这种同义反复,高下立判。
工作台的第三区是可交互的提审清单,五大类十五条:权限合规(最小化、文案、弹窗时机)、隐私与声明(隐私政策可达且内容完整、三方一致、首启协议)、资源与包体(大资源出包、未引用资源清理、混淆与 namecache)、版本与兼容(versionCode 单调、多设备自测、ABI 与签名)、功能完整性(无死链占位、账号支付可测、稳定性自查)。每条点击勾选,顶部进度条实时汇总,全部走完意味着可以放心点"提交审核"。
清单数据的结构很朴素,价值在内容本身:
cats.push({
id: 'perm',
title: '权限合规',
items: [
{ id: 'perm-1', title: '权限最小化',
detail: '逐条核对 requestPermissions:每个权限都能指出对应功能入口,用不上的删掉' },
{ id: 'perm-2', title: 'user_grant 权限文案',
detail: 'reason 与 usedScene 齐全,文案说人话:用于语音识别 优于 获取麦克风权限' },
{ id: 'perm-3', title: '弹窗时机',
detail: '进入用到该权限的界面再弹,不在启动时连弹;被拒后给出引导但不阻断主流程' }
]
})

第一区的体积仪表盘直接把前面五阶段实验做成了横向柱条,数据是构建实测值写进常量的:
export const SIZE_STAGES: SizeStage[] = [
{ stage: 'E0 Debug 基线', sizeBytes: 79929450, note: 'rawfile 全量打入,未混淆,还带着 2.2MB sourceMaps.map' },
{ stage: 'E1 移除内置音频', sizeBytes: 8797717, note: 'rawfile 的 8 个 mp3 改为按需下载,一刀降 89%' },
{ stage: 'E2 Release 构建', sizeBytes: 75012507, note: '构建模式切换:字节码 -45%,调试映射消失' },
{ stage: 'E3 开启混淆', sizeBytes: 74911716, note: 'toplevel + compact + remove-log,再压一层代码体积' },
{ stage: 'E4 全开终态', sizeBytes: 3811860, note: 'release + 混淆 + 资源出包,累计降 95.2%' }
]
柱条宽度按五阶段最大值归一化,自绘 Row 百分比宽度即可,不需要图表库。E0 的柱条几乎撑满、E4 只剩一根细线——这张图本身就是"资源治理是主刀"最直观的证明。
七、提审前的最后一遍
清单之外,还有几件事在 AGC 后台操作时才暴露,提前记在这里。
上架包的架构要对。本地开发产出的 x86 构架包只用于调试,AppGallery Connect 只收 arm 构架的签名包。如果你的构建历史里混着 x86 产物,提审前确认清楚当前 HAP 的 ABI——用 unzip -l 看 libs/ 目录下有没有 x86_64 字样是最快的自查。
versionCode 只增不减。AppScope/app.json5 里的 versionCode(本工程是 1000000)每次提审必须大于上一次,versionName 要与提审表单填写一致。测试期间频繁构建时,建议把"提审版本号"固定记录在工程 README 或清单里,避免"上次传的是 1000001 还是 1000002"的悬案。
签名证书的有效期。调试签名与发布签名是两套材料,发布证书临期时续期、换证书要重新走签名流程,提前一周看一眼,别在提审当天发现证书只剩三天。
隐私政策与权限文案三方一致。module.json5 的 reason、应用内隐私弹窗的文案、AGC 上传的隐私政策 URL,三个地方描述同一件事时的口径必须一致。工作台权限自检区展示的 reason 就是用来做这个核对的底稿。
检测报告要读。提审后 AGC 会返回自动化检测报告,安全扫描、隐私检测、性能检测三项里," so 库未裁剪 “” 明文密钥硬编码 “” 权限申请超出功能范围 "是高频项——好消息是,这三项正好对应这篇文章的第三、四、六节,做完它们,报告基本是绿的。
八、写在最后
回头看这趟从 76.2MB 到 3.6MB 的行程,真正有效的动作按贡献排序是:资源出包(-89%)、release 构建(字节码 -45%)、混淆(再 -3%)、加上合规自检兜住上架的底线。方法论浓缩成三句话:
- 先解包再优化——
unzip -l五分钟看清构成,占比最高的部分拿最大的刀; - 每刀只管一段——资源、构建模式、混淆各管各的势力范围,别指望一把刀解决所有问题;
- 混淆按需开启——toplevel/compact/remove-log 是低风险基本盘,property/filename/export 开之前先排查 JSON 反序列化与按名寻址的场景,namecache 按版本归档。
工程里留下来的东西都是可复用的:obfuscation-rules.txt 可以整个拷去别的工程(注释里写清了每个开关的取舍理由),上架冲刺工作台的权限自检接的是系统真实数据,换一个工程一样能跑——权限清单变了,风险分级函数补几行就行。提审前打开这个页面过一遍清单,比通宵人肉检查踏实得多。
更多推荐



所有评论(0)