路由框架实现:ARouter原理、使用KSP实现页面路由注解处理

路由框架,说白了就是帮你解决「页面跳转」这件事的。你可能会想:不就是startActivity吗?嗯,小项目确实够用。但一旦项目膨胀到几十上百个模块,你会发现手动管理跳转路径简直是一场噩梦。

我在几年前接手过一个大型电商项目,光业务模块就有二十多个。每次改个页面路径,都得全局搜索字符串,生怕漏掉哪个地方。更别提跨模块调用了——A模块想跳B模块的页面,还得先依赖B模块的类。耦合度高得吓人。后来我们引入了ARouter,才算是把这个问题彻底解决了。

ARouter的核心原理

ARouter是阿里开源的路由框架,它的核心思想其实很简单:用注解标记页面,编译时生成路由表,运行时通过路由表完成跳转

具体来说,ARouter做了三件事:

  • 注解标记:用@Route注解标记Activity或Fragment,指定路径
  • 编译期处理:通过APT(Annotation Processing Tool)扫描注解,生成路由映射文件
  • 运行时加载:App启动时加载路由表,提供跳转服务

你想想看,这其实就是个「注册中心」模式。每个页面在编译期就把自己注册到路由表中,运行时直接查表跳转,完全不需要硬编码类名。

核心流程:

@Route(path="/main/home") → APT处理 → 生成路由表文件 → 运行时加载 → 根据path找到目标Activity → 完成跳转

ARouter的APT处理细节

ARouter用的是Java的APT机制,在编译期扫描所有被@Route注解的类。它会生成一个以"ARouter$$Group$$"开头的类文件,里面维护了该分组下的所有路由映射关系。

举个例子,假设你有这样一个页面:

@Route(path = "/user/profile")
public class UserProfileActivity extends AppCompatActivity {
    // ...
}

APT会生成类似这样的代码:

public class ARouter$$Group$$user implements IRouteGroup {
    @Override
    public void loadInto(Map<String, RouteMeta> atlas) {
        atlas.put("/user/profile", RouteMeta.build(
            RouteType.ACTIVITY,
            UserProfileActivity.class,
            "/user/profile",
            "user",
            null, -1, -2147483648
        ));
    }
}

运行时,ARouter会通过类名反射加载这些生成类,把路由信息填充到一个全局Map中。之后每次调用ARouter.getInstance().build("/user/profile").navigation(),就是从Map里查找到目标Activity,然后执行startActivity。

我个人习惯:路由路径的命名最好遵循「模块/功能」的格式,比如"/user/profile"、"/order/detail"。这样一眼就能看出属于哪个模块,后期维护也方便。

为什么需要KSP?

ARouter用的是APT,但APT有个问题——它只能处理Java源文件,不能直接处理Kotlin源文件。虽然Kotlin可以通过kapt兼容APT,但kapt的性能一直是个痛点。KSP(Kotlin Symbol Processing)是Google专门为Kotlin设计的注解处理器,它直接操作Kotlin的语法树,速度比kapt快2-3倍。

我在项目中做过实测:一个包含200多个Kotlin文件的模块,kapt编译需要40秒,换成KSP后降到了15秒。这个差距在大型项目中会非常明显。

使用KSP实现页面路由注解处理

好,接下来我们手写一个简化版的路由框架,用KSP处理注解。这样你就能彻底理解背后的原理了。

第一步:定义注解

// 路由注解
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class Route(
    val path: String,
    val group: String = ""
)

这里我加了个group字段。为什么要分组?因为路由表如果太大,一次性加载会影响启动速度。分组后可以按需加载,比如用户模块的页面只在用户进入相关功能时才加载路由表。

第二步:创建KSP处理器

class RouteSymbolProcessor : SymbolProcessor {
    private lateinit var codeGenerator: CodeGenerator
    private lateinit var logger: KSPLogger

    override fun process(resolver: Resolver): List<KSAnnotated> {
        // 1. 获取所有被@Route注解的类
        val symbols = resolver.getSymbolsWithAnnotation("com.example.Route")
            .filterIsInstance<KSClassDeclaration>()
        
        // 2. 收集路由信息
        val routeMap = mutableMapOf<String, MutableList<RouteInfo>>()
        
        symbols.forEach { symbol ->
            val annotation = symbol.annotations
                .first { it.shortName.asString() == "Route" }
            
            val path = annotation.arguments
                .first { it.name?.asString() == "path" }
                .value as String
            
            val group = annotation.arguments
                .firstOrNull { it.name?.asString() == "group" }
                ?.value as? String ?: extractGroupFromPath(path)
            
            val className = symbol.qualifiedName?.asString() ?: return@forEach
            
            routeMap.getOrPut(group) { mutableListOf() }
                .add(RouteInfo(path, className))
        }
        
        // 3. 生成路由表文件
        generateRouteFiles(routeMap)
        
        return emptyList()
    }
    
    private fun generateRouteFiles(routeMap: Map<String, List<RouteInfo>>) {
        routeMap.forEach { (group, routes) ->
            val file = codeGenerator.createNewFile(
                dependencies = Dependencies(false),
                packageName = "com.example.router",
                fileName = "RouteTable_${group}"
            )
            
            file.appendText("""
                package com.example.router
                
                object RouteTable_${group} {
                    fun load(): Map = mapOf(
                        ${routes.joinToString(",\n") { "\"${it.path}\" to \"${it.className}\"" }}
                    )
                }
            """.trimIndent())
            
            file.close()
        }
    }
}

这段代码的核心逻辑就三步:扫描注解、收集信息、生成文件。嗯,其实跟APT的思路一模一样,只是API换成了KSP的。

我曾经踩过一个坑:KSP的resolver.getSymbolsWithAnnotation()返回的是所有被注解的符号,包括接口、抽象类。如果你只处理Activity,一定要加类型过滤。否则生成的路由表里混入了抽象类,运行时反射创建实例会直接崩溃。

第三步:注册KSP处理器

在模块的build.gradle.kts中配置:

plugins {
    id("com.google.devtools.ksp")
}

dependencies {
    implementation(project(":router-annotation"))
    ksp(project(":router-processor"))
}

然后在src/main/resources/META-INF/services目录下创建文件com.google.devtools.ksp.processing.SymbolProcessor,内容写上你的处理器全限定类名。

第四步:运行时加载路由表

object Router {
    private val routeTable = mutableMapOf<String, String>()
    
    fun init(context: Context) {
        // 通过反射加载所有生成的路由表
        val tables = listOf("RouteTable_user", "RouteTable_order", "RouteTable_main")
        tables.forEach { tableName ->
            try {
                val clazz = Class.forName("com.example.router.$tableName")
                val method = clazz.getMethod("load")
                @Suppress("UNCHECKED_CAST")
                val map = method.invoke(null) as Map<String, String>
                routeTable.putAll(map)
            } catch (e: Exception) {
                // 模块不存在时跳过
            }
        }
    }
    
    fun navigate(path: String, context: Context) {
        val className = routeTable[path] 
            ?: throw IllegalArgumentException("路由未注册: $path")
        
        val clazz = Class.forName(className)
        val intent = Intent(context, clazz)
        context.startActivity(intent)
    }
}

你看,运行时逻辑其实很简单——就是查表、反射、跳转。真正的复杂度都在编译期处理完了。

KSP vs APT:性能对比

对比维度 APT (kapt) KSP
处理对象 Java源文件(Kotlin需先转Java存根) 直接处理Kotlin语法树
编译速度 慢(需生成Java存根) 快2-3倍
增量编译 支持较差 原生支持
Kotlin兼容性 通过kapt桥接 原生支持
API复杂度 较复杂(需处理Element、TypeMirror等) 更简洁(直接操作KSNode)

从表格能看出来,KSP在Kotlin项目里几乎是全面领先。我个人建议:新项目直接用KSP,老项目如果Kotlin代码占比超过60%,也值得迁移

SVG:路由框架核心流程

路由框架核心流程 ① 编译期:注解扫描 ② KSP/APT处理注解 ③ 生成路由表文件 ④ App启动:加载路由表 ⑤ 构建路由Map ⑥ 根据path查表跳转 编译产物 ⑦ 目标Activity 编译期(KSP/APT)→ 生成路由表 → 运行时加载 → 按路径跳转 编译期处理 运行时处理 目标页面

实际项目中的避坑指南

讲完了原理和实现,我再分享几个实际项目中遇到的坑:

  • 路由路径冲突:多人协作时,不同模块可能定义了相同的路径。我建议在CI流程中加一个路由表检查脚本,发现重复路径直接报错。
  • 模块化后路由表加载:如果模块是动态加载的(比如插件化),路由表需要在模块加载时动态注册,不能只在App启动时加载一次。
  • 参数传递:路由跳转通常需要传参。ARouter的做法是用Bundle,但更好的方案是定义接口,通过依赖注入传递参数。我在项目中就封装了一个RouteParam注解,配合KSP自动生成参数解析代码。
  • 拦截器:路由框架一般都会提供拦截器机制,用于登录校验、埋点等。实现方式是在路由表中加一个拦截器列表,跳转前依次执行。

一个小技巧:KSP处理注解时,可以用resolver.getNewFiles()获取增量编译时新增的文件,只处理变化的部分,能进一步提升编译速度。

好了,关于路由框架的原理和KSP实现,就讲到这里。你可能会觉得代码量不大,但核心思想就是「编译期生成路由表,运行时查表跳转」。把这个思路吃透了,不管是ARouter还是其他路由框架,你都能快速上手。


公众号:蓝海资料掘金营,微信deep3321