使用Kotlin打造Android路由框架-KRouter

KRouter(https://github.com/richardwrq/KRouter)路由框架借助gradle插件、kapt实现了依赖注入、为Android平台页面启动提供路由功能。
源码不复杂,在关键地方也有注释说明,建议打算或正在使用kapt+kotlinpoet遇到坑的同学可以fork一下项目,或许能找到你想要的答案,只要将整个流程了解清楚了,相信你自己也能撸一个轮子出来,目前许多开源框架daggerbutter knifegreendao等实现原理都是一致的。

从startActivity开始说起

在组件化开发的实践过程中,当我完成一个模块的开发后(比如说这个模块中有一个Activity或者Service供调用者调用),其他模块的开发者要启动我这个模块中的Activity的代码我们再熟悉不过了:

val intent = Intent(this, MainActivity::class.java)
intent.putExtra("param1", "1")
intent.putExtra("param2", "2")
startActivity(intent)

当然,其他模块的开发人员需要知道我们这个Activity的类名以及传入的参数对应的key值(上面的param1和param2),这时候我就想,在每一个需要启动这个页面的地方都存在着类似的样板代码,而且被启动的Activity在取出参数对属性进行赋值时的代码也比较繁琐,于是在网上查找相关资料了解到目前主流的路由框架(ARouter、Router等)都支持这些功能,秉着尽量不重复造轮子的观念我fork了ARouter项目,但是阅读源码后发现其暂时不支持Service的启动,而我负责的项目里面全是运行在后台的Service。。。
紧接着也大概了解了一下其他一些框架,都存在一些不太满意的地方,考虑再三,干脆自己撸一个轮子出来好了。


首先来看一段最简单的发起路由请求的代码(Java调用):

KRouter.INSTANCE.create("krouter/main/activity?test=32")
                .withFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
                .withString("test2", "this is test2")
                .request();

其中krouter/main/activity?test=32为对应的路由路径,可以使用类似http请求的格式,在问号后紧接着的是请求参数,这些参数最终会自动包装在intent的extras中,也可以通过调用with开头的函数来配置请求参数。
上面的代码执行后最终会启动一个Activity,准确来说是一个带有@Route注解的Activity,它长这样:

@Route(path = "krouter/main/activity")
public class MainActivity extends Activity {
    ...
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        getIntent().getIntExtra("test", -1);//这里可以获取到请求参数test
    }
    ...
}

这是一个最基本的功能,怎么样,看起来还不错吧?跟大部分路由框架的调用方式差不多。现在主流的路由框架是怎么做到的呢?下面就看我一一道来。


在使用KRouter的API前首先需要为一些类添加注解:

/**
 * User: WuRuiqiang(263454190@qq.com)
 * Date: 18/1/2
 * Time: 上午10:53
 * Version: v1.0
 * Description:用于标记可路由的组件
 */
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class Route(
        /**
         * Path of route
         */
        val path: String,
        /**
         * PathPrefix of route
         */
        val pathPrefix: String = "",
        /**
         * PathPattern of route
         */
        val pathPattern: String = "",
        /**
         * Name of route
         */
        val name: String = "undefined",
        /**
         * Priority of route
         */
        val priority: Int = -1)

/**
 * User: WuRuiqiang(263454190@qq.com)
 * Date: 18/1/2
 * Time: 上午10:53
 * Version: v1.0
 * Description:用于拦截路由的拦截器
 */
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class Interceptor(
        /**
         * Priority of interceptor
         */
        val priority: Int = -1,
        /**
         * Name of interceptor
         */
        val name: String = "DefaultInterceptor")

/**
 * User: WuRuiqiang(263454190@qq.com)
 * Date: 18/1/2
 * Time: 上午10:53
 * Version: v1.0
 * Description:属性注入
 */
@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.SOURCE)
annotation class Inject(
        /**
         * Name of property
         */
        val name: String = "",
        /**
         * If true, app will be throws NPE when value is null
         */
        val isRequired: Boolean = false,
        /**
         * Description of the field
         */
        val desc: String = "No desc.")

/**
 * User: WuRuiqiang(263454190@qq.com)
 * Date: 18/1/2
 * Time: 上午10:53
 * Version: v1.0
 * Description:Provider
 */
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class Provider(/**
                           * Path of Provider
                           */
                          val value: String)

被注解的元素的信息最终被保存在对应的数据类中:

/**
 * User: WuRuiqiang(263454190@qq.com)
 * Date: 18/1/4
 * Time: 上午10:46
 * Version: v1.0
 * Description:Route元数据,用于存储被[com.github.richardwrq.krouter.annotation.Route]注解的类的信息
 */
data class RouteMetadata(
        /**
         * Type of Route
         */
        val routeType: RouteType = RouteType.UNKNOWN,
        /**
         * Priority of route
         */
        val priority: Int = -1,
        /**
         * Name of route
         */
        val name: String = "undefine",
        /**
         * Path of route
         */
        val path: String = "",
        /**
         * PathPrefix of route
         */
        val pathPrefix: String = "",
        /**
         * PathPattern of route
         */
        val pathPattern: String = "",
        /**
         * Class of route
         */
        val clazz: Class<*> = Any::class.java)
/**
 * User: WuRuiqiang(263454190@qq.com)
 * Date: 18/1/8
 * Time: 下午10:46
 * Version: v1.0
 * Description:Interceptor元数据,用于存储被[com.github.richardwrq.krouter.annotation.Interceptor]注解的类的信息
 */
data class InterceptorMetaData(
        /**
         * Priority of Interceptor
         */
        val priority: Int = -1,
        /**
         * Name of Interceptor
         */
        val name: String = "undefine",
        /**
         * Class desc of Interceptor
         */
        val clazz: Class<*> = Any::class.java)

/**
 * User: WuRuiqiang(263454190@qq.com)
 * Date: 18/3/14
 * Time: 上午1:28
 * Version: v1.0
 * Description:Injector元数据,用于存储被[com.github.richardwrq.krouter.annotation.Inject]注解的类的信息
 */
data class InjectorMetaData(
        /**
         * if true, throw NPE when the filed is null
         */
        val isRequired: Boolean = false,
        /**
         * key
         */
        val key: String = "",
        /**
         * field name
         */
        val fieldName: String = "")

其中被@Route注解的类是Android中的四大组件和Fragment或者它们的子类(目前尚不支持Broadcast以及ContentProvider),被@Route注解的对象目前有3种处理方式:

  1. 若被注解的类是Activity的子类,那么最终的处理方式是startActivity;
  2. 若被注解的类是Service的子类,最终的处理方式有两种,也就 是Android中启动Service的两种方式,使用哪种启动方式取决于是否调用了withServiceConn函数添加了ServiceConnection;
  3. 若被注解的类是Fragment的子类,最终的处理方式是调用无参构造函数构造出这个类的实例,并调用setArguments(Bundle args)将请求参数传入Fragment的bundle中,最后返回该实例

@Interceptor注解的类需实现IRouteInterceptor接口,这些类主要处理是否拦截路由的逻辑,比如某些需要登录才能启动的组件,就可以用到拦截器
@Inject用于标记需要被注入的属性
@Provider注解的类最终可以调用KRouter.getProvider(path: String)方法获取该类的对象,如果该类实现了IProvider接口,那么init(context: Context)方法将被调用
这些注解最终都不会被编译进class文件中,在编译时期这些注解会被收集起来最终交由不同的Annotation Processor去处理。

KRouter路由框架分为3个模块:

  • KRouter-api模块,作为SDK提供API供应用调用,调用KRouter-compiler模块生成的类中的方法加载路由表,处理路由请求
  • KRouter-compiler模块,各种注解对应的Processor的集合,编译期运行,负责收集路由组件,并生成kotlin代码
  • KRouter-gradle-plugin模块,自定义gradle插件,在项目构建时期添加相关依赖以及相关参数的配置
各模块运行时期.png
KRouter-compiler

在介绍该模块之前如果有同学不知道Annotation Processor的话建议先阅读 Annotation Processing-Tool详解一小时搞明白注解处理器(Annotation Processor Tool)这两篇文章,简单来说,APT就是javac提供的一个插件,它会搜集被指定注解所注解的元素(类、方法或者属性),最终将搜集到的这些交给注解处理器Annotation Processor进行处理,注解处理器通常会生成一些新的代码(推荐大名鼎鼎的square团队造的轮子javapoet,这个开源库提供了非常友好的API让我们去生成Java代码),这些新生成的代码会与源码一起在同一个编译时期进行编译。
但是Annotation Processorjavac提供的一个插件,也就是说它只认识Java代码,它压根不知道kotlin是什么,所以如果是用kotlin编写的代码文件最终将会被javac给忽略,所幸的是JetBrains在2015年就推出了kapt来解决这一问题。而且既然有javapoet,那square那么牛逼的团队肯定也会造一个生成kotlin代码的轮子吧,果不其然,在github一搜kotlinpoet,还真有,所以最终决定KRouter-compiler模块使用kapt+kotlinpoet来自动生成代码(kotlinpoet文档过于简单了,建议使用该库的同学通过它的测试用例或者参照Javapoet文档了解API的调用)。

开头的例子中我们可以看到使用KRouter启动一个Activity只需要知道该Activity的路径即可,并不需要像Android原生的启动方式一样传入Class<*>或者Class Name,那么KRouter是怎么做到的呢?
原理很简单,KRouter-compiler模块生了初始化路由表的代码,这些路由表内部其实就是一个个map,这些map以路径path作为key,数据类作为value(比如RouteMetadata),SDK内部会通过path获取到数据类,像开头启动Activity的例子中,SDK就通过path获取到一个RouteMetadata对象,在这个对象中取出被注解的类的Class<*>,有了这个Class<*>就可以完成启动Activity的操作。
接下来说说路由表初始化代码生成之后是怎么被执行的,首先我定义了这样一些接口:

/**
 * 加载路由
 *
 * @author: Wuruiqiang <a href="mailto:263454190@qq.com">Contact me.</a>
 * @version: v1.0
 * @since: 18/1/4 下午6:38
 */
interface IRouteLoader {
    fun loadInto(map: MutableMap<String, RouteMetadata>)
}

/**
 * 加载拦截器
 *
 * @author: Wuruiqiang <a href="mailto:263454190@qq.com">Contact me.</a>
 * @version: v1.0
 * @since: 18/1/5 上午9:12
 */
interface IInterceptorLoader {
    fun loadInto(map: TreeMap<Int, InterceptorMetaData>)
}

/**
 * 加载Provider
 *
 * @author: Wuruiqiang <a href="mailto:263454190@qq.com">Contact me.</a>
 * @version: v1.0
 * @since: 18/1/5 上午9:12
 */
interface IProviderLoader {
    fun loadInto(map: MutableMap<String, Class<*>>)
}

@Route注解为例,在KRouter-compiler中定义了一个继承自AbstractProcessor的类RouteProcessor,在编译期间编译器会收集@Route注解的元素的信息然后交由RouteProcessor处理,RouteProcessor会生成一个实现了IRouteLoader接口的类,在loadInto方法中把注解中的元数据与被注解的元素的部分信息存到RouteMetadata对象,然后将注解的路径path作为key,RouteMetadata对象作为value保存在一个map当中。生成的代码如下(项目build之后可以在(module)/build/generated/source/kaptKotlin/(buildType)目录下找到这些自动生成的类):

/**
 *    ***************************************************
 *    * THIS CODE IS GENERATED BY KRouter, DO NOT EDIT. *
 *    ***************************************************
 */
class KRouter_RouteLoader_app : IRouteLoader {
    override fun loadInto(map: MutableMap<String, RouteMetadata>) {
        map["krouter/sample/MainActivity"] = RouteMetadata(RouteType.ACTIVITY, -1, "undefined", "krouter/sample/MainActivity", "", "", MainActivity::class.java)
        map["myfragment"] = RouteMetadata(RouteType.FRAGMENT_V4, -1, "undefined", "myfragment", "", "", MainActivity.MyFragment::class.java)
        map["krouter/sample/fragment1"] = RouteMetadata(RouteType.FRAGMENT, -1, "undefined", "krouter/sample/fragment1", "", "", Fragment1::class.java)
        map["krouter/sample/fragment2"] = RouteMetadata(RouteType.FRAGMENT, -1, "undefined", "krouter/sample/fragment2", "", "", Fragment2::class.java)
        map["krouter/sample/Main2Activity"] = RouteMetadata(RouteType.ACTIVITY, -1, "undefined", "krouter/sample/Main2Activity", "", "", Main2Activity::class.java)
        map["krouter/sample/Main3Activity"] = RouteMetadata(RouteType.ACTIVITY, -1, "undefined", "krouter/sample/Main3Activity", "", "", Main3Activity::class.java)
    }
}

代码生成之后,我们需要执行loadInto方法才算是把数据存入到map中去,我们可以通过Class.forName(ClassName).newInstance()获取该类实例,然后将其强制转换为IRouteLoader类型,接着调用loadInto方法传入map即可,现在问题来了,加载一个类我们需要知道这个类的路径和名称:com.x.y.ClassA,但是SDK并不知道KRouter-compiler会生成哪些类。
为此我准备了两种解决方案:

  1. 类似ARouter的做法,扫描所有dex文件,找出实现了ARouter接口的类,然后将这些类的ClassName缓存至本地,下次应用启动时如果存在缓存且没有新增文件则读取缓存内容即可;
  2. 第二种是生成的类及其路径遵循一定的规则,比如由RouteProcessor生成的类路径规定为com.github.richardwrq.krouter,类名规定以“KRouter_RouteLoader_”作为开头然后拼接上Module名称(以Module名称作为后缀是避免在不同的Module下生成类名一样的类,导致编译时出现类重复定义异常),所以RouteProcessor名称为app的Module下生成的类就是com.github.richardwrq.krouter.KRouter_RouteLoader_app,在程序运行的时候,我们的SDK只需要获取项目中所有Module的名称,然后依次加载它们并执行loadInto方法即可。

基于性能考虑我采取了第二种方案,这就需要解决一个问题,因为RouteProcessor是无法知道当前是处于哪个Module的,所以我们需要在Module的build.gradle做如下配置:

kapt {
    arguments {
        arg("moduleName", project.getName())
    }
}

这样我们就配置了一个名为“moduleName”的参数,它的值就是当前Module的名称。这个参数可以在ProcessingEnvironmentgetOptions()方法获取的map中取出,
RouteInterceptorProvider三者的处理流程大致相同,就不一一赘述了。
在这里提一下关于依赖注入Inject的实现,关于如何对属性进行注入我想了两种解决方案:

  1. 第一种就是通过反射,了解反射的同学都知道可以通过反射获取类的运行时注解,并且通过反射API为类的属性进行赋值,但由于时反射,所以性能上有所损耗,但是可以无视属性的访问权限;
  2. 第二种是生成需要被注入的类的扩展方法,在扩展方法里面对接收者的属性进行赋值,性能更好,但是缺点是无法对private以及protected成员进行赋值。

一开始是希望偷懒,就选择了第一种方案,但是问题来了,我知道Java的反射会有一些性能上的问题,但速度还不至于让用户感知明显,但是当我调用kotlin反射相关API时(最主要是获取Properties相关API),发现第一次调用花费的在4~5s 左右,之后调用速度是毫秒级的,我猜测是第一次调用加载了大量数据,然后将这些数据缓存起来了,但这4~5s的调用时间实在是恶心,所以最终还是决定采用方案2,有兴趣的同学可以查看com/github/richardwrq/krouter/compiler/processor/RouteProcessor.kt,生成的代码如下:

class com_github_richardwrq_krouter_activity_Main2Activity_KRouter_Injector : IInjector {
    override fun inject(any: Any, extras: Bundle?) {
        val bundle = getBundle(any, extras)//getBundle为自动生成的顶层方法
        (any as Main2Activity).exInject(bundle)
    }

    private fun Main2Activity.exInject(bundle: Bundle) {
        person = bundle.get("person") as? Person ?: KRouter.getProvider<Person>("person") ?: parseObject(bundle.getString("person"), object : TypeToken<Person>() {}.getType()) ?: throw java.lang.NullPointerException("Field [person] must not be null in [Main2Activity]!")//parseObject为自动生成的顶层方法
        provider = bundle.get("NoImplProvider") as? NoImplProvider ?: KRouter.getProvider<NoImplProvider>("NoImplProvider") ?: parseObject(bundle.getString("NoImplProvider"), object : TypeToken<NoImplProvider>() {}.getType()) ?: throw java.lang.NullPointerException("Field [provider] must not be null in [Main2Activity]!")
        myProvider = (KRouter.getProvider<MyProvider>("provider/myprovider")) ?: throw java.lang.NullPointerException("Field [myProvider] must not be null in [Main2Activity]!")
    }
}

生成的类路径与扩展方法接收者的类路径相同(解决Java包内访问权限问题),类名命名规则为扩展方法接收者类路径的“.“替换为”_“作为前缀,后缀为”_KRouter_Injector“,比如被被注入的类是com.github.richardwrq.krouter.activity.Main2Activity,那么自动生成的类为com.github.richardwrq.krouter.activity.com_github_richardwrq_krouter_activity_Main2Activity_KRouter_Injector

KRouter-api

该模块其实就是提供API给用户调用的SDK
上面提到SDK需要执行KRouter-compiler模块类的代码才能真正完成路由表初始化的工作,由于最终编译器会将所有Module打包成一个apk,所以在APP运行时是不存在Module的概念的,但是按照解决方案2各Module生成的类会以Module名称作为后缀,因此必须想办法让SDK获取到项目中所有Module的名称,考虑再三,我采取的解决方案是从assets目录入手,在项目构建时期创建一个task,这个task会在Module的src/main/assets目录下生成一个“KRouter_ModuleName”的文件,在SDK初始化的时候只需要列出assets目录下所有"KRouter_"开头的文件并截取下划线“_”后的内容,即可得到一个包含所有Module名称的列表。
下面给出SDK的类图,同学们可以对照源码参考

KRouter-api类图.png

KRouter-gradle-plugin

完成上述两个模块后其实KRouter框架已经可以正常使用了,引用方式如下:
在各Module的build.gradle加入下面的代码

kapt {
    arguments {
        arg("moduleName", project.getName())
    }
}
dependencies {
    implementation 'com.github.richardwrq:krouter-api:x.y.z’
    kapt 'com.github.richardwrq:krouter-compiler:x.y.z'
}
afterEvaluate {
    //在assets目录创建文件的task
    ...
}

当项目中Module较多时,手动在每一个Module加入这些配置未免有些蠢。。所以我写了一个gradle插件用来自动完成这些配置工作,具体实现参考源码,逻辑非常简单,最后使用引用方式变成下面这样:
在项目根目录build.gradle文件加入如下配置

buildscript {

    dependencies {
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:x.y.z"
        classpath "com.github.richardwrq:krouter-gradle-plugin:x.y.z"
    }
}

然后在各Module的build.gradle文件加入如下配置

apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: "com.github.richardwrq.krouter"

到这里KRouter路由框架就粗略的介绍了一遍,由于kapt仍在不断完善,所以使用过程中难免碰到一些坑或者本身API功能不够完善,下面就列举一些遇到的问题以及解决方法:

ToDoList
  • 通过gradle插件修改AndroidManifest.xml文件,自动注册路由组件(Activity、Service)
  • 目前尚不支持动态加载的插件的路由注册,但有解决方案,hook classloader装载方法,在加载dex文件时扫描KRouter的路由组件
  • 支持多应用多进程环境下的页面路
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,214评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,307评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,543评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,221评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,224评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,007评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,313评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,956评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,441评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,925评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,018评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,685评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,234评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,240评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,464评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,467评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,762评论 2 345

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,499评论 25 707
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,599评论 18 139
  • 听到闺蜜放年假要来重庆耍我欣喜若狂,和丹青有一年多没有见,她在深圳,我在重庆,赏的是同一轮明月,但相隔千里。...
    云影M阅读 224评论 0 1
  • The sun has disappeared into a distance. 太阳慢慢消失在远方, There...
    谭树君阅读 452评论 0 1
  • 能为人师和好为人师的区别很大。
    十年一井阅读 135评论 0 0