写点什么

有道词典 Android 客户端包体积优化之路

  • 2022 年 4 月 21 日
  • 本文字数:5118 字

    阅读完需:约 17 分钟

有道词典Android客户端包体积优化之路

1 背景

有道词典从移动互联网之初就凭借小巧快速、功能强大的印象让用户爱上翻译查词,爱上学习。随着业务不断地迭代以及功能不断完善,有道词典不再是单纯的查词软件,而是变成了用户的综合学习平台。我们探索过社区、问答、直播、信息流等业务,目前也承载着音频、视频、课程、背单词、写作批改等等的功能。词典已经发展成为一个综合性的学习平台,小巧快速的初心仍然指引着我们不断进行启动速度以及包体积优化。


经过了不断的性能优化,目前我们的冷启动时间已经能维持在业界标准水平 3s 以内。我们近一个季度主要的性能优化工作集中在安装包体积优化上面。经过一系列的努力,我们包体积减少了 23.7%,安装包体积从 177MB 减少到 135MB,整体少了 42MB。



以下详细介绍我们的分析以及实现细节。

2 分析

介绍下包体积包含的内容以及优化方法概述


一般的 APK 安装包包含了以下一些目录和资源:

META-INF/ 签名文件

assets/ 程序使用的辅助资源文件

res/ 没有编译进入 resources.arsc 资源文件,一般是图片

lib/ 依赖的不同 native 平台的库文件

resource.arsc 编译之后的文案、色值、大小、主题等资源索引

classes.dex 编译后的代码

AndroidMenifest.xml 应用的名称、版本、访问权限和引用的库文件信息



可以看出占比较大的部分主要是分别是 assets/、lib/、res/、classes.dex 以及 resources.arsc,大概对应的就是资源、库文件、代码以及资源索引。我们主要的优化思路如下(其中蓝色框部分为目前已经处理部分):



3 技术实现细节 3.1 图片压缩在 APK 打包的过程中,aapt 工具会默认对图片进行无损压缩,不过默认的压缩并不能达到一个很好的压缩效果,经过了对比 webp 以及 tinypng 的压缩效果,我们最终选择了使用 tinypng 对图片进行压缩。并且我们编写了编译工具,对图片进行自动化压缩。


有损 webp > tinypng > 无损 webp



比如这张启动图,原大小 724KB,压到 75%左右的质量只有 23.7KB。效果上有一点点差异,但可以接受。那么我们是否可以把全部 png 图压成有损 webp 呢?答案是否定的,可以看看下面的例子:


压缩前:



压缩后:



可以看到,相同的压缩质量下(75%),这个图就变得十分模糊,哪怕选择到了 99%的压缩质量,渐变区域依然会出现一些没有自然过渡的条纹。


对于上述的情况,用 tinypng 方案更好


原图:643KB,


tinypng: 152KB,


webp:339KB


综上,对于有损 webp,无法找到一个固定的压缩质量来适配所有场景。有损 webp 有些时候甚至比 tinypng 还大,但显示质量更差。


我们最初使用的抖音的 McImage 插件对图片进行处理,不过这个方案存在一些明显的问题:

1. 方案采用有损 webp,有损 webp 无法定一个通用的压缩质量适应所有场景。

2. 每次打包都要对所有图进行压缩,严重影响迭代效率。打包机要 40 分钟,且经常 OOM。

3. 没有对 assets 目录的图片进行处理。


针对以上问题,我们自己开发了一套使用 tinypng 的自动化图片压缩工具,做出以下调整:


1.对于大图 png,用手工压成有损 webp。收益大,且风险可控。

2.对于非大图,开发了一个 image-optimization 插件进行压缩。该插件方案为:png 转 tinypng。虽然是有损的,但从抽样来看,肉眼完全看不到明显变化。

  • 对 assets 进行处理。assets 内有前端 png 图,转 tinypng 不转 webp 的好处是不需要单独改 html、js 等文件,且对低版本系统兼容性更友好。flutter 相关项目的 flutter_assets 图片比较大且没注意压缩。

  • 插件统一处理可以不需要打开 flutter 工程单独优化、重新打包。

  • 对于已压缩的图片,做缓存处理,不需要重新压缩,打包的时候动态替换。压缩缓存跟随词典工程提交到 gitlab 统一管理。


以下是我们图片自动化压缩插件处理的流程图:


这里压缩图是否可用判断,主要是大小判断,如果压出来比原图大,那么将舍弃。比如 crunchPng 压缩就存在这种情况


附加 1:因为已经用了 tinypng 统一压缩,那么 google 官方自带的 crunchPng 建议关闭,否则打包速度变慢,而且优化好的图片也可能又变大,加入这行即可:


  isCrunchPngs = false\}
复制代码


附加 2:无损 webp 和 tinypng 对比


如图所示,全量换 tinypng 比全量换 webp(包含 assets)少 7.7MB。如果考虑到 assets 内的 14.7MB 其实是不能简单换 webp 的,差距会更大。



附加 3:tinypng 已经是最好的方案吗?


参考另一个 ImageOptim 工具,它结合 OptiPNG, PNGCrush, AdvanceComp, PNGOUT, Jpegoptim + Jpegtran, 和 Gifsicle 等几个工具提供最好的优化效果,而且是几乎无损的。对于小部分图片 ImageOptim 压出来小,看起来没有差别。不过压缩速度非常慢。


所以,如果做到极致的话,可以进行多种压缩方案,选最佳的图作为替换。且我们的 image-optimization 插件从一开始设计的时候就预留了这种可扩展性。


附加 4:AndResGuard 优化对比


试了一下效果不明显,且出现部分资源丢失而崩溃的情况。效果不明显的原因,猜测是目前 R8 对资源名也有混淆压缩(以前 proguard 没有),所以 AndResGuard 现在的作用比较微弱。至于 7zip 的压缩没有开,理论上会导致启动速度变慢,觉得得不偿失(另外会导致 Google Pay 的 Patch 优化算法失效)。

3.2 resources.arsc 优化

  • 语言包优化



打开 resources.arsc 的 string,我们可以看到如下表格,会发现大量空的地方(如上图)。这些空白的地方,其实是用 FF FF FF…字符进行占位的,占用了很多空间(如下图)。由于有道词典没有进行国际化翻译(有一个国际化版本叫 U-Dictionary,欢迎支持),因此删掉不必要的语言版本有助于减少体积。



    defaultConfig {\        resConfigs "zh"\    }\
复制代码


  • 如上所示,增加一行,保留中文即可。收获比想象中大,直接减少了 3MB。

  • dimens 优化查看了最近几个版本的 arsc 体积,发现有一个版本增加了 5MB。

  • 在这个版本我们做了平板适配功能,由于我们采用的是 SmallestWith 限定符适配方案(可以先了解下这个屏幕适配方案),因此产生大量的尺寸资源。



一共是有 3000 多个资源,每一个资源有“values-sw300dp”到"values-sw1200dp"共 90 个版本,这块存在较大的优化空间。


sqb_px_xx”这一项是用于字体适配的,但词典用到最大的字体是“sqb_px_144”,所以优化了生成规则,减少了这一类资源。


优化后,资源数量由 3012 变成 1662,减少了近一半。直接减少了 2.5MB。

3.3 业务代码删除

由于 Proguard 以及 lint 等工具是从代码引用的角度进行分析和代码裁剪,如果一些废弃的代码不先进行删除会影响后续工作的效果。对于一些已经废弃没有入口的业务,不进行处理的话那么代码、资源会只增不减。业务删减应该是所有包体积流程的第一步,否则后面的去掉无用资源、图片压缩、混淆等等效果都要打一个折扣。如果时间有限的话,那么删最近的需求会比删远古时代的需求收益会大点,原因是越靠近现在的项目,图片资源、字体资源,以及用到 so 库都会比较大(尤其是音视频)。


这部分工作主要是对业务功能的整理以及沟通部分陈旧业务是否可以进行删除,除此之外就是需要细致的引用分析将废弃业务相关代码剥离出来进行删除。


一个良好的项目架构对于日后业务代码的剥离有很大好处。目前新开发的功能我们采用的是分层分模块的组织架构,功能模块之间不存在相互依赖,因此以后对于业务的抽离或者删除会更加方便。


3.4 无用资源删除

对于无用资源删除我们主要使用了两个方法,一个是通过 lint 工具找到应用中可能没有使用的资源并逐一进行判断确认没有使用后进行删除,第二个是在 build.gradle 文件中加入 shrinkResources 在编译阶段使用 R8 工具进行删除


        release {\            // Zipalign优化\            zipAlignEnabled true\            // 移除无用的resource文件\            shrinkResources true\            // 移除没用的代码\            minifyEnabled true\        }\}
复制代码


使用 lint 工具需要注意对以下一些场景进行再次判断确认


  1. 对于反射性引用资源,可能会被识别成无用资源,比如 push 用到的通知栏 icon

  2. DataBinding 用到的 layout 资源会被识别成无用资源

3.5 压缩混淆

使用 R8 工具在编译阶段对代码进行压缩混淆,从而达到压缩安装包体积的效果。主要分为以下 4 个步骤:

  1. 压缩(shrink) 移除未使用的类、方法、字段等;

  2. 优化(optimize) 优化字节码、简化代码等操作;

  3. 混淆(obfuscate) 使用简短的、无意义的名称重命名类名、方法名、字段等;

  4. 预校验(preverify) 为 class 添加预校验信息。


我们在两年前就引入了 Proguard,不过考虑到混淆带来的问题使用了-dontobfuscate 配置取消混淆。我们发现之前的规则中从依赖库中继承了 -dontoptimize 的配置导致优化也没有生效。这次优化中,我们全面解决了混淆带来的众多问题,全面开启了优化以及混淆。


由于我们之前已经开启过了压缩,因此需要使用到的类已经在 proguard 中进行了保留。开启混淆后还需要处理以下一些问题:


  • getIdentifier 通过名称获取资源问题。如果是普通模式,则会自动不去掉相关资源:



  • 检查 Resources.getValue 相关逻辑

  • 检查 AssetManager.open 相关逻辑

  • 反射,全局搜一下反射包,修改相关位置 java.lang.reflect

  • 处理 Retrofit 报错问题(https://github.com/square/retrofit/issues/3588),目前使用升级 Gradle 插件版本进行解决


    for method CheckInApi.popupConfig\    at retrofit2.Utils.methodError(SourceFile:5)\    at retrofit2.Utils.methodError(SourceFile:1)\    at retrofit2.ServiceMethod.parseAnnotations(SourceFile:7)\    at retrofit2.Retrofit.loadServiceMethod(SourceFile:4)\    at retrofit2.Retrofit$1.invoke(SourceFile:6)\    at java.lang.reflect.Proxy.invoke(Proxy.java:1006)\    at $Proxy23.popupConfig(Unknown Source)\    at com.youdao.dict.checkin.CheckInPopupManager.requestPopupConfig(SourceFile:3)\    at java.lang.reflect.Method.invoke(Native Method)
复制代码


Proguard 的规则会很大程度上影响 R8 对代码压缩和混淆带来的效果,因此对压缩规则的回顾以及整理可以帮助进一步的体积压缩。

3.6 字体优化

字体优化这部分是在之前的版本已经实现过的,取得的效果也挺明显,这里补充说明一下。


- 字体裁剪

一般的字体库大小会有十几二十兆。但实际上用到的字符只有很少一部分,因此针对实际的使用场景对字体库进行适当的裁剪,收益非常大。

常用字列表:https://github.com/DavidSheh/CommonChineseCharacter

字体压缩工具:https://github.com/forJrking/FontZip

- 字体合并

一般来说,我们开发都会模块化,不同的团队采用在开发不同功能的时候,有可能用到相同的字体。如果稍不注意就会复制成两份、三份,文件大大增加。词典这边的方案是把共有的字体下沉到底层 core 基础库,供各个模块引用。

4 展望

经过了上述的工作,目前词典的安装包体积优化了 23.7%,整体减少了 42MB。在接下来的 Q2,我们将准备做两方面的事情。

4.1 包体积监控

在包体积优化的过程中,我们在含辛茹苦地砍掉一点体积之后,转过头来发现别的同学又随随便便扔进去几 MB 的大图。因此,如何坚守胜利的果实,让包体积保持最佳状态成了重中之重。


打包任务增加了是否检查包大小限制(默认都要检查) 的选项;merge request 之后,词典的打包任务会触发自动构建;


打包任务完成之后,如果需要检查包大小,那就开始触发 apkcheck 步骤;具体如下:

  1. 打包任务完成之后增加脚本操作,把本次构建的数据(如 apk 文件地址,mapping 文件地址,R 文本地址等)写入临时文件;

  2. 打包任务构建后操作增加 Trigger parameterized build on other projects,触发 apk 大小检查任务;

  3. 开始检查流程,检查流程根据参数对 apk 进行检查任务,并且把任务结果生成 html;

4.2 动态分发

- 整体业务分发


可以使用插件化以及动态加载等技术,不过这些可能不是最难的,最难的是如何把一些祖传的、低频的、而又相互依赖的代码抽离出来,形成独立模块去做分发、动态加载。


- 业务子功能分发(预计可优化 39.6MB)


  1. 数据库(单词锁屏 8MB)

单词锁屏可以保留几百 kb 数据在本地让用户备用,同时再下载完整的词库。

  1. OCR 引擎数据(22.5MB)

用户应该可以按需下载训练模型,而不是直接内置;当没有训练模型的时候,可以直接网络请求。

  1. 字体(9.1MB)

除了查词等高频业务,低频业务的字体可以动态分发,有则显示,无则使用系统的即可。但 emoji 的兼容库比较特殊,主要用在首页信息流的帖子、UGC 发帖等。如果没有兼容库,用户在遇到特别的 emoji 中可能会显示“豆腐块”,这个时候如果 emoji 字体库还没下载完成,需要进行替换兜底处理。另外哪怕用户系统已经有这个 emoji 内置字体,也有可能显示效果各个手机不太一样,需要跟 UI 确认一下是要替换掉,还是暂时这样显示。


保持住词典小巧快速、功能强大的初心是我们不停进行性能优化的动力,在接下来的工作中,我们会对启动速度、安装包体积以及内存占用等多方面进行持续优化和改进,欢迎大家继续关注和支持!

5 参考

  • Reduce your app size


  • Shrink, obfuscate, and optimize your app


  • 抖音图片压缩插件 McImage


  • 腾讯包体积监控 ApkChecker

发布于: 刚刚阅读数: 2
用户头像

高效学习,从有道开始 2021.03.10 加入

分享有道人的技术思考与实践。

评论

发布
暂无评论
有道词典Android客户端包体积优化之路_andiod_有道技术团队_InfoQ写作社区