体验专题——Android 应用瘦身实战

1. 背景

从用户出发:为什么要做包体积压缩?

故事的起点是一场线下推广会。当大屏幕上展示出 App 的下载二维码后,我们发现不少用户只是拍了张照片保存下来,并没有当场下载安装。追问原因,回答很朴实------"流量不够,不想下 100 多 MB 的东西。"

这个场景非常直观:用户可能因为流量不足、手机存储不够,或者单纯是下载时间太长而放弃安装。而下载安装本身是用户动线的最前置环节------如果这一步流失了,后面的注册、浏览、深度使用全部归零。在相同的推广预算下,降低这一步的"摩擦力",就能让更多用户涌入。

围绕这个问题做了一些调研,得出几个关键结论:

  • Google 在 2019 年的实验表明,APK 每减少 10MB,全球平均下载转化率提升约 1.75%
  • 下载转化率 = 完成安装并访问应用的用户数 / 触发下载行为的用户数。降低包体积意味着相同预算下能获得更多装机收益
  • 降低包体积还能提升更新率------老用户也更愿意及时升级,有利于新业务快速落地
  • 团队此前已在主客户端完成过类似优化(111MB → 66MB),有成熟经验可复用

2. 现状分析

2.1 行业数据

截止 2025 年 2 月,我们对比了业内几个主流电商平台的商家端 App 安装包大小。在相同网络环境下实测,体积最小的竞品从下载到打开只需 10 秒出头,而我们的应用超过 1 分钟------对比十分鲜明。

商家平台 安装包大小(MB) 对应主客户端大小(MB)
待优化应用 103 63
竞品A 25 25
竞品B 165 61
竞品C 88 104
竞品D 95 271

2.2 近况分析

整个 2024 年,我们的商家版应用安装包一直维持在 103MB 左右。好消息是业务功能已相对齐全、包体增速缓慢,正适合对各业务模块进行拆分和压缩:

3. 目标制定

基于 2024 年 12 月的版本,计划在 2025 年 2 月发布新版本:

  • 安装包体积从 103MB 压缩至 65MB 左右
  • 预计提升 7%~10% 的下载转化率
  • 由于体积降低,启动速度等相关性能指标也相应提升
  • 在上述目标达成的同时,尽可能降低 crash 率

4. 方案设计

4.1 包体现状分析

首先使用 Android Studio 的 APK Analyzer 对安装包进行深度解析。体积占比最大的是 so 库,将近 56MB------地图、直播推流、Flutter、Weex 引入的 Native 库非常庞大:

其次是 dex 文件(约 28MB),由所有 Java/Kotlin class 编译而成:

最后是资源文件------assets 资源约 7MB,包含各种格式的资源文件(字体、音频等):

res 资源约 10MB,包括图片、页面布局文件、字符串资源等:

如果直接逐个处理单独的 so 库,往往会因为多个模块交叉依赖同一个 so 而难以评估风险。好在应用采用了模块化架构,可以从模块维度审视体积------借助 CI 平台的依赖树分析,对各模块体积进行排序:

各模块对应独立的业务功能,耦合度相对较低。经产品和技术协同评估,综合业务影响、包体收益、研发投入等因素,对不同模块采取不同策略:

模块名称 地图 Flutter Weex 相册 端智能 消息模块 ...
缩减措施 远程化 前期远程化,后期下线 后续下线→H5替换 远程化 远程化 so库裁剪 ...

4.2 方案制定

综合以上分析,确定了四条瘦身路径:

  1. 拔除冗余:下线无用三方库,移除无用代码和资源
  2. 远程化非主链路模块:地图、直播推流、端智能、相册等(核心手段)
  3. 资源缩减:图片资源编译时自动转换 WebP 压缩
  4. 长期方案:包体积监控、准入卡口、代码覆盖率监测

5. 方案实现

5.1 远程化

5.1.1 原理介绍

远程化(也称插件化),就是将 App 拆分成一个宿主(base)和多个插件(feature),每个模块都是一个独立的 APK。上传到应用商店的只有 base APK,插件 APK 在运行时按需从云端下载并加载。

这个过程分为编译期运行时两个阶段:

  • 编译期:不需要远程化的模块打包成主 APK(base.apk);各个远程化模块(地图、推流等)分别打包成独立的 feature.apk 并上传到 CDN;上传的元信息(模块名、下载地址、MD5 等)内嵌在宿主 APK 中
  • 运行时:base.apk 运行过程中完成 feature.apk 的下载、校验、安装和类加载,最终正常使用对应功能

远程化的主要优势

  • 非主链路模块按需加载,有效降低安装包体积
  • 各模块解耦,灵活性强,支持独立热修复
  • 易于维护和扩展

同时也存在一些风险

  • 有一定技术门槛,需根据应用自身情况适配
  • 系统架构复杂度上升
  • 运行时可能遇到类加载、资源加载相关的兼容性问题
5.1.2 方案实施

技术选型与业内方案对比

2018 年 Google I/O 发布了 Android App Bundle(AAB),基于 base APK + split APK 实现动态交付。但 AAB 强依赖 Google Play------国内应用无法直接使用。

围绕"国内如何实现动态交付"这个命题,业内诞生了多代插件化/远程化技术方案。在做技术选型时,我们对主流框架做了系统对比:

维度 Google AAB RePlugin (360) VirtualAPK (滴滴) Shadow (腾讯) Qigsaw (爱奇艺) Cigsaw (本方案)
技术代际 官方方案 第一代 第一代 第二代 第三代(AAB系) 第三代(AAB系)
Hook 数量 0 1个(ClassLoader) 多个(AMS/PMS等) 0 0 0
反射私有API 少量 较多
Android新版兼容 原生支持 需适配 需大量适配 天然兼容 天然兼容 天然兼容
四大组件 原生支持 占坑方案 Hook方案 代理+字节码 原生支持 原生支持
Google Play兼容 原生 不兼容 不兼容 不兼容 兼容 兼容
国内独立部署 不支持 支持 支持 支持 支持 支持
维护状态(2025) 活跃 停止维护 停止维护 低频维护 低频维护 活跃

第一代框架(RePlugin、VirtualAPK) 的核心思路是通过 Hook 系统框架层来"欺骗" Android 系统,让系统以为插件中的组件是预注册的。RePlugin 以"仅 1 个 Hook 点"著称(只 Hook ClassLoader),追求极致稳定性,但代价是不支持 Service、ContentProvider 的完整动态化。VirtualAPK 则 Hook 了 AMS、PMS 等多处系统服务,功能更全面,但每次 Android 大版本升级都需要大量适配工作------Android 9 开始限制反射调用隐藏 API 后,这类方案的维护成本急剧上升。

第二代框架(Shadow) 由腾讯开源,做出了"零反射、零 Hook "的突破性设计。它通过编译期字节码替换(将插件代码中的 Activity 替换为 ShadowActivity)和运行时代理转调来实现组件生命周期管理,完全不依赖系统私有 API。理论上天然兼容所有 Android 版本------这在当时是行业的重大进步。但 Shadow 的代价是侵入性较强 :插件开发者需要继承特定基类(ShadowActivity),现有代码迁移成本高;且它不基于 AAB 标准,无法与 Google Play 动态交付体系兼容。

第三代框架(Qigsaw/Cigsaw) 走了另一条路------拥抱 AAB 标准,替换交付通道。它的核心思想是:保留 Google AAB 的 split APK 打包格式和 Play Core API 接口,但把底层的"从 Google Play 下载"替换为"从自有 CDN 下载"。这意味着:

  • 零 Hook、零反射:基于 Android Framework 原生支持的 split APK 机制,不需要任何系统 Hack
  • 天然兼容新 Android 版本:只要 Android 系统支持 split APK(API 21+),就不需要额外适配
  • 双轨兼容:同一份代码既能在国内走自有 CDN 下载,也能在海外走 Google Play------有出海需求时无需二次开发
  • 开发体验一致:上层 API 与 Google Play Core Library 完全一致,业务开发者无感

我们最终选择的 Cigsaw 是在 Qigsaw 基础上的深度适配版本,在继承上述优势的基础上,还补充了几个关键能力:

  • 支持 feature.apk 内置降级:split APK 可选内置于基础包中,网络异常时直接使用本地版本,保证功能可用
  • 跨进程 AIDL 安装服务:安装过程独立进程执行,彻底隔离主进程 ANR 风险
  • Native Hook 资源监控 :通过 JNI Hook AssetManager.nativeSetApkAssets() 实时感知资源变化(覆盖 API 28~36)
  • 远程配置热更新:支持不发版更新模块下载地址和版本信息
  • 支持断点续传、MD5 完整性校验、下载优先级控制

与 Shadow 的"零 Hook 但需改代码"不同,Cigsaw 的"零 Hook"是建立在系统原生能力之上的------既不需要 Hook 系统,也不需要修改业务代码,这是选择它的决定性因素。

远程化框架核心架构

整个框架分为五层,层次清晰、职责明确:

复制代码
┌─────────────────────────────────────────────────────────┐
│                  Application Layer                        │
│           业务方通过 API 按需请求功能模块                   │
└────────────────────────┬────────────────────────────────┘
                         │
┌────────────────────────▼────────────────────────────────┐
│               Play Core 兼容层                            │
│      SplitInstallManager / SplitInstallRequest           │
└────────────────────────┬────────────────────────────────┘
                         │
┌────────────────────────▼────────────────────────────────┐
│                    核心门面层                              │
│          框架初始化 / 生命周期管理 / 配置路由               │
└───────┬────────────────┬────────────────┬───────────────┘
        │                │                │
┌───────▼──────┐  ┌──────▼───────┐  ┌────▼─────────┐
│  类加载引擎   │  │  安装管理器   │  │  远程下载器   │
│ ClassLoader  │  │ 签名/校验    │  │ 并行下载     │
│ Hook+资源注入│  │ 跨进程AIDL   │  │ 断点续传     │
└──────────────┘  └──────────────┘  └──────────────┘

远程下载:接口与实现分离

框架定义了标准的 Downloader 接口,业务方可以插入任意下载引擎实现:

java 复制代码
public interface Downloader {
    // 立即下载(高优先级,用户正在等待)
    void startDownload(int sessionId, List<DownloadRequest> requests,
                       DownloadCallback callback);
    // 延迟下载(低优先级,后台预加载)
    void deferredDownload(int sessionId, List<DownloadRequest> requests,
                         DownloadCallback callback, boolean usingMobileDataPermitted);
    // 取消下载
    boolean cancelDownloadSync(int sessionId);
    // 移动网络下载阈值(超过时需用户确认)
    long getDownloadSizeThresholdWhenUsingMobileData();
    // 是否仅 WiFi 下延迟下载
    boolean isDeferredDownloadOnlyWhenUsingWifiData();
}

默认实现采用 GroupTaskDownloader 管理并行下载,最大并行数为 5,支持进度聚合和零拷贝缓存优化。当检测到本地已有匹配缓存时,直接跳过网络请求:

java 复制代码
public void startDownload(int sessionId, List<DownloadRequest> requests,
                          DownloadCallback callback) {
    List<DownloadParams> downloadParams = new ArrayList<>();
    for (DownloadRequest request : requests) {
        // 过滤本地协议(assets://、native://),只下载远程资源
        if (!request.getUrl().startsWith("assets://")
                || !request.getUrl().startsWith("native://")) {
            downloadParams.add(DownloadParams.newInstance(request));
        }
    }
    // 高优先级并行下载
    groupTaskDownloader.startParallelDownload(sessionId, downloadParams,
                                             HIGH_PRIORITY, callback);
}

跨进程安装服务(AIDL)

下载完成后的安装过程通过 AIDL 运行在独立服务进程中,避免安装过程中的 IO 密集操作(解压、签名校验、dex 编译)导致主进程 ANR:

java 复制代码
// ISplitInstallService.aidl
interface ISplitInstallService {
    void startInstall(String packageName, in List<Bundle> moduleNames,
                      in Bundle versionCode, ISplitInstallServiceCallback callback);
    void cancelInstall(String packageName, int sessionId,
                       in Bundle versionCode, ISplitInstallServiceCallback callback);
    void getSessionState(String packageName, int sessionId,
                         ISplitInstallServiceCallback callback);
    void deferredInstall(String packageName, in List<Bundle> moduleNames,
                         in Bundle versionCode, ISplitInstallServiceCallback callback);
    void deferredUninstall(String packageName, in List<Bundle> moduleNames,
                           in Bundle versionCode, ISplitInstallServiceCallback callback);
}

安装流程包括:签名验证(确保 split 与主 APK 证书一致)→ MD5 完整性校验 → 提取 Native 库(文件锁保证并发安全)→ 提取 DEX → 触发 OAT 预编译 → 写入安装标记。

运行时类加载:委托 ClassLoader 架构

这是远程化方案中最精妙的部分------如何让远程下载的代码无缝被业务调用?框架通过替换系统 PathClassLoader,实现了透明的类加载委托:

复制代码
SplitDelegateClassloader (替代原始 PathClassLoader)
    │
    findClass(name)
    │
    ├─→ 原始 ClassLoader 查找(主应用代码)
    │
    └─→ ClassNotFoundInterceptor 拦截
              │
              ▼
        遍历所有 SplitDexClassLoader(远程模块代码)
              │
              └─→ 沿依赖链逐级查找

每个远程模块拥有独立的 SplitDexClassLoader,支持模块间的依赖链加载------当模块 A 依赖模块 B 中的类时,A 的 ClassLoader 会自动沿依赖链到 B 的 ClassLoader 中查找:

java 复制代码
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
    try {
        return super.findClass(name);  // 先在自己的 dex 中找
    } catch (ClassNotFoundException e1) {
        if (dependenciesLoaders != null) {
            for (SplitDexClassLoader loader : dependenciesLoaders) {
                try {
                    return loader.loadClassItself(name);
                } catch (ClassNotFoundException e2) {
                    // 继续下一个依赖
                }
            }
        }
        throw e1;
    }
}

Android 版本兼容:符号链接绕过后台验证

Android 11 引入了后台 DEX 验证机制(RunBackgroundVerification),可能拒绝动态加载的 split APK。框架通过创建去掉 .apk 后缀的符号链接 绕过------系统只对 .apk 文件触发验证:

java 复制代码
protected static String symbolicLink(String dexPath) {
    if (Build.VERSION.SDK_INT >= 30 && Build.VERSION.SDK_INT <= 33
            && dexPath.endsWith(".apk")) {
        // Android 11~13:创建符号链接,绕过后台验证
        return createSymbolicLink(dexPath);
    } else if (Build.VERSION.SDK_INT >= 34) {
        // Android 14+:设为只读,满足安全要求
        try { (new File(dexPath)).setReadOnly(); } catch (Throwable ignored) {}
    }
    return dexPath;
}

Native Hook 监控资源变化

远程模块还带来资源文件(布局、图片等)。框架通过 JNI Hook 拦截 AssetManager.nativeSetApkAssets() 方法,实时感知资源表变化并通知 Java 层刷新:

cpp 复制代码
// bridge.cc
static void nativeSetApkAssetsProxy(JNIEnv* env, jclass klass,
        jlong ptr, jobjectArray apkAssets, jboolean invalidateCaches) {
    // 1. 执行原始逻辑
    ((OriginalFunc)originFuncPtr)(env, klass, ptr, apkAssets, invalidateCaches);
    // 2. 通知 Java 层资源已变更
    env->CallStaticVoidMethod(assetManagerMonitor, apkAssetsChanged);
}

覆盖 Android API 28~36,并针对 Android 15+ 方法签名变化做了专门适配。

远程配置热更新

除了按需下载,框架还支持不发版更新模块 ------当服务端推送新版本时,SplitUpdateService 后台验证并原子化替换本地配置:

java 复制代码
protected void onHandleIntent(Intent intent) {
    String newVersion = intent.getStringExtra(NEW_SPLIT_INFO_VERSION);
    String newPath = intent.getStringExtra(NEW_SPLIT_INFO_PATH);
    // 版本去重 → 配置文件解析 → 应用标识校验 → 原子更新
    if (manager.updateSplitInfoVersion(context, newVersion, newFile)) {
        onUpdateOK(oldVersion, newVersion, updateSplits);
    }
}

下次用户触发对应模块时,框架自动使用新 URL 下载新版 split APK。

编译阶段

根据安装包用途分为不同的编译模式:

  • 本地调试 Debug 包:Android Studio 直接 Run,采用 Google 官方 AAB 模式,base + dynamic features 一起安装
  • 发布到应用商店的正式包:CI 平台执行 Gradle 脚本,各 feature split APK 上传到 CDN,以便运行时动态加载
  • 完整功能包/海外 Google Play 包:关闭远程模块能力,正常打包

运行时

运行时需要明确处理的几件事:

  1. 流量保护:用户首装时,非 WiFi 环境下有固定流量阈值,单次下载超过阈值会弹窗确认。防止远程化被滥用导致静默下载体积过大
  2. 入口拦截 :远程化模块的每个页面入口都需要拦截,确保模块加载完成后才放行。模块越多,需要拦截的入口越多,遗漏会导致线上 ClassNotFoundException crash
  3. 兜底机制:断网、弱网、覆盖安装等场景,需要增加 loading 页面和错误提示
5.1.3 实现效果

对地图、相册、Flutter、直播推流、视频剪辑、端智能等模块进行了插件化,有效降低了约 38~40MB 的包体积。

首次安装效果:中端机型上,首次安装登录后,上传视频、开启直播、发送图片等功能正常使用,无需等待。地图功能仅在第一次使用时有一个短暂的 loading 页面。

二次启动时,各远程化组件基本无阻塞感。

5.2 图片压缩插件

5.2.1 背景

由于历史原因,应用的壳工程和二三方库中存在大量 PNG/JPG 图片。这些图片可以转换为 WebP 格式来压缩,但手动处理效率低且容易遗漏------依赖库数量众多,逐个处理不现实。

5.2.2 原理

解决思路分两步:拿到所有图片 → 批量压缩

第一步 借鉴了 Android 构建流程中的 mergeResourcesTask------这个 Task 负责合并所有 AAR、module 的资源,在它执行之后,可以通过 AGP 提供的 allRawAndroidResources.files 接口拿到所有资源文件(包括二三方库中的)。然后插入一个自定义 Task,遍历并筛选图片文件进行压缩。

第二步 使用 Google 官方的 cwebp 命令行工具进行 WebP 转换,并采用多线程加速(根据 CPU 核心数开启并行),避免拖慢编译。同时做了体积比对------转换后反而变大的图片不做处理。

这套 Gradle 插件的优势:

  • 接入简单:引入插件即可自动执行,无需手动操作
  • 面向切面:壳工程、二方库、三方库的图片资源全覆盖
  • 多线程加速:CI 平台上对编译时长增加不超过 20s,本地调试时不执行
  • 白名单机制:关键图片(安全组件生成的图片、.9.png 等)可配置跳过
5.2.3 实现效果

CI 平台打包时的日志显示,在手动处理壳工程后,插件仍然处理了约 1400 张图片 ,节省了大量手动操作。图片压缩总收益约 5MB(经 APK 二次压缩后,最终包体积降低约 0.7MB)。

6. 数据指标

截至上线一个月后的数据统计:

指标 数值
包体积 63.8MB(较优化前降低 38%)
稳定性 crash 率 0.011%,远程化引起的不足总 crash 数 5%
模块下载成功率 > 98%
模块加载成功率 99.98%
版本覆盖率 上线一个月覆盖设备 > 87%

上架后应用商店下载量出现明显激增。

7. 总结和展望

7.1 总结

这次历时两个月的瘦身,收获体现在三个层面:

对用户而言:无论新安装还是更新,下载等待时间和流量消耗都大幅降低,有利于新业务在用户群体中的快速触达。

对应用自身而言:第一次系统性地为应用做了"减肥"------移除了大量冗余架构、代码和资源,把一些历史包袱远程化以备后续下线或改造。模块间耦合度降低,架构层次更加清晰。

技术沉淀而言:远程化方案从原理验证到大规模落地,积累了完整的问题排查经验------从 ClassNotFoundException 的入口拦截遗漏,到 Android 11+ 的 dex2oat 后台验证问题,到多进程并发安装的文件锁设计,每一个坑都变成了可复用的工程能力。

7.2 展望

包体积优化是一场长效治理的持久战。这次瘦身成功了,但没有防腐化措施,减肥成效很快就会"反弹"。需要在交付流程上建立规约:

"非必要不依赖,依赖原因要透明,引新需要去旧,变更留记录"

------这既是包体防腐,更是架构防腐。

包体积压缩只是应用性能优化的一部分。在这个过程中也发现了内存、启动速度等方面的潜在优化空间,未来会持续推进,把应用的性能和用户体验做到更好。

相关推荐
AFinalStone1 小时前
Android12 U盘插拔链路源码全解析(七):应用层 —— MediaScanner与SAF
android·frameworks
Multipath7121 小时前
急救车上的“信号堡垒”:多链路聚合路由如何让生命连线永不掉线
网络·5g·安全·实时音视频
InHand云飞小白1 小时前
连锁门店网络困境?5G Wi-Fi 6边缘路由器赋能分布式企业
网络·5g·路由器·网络运维·5g路由器·5gcpe·连锁联网
AI玫瑰助手1 小时前
Python模块:import导入模块与模块的搜索路径
android·开发语言·python
拼搏的小浣熊2 小时前
【通用教程】Windows\+Linux\+银河麒麟系统 固定静态IP地址|解决打印机扫描IP变动、网络掉线问题
linux·网络·windows·麒麟·固定ip·麒麟系统·统信系统
AI科技星2 小时前
第四卷:橡皮泥江湖(拓扑学)――诸同奥义,九同立境贯拓扑
网络·人工智能·线性代数·架构·概率论·学习方法·拓扑学
FreeBuf_2 小时前
Anthropic新发模型Claude Fable 5快速被越狱
网络·安全·web安全
极客范儿2 小时前
华为HCIP网络工程师认证—交换基础
网络·华为
AI科技星2 小时前
第四卷:橡皮泥江湖(拓扑学)
c语言·开发语言·网络·量子计算·agi·拓扑学