一个应用从"功能写完"到"用户能下载",中间隔着一道很多人临时抱佛脚的关卡:包体积与上架检测。包体积直接影响下载转化——应用市场对超体积包有明确的限速与提醒策略,用户在流量环境下看到一个 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%

mintty_4wcxC02Asm.png

这张表把优化顺序直接拍在桌上:

  • 九成体积是资源,而且是 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().filesDircacheDir),用 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",
    ...
  }
}

线上崩溃栈里的 ab 要靠这份文件还原成 curvesWEEKDAY_LABELSnamecache 要跟版本归档:每次发版把它和 HAP 一起存下来,否则混淆包的崩溃日志就是天书。这一条进了后面的提审清单。
obfuscation-rules.txt:
devecostudio64_C7xn3LJYzk.png

build-profile.json5:
devecostudio64_2kdGJnm5hH.png

entry-nameCache.json:
image.png

五、四刀全开: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.json5requestPermissions 逐条问:这个权限对应哪个功能?弹窗时机对不对?理由文案和隐私政策一致吗?

这些问题可以在提审前自己先问一遍。而且不用靠人肉翻配置文件——应用在运行时就能读到自己的权限声明。这要靠 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(使用场景,含 abilitieswhen——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 会炸,所以先判空再取。permissionGrantStatesreqPermissionDetails 按文档一一对应,但仍按下标做越界保护——接口行为在版本间有微调的可能,自检工具自己先不能崩。

风险分级是数据层的纯函数,用一个工程里真实存在的四条权限举例:

权限 类型 分级 自检要点
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 这条的真实返回值是"用于语音识别:采集您说的话并转换为文字",这种把功能与数据去向说清楚的文案,就是审核想看的样子;对比"获取麦克风权限"这种同义反复,高下立判。
Emulator_oJMep6tf62.png

工作台的第三区是可交互的提审清单,五大类十五条:权限合规(最小化、文案、弹窗时机)、隐私与声明(隐私政策可达且内容完整、三方一致、首启协议)、资源与包体(大资源出包、未引用资源清理、混淆与 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: '进入用到该权限的界面再弹,不在启动时连弹;被拒后给出引导但不阻断主流程' }
  ]
})

Emulator_DLmQ2cxbRE.gif
第一区的体积仪表盘直接把前面五阶段实验做成了横向柱条,数据是构建实测值写进常量的:

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 只剩一根细线——这张图本身就是"资源治理是主刀"最直观的证明。
image.png

七、提审前的最后一遍

清单之外,还有几件事在 AGC 后台操作时才暴露,提前记在这里。

上架包的架构要对。本地开发产出的 x86 构架包只用于调试,AppGallery Connect 只收 arm 构架的签名包。如果你的构建历史里混着 x86 产物,提审前确认清楚当前 HAP 的 ABI——用 unzip -llibs/ 目录下有没有 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%)、加上合规自检兜住上架的底线。方法论浓缩成三句话:

  1. 先解包再优化——unzip -l 五分钟看清构成,占比最高的部分拿最大的刀;
  2. 每刀只管一段——资源、构建模式、混淆各管各的势力范围,别指望一把刀解决所有问题;
  3. 混淆按需开启——toplevel/compact/remove-log 是低风险基本盘,property/filename/export 开之前先排查 JSON 反序列化与按名寻址的场景,namecache 按版本归档。

工程里留下来的东西都是可复用的:obfuscation-rules.txt 可以整个拷去别的工程(注释里写清了每个开关的取舍理由),上架冲刺工作台的权限自检接的是系统真实数据,换一个工程一样能跑——权限清单变了,风险分级函数补几行就行。提审前打开这个页面过一遍清单,比通宵人肉检查踏实得多。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐