Android动态注入之APT方案

0 阅读1分钟

在Android开发中,编译时动态插入代码是一种在代码编译阶段而非运行阶段,向目标代码中插入自定义逻辑的技术。编译时动态插入代码作为一种高效,且对业务逻辑侵入性低的代码插入方案,常用于日志埋点、性能监控、权限校验等通用逻辑注入开发场景,而Hilt、Dagger、Room等常见的Android依赖库也能够看到它的影子。

与在应用运行时使用反射来动态获取、修改类的信息不同,编译时动态注入修改的编译产物而非源码,编译时注入具有压倒性的优势。

  • 极致的性能:所有的代码生成和逻辑绑定都在打包前完成,应用在用户手机上运行时执行的都是原生的、直接调用的代码,没有任何运行时的性能损耗。

  • 安全性与提早报错:如果配置或依赖关系有误,编译器会在编译时直接报错,而不是等到用户打开应用时才崩溃。

  • 消灭样板代码:可以帮助开发者自动完成大量枯燥、重复的代码,让开发者专注于核心业务逻辑的开发。

在Android开发中,编译时代码注入主要分为源码级注入和字节码级注入两个截然不同的技术方向,分别对应注解处理器方案和字节码插桩方案。注解处理器方案通过扫描源代码中的特殊注解,然后生成全新的源文件;字节码插桩方案则是在源代码被编译成字节码文件后,在转换为Android能识别的dex 文件前,直接去修改并注入字节码。虽然实现方式存在不同,但是两者的最终目的都是一致的,即在不改动业务源码的前提下让最终运行的代码包含注入的逻辑。

注解处理器(Annotation Processor,简称APT)方案是目前Android开发中最主流、最稳健的编译时代码生成技术。它通过在编译期扫描自定义注解、解析注解信息,并自动生成辅助代码。相比直接操作字节码方案,注解处理器方案更易上手、可读性更高,是日志埋点、路由控制、性能监控、依赖注入等场景的主流实现方式。

在Android项目中,一个标准的APT项目通常需要拆分为三个模块,分别是annotations模块、compiler模块和api模块,说明如下。

• annotations模块:用于存放自定义注解,被app模块依赖。

• compiler模块:核心处理器,负责生成代码。

• api模块:提供给开发者调用的接口。

下面通过使用注解处理器技术实现Android早期开发中类似ButterKnife视图自动绑定的功能为例。按照APT项目组成及模块责任划分,首先需要创建一个annotations模块,该模块不依赖任何Android SDK,主要作用是定义视图绑定框架中所有编译期注解,如下所示。

@Target(ElementType.FIELD)             // 作用于成员变量
@Retention(RetentionPolicy.SOURCE)      // 源码级别
annotation class BindView(val value: Int)

上面的代码定义了一个只在编译时生效的注解,核心功能是记录变量对应的View资源ID。其中,@Target用于定义注解的使用范围,@Retention则用于定义注解的生命周期。在APT技术的核心配置中,注解的保留策略通常有三种,说明如下。

  • SOURCE:源码级,注解只存在于kt 或java源码中,编译成 class字节码后就消失了。

  • BINARY/CLASS:字节码级,注解会保留在class字节码文件中,但运行时无法通过反射读取。

  • RUNTIME:运行期级,运行期间可以通过反射读取。

之所以将编译器的注解级别限制在源码级别,是因为编译器在把源码变成字节码的过程中,已经通过处理器读取了这些注解并生成了辅助代码。一旦辅助代码生成完毕,注解的任务就完成了,没必要带入到最终的APK中,这样做的好处是可以减小APK的包体积。

然后再创建一个compiler模块,该模块是一个纯Kotlin/JVM库,核心功能是依赖AutoService来注册处理器,并使用JavaPoet来生成对应的Java文件,所以需要添加如下依赖。

plugins {
    kotlin("jvm")
    kotlin("kapt")
}

dependencies {
    // KotlinPoet:用于优雅地生成 Kotlin 源代码
    implementation("com.squareup:kotlinpoet:2.2.0")

    // AutoService:自动生成META-INF/services 注册文件
    implementation("com.google.auto.service:auto-service-annotations:1.1.1")
    kapt("com.google.auto.service:auto-service:1.1.1")
}

在本例中,compiler模块的核心作用是在编译期通过kapt触发扫描自定义的@BindView注解,并使用KotlinPoet为每个宿主Activity自动生成绑定类。具体实现时,需要先创建一个继承自AbstractProcessor的注解处理器类,并重写process()和generateCode()方法。

@AutoService(Processor::class)
class BindingProcessor : AbstractProcessor() {
 
    override fun process(annotations: MutableSet<out TypeElement>, roundEnv: RoundEnvironment): Boolean {
        roundEnv.getElementsAnnotatedWith(BindView::class.java)
            .filterIsInstance<VariableElement>()
            .groupBy { it.enclosingElement as TypeElement }
            .forEach { (type, fields) -> generateCode(type, fields) }
        return true
    }
 
    private fun generateCode(type: TypeElement, fields: List<VariableElement>) {
        val pkg = processingEnv.elementUtils.getPackageOf(type).toString()
        val bindingName = "${type.simpleName}_ViewBinding"
 
        FileSpec.builder(pkg, bindingName)
            .addType(TypeSpec.classBuilder(bindingName)
                .primaryConstructor(FunSpec.constructorBuilder()
                    .addParameter("target", type.asType().asTypeName()).build())
                .addInitializerBlock(buildCodeBlock {
                    fields.forEach { field ->
                        val id = field.getAnnotation(BindView::class.java).value
                        addStatement("findViewById(%L)", field.simpleName, id)
                    }
                }).build())
            .build().writeTo(processingEnv.filer)
    }
}

上面代码的核心功能是在编译期扫描注解,把源码里的注解信息翻译成对应的findViewById赋值语句并写进新生成的kt文件里,从而免去繁琐的手动绑定视图操作。其中,process()是APT的解析注解逻辑的入口,主要作用是扫描和分析代码注解;generateCode()则是开发者自定义的辅助方法,主要作用是根据process()的分析结果,利用Filer API生成新的Java源文件或资源文件。 在完成annotations模块和compiler模块的开发工作后,还需要开发api模块。api模块属于Android Library类型,是整个注解处理器方案的契约层,主要负责连接用户手写的业务代码和compiler模块自动生成的代码,代码如下。

object ViewBinder {
    fun bind(target: Any) {
        val bindingClass = Class.forName("${target::class.java.name}_ViewBinding")
        val constructor = bindingClass.getConstructor(target::class.java)
        constructor.newInstance(target)
    }
}

上面代码通过使用反射,在运行时动态查找注解并将实例化的名称约定为原类名_ViewBinding的生成类,从而实现将注解定义的视图绑定逻辑注入到对应的Activity中。 在APT方案中,api模块作用提供视图绑定框架的运行时入口和解绑接口,是使用方在代码中直接调用的唯一模块。为了能在Android应用中顺利使用APT模块实现视图自动绑定,还需要在app/build.gradle文件中添加如下依赖。

dependencies {
    implementation project(':annotations')
    implementation project(':api')
    kapt project(':processor')
}

完成上述APT插件的依赖后,就可以在Android应用的Activity页面中使用@BindView注解实现视图自动绑定功能了,使用方式、步骤与早期Android开发中的ButterKnife插件类似,如下所示。

class MainActivity : AppCompatActivity() {
    @BindView(R.id.tv_title)
    lateinit var tvTitle: TextView

    @BindView(R.id.tv_content)
    lateinit var tvContent: TextView

    lateinit var unbinder: Unbinder

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        unbinder = ViewBinder.bind(this)
    }

    override fun onDestroy() {
        super.onDestroy()
        unbinder.unbind()
    }
}

可以看到,通过APT编译时代码生成技术,可以将繁琐的findViewById工作交给编译器,由编译器在程序编译时自动生成模版逻辑。重新编译并运行Android应用,如果没有任何报错可以看到在app/build/generated目录下看到生成的绑定类,如下图所示。

image.png 需要注意的是,如果APT项目使用的Kotlin版本是2.1.0及其以上版本,Kotlin会默认开启K2编译器,由于K2编译器的不稳定性,在处理注解时可能会报编译时常量失败的错误,解决错误的最快方法是禁用K2模式或进行降级,如果是选择禁用K2模式,那么只需要打开gradle.properties文件添加如下配置脚本即可。

# gradle.properties
kotlin.experimental.tryK2=false
android.nonFinalResIds=false