DSL在路由导航中的应用

路由导航,说白了就是帮你在页面之间跳来跳去的那套机制。Android原生有Intent,但用起来嘛……我个人觉得有点啰嗦。尤其是大型项目,几十个Activity互相跳转,光写那些字符串常量就够你受的。

今天我们来聊聊,怎么用Kotlin DSL把ARouter和ActivityRouter这类框架,封装得更优雅、更安全。

为什么需要DSL封装路由?

先说说痛点。我在项目中遇到过这样的场景:一个模块有20多个页面,每个页面跳转都要写:

ARouter.getInstance()
    .build("/user/profile")
    .withString("userId", "12345")
    .withInt("age", 28)
    .withBoolean("isVip", true)
    .navigation()

嗯,看起来还行对吧?但问题是:

  • 路径字符串写错了,编译不报错,运行时才崩
  • 参数类型不匹配,也是运行时才知道
  • 每个页面都要记参数名,团队协作容易出错

说白了,就是类型不安全。你想想看,一个字符串"userId"写错了,IDE不会提醒你,测试也不一定能覆盖到,上线后用户点一下就崩了。这种事我经历过不止一次。

核心思路:用DSL把路由配置变成类型安全的Kotlin代码,让编译器帮你检查错误。

类型安全的路由构建

我们先从最基础的开始。假设我们有一个用户详情页,需要传userId和isVip两个参数。传统写法是这样的:

// 传统方式
ARouter.getInstance()
    .build("/user/detail")
    .withString("userId", userId)
    .withBoolean("isVip", isVip)
    .navigation()

用DSL封装后,可以写成这样:

// DSL方式
navigate {
    to(UserDetailRoute)
    params {
        userId = "12345"
        isVip = true
    }
}

是不是清爽多了?而且UserDetailRoute是一个类型安全的对象,不是字符串。怎么做到的呢?

定义路由DSL

我们先定义一个路由描述类:

data class RouteDescriptor(
    val path: String,
    val params: Map<String, Any?> = emptyMap()
)

然后定义每个页面的路由对象:

object UserDetailRoute : RouteDescriptor(
    path = "/user/detail"
) {
    var userId: String by Delegates.notNull()
    var isVip: Boolean by Delegates.notNull()
}

这里用到了委托属性。为什么?因为我想让参数在编译期就确定类型。你传一个Int给userId?编译器直接报错。

接下来是DSL构建器:

class RouteBuilder {
    private var descriptor: RouteDescriptor? = null
    private val params = mutableMapOf<String, Any?>()

    fun to(route: RouteDescriptor) {
        descriptor = route
    }

    fun params(block: ParamsBuilder.() -> Unit) {
        val builder = ParamsBuilder()
        builder.block()
        params.putAll(builder.params)
    }

    fun build(): RouteDescriptor {
        return descriptor?.copy(params = params)
            ?: throw IllegalStateException("Route not specified")
    }
}

class ParamsBuilder {
    val params = mutableMapOf<String, Any?>()

    infix fun String.to(value: Any?) {
        params[this] = value
    }
}

嗯,这里有个小技巧。我用了infix关键字,让参数赋值看起来更自然:

params {
    "userId" to "12345"
    "isVip" to true
}

但说实话,我个人更推荐用属性委托的方式,因为类型更安全。上面那个to写法,value是Any?,传什么类型都行,容易出错。

参数传递的DSL封装

参数传递是路由中最容易出问题的环节。我记得有一次,后端返回的userId从String变成了Int,前端没适配,结果跳转时传了个Int进去,页面直接崩了。

为了避免这种问题,我们可以用密封类来定义参数类型:

sealed class RouteParam<out T> {
    data class StringParam(val value: String) : RouteParam<String>()
    data class IntParam(val value: Int) : RouteParam<Int>()
    data class BooleanParam(val value: Boolean) : RouteParam<Boolean>()
    data class LongParam(val value: Long) : RouteParam<Long>()
    data class SerializableParam(val value: java.io.Serializable) : RouteParam<java.io.Serializable>()
}

然后在路由描述类中,用泛型约束参数类型:

class TypedRouteDescriptor<T : RouteParam<*>>(
    val path: String,
    val param: T? = null
)

这样,你在构建路由时,编译器就能检查参数类型是否匹配:

// 编译通过
val route = TypedRouteDescriptor(
    path = "/user/detail",
    param = RouteParam.StringParam("12345")
)

// 编译报错!类型不匹配
val route = TypedRouteDescriptor(
    path = "/user/detail",
    param = RouteParam.IntParam(12345)  // 期望String,给了Int
)
小提示:如果项目已经用了ARouter,不想完全替换,可以在ARouter基础上包一层DSL。这样既享受了ARouter的成熟稳定,又获得了类型安全。

拦截器的DSL配置

拦截器是路由中很实用的功能。比如登录检查、埋点统计、权限验证等。传统写法是这样的:

ARouter.getInstance()
    .build("/order/confirm")
    .withString("orderId", orderId)
    .navigation(this, object : NavigationCallback {
        override fun onFound(postcard: Postcard?) {
            // 路由找到
        }

        override fun onLost(postcard: Postcard?) {
            // 路由丢失
        }

        override fun onArrival(postcard: Postcard?) {
            // 到达目标
        }

        override fun onInterrupt(postcard: Postcard?) {
            // 被拦截
        }
    })

用DSL封装后:

navigate {
    to(OrderConfirmRoute)
    params {
        orderId = "ORD2024001"
    }
    interceptors {
        +LoginInterceptor()   // 登录检查
        +AnalyticsInterceptor() // 埋点统计
        +PermissionInterceptor() // 权限验证
    }
    onArrival {
        // 到达后的回调
    }
    onInterrupt {
        // 被拦截后的处理
    }
}

这里用到了+操作符重载,让拦截器列表看起来更简洁:

class InterceptorBuilder {
    private val list = mutableListOf<Interceptor>()

    operator fun Interceptor.unaryPlus() {
        list.add(this)
    }

    fun build(): List<Interceptor> = list.toList()
}

为什么用+?我个人觉得这样写起来很顺手,有点像在列表里添加元素的感觉。当然你也可以用add方法,看团队习惯。

完整的DSL路由示例

把上面这些整合起来,一个完整的DSL路由调用大概是这样的:

navigate {
    to(UserDetailRoute)
    params {
        userId = "u_10086"
        isVip = true
        score = 98
    }
    interceptors {
        +LoginInterceptor()
        +VipCheckInterceptor()
    }
    onArrival { postcard ->
        log("到达用户详情页: ${postcard.path}")
    }
    onInterrupt { postcard ->
        showToast("路由被拦截: ${postcard.interruptMessage}")
    }
}

对应的DSL构建器实现:

class SafeRouteBuilder {
    private var target: TypedRouteDescriptor<*>? = null
    private val params = mutableMapOf<String, Any?>()
    private val interceptorList = mutableListOf<Interceptor>()
    private var onArrivalCallback: ((Postcard) -> Unit)? = null
    private var onInterruptCallback: ((Postcard) -> Unit)? = null

    fun to(descriptor: TypedRouteDescriptor<*>) {
        target = descriptor
    }

    fun params(block: ParamsScope.() -> Unit) {
        val scope = ParamsScope()
        scope.block()
        params.putAll(scope.params)
    }

    fun interceptors(block: InterceptorScope.() -> Unit) {
        val scope = InterceptorScope()
        scope.block()
        interceptorList.addAll(scope.interceptors)
    }

    fun onArrival(callback: (Postcard) -> Unit) {
        onArrivalCallback = callback
    }

    fun onInterrupt(callback: (Postcard) -> Unit) {
        onInterruptCallback = callback
    }

    fun navigate(context: Context) {
        val route = target ?: throw IllegalStateException("路由目标未设置")
        // 实际调用ARouter或ActivityRouter
        ARouter.getInstance()
            .build(route.path)
            .apply {
                params.forEach { (key, value) ->
                    when (value) {
                        is String -> withString(key, value)
                        is Int -> withInt(key, value)
                        is Boolean -> withBoolean(key, value)
                        is Long -> withLong(key, value)
                        else -> withObject(key, value)
                    }
                }
            }
            .navigation(context, object : NavigationCallback {
                override fun onArrival(postcard: Postcard?) {
                    onArrivalCallback?.invoke(postcard!!)
                }

                override fun onInterrupt(postcard: Postcard?) {
                    onInterruptCallback?.invoke(postcard!!)
                }
            })
    }
}
注意:DSL封装虽然好用,但不要过度设计。如果你的项目只有几个页面,直接用ARouter原生API就够了。DSL封装适合中大型项目,尤其是多模块协作的场景。

知识体系总览

下面这张图展示了DSL路由导航的核心结构:

DSL路由导航核心结构 navigate { } DSL入口 路由目标 to() 参数配置 params {} 拦截器 interceptors {} 回调 onArrival() 类型安全路由描述 RouteDescriptor / 密封类 参数类型校验 泛型约束 / 委托属性 拦截器链 +操作符重载 / 责任链 回调处理 Lambda / 高阶函数 ARouter / ActivityRouter 实际调用

避坑指南

最后分享几个我踩过的坑:

  • 参数默认值问题:我曾经在DSL中给参数设置了默认值,结果某个页面改了默认值,其他页面没同步更新,导致数据错乱。建议所有参数都显式传递,不要依赖默认值。
  • 拦截器顺序:拦截器的执行顺序很重要。比如登录检查应该在权限验证之前。我建议用orderedInterceptors方法,显式指定优先级。
  • 模块间路由解耦:如果模块A要跳转模块B的页面,不要在A中直接引用B的路由类。可以用接口+服务发现的方式,让路由注册中心统一管理。
我的建议:DSL封装路由,核心目标是类型安全和可读性。不要为了炫技而过度设计。保持简单,让团队其他成员能快速上手,这才是好代码。

好了,关于DSL在路由导航中的应用,就聊到这里。下一节我们会继续深入DSL的其他实战场景。


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