美文网首页Android
手撸一个 Router 框架(上):熟悉 APT

手撸一个 Router 框架(上):熟悉 APT

作者: 王晨彦 | 来源:发表于2019-08-24 20:22 被阅读0次

    前言

    目前业界已经有很多成熟的路由框架,最著名的应该是 ARouter,那么我们今天为什么还要重新造轮子呢?
    我个人觉得有以下原因:

    1. ARouter 过于强大,很多功能我们不一定用得上,而且不一定适合我们的项目,自己撸一个,可以在满足项目需求的情况下,功能上去繁就简。
    2. 实践出真知,我想这也是很多开发者重复造轮子的主要原因吧。我们经常阅读许多大牛对于优秀框架的剖析,但那也只是大牛的理解,我们自己的呢?
    3. 便于排查问题。使用开源框架遇到问题一般会耗费更多的排查时间,因为我们对源码“不够熟悉”,而自己撸的一般都可以快速定位问题。

    准备

    进入正题前,我们先预告一下接下来会涉及到的知识点

    1. Kotlin,本文代码主要基于 Kotlin 语言编写,相信大家都知道 Kotlin 的好处了吧?
    2. APT,即 Annotation Processing Tool,注解处理器,用于在编译时扫描和处理注解,即解析和保存路由信息。
    3. 拦截器机制,众所周知 OKHTTP 的拦截器机制是十分强大的,我们也将参考并沿用这套机制。

    正文

    使用注解处理器,一般需要3个 Module:

    1. annotation - 包含注解类,提供给 compiler、api 和 app 使用
    2. compiler - 编译器,即注解处理器,在打包时处理注解
    3. api - 提供路由的 api 接口

    注解 Module

    新建 Java Module

    image

    创建 Router 注解

    /**
     * 标记路由信息,仅支持 Activity
     */
    @Target(AnnotationTarget.CLASS)
    @Retention(AnnotationRetention.RUNTIME)
    annotation class Router(
            /**
             * URL path,可以为 "" 或者以 "/" 开头,例如 "/example\\.html",支持正则表达式,注意转义
             */
            val value: String,
            /**
             * URL scheme,不包含 "://",例如 "http",支持正则表达式,注意转义
             */
            val scheme: String = "(http|https|native|domain)",
            /**
             * URL host,不包含 "/",例如 "www\\.google\\.com",支持正则表达式,注意转义
             */
            val host: String = "(\\w+\\.)*domain\\.com",
            /**
             * 是否需要登录,默认不需要
             *
             * 需要调用 [CRouter#setLoginProvider] 才能生效
             */
            val needLogin: Boolean = false
    )
    

    提供以下参数

    • value: 路由路径,即 path,为了方便这里直接用 value,不用显式指定参数名
    • scheme、host: 这两个即是字面意思,提供默认值,一般使用默认值即可
    • needLogin: 用于登录拦截,拦截机制下篇会讲到

    注意一点,这里为了便于匹配,这里 scheme、host、path 都支持正则表达式,这样一条规则可以匹配 N 多链接,也可以支持参数在 path 中的链接形式,不过要注意对于特殊字符的转义

    举个栗子,要支持如下链接

    https://www.wanandroid.com/blog/show/2657
    

    参数文章 ID 是 2657,那么 path 就可以写为

    /bolg/show/\\d+
    

    看一下在 Activity 中的使用

    @Router("/home/rankList")
    class RankListActivity : BaseActivity() {
        ......
    }
    

    注解处理 Module

    新建 Java Module,和上一步类似,这里不再截图

    在 Module build.gradle 中添加以下依赖

    dependencies {
        compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
        implementation 'com.google.auto.service:auto-service:1.0-rc6'
        implementation 'com.squareup:javapoet:1.11.1'
        implementation project(':crouter-annotation')
    }
    
    • auto-service: Google 出品,用于自动注册注解处理器
    • javapoet: square 大厂的杰作,用于便捷的生成 Java 文件

    接下来新建 RouterProcessor

    @AutoService(Processor::class)
    class RouterProcessor : AbstractProcessor() {
    
        override fun getSupportedAnnotationTypes(): MutableSet<String> {
            val supportAnnotationTypes = mutableSetOf<String>()
            supportAnnotationTypes.add(Router::class.java.canonicalName)
            return supportAnnotationTypes
        }
    
        override fun getSupportedSourceVersion(): SourceVersion {
            return SourceVersion.latestSupported()
        }
    
        override fun process(annotations: MutableSet<out TypeElement>?, roundEnv: RoundEnvironment): Boolean {
            return false
        }
    }
    
    • 继承自 AbstractProcessor,表明是一个注解处理器

    • 添加 AutoService 注解,用于自动生成 META-INF 配置信息

    这里遇到一个坑,我使用的是 Android Studio 3.1.4 和 Kotlin 1.2.60,无论如何也不会自动生成 META-INF,导致编译时无法识别 Processor,最后只能手动添加:

    在 src/main 目录下新建 /resources/META-INF/services/javax.annotation.processing.Processor 目录和文件

    image

    文件内容是 Processor 的包名 + 类名

    me.wcy.crouter.compiler.RouterProcessor
    
    • 重写 getSupportedAnnotationTypes,指定支持的注解类型,即 Router::class

    • 重写 getSupportedSourceVersion,指定支持源码版本,这个是固定模板

    • 主要在 process 中对注解进行处理

    确认注解生效

    为了确认我们的注解已经创建成功了,我们在 app 中引入注解处理器

    app build.gradle

    apply plugin: 'kotlin-kapt'
    
    dependencies {
        implementation project(':crouter-annotation')
        kapt project(':crouter-compiler')
    }
    

    Kotlin 中使用 kapt 添加注解处理器

    我们在 Processor 的 process 方法中输出一条日志

    private lateinit var messager: Messager
    
    override fun init(processingEnv: ProcessingEnvironment) {
        super.init(processingEnv)
        // 保存 messager 对象
        this.messager = processingEnv.messager
    }
    
    override fun process(annotations: MutableSet<out TypeElement>?, roundEnv: RoundEnvironment): Boolean {
        this.messager.printMessage(Diagnostic.Kind.WARNING, "=============> RouterProcessor 已经生效")
        return false
    }
    

    这里也遇到了一个坑,Kotlin 中 NOTE 及以下级别的日志不会在控制台打印,所以至少要使用 WARNING 级别以上的日志

    不得不说 Kotlin 的坑还是不少的

    image

    不过据说在新版本都已经修复了,我还没有验证,大家可以试一下

    尝试一下,Build -> Rebuild Project,然后观察 build 日志

    正常情况下,我们已经可以看到 Processor 的日志了,激动

    image

    [站外图片上传中...(image-5d4e66-1566649332132)]

    如果没有看到日志,需要回过头一步步排查下哪里没写对

    收集路由注解

    我们已经验证 Processor 有效,下面开始解析路由注解

    首先,在 init 中保存需要的对象

    private lateinit var filer: Filer
    private lateinit var elementUtil: Elements
    private lateinit var typeUtil: Types
    
    override fun init(processingEnv: ProcessingEnvironment) {
        super.init(processingEnv)
    
        filer = processingEnv.filer
        elementUtil = processingEnv.elementUtils
        typeUtil = processingEnv.typeUtils
        Log.setLogger(processingEnv.messager)
    }
    

    这里对日志进行封装,方便使用

    override fun process(annotations: MutableSet<out TypeElement>?, roundEnv: RoundEnvironment): Boolean {
        val routerElements = roundEnv.getElementsAnnotatedWith(Router::class.java)
        val activityType = elementUtil.getTypeElement("android.app.Activity")
    
        for (element in routerElements) {
            val typeMirror = element.asType()
            val router = element.getAnnotation(Router::class.java)
    
            if (typeUtil.isSubtype(typeMirror, activityType.asType())) {
                Log.w("[CRouter] Found activity router: $typeMirror")
                var routerUrl = ProcessorUtils.assembleRouterUrl(router)
                routerUrl = ProcessorUtils.escapeUrl(routerUrl)
            }
        }
    
        ......
    }
    

    通过 roundEnv.getElementsAnnotatedWith(Router::class.java) 获取注解 Router 注解的 Class 信息

    遍历 Class 信息,通过 element.getAnnotation(Router::class.java) 获取 Router 注解信息,即路由信息,根据路由信息拼装路由 URL

    路由仅支持 Activity,因此需要排除掉不是 Activity 的 Class

    保存路由信息

    路由信息已经收集完成,接下来要保存到 Java 文件中,那么问题来了,我们首先要先预想一下保存的 Java 文件的结构是什么样的?

    首先我们要有一个实体保存路由信息,这里我们可以使用接口

    /**
     * 真正的路由信息
     */
    interface Route {
        fun url(): String
    
        fun target(): Class<*>
    
        fun needLogin(): Boolean
    }
    

    路由信息最终需要汇总到一个列表中,提供一个接口,用于加载路由信息

    /**
     * 路由加载器
     */
    public interface RouterLoader {
        void loadRouter(Set<Route> routeSet);
    }
    

    routeSet 由外部传入,用于保存路由信息

    生成的 Java 文件可以实现该接口,将扫描到的路由信息保存起来

    这时有请 javapoet 登场

    image
    /**
     * Method: @Override public void loadRouter(Set<Route> routerSet)
     */
    val loadRouterMethodBuilder = MethodSpec.methodBuilder(ProcessorUtils.METHOD_NAME)
            .addAnnotation(Override::class.java)
            .addModifiers(Modifier.PUBLIC)
            .addParameter(groupParamSpec)
    
    for (element in routerElements) {
        val typeMirror = element.asType()
        val router = element.getAnnotation(Router::class.java)
    
        if (typeUtil.isSubtype(typeMirror, activityType.asType())) {
            Log.w("[CRouter] Found activity router: $typeMirror")
    
            val activityCn = ClassName.get(element as TypeElement)
            var routerUrl = ProcessorUtils.assembleRouterUrl(router)
            routerUrl = ProcessorUtils.escapeUrl(routerUrl)
    
            /**
             * Statement: routerSet.add(RouterBuilder.buildRouter(url, needLogin, target));
             */
            loadRouterMethodBuilder.addStatement("\$N.add(\$T.buildRouter(\$N, \$N, \$T.class))", ProcessorUtils.PARAM_NAME,
                    routerBuilderCn, routerUrl, router.needLogin.toString(), activityCn)
        }
    }
    
    /**
     * Write to file
     */
    JavaFile.builder("me.wcy.router.annotation.loader",
            TypeSpec.classBuilder(ProcessorUtils.getFileName())
                    .addJavadoc(ProcessorUtils.JAVADOC)
                    .addSuperinterface(ClassName.get(RouterLoader::class.java))
                    .addModifiers(Modifier.PUBLIC)
                    .addMethod(loadRouterMethodBuilder.build())
                    .build())
            .build()
            .writeTo(filer)
    

    这里贴出了主要代码,主要是创建了一个 Java 类,实现上面的 RouterLoader 接口,添加 loadRouter 方法,保存路由信息,最后添加注释、修饰符等属性,写入文件,javapoet 的使用不属于本文范畴,因此不再展开讲解,完整代码可参考源码

    为了方便生成代码,将构造路由信息封装为一个方法

    public class RouterBuilder {
        public static Route buildRouter(String url, boolean needLogin, Class target) {
            return new Route() {
                @NotNull
                @Override
                public String url() {
                    return url;
                }
                @NotNull
                @Override
                public Class target() {
                    return target;
                }
                @Override
                public boolean needLogin() {
                    return needLogin;
                }
            };
        }
    }
    

    不知道泥萌有没有发现,这里出现了 Java 代码的身影(不对,好像前面就出现了,算了,我也懒得找了😓),不是说好用 Kotlin 吗,欺骗感情?

    少侠请息怒,真的不是我欺骗大家感情,我也想全程 Kotlin 啊,可是 javapoet 他不支持 Kotlin 啊...

    生成的 Java 文件使用固定包名 me.wcy.router.annotation.loader,生成类名的方法

    fun getFileName(): String {
        return "RouterLoader" + "_" + UUID.randomUUID().toString().replace("-", "")
    }
    

    大家不妨思考一下,这里为什么使用 RouterLoader + UUID 的方式生成类名?

    image

    是因为对于多 Module 项目,每个 Module 都需要收集路由信息,使用随机命名防止被覆盖

    这时有些同学站起来了:随机类名看着太乱,如果我想以 Module 的名字命名怎么办?

    好问题!

    如果想要根据 Module 命名,可以利用 kapt 设置 Module 的参数,在 Processor 的 init 方法中读取参数 官方文档传送门

    • 在使用 apt 的 Module 的 build.gradle 中添加
    android {
    }
    
    kapt {
        arguments {
            arg("moduleName", project.name)
        }
    }
    

    到这里,我们完成了路由信息解析和创建 Java 文件保存路由信息,下面让我们 Rebuild 一下

    正常情况下,我们已经可以在 app/build/generated/source/kapt/debug/me/wcy/router/annotation/loader 下看到我们在编译器生成的 Java 文件了

    image

    打开看一下内容

    /**
     * DO NOT EDIT THIS FILE! IT WAS GENERATED BY CROUTER.
     */
    public class RouterLoader_52def16bb9fa438ca17fec7b3b3f6787 implements RouterLoader {
      @Override
      public void loadRouter(Set<Route> routerSet) {
        routerSet.add(RouterBuilder.buildRouter("(http|https|native|domain)://(\\w+\\.)*domain\\.com", false, HomeActivity.class));
        routerSet.add(RouterBuilder.buildRouter("(http|https|native|domain)://(\\w+\\.)*domain\\.com/home/rankList", false, RankListActivity.class));
        routerSet.add(RouterBuilder.buildRouter("(http|https|native|domain)://(\\w+\\.)*domain\\.com/home/newTask", false, NewerTaskActivity.class));
      }
    }
    

    大功告成!

    image

    文章篇幅所限,本文暂且讲到这里,敬请期待下篇 「手撸一个 Router 框架(上):路由拦截机制」

    总结

    本文是 手撸一个 Router 框架 的上篇,主要讲了 APT 在 Kotlin 环境下的使用,并实现了一个完整的 APT 框架。小弟资历有限,如果那哪里说得不对,还望各位大哥指出🙏

    如果觉得本文对你有帮助,还请不吝赐赞😄

    相关文章

      网友评论

        本文标题:手撸一个 Router 框架(上):熟悉 APT

        本文链接:https://www.haomeiwen.com/subject/rtzqectx.html