阅读 650

基于AGP的Android组件化编译插件实践

概述

组件化算是大型项目的一种较好的组织方案,可以很好解藕逻辑,单独编译需求模块方便测试复用,减少维护成本,甚至良好组件化的项目可以无痛过度到插件化。但是其也有相应的缺点,比如模块间需要额外通信手段导致编写复杂效率降低,不同模块需根据版本依赖并作额外管理。
为了开发编译插件帮助项目达成解藕、复用、单独编译的目的,一般情况下要提供以下几种功能:

  • 单独运行(支持module作为App运行)
  • 不同module代码资源隔离(或者仅仅暴露指定接口)
  • 资源增加前缀以减少冲突
  • Application类处理(不同module的Application初始化)
  • 特殊处理

下面会根据上述几点,基于Google提供的agp,详细阐述下本人这半年来进行的组件化实践方案和技术细节。本文涉及的技术点主要有:gradle, agp, transform api, AOP, class文件结构, Apk打包流程。

单独运行

众所周知,agp区分一个module(或者说project)的类型,依靠的是其依赖的编译插件类型,如果依赖的是com.android.application,则该module可以编译为一个apk文件,并安装运行。如果apply的插件为com.android.library,则agp会将该module编译为aar供其他module依赖。
为了实现单独运行,那么就要让module直接依赖application plugin,并在某种条件下可以编译成aar文件,作为其它将要以app运行的module的一部分。
接触过的大部分框架所做的是以一个标记位来区分module apply哪个插件,但是这样会导致每次切换都需要重新sync项目才可以让IDE正确识别项目类型,并且要准备两份build.gradle和AndroidManifest.xml用来打包进不同的环境内。
在我的实践中用了另一种方法,通过执行的task来判断apply哪个plugin,并且在打包中处理build.gradle中的配置和AndroidManifest.xml
比如定义一个task,名字为uploadComponent,当执行该task的时候认为module将会打包成aar,apply library plugin,并新增task处理AndroidManifest.xml。这里要做的主要有以下两点:删除Application节点内所有属性(防止合并冲突,也可配置白名单保留某些属性),删除MainActivity的intent-filter节点,防止安装后出现多个入口Activity。除此之外,需要处理加载build.gradle内配置后agp的某些属性,比如剔除applicationId属性,将buildType内的isShrinkResources改为false(library不支持上述属性)。
这样处理过后便可以实现一般情况下单独运行,特殊情况下(执行uploadComponent)可以打包成为aar供其他module依赖。
一般情况下所有承载业务的module都是application,而agp是无法依赖application的module的,因此直接依赖是不可行的,需要将module打包为aar上传到公共仓库,再通过依赖仓库内包的形式进行依赖。因此这个方案要额外一步手动upload,这也是目前发现的缺陷,不过计划中该操作可以通过脚本帮助简化。

代码资源隔离

代码资源隔离最主要的目的就是解除耦合,杜绝直接引用,方便模块插拔而不影响编译,理想情况下两个模块无任何耦合。实现隔离其实只需在开发期无法调用依赖module的代码即可,非常简单,在执行打包task(如assembleRelease, bundleReleaseResource)时再进行依赖。
但是实际中,让同一个应用内的两个模块没有任何直接应用所付出的维护代价还是比较大的,就以模块间通信来说,如果没有直接耦合,就必须通过base lib进行支持(Event或公有接口下沉),或者定义新的交互接口(类似网络交互)。前者需要在base lib内维护所有用于通信的内容,会导致base lib过分冗余;后者太过复杂,无论是开发还是执行效率都会有一定损失。因此一般情况下在可控范围内暴露一定的接口还是有必要的,供module间直接交互,在解藕和开发效率间做一个平衡。
继续上面的思路。编译插件提供了注解供调用者自己决定该暴露哪个接口。插件所做的处理就是将被注解的类,和其内部所有引用的类都暴露给外部。
首先来看如何找到所有类的引用类,又是众所周知,class文件内的常量池有一项为CONSTANT_Class_info,该项表示的是类中对其他类或接口的符号引用,Code类型的Attribute_info内就会使用这些常量,但是仅仅如此还是不够的——返回类型,参数,注解和范型都会有对其他类进行引用的情况,因此还需对这些进行处理。 如果使用Javassist做class结构的解析的话,CtClass#getRefClassess()方法已经处理了前几种情况,具体的实现可以参照Javassist源码,这里主要说下如何处理范型的引用类型。
可能在大部分人的印象中,范型类型会在运行时被虚拟机擦除成为Object,所以会认为从class文件中无法获取范型的类型,但某些情况下实际的范型类型都会被写入class文件中。这些情况如下:

  • Class声明的范型
  • Field的范型
  • 方法返回值的范型
  • 方法参数的范型

Class声明的范型可以通过Class#getTypeParameters()获取。其余情况的范型类型会被记录在Signature类型的AttributeInfo中,这些同样也可以通过class文件解析拿到对应类型,此处不再赘述,不过需要注意的是有可能出现范型嵌套的情况,要额外处理一下。

资源前缀

最简单的资源前缀约束方式,就是通过apg的一项配置resourcePrefix,但是该选项只是开发时检查,并不能强制约束,最终还是要人力修改,容易疏漏,因此无法被采用。还有的方案是修改aapt为不同模块的资源分配不同的id。不过这里采取了完全不同的一种方案——通过索引和字节码修改完成前缀增加。
该方案在module打包为aar时处理项目中的资源,且范围仅是当前module内,因此将一系列操作加入打包aar的过程中。依照一般的开发经验,需要处理资源的地方有四处:

  • 资源本身的命名
  • 资源在xml中的引用(@string/app_name)
  • 资源在代码中的引用(R.string.app_name)
  • R.jar/R.txt

理论上,第一步先遍历res文件夹下的所有资源,并根据类型做相应资源的记录,之后的前缀处理中,仅会处理在记录中的资源引用。之后再次遍历xml后缀文件,处理每一个element中,value为@xxx/xxx形式的label,为其加上前缀,并修改文件名,加上前缀。
但在实际操作中会发现对resources类型的区分还是有一定难度的。比如strings.xml是一个以属性项为主的文件,不必对其文件名增加前缀,在开发环境中,各个文件夹下都可能会有类似的xml文件,对此做判断策略就比较复杂了,所以这里把目光放到如何利用agp上面。
经过对打包流程的分析,发现了mergeResouce这个task会将module内所有资源合并,类似strings.xml的属性也会统一合并到values.xml中,这时对资源进行处理就简便许多了,只需遍历mergeResource对应的生成目录,并单独处理values.xml即可。
到这里就要注意处理资源的时机了,若是将前缀处理放到R文件(R.txt,R.class)生成的task之前,就会导致R文件中的id已经加上了前缀,产生编译时无法引用的错误。所以要把prefix放到R文件生成步骤的后面,class编译完成后,在transform过程中再处理代码引用的前缀。
讨论完了前两种情况,接下来是代码中资源id引用的前缀添加。这一步实际上也是class文件的处理,通过对CodeAttributeInfo进行解析,找到所有R文件内字段的引用,如果字段在最开始的集合中,则进行添加处理,否则跳过。这里可以用一些AOP框架帮助简化,比如Javassist的instrument API就可以很方便的检索到目标引用。不过如果使用Javassist,因为框架本身的编译步骤,要额外处理R.class内的id。
最后,作为aar提供的R.txt文件也必须进行处理(R.txt是App用来为lib生成资源id的,lib的R.class不会被打包进class.jar中),回顾下上面的步骤,对资源的处理是在生成R文件之后的,没有影响到默认的生成逻辑,要生成修改过后的R.txt,最简单的方式就说对修改后的资源重新执行一遍生成步骤。在agp 3.5.0中,该task对应的类是GenerateLibraryRFileTask,重新定位他的输出后,将结果代替最开始的R.txt,打包进aar文件中即可。
增加前缀之后还有可能出现重复资源的情况,要再做一下资源去重。

Application处理(初始化处理)

每个单独的module都有可能有其自己的初始化流程,势必不能将这些全部放到单独的模块中,而是单独处理自己的初始化,打包时再统一收集排序(如果需要)。而通常都会在Application中进行全局的初始化配置,为了防止使用插件化后导致的开发割裂感,该实践方案仍然使用了Application类,并在此之上做了一些处理。
因为单独运行的功能,可能会导致作为App运行和打包为aar时初始化的步骤不同,因此这里提供了两种不同的初始化方式:

abstract public class ComponentApplication extends Application {
    public abstract void initAsApplication();
    public abstract void initAsComponent(Application realApplication);
}
复制代码
利用在AndroidManifest.xml中,application element的name属性,可以拿到对应module配置的Application,将其类名记录到module内对应的表中。正式打包运行时,收集所有之前建立表的信息,然后根据执行apk打包任务的模块的包名,选择每个Application要执行的方法(Application或Component)。

上面这段逻辑的执行就要用到一个小技巧——Application替换。假如我们实现的逻辑类时class ComponentApplication,在打包过程中,合并完AndroidManifest.xml之后,就可以把application element的name属性修改成ComponentApplication。原先的Application就作为前一步被收集的信息加入信息表中,在ComponentApplication中运行。
处理完各个模块初始化问题,还要再考虑一个问题,初始化顺序。有可能某些模块确实有依赖关系,需要在一个其他模块的初始化完成后才能初始化。那么为初始化加上顺序就是必要的了。这里参考了gradle task内的依赖方式,和一篇微信的文章
在此定义了一个接口,用于暴露需要初始化的api

public interface InitTask {
    void onDependency();
    void onExecute();
    InitTask dependsOn(Object... dep);
    Set<Object> getDependsOn();
    Set<InitTask> getDependencyTasks();
    String getName();
}
复制代码
编译期执行dependency方法,建立起有向无环图,最后的执行顺序就是这个图的拓扑排序。

特殊处理

这部分比较简单,就是处理一下BuildConfig在不同module中引用的情况(每个module编译时都会产生对应报名的BuildConfig,但运行时有可能依赖主模块的BuildConfig),所以要为其做一个中间层,编译时注入主模块的BuildConfig全限定名,通过类名即可在运行时获取到指定属性。
组件化后,模块间通信依赖路由框架,关于这部分会放到另一篇文章来讲。

插件已经开源至github: https://github.com/nebulae-pan/Khala,如有使用的问题和改进建议,欢迎各位于此进行探讨。