知乎 Android Gradle plugin 实践

1,614 阅读15分钟
原文链接: www.jianshu.com

前言

自从 Android Studio 发布以来,Gradle 就是 Android 官方推荐的构建工具,它可以灵活的管理依赖与构建过程,同时提供了强大的插件体系,可以很方便的自定义插件以实现各种自定义的扩展功能。知乎在很早的时候就引入了 Android Studio 并进行了 Gradle plugin 的开发,这篇文章会介绍一些知乎在这方面的一些工作。

实践

与 Android Gradle plugin (AGP)类似,我们的插件也分为 application 插件和 library 插件,分别应用在 app 和 library 模块中,只要使用了知乎 Gradle plugin,AGP 就会被自动使用。原则上不允许使用绑定的 AGP 之外的版本,这有两个原因:

  1. 兼容多个版本 AGP 的开发成本,由于 AGP 除了 DSL 之外并没有公开的文档,内部 API 经常变动,兼容多个版本的维护成本比较高。
  2. 内部统一,避免出现因 AGP 版本不同导致的不兼容问题,比如 databinding(databinding 经常出现破坏性 api 变更,多组件情况下很是难受)

关于语言的选择:最早一版的插件是使用 java 进行开发的,但是对比 DSL 发现写起来实在过于啰嗦,于是转而使用 Groovy,由于 Groovy 语法基本上兼容 Java,也可以直接使用 DSL 语法,上手成本几乎没有,同时由于是动态语言,开发起来非常灵活,自带很多方便的扩展方法,很多功能写起来非常方便,还可以配合 @TypeChecked 注解来解决静态检查的问题,所以后来绝大部分功能都使用 Groovy 来编写。随着 google 对 kotlin 的支持力度越来越大,AGP 的很多代码都已经用 kotlin 重写了,但是由于 Groovy 与 kotlin 混编问题很大,并且使用 groovy 没有什么明显缺点,所以目前仍然是使用 groovy 进行开发。

知乎最早的插件功能很简单,就是提供了一个可动态配置的多渠道打包功能并输出为不同的文件,这也是大部分同学第一次接触 gradle 时使用的功能。后来工程越来越大,开发人员越来越多,不断的出现各种问题与需求,插件的功能不断丰富,现在已经集成了近五十种功能,这篇文章会选择部分典型的功能介绍一下:

统一配置

组件化之后,组件工程越来越多,不可避免的会有依赖和配置的冲突,对新来的同学也不利,所以我们的 plugin 会为工程添加统一的配置,比如:

  1. 统一 compileSdkVersion、minSdkVersion、targetSdkVersion
  2. 强制使用相同的 support 库、 kotlin 和 java 的版本
  3. 统一各个依赖的版本号以及版本号的自动升级
  4. 统一添加 git hook
  5. 提供默认的单元测试、覆盖率、pmd 等的配置
  6. 一些组件化相关的默认配置
  7. 等等

新来的开发同学在新建仓库的时候只要应用一下我们的 plugin 即可完成大部分的通用配置,避免了由于配置不当造成的冲突,减少新建仓库配置的复杂度,同时我们的版本号会自动升级,方便对通用配置的统一升级。

依赖管理

在组件化之前,单体 APP 的依赖管理是一个很简单的事情,绝大部分代码都在同一个仓库中,一目了然,但是随着组件化的深入,各个组件越来越多,因为依赖导致的问题也逐渐出现。除了曾经在 《Databinding 变慢之谜》 中提到过的限制不合理依赖和 《知乎 Android 客户端组件化实践》中提到的多组件合并之外,我们针对常见的问题,还在 plugin 中添加了下面的一些解决方案:

Peter Porker:Android DataBinding 编译变慢之谜​zhuanlan.zhihu.com

Peter Porker:知乎 Android 客户端组件化实践​zhuanlan.zhihu.com
图标

限制第三方库的引入

在组件化之前,知乎曾经制定过第三方库的引入标准,经过一系列流程后,第三方库以向主工程提 merge request 的形式来引入进来,这在只有一个集中式代码仓库的情况下并没有问题。组件化拆分之后,由于依赖的传递性,只要在组件中添加一个依赖,它就会被带到主工程中来;由于组件代码分散在不同的仓库中,缺少了最终引入的统一把控,各个组件对第三方库的依赖已经不受控制,不少人会按照自己的喜好添加自己用的顺手的库,有时候会因为一个微小的功能而引入一个第三方库,甚至会因为已有的库不符合自身喜好而自行添加另外一个的情况,这些往往会导致功能重复、依赖冗杂,难以控制标准化。

我们的解决办法是添加一个全局依赖的白名单,放在一个只有极少数人有写入权限的远程配置文件中,应用编译时会检查依赖是否在白名单中,如果出现了白名单之外的依赖,编译会直接报错并引导开发人员发起引入流程。比如,知乎使用的图片库是 fresco,没有使用 picasso,如果这时候直接添加 picasso 依赖,会报错如下:

image

通过强制检查白名单+制定引入流程的方式,我们杜绝了滥用第三方库的问题

解决依赖冲突

发生依赖冲突的情况很多,常见的有两种:一种是 Gradle configuration 导致的冲突,另一种是是不同依赖之间的内容确实会有冲突,比如都包含了相同的类导致的类重复

configuration 冲突

自从 AGP 3.0 开始,Android 开始弃用 compile 改为 implementation 和 api,并且官方推荐使用 implementation,然而我们在实践过程中发现,使用 implementation 并不能带来实际的收益,并且如果不同的组件 implementation 了不同的版本,编译很有可能会报错,虽然可以强制在 app 中指定版本号来忽略冲突,但是对上百个库都使用强制版本号是比较麻烦的事情,所以我们开始推荐使用 api 而不是 implementation,但是君子协定式的推荐并不能 100% 通知到所有人,于是我们便通过插件直接将 implementation 转换成 api,这样便不用再担心再遇到 implementation 的坑,开发同学也不必关心 api 与 implementation 的区别, plugin 已经在背后默默地做好了一切

依赖内容冲突

有一段时间,我们的组件变动很频繁,虽然版本号是自动同步的,但是并不能识别组件的分裂、合并与改名这样的变动,这就会导致原来依赖得好好的,突然因为一个依赖改了名或者合并了,导致老组件和新组件同时被依赖,编译时经常报类重复问题,一个组件如果因为某个原因需要改名,不得不跑遍所有的组件都改一遍,才能保证各个组件没有问题,过程很是麻烦。我们的解决方案是定义一个远程配置文件,在其中定义了各个库的版本与依赖替换关系,编译 apk 时插件会读取配置文件并自动根据配置文件自动解决冲突。这样如果某个组件发生了上述变动,只要在配置文件中修改一下,即可直接接入,无需再修改所有依赖它的组件,而依赖它的组件慢慢将此依赖修改即可。

no-op 冲突问题

还有一种不常见的依赖冲突,比如为了开发方便我们会在 debug 版本使用 stetho ,而 release 版本我们并不需要 stetho,所以我们会在 release 版本中使用一个 no-op 版本,使用方式如下:

debugImplementation 'com.facebook.stetho:stetho-okhttp3:1.5.0'
releaseImplementation 'com.zhihu.android.library:stetho-no-op:1.0.0'

但是我们也想要在在 mrRelease 版本中使用 stetho 而不是 no-op,这时候很自然的会想到使用 mrImplementation

mrImplementation 'com.facebook.stetho:stetho-okhttp3:1.5.0'

这时候由于 stetho 与 stetho-no-op 不是相同的库但是存在同名的类,编译时便会报类重复错误,于是我们定义了一个新的 extension,可以解决这个问题,新的写法:

debugImplementation 'com.facebook.stetho:stetho-okhttp3:1.5.0'
releaseImplementation 'com.zhihu.android.library:stetho-no-op:1.0.0'
dependencyReplace {
    replace "com.zhihu.android.library:stetho-no-op" with "com.facebook.stetho:stetho-okhttp3:1.5.0" on "mr"
}

这也可以用来解决相同的 library 针对不同渠道提供不同版本的问题

代码质量

强制 lint

为了保证代码质量,我们的插件会在组件以任何形式发布之前强制运行 lint,以防止有严重问题的代码被发布出去,并且禁止忽略 lint 的错误,在 lintOptions 中设置 abortOnError=false 将无效,同时我们也会将在实践中发现容易出问题的规则级别升级为 fatal 以防止出现问题:

**防止外部调用 **java 代码

一个组件中绝大部分代码都是不应该被外部调用到的,但是由于 java 的可见性修饰符比较弱,无法对跨组件的引用做有效的限制。有时候为了代码结构清晰(更多的是习惯性地)我们会一些类设置为 public,这些被标记为 public 的代码就可以被外部任意调用,即使将类标记为 protected 或者默认的 package private,仍然有可能被外部使用到。于是这就经常出现一种情况:A 组件中有某个类 Foo.java,做为内部使用,但是 B 组件的开发同学在写代码的时候无意(或者有意)间找到了这个类,发现可以被自己使用,便直接使用了 Foo.bar 方法,有一天 A 组件的开发同学因为业务变动,给 Foo.bar 方法添加新增了一个参数,自己运行起来没有问题,但是一编译主工程就会发现打包挂了,因为 B 组件引用了 Foo.java,A 组件同学莫名背锅。

kotlin 和 swift 都提供了 internal 修饰符来解决这个问题,而 java 本身并没有这个功能,但是 Android 官方提供了一种解决方案,那就是 support-annotations 库的 RestrictTo 注解,配合 lint 使用。比如 Foo.java 要禁止外部使用,可以这样:

@RestrictTo(RestrictTo.Scope.LIBRARY)
public class Foo {
 ...
}

如果类很多,挨个打注解也是一个麻烦事儿,这时候 plugin 便派上用场了,它可以自动化为类打上 RestrictTo 注解,同时我们新增了一个 @Open 注解,只有打上这个注解的类,才可以被外部调用

@Open
public class Foo {
 ...
}

当然,如果仅仅这样还是不够的,RestrictTo 对应的 lint 规则为 RestrictedApi,在 官网 可以看到,它仅仅是 4 / 10 ,Error 级别,我们还要将它升一级

lintOptions {
    enable "RestrictedApi"
    fatal "RestrictedApi"
}

配合强制 lint,这种代码将无法发布

资源限制

与 java 代码类似,Android 资源文件正常情况下也是可以被外部任意使用的,官方提供的限制方式为声明一个 public.xml 文件并在里面声明标记为 public 的资源名称,而未被声明为 public 的就是 private 的,但是这样做也比较麻烦。与 java 代码限制类似,我们的解决方案为:约定一个 res-public 文件夹,如果一个资源被添加到了 res-public 文件夹,则认为是 public,其他文件夹的则认为是 private ,这样哪些资源是 public 的便一目了然。

image

同样是配合 lint 使用,对应规则为 PrivateResource,与上面类似,不再赘述。

禁止手写 Parcelable

Parcelable 是 Android 特有的序列化方式,官方声称其比 Serializable 更加高效,但是在实际使用中会发现它存在一个严重的问题:序列化和反序列化的顺序和类型必须完全一致,如果不一致,会发生一些奇奇怪怪的很难排查的问题。常用的解决方案是:1. 使用 IDE 插件自动生成 Parcelable 代码;2. 使用 apt 注解自动生成 Parcelable 代码。但是实践中经常会出现这样的情况:

  1. 有些同学喜欢炫技,完全手写 Parcelable
  2. 使用 IDE 生成的代码,在新增字段的时候忘了再次生成
  3. 虽然 apt 可以自动生成,但是可能是新人不知道有这事或者主观忽略了此功能

我们会通过 plugin 检查没有使用 apt 自动生成 Parcelable 代码的类,并在编译时报错提醒,引导开发同学使用自动化工具

重复资源检查

不同于 java 代码,不同组件的资源是允许重名的,上层的组件资源名会覆盖掉下层的同名资源,app 在运行的时候,下层组件代码会发现引用的资源与预期的并不一致,就会出现显示出错或者 crash 等问题,解决方案有两种:

  1. 对于新组件,添加资源前缀。对于老组件,由于拆组件的时候,知乎工程已经很大了,考虑各种依赖情况,全部添加前缀的话工程过于浩大(虽然已经提供了自动化的工具)
  2. 编译期检查,我们的插件会检查当前组件的资源与其他组件的资源是否有冲突

Proguard 规则限制

proguard 除了可以混淆和删除无用代码之外,还有一个很重要的功能就是检查部分不兼容的变更,上面提到的修改 Foo.bar 参数就可以通过 progurad 来及时发现。不过 proguard 规则有一个与第三方依赖差不多的问题:aar 中可以携带 proguard 规则,理论上来说,开发同学可以在自己组件中任意添加 proguard 规则并影响到主工程。虽然 proguard 的语法比较简单,但是大部分同学经常使用的只有梭哈 keepdontwarn

某个字段被 proguard 删掉了,怎么办,全组件 keep 一把梭

-keep class com.xxx.xxx.** { *; }

某个方法报找不到了,全组件 dontwarn 一把梭

-dontwarn com.xxx.xxx.**

少数机智的同学会放大招

-dontwarn **

部分丧心病狂的第三方 sdk 会给出狂放的 proguard 规则,而引入的同学可能会不加分辨的照单全收

-ignorewarnings

这就会造成几个问题,一是盲目的 keep 导致部分代码没有被混淆压缩,包体积无谓的增大,另外一个就是盲目的 dontwarn 导致不兼容变更无法及时被发现。对于开发来说,后者的影响更大。所以我们在插件中增加了检查 proguard 规则的功能:

  1. 禁止在组件中使用 -*keep ***** 这样的规则,在 proguard 语法中,表示的类的时候,双星 ****** 可以表示任意长的类名段,比如 com.zhihu. 会覆盖 com.zhihu 及其包下的所有类与子包中的类,影响范围过大,很容易滥用,禁止;
  2. 禁止使用 -ignorewarnings ,ignorewarnings 会忽略所有的代码不兼容的情况,一旦使用此设置,后患无穷;
  3. dont-dontwarn : 禁止对特定包使用 -dontwarn ,-dontwarn 命令会忽略指定包下的所有代码不兼容的情况。比如 -dontwarn ** ,等同于 -ignorewarnings, -dontwarn com.zhihu.** 会忽略所有知乎代码的不兼容情况。我们禁止了能够影响 com.zhihu 包代码的 dontwarn 通配规则,比如 -dontwarn com.** 以及 -dontwarn com.zhihu.library.** 都会被禁止(当然,也可以扩大到其他的包)

如果组件的 proguard 规则违反了我们的规定,打包会直接挂掉,并引导修改:

image

自动查错

有些同学可能会注意到上面 proguard 规则限制示例图的底部出错提示,它是我们插件的一个自动查错功能,对于那些常见的编译错误,在插件能够自动解决之前(或者无法解决的时候),插件会自动寻找对应错误的解决方案。原理就是手动收集常见的错误,并将错误特征和解决方法都配置在远端,当在编译出错的时候插件会收集错误信息,并根据错误信息查询是否有已知的解决方案,并提示给开发同学。

除了上述功能之外,知乎 Gradle plugin 还有很多其他的功能,比如删除 R 文件、检查不合理资源、资源瘦身、jacoco 全量插桩等等功能,不少功能都可以单独写一篇文章了,这里就不再一一展开了

最后

工欲善其事,必先利其器。虽然我们在团队文档中维护了不少的实践、代码规范和行为准则,但是只在文档中声明这些东西是远远不够的,实践经验告诉我们,依赖自觉性的规则往往是靠不住的,你无法保证所有人都被通知到完全理解,更无法让所有人都会按标准执行,典型的如上面提到的第三方库引入限制功能,如果没有强有力的统一执行,规范很快就会被淡忘甚至被绕过。所以我们在不断的实践中,对于典型的问题,在建立规范的同时,还会建立相应的工具化的检查措施,不仅仅是 Gradle plugin,还包括其他的各种 CI 工具,只有这样,才能更好的执行标准,落实规范。

与此同时,知乎移动平台推进各种自动化与工具化的工作正在如火如荼的进行中,未来会加入越来越多的自动化工具,如果你有兴趣的话,快快 加入知乎 吧 ~

关于作者

Peter Porker,2016 年加入知乎,现为知乎 Android 基础架构团队负责人,有着丰富的 Android 工程化,组件化经验,设计并主导了知乎的 Android 组件化拆分工作。