有赞 Android 编译优化方案 Savitar 2.0

Android社区 收藏文章

前言

Android 的编译速度一直是业内不断被讨论的话题,2019 年有赞移动沙龙之后,发表了「有赞 Android 编译进阶之路 —— 增量编译提效方案 Savitar 」文章,描述了有赞移动对于编译提效的探索,并提出 Savitar 解决方案。2020 年,这不平凡的一年,Savitar 经过不断打磨,迎来了 Savitar 2.0 版本。

一、Savitar 的设计

Savitar 整体分成四个部分:

  • GUI 插件部分:所有能力集成在插件⚡️按钮之中,内部包含了可运行 Runner 的自动更新、各种检查任务、编译脚本调用执行
  • Runner 部分:一个 Jar 包,包含 Savitar 核心逻辑代码,完成修改获取、脚本生成、编译执行等任务。Runner 可动态更新,Jar 的形式也有利于在 CLI 环境使用
  • 工程支持部分:一个 Gradle 插件,完成对工程信息的获取和产物加载代码的插入,达到对工程代码 0 入侵的效果
  • 外部依赖部分:完成整个流程所需要的外部依赖程序,包括文件监控的 watchman、完成源码编译的 javac、kotlinc 等

主要流程: 增量加速理论基础源于热修复技术,将本地改动的代码编译生成一个个补丁,然后让 APP 加载补丁,达到加速的目的。在设计和流程方面,相对 1.0 基本不变,只是内部的功能实现得到了加强,并且提升工具整体的稳定性,降低接入难度。

二、使用效果数据

从 2019 年 6月至今,Savitar 累计使用超过 18000 次,平均每天使用超过 30 次,节省了大量的编译时间,下面是 Savitar 部分使用效果统计数据:

项目 数据
成功率 92.4%
平均成功时间 13.63s
平均使用时间 13.74s
累计节省时间* 535+h

累计节省时间以零售 Android 工程为统计对象,加速前平均编译安装时间为 120s。

一般的工程,会随着代码量的增加导致工程或模块的编译时间不断增加,但是在使用了增量编译加速之后,可以让工程的增量编译速度不受工程模块代码量规模影响,只与改动量正相关(理论上规模越大的工程,得到的编译提效收益越明显),并且免去安装的过程。在日常的开发中,平均在 15s 内即可完成编译加载运行。

在开发维护方面,得益于动态下发的机制,Savitar 实现了问题快速修复,功能无感更新的能力,维护性更强,体验更好。截止至今,Savitar 的 Runner 已经迭代超过 20 个版本,但插件部分只有几个版本升级。

三、Savitar 2.0 版本新功能

在早期 1.0 版本的 Savitar 中,是使用引入本地 build.gradle 文件和工程引入加载代码的方式完成集成的,存在较强的入侵性,集成的体验不是很好。在 2.0 中,完成了 Gradle Plugin 完整封装,一键集成,无代码入侵,并且拥有更好的工程通用性,支持任意工程,降低了集成和使用的难度。不仅如此,在 2.0 中还加入了一键化支持、MultiDex 支持、Kotlin internal 关键字支持等功能,完善了支持场景。

3.1 一键化支持

由于增量编译需要建立在全量编译基础上的原因,在 1.0 中,需要小伙伴先点击 AS 运行按钮编译一遍,在之后的修改中再使用 Savitar 运行按钮完成编译加速。

在实际使用过程中,很多小伙伴都遇到一个困扰:不知道何时需要点 AS 的运行按钮,何时点 Savitar 的运行按钮。为了解决这个选择困扰,Savitar AS 插件实现了构建一键化支持:无需使用原有 AS 运行按钮,只点击 Savitar 运行按钮就行。Savitar 会根据本地构建配置自动识别是否可以进行加速编译,如果不行,就自动执行现有 AS 运行配置(支持 Flavor 切换),达到无缝切换。

3.2 MultiDex 支持

此处的 multi-dex 问题并不是我们在 Android 开发过程中处理的 multi-dex 问题,是指 dx 工具把 .class 转 .dex 的流程,存在单个 dex 引用数量不能超过 64K 的限制。超出限制会导致编译失败。

这个问题常常出现在修改资源之后,因为工程本身模块较多,R 文件引用数量过多导致在修改资源之后编译 R 文件会出现:

trouble writing output: Too many field references to fit in one dex file: 86151; max is 65536.

dx 工具是支持 multi-dex 编译的,只需要在参数里面填入相关的参数即可:-

dx ... --min-sdk-version=xx --multi-dex --main-dex-list={path_to_main_dex_list.txt}

在以上参数中,关键参数是 path_to_main_dex_list.txt,用于自定那些 class 需要加入 classes.dex 中。Gradle 针对 minSdkVersion < 5.0 的应用,会在编译时在 app 的 build 目录下面生成一份 mainDexList.txt 文件,可以直接将此文件作为参数传递。但是在 5.0 或以上不会生成这份文件,并且在 5.0 以下但是当前连接调试设备系统大于 5.0 时也是不会生成的。

经过分析,其实在 5.0 以上的情况 dx 执行过程我们并不需要工程里面的 mainDexList.txt,只需要一个空白的 mainDexList.txt 即可。原因是 Savitar 的产物是用于动态加载的,并不需要像生成 APK 那样关心系统对于 multi-dex 的支持问题。

3.3 Kotlin internal 关键字支持

在 kotlin 中,用 internal 访问修饰符声明的包、类、成员变量或者函数可以在 同模块内任何地方访问到,这对于 SDK 的封装非常有用。关于 模块这个概念,官方的解释如下:-

1 一个模块是编译在一起的一套 Kotlin 文件,例如:
2 一个 IntelliJ IDEA 模块;
3 一个 Maven 项目;
4 一个 Gradle 源集(例外是 test 源集可以访问 main 的 internal 声明);
5 一次 <kotlinc> Ant 任务执行所编译的一套文件。

简单的讲就是:使用 kotlinc 在一次编译中所有的文件的集合。因为每次 kotlinc 编译文件之后都会生成一个 xxx.module 的文件,这个文件会记录本次编译所有的类的信息,在编译 internal 修饰的方法时,会进行可见性检查。举一个例子:A、B 两个类使用 kotlinc 一起编译后,两个算是一个模块,但是如果 kotlinc 分别两个文件编译就不算一个模块。在增量编译中,如果修改文件里面存在对于 internal 修饰的代码调用时,就会出现:

1 // B 中方法 A 的 internal 方法 sayHello()
2 error: cannot access 'sayHello': it is internal in 'A'

这个问题在官方文档和 kotlinc 自带的 help doc 中没有说明如何解决,在研究了 kotlin 的源代码之后,发现存在一个friend_paths参数,内容是:

1 value = "-Xfriend-paths"
2 valueDescription = "<path>"
3 description = "Paths to output directories for friend modules (whose internals should be visible)"

通过使用这个参数,可以为正在编译的文件扩展 internal 可访问区域,解决增量编译中由于单独编译导致的 internal 访问问题。

四、1.0版本中的问题与解决方案

在 1.0 的版本中,主要目的是尽快解决困扰已久的编译耗时问题,大幅度提升日常开发编译速度。开发时也是主要覆盖了一些核心的开发场景,针对一些特定场景的问题没有投入太多时间解决,因此遗留了一些问题待解决。

类依赖编译问题

为了达到极致的编译速度,Savitar 只会编译修改的文件,这样的策略可以尽可能多的减少编译量,但是会带来一个正确性问题:类之间可能是存在依赖关系的,如果修改了类公开的 API (方法,变量,常量,类等)但是没有编译依赖方,可能会导致运行时崩溃。例如:

1 // 原始函数
2 public void sayHello()
3 // 修改后函数
4 public String sayHello()

返回值从 void变为 String并不会导致编译错误,但是在运行时会导致 NoSuchMethodException异常。如果能再次编译依赖方,加入到最终产物中就可以避免这样的错误。

解决这个问题有两个关键点:文件改动分析与改动文件的直接依赖方定位。文件改动分析是指文件的改动是否需要检查依赖方,例如方法内部实现改动是不需要的,公开常量修改时需要。精确的分析结果是保证最小编译量的前提。

改动文件的依赖方定位只针对直接依赖形式,不需要分析间接依赖的情况。对直接依赖方编译会有两个结果:编译成功或者失败。这样也可以将一些编译错误问题提前暴露。

我们可以通过 ASM 技术进行字节码分析,在全量编译阶段对工程内源文件生成的 class 进行解析,记录所有 class 的信息,存储到文件中。

1 public class ClassInfo {
2    public int access;
3     public String name;
4     public String superName;
5     public Set<String> interfaces;
6     public Set<String> importClass;
7     public List<Fields> fields;
8     public List<Methods> methods;
9     // ...
10 }

在完成改动文件编译后,生成改动类的 ClassInfo,再比较新旧类信息的每个字段,得出需要编译的依赖方,最后再进行依赖方编译即可。

1 /**
2  * src      - 原始类信息
3  * changed  - 修改类信息
4  * deleted  - 删除类文件
5  */
6 Set<String> analyzerChange(src, changed, deleted)

对于修改为删除的文件需要注意的一点是需要删除对应原工程里面生成 .class 文件,否则会导致不能检查到编译失败的问题。

在「QQ音乐Android编译提速之路」也针对这个问题进行的分析和提供解决方案思路,解法大同小异。

五、后续规划

此前已经将 Savitar 的 AS Plugin 发布到了 Jetbrains 的插件仓库中,但是并未将 Runner 开放,因为使用问题收到了很多热心小伙伴的邮件反(tu)馈(cao),很多小伙伴都表示对 Savitar 的期待和希望可以早日用上,在此先感谢各位热心小伙伴的关心,开源的计划已经在准备中,不过在此之前,稳定性和更多场景适配仍然是主要内容,下面是一些规划:- 实现 CLI 接入,可以应用到跨平台技术开发过程

  • 完善场景适配度,提升加速稳定性
  • 完善本地化能力,支持离线模式
  • 完善自动化测试,提升质量
  • 开源准备

结语

Savitar 从一个想法到现在的 2.0 版本,离不开每一个小伙伴的投入。对于每个成员来说也是一次学习的过程。目前 Savitar 已经在零售和微商城团队中应用,未来会应用在更多团队中,在实际的业务中不断磨练、完善,最终完成社区开源计划。我们对于开发效率提升的追求永不停止,感谢大家的关注。

相关标签

扫一扫

在手机上阅读