不久前, 谷歌推出了一个超酷的更新, 用于自动生成模板. 当然, 我说的是 KSP(Kotlin Symbol Processing). 在使用 Java 注解处理, KAPT 以及现在的 KSP 的这些年里, 我看到过大量关于创建第一个生成器, 注解等的文章. 他们通常会建议: "让我们为模型生成 getter 和 setter, 作为练习". 这对于尝试这项技术绰绰有余, 但我一直想要更多. 一个能对项目有实际帮助的真实用例. 在我的一个项目中, 我遇到了一个完美的问题, 这就是我今天要写的内容. 我们将讨论在 Compose Navigation 库中自动进行导航描述的问题.
已经在 Compose 中编写过程序的人肯定知道, 导航是非常枯燥乏味的常规工作. 想想添加参数🥲, 唉!简而言之, 解决方案显而易见--我们需要生成这个.
我想指出的是, 已经有很多很好的解决方案来解决这个特定的问题, 而且, 在写这篇文章的时候, 谷歌终于发布了 Compose Navigation 库的一个很酷的更新, 并添加了
SafeArgs功能, 类似于我们熟悉的 Fragment 的工作方式. 我对注解处理的理解很感兴趣, 而且现有的解决方案不太适合我, 因为它们只能生成目的地和参数, 但我想自动化整个过程, 包括构建和向NavHost交付.
首先, 让我们来分析一下 KSP 的实际功能:
- 分析现有代码(这里唯一的限制是 KSP 无法构建 AST, 因此无法分析方法代码).
- 生成新的类, 接口等. KSP 有一个相当灵活的机制, 允许你生成依赖于其他尚未生成文件的文件.
此外, KSP 还不能做什么:
- 修改现有代码(最多从头开始重新生成代码).
- 如上所述, 分析方法代码. KSP 是作为 KAPT 的轻量级替代品而设计的, 并非用于深度代码分析.
- 不保证处理器调用的顺序. 如果需要在一个符号之后处理另一个符号, 则必须通过返回那些在当前轮中"不可达"的符号来明确指定, 这些符号是
process方法的结果.
因此, KSP 允许你查找特定的符号, 如注解, 类或函数, 访问参数列表, 等等.
在我们深入实践之前, 再多讲一点理论. 要创建自己的处理器, 你需要遵循以下步骤:
- 在项目中创建一个模块, 例如
navigation:generator(必须是jvm-module, 而不是android-module). - 你还需要创建一个单独的模块
navigation:library, 用于放置主合约和辅助方法(该模块应为android-module, 并部分依赖于 Compose). - 在相应的软件包中创建自己的
SymbolProcess和SymbolProcessorProvider实现. - 最后但并非最不重要的一点是, 在
resources/META-INF/services文件夹中创建一个文件, 并在其中标明SymbolProcessorProvider的完整路径.
因此, 你应该有以下结构:
起初, 这曾有一个注解…
当然, 如果没有注解, 用 KSP 搜索就没有意义了. 我的情况是这样的:
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.SOURCE)
annotation class Destination constructor(
val isStart: Boolean = false,
val installIn: KClass<out Any> = Any::class,
val baseRoute: String = "",
val args: KClass<out Any> = Any::class,
)
让我再详细说明一下为什么要这样设计. 首先, 我们需要了解这是否是一个起始屏幕. 其次, 我们需要知道这个目的地属于哪个图. 在设计过程中, 我考虑到了屏幕可能不属于任何图表而独立存在的可能性. 当一个项目有全局屏幕(如用户详情屏幕)时, 这通常是必要的(注意, 这在很大程度上取决于你的架构和整体方法, 这只是本文中的一个例子). 它不必是我们任何功能的一部分, 因此也不必属于某个图. 下一部分是baseRoute. 我加入这个参数是为了以防万一我需要手动指定一个屏幕的路径. 在设计这一机制时, 我假定baseRoute将根据屏幕名称创建, 例如, 对于UserDetailsScreen, 路由将如下所示: /user/details. 但是, 这样做并不总是很方便, 而且, 这种导航机制可能会被集成到一个已有屏幕的项目中, 而且可能会有一定的命名约定. 因此, 为了保持灵活性, 我决定这样做. 最后, 类型是我们声明为参数的 Kotlin 数据类.
下一步是创建协议. 在导航设计过程中, 我确定了以下合约:
interface Navigator {
fun navigateTo(route: NavRoute)
fun replaceTo(route: NavRoute)
fun navigateUp()
}
sealed interface NavEntry {
val baseRoute: String
}
interface NavDestination : NavEntry {
@Composable
fun Composable(entry: NavBackStackEntry, navigator: Navigator)
}
interface NavGraph : NavEntry {
val entries: Set<NavEntry>
val startRoute: NavRoute
}
interface NavRoute {
val baseRoute: String
val args: NavArguments?
fun computeRoute(): String
}
interface NavArguments : Parcelable {
interface Serializer {
fun deserializeFrom(string: String): NavArguments?
fun serializeToString(args: NavArguments): String
}
}
NavDestination and NavGraph 分别是屏幕和图形的接口. 它们统一在一个共同的父接口 NavEntry 下, 以便我们将它们放在图形中的一个列表中, 因为任何功能都可以是一个屏幕或整个图形.
NavRoute是一个描述导航路线的接口. 每个图形和目的地都有自己独特的路线.
NavArguments 是所有用作参数的数据类必须实现的契约. 内部的 Serializer 接口将用于生成从路由中序列化和反序列化参数的代码.
我确定的最后一个重要接口是 Navigator. 它将从我们的代码中抽象出NavHostController, 这样最终的模块就不会意识到 Compose Navigation 库的存在.
在继续前行之前, 我想告诉大家另一个概念性想法: 参数序列化. 为此, 我选择了Parcelable格式和Base64编码. Google 在实现 Compose Navigation 的SafeArgs时, 选择了使用kotlinx.serialization进行 JSON 序列化(他们在文章中解释了自己的理由). 就我而言, 只支持特定于 Android 的格式就足够了, 因此框架依赖性并不重要. 此外, 从序列化的角度来看, 序列化为字节流比序列化为纯字符串要好得多.
下一步是添加几个将在生成的代码中使用的辅助方法:
inline fun <reified T : NavArguments?> NavBackStackEntry.getNavArguments(deserializer: NavArguments.Factory): T {
val args = arguments?.getString(ArgsBundleKey)?.let(deserializer::deserializeFrom)
return if (null is T) args as T else requireNotNull(args) as T
}
inline fun <reified T : NavArguments> T.encode(): String {
return Base64.encodeToString(toByteArray(), Base64.URL_SAFE or Base64.NO_WRAP)
}
inline fun <reified T : NavArguments> String.decode(): T {
return Base64.decode(toByteArray(), Base64.URL_SAFE or Base64.NO_WRAP).fromByteArray<T>()
}
inline fun NavArguments.toByteArray(): ByteArray {
return Parcel.obtain().use {
writeToParcel(this, 0)
marshall()
}
}
inline fun <reified T : NavArguments> ByteArray.fromByteArray(): T {
return Parcel.obtain().use {
unmarshall(this@unmarshall, 0, size)
setDataPosition(0)
T::class.java.parcelableCreator.createFromParcel(this)
}
}
inline fun <reified R> Parcel.use(noinline callback: Parcel.() -> R): R {
return try {
callback(this)
} finally {
recycle()
}
}
@Suppress("UNCHECKED_CAST")
inline val <reified T : Any> Class<T>.parcelableCreator
get() : Parcelable.Creator<T> = try {
getField("CREATOR").get(null) as Parcelable.Creator<T>
} catch (e: Exception) {
throw BadParcelableException(e)
} catch (t: Throwable) {
throw BadParcelableException(t.message)
}
我们已经在上文讨论了参数问题, 因此在此我想对几个要点发表一下意见, 即 parcelableCreator 和 getNavArguments. 遗憾的是, 我还没有找到一种无需反射就能获得 Creator 的方法, 因此需要注意的是, 这并不是编译时的方法. 此外, 整个 Parcelable 方法需要手动实现相应的合约或使用 kotlinx-parcelize 生成. 在我的使用案例中, 依赖这类插件是没有问题的, 但在你的场景中, 你可能需要做一些调整.
下一步是
SymbolProcessor
在我们深入研究代码之前, 我想先概述一下SymbolProcessor中的分步过程:
- 我们需要找到所有标注了
@Destination注解的符号. - 对于每个注解符号, 我们将收集必要的信息, 如名称, 包以及是否有参数.
- 根据收集到的数据, 我们将生成代表导航目的地和路线的
NavDestination和NavRoute实例. - 根据生成的
NavDestination实例所属的图谱对其进行分组. 这将允许我们创建一个分层导航结构. - 创建一个
NavGraph实例, 并将所有相应的NavDestination实例添加到其中.
override fun process(resolver: Resolver): List<KSAnnotated> {
sequenceOf(Destination::class)
.mapNotNull(KClass<out Annotation>::qualifiedName)
.flatMap(resolver::getSymbolsWithAnnotation)
.flatMap { it.accept(NavDestinationVisitor(), logger) }
.onEach(navDestinationGenerator::generate)
.groupBy(NavDestinationData::graphInstallInto)
.asSequence()
.filter { it.key != null }
.associate { requireNotNull(it.key) to it.value }
.forEach(navGraphGenerator::generate)
return emptyList()
}
听起来很简单, 不是吗?在我看来, 代码生成过程本身就是一个独立的话题, 所以我在这里就不详细介绍了. 相反, 我将对 KSDefaultVisitor 稍作阐述. KSDefaultVisitor 为我们可能需要的各种数据类型(注解, 函数, 类等)提供了丰富的方法集. 在我的具体案例中, 我正在搜索一个注解为 Destination 的函数:
override fun visitFunctionDeclaration(
function: KSFunctionDeclaration,
data: KSPLogger
): Sequence<R> {
return emptySequence()
}
要生成目的地和路线, 我需要获取以下信息:
- 包名和函数名称.
- 参数类型或空值.
baseRoute, 我们稍后将在导航中使用它.- 该目的地是否是其图中的起始目的地.
- 应添加目的地的图形(或为空).
- 一个可选的
Navigator参数, 可能在函数中不存在.
因此, 我最终得到了下面的 NavDestinationVisitor:
class NavDestinationVisitor : BaseVisitor<NavDestinationData>() {
override fun visitFunctionDeclaration(function: KSFunctionDeclaration): Sequence<NavDestinationData> {
val annotation = function.findAnnotation<Destination>()
val (packageName, simpleName) = (function.packageName.asString() to function.simpleName.asString())
val navArgs = obtainNavArgs(annotation)
val baseRoute = obtainBaseRoute(annotation, simpleName, navArgs != null)
val graphInstallInto = obtainGraphInstallInto(annotation)
val isStartDestination = isStartDestination(annotation)
val requiresNavigator = findNavigatorInParamsOf(function)?.name != null
return sequenceOf(
NavDestinationData(
annotatedTarget = function,
annotatedTargetPackageName = packageName,
annotatedTargetName = simpleName,
navArgs = navArgs,
baseRoute = baseRoute,
graphInstallInto = graphInstallInto,
isStartDestination = isStartDestination,
requiresNavigator = requiresNavigator,
)
)
}
private inline fun <reified A : Annotation> KSAnnotated.findAnnotation(): KSAnnotation {
val simpleName = A::class.java.simpleName
return annotations.first { it.shortName.asString() == simpleName }
}
private fun findNavigatorInParamsOf(function: KSFunctionDeclaration): KSValueParameter? {
return function.parameters.find { it.type.resolve().toClassName() == NavigatorClassName }
}
private fun obtainBaseRoute(annotation: KSAnnotation, annotatedTargetName: String, hasArguments: Boolean): String {
val baseRoute = annotation.findArgument<String>(BaseRouteTypeName)
val routeParts = buildList {
when (baseRoute.isNotBlank()) {
true -> add(baseRoute)
false -> addAll(annotatedTargetName.split("(?<=.)(?=\p{Lu})".toRegex()).dropLast(1))
}
if (hasArguments) add("{${DestinationArgsTypeName}}")
}
return routeParts.joinToString("/").let { if (!it.startsWith("/")) "/$it" else it }.lowercase()
}
private fun obtainNavArgs(annotation: KSAnnotation): ClassName? {
return annotation.findArgument<KSType?>(DestinationArgsTypeName)?.toClassName()
.thisOrNullIf { it != Any::class.asClassName() }
}
private fun obtainGraphInstallInto(annotation: KSAnnotation): ClassName? {
return annotation.findArgument<KSType?>(NavGraphInstallIntoName)?.toClassName()
.thisOrNullIf { it != Any::class.asClassName() }
}
private fun isStartDestination(annotation: KSAnnotation): Boolean {
return annotation.findArgument<Boolean>(IsStartDestinationArgName)
}
private inline fun <reified T : Any?> KSAnnotation.findArgument(name: String): T {
val value = arguments.firstOrNull { it.name?.asString() == name }?.value.let { it as T }
return if (null is T) value else requireNotNull(value)
}
private inline fun <reified T> T.thisOrNullIf(condition: (T) -> Boolean): T? {
return if (condition(this)) this else null
}
}
该访问者为我的每个目的地生成一个 NavDestinationData 模型, 然后我在生成过程中使用该模型. 你可以查看仓库, 更深入地了解这一切是如何工作的.
让我们快速回顾一下本文的内容:
- KSP - 是一种帮助在项目中生成代码的新机制.
- KSP - 只能创建新代码, 不能修改现有代码.
- 要在项目中添加第一个处理器, 需要创建一个新的
jvm-module. - 然后, 创建一个实现
SymbolProcessor的类, 并在resources/META-INF/services目录中添加一个文件, 其中包含SymbolProcessorProvider的完整路径. - 就是这样!有了 KSP 工具包, 你几乎可以自动化任何你想要的东西:-)
好吧, 今天的内容就分享到这里啦!
一家之言, 欢迎拍砖!
Happy Coding, Stay GOLDEN!
