Kotlin KSP 原理及实战解析

picture.image

Kotlin KSP - 如何自动化处理了一切

不久前, 谷歌推出了一个超酷的更新, 用于自动生成模板. 当然, 我说的是 KSP(Kotlin Symbol Processing). 在使用 Java 注解处理, KAPT 以及现在的 KSP 的这些年里, 我看到过大量关于创建第一个生成器, 注解等的文章. 他们通常会建议: "让我们为模型生成 getter 和 setter, 作为练习". 这对于尝试这项技术绰绰有余, 但我一直想要更多. 一个能对项目有实际帮助的真实用例. 在我的一个项目中, 我遇到了一个完美的问题, 这就是我今天要写的内容. 我们将讨论在 Compose Navigation 库中自动进行导航描述的问题.

已经在 Compose 中编写过程序的人肯定知道, 导航是非常枯燥乏味的常规工作. 想想添加参数🥲, 唉!简而言之, 解决方案显而易见--我们需要生成这个.

我想指出的是, 已经有很多很好的解决方案来解决这个特定的问题, 而且, 在写这篇文章的时候, 谷歌终于发布了 Compose Navigation 库的一个很酷的更新, 并添加了SafeArgs功能, 类似于我们熟悉的 Fragment 的工作方式. 我对注解处理的理解很感兴趣, 而且现有的解决方案不太适合我, 因为它们只能生成目的地和参数, 但我想自动化整个过程, 包括构建和向 NavHost 交付.

首先, 让我们来分析一下 KSP 的实际功能:

  1. 分析现有代码(这里唯一的限制是 KSP 无法构建 AST, 因此无法分析方法代码).
  2. 生成新的类, 接口等. KSP 有一个相当灵活的机制, 允许你生成依赖于其他尚未生成文件的文件.

此外, KSP 还不能做什么:

  1. 修改现有代码(最多从头开始重新生成代码).
  2. 如上所述, 分析方法代码. KSP 是作为 KAPT 的轻量级替代品而设计的, 并非用于深度代码分析.
  3. 不保证处理器调用的顺序. 如果需要在一个符号之后处理另一个符号, 则必须通过返回那些在当前轮中"不可达"的符号来明确指定, 这些符号是 process 方法的结果.

因此, KSP 允许你查找特定的符号, 如注解, 类或函数, 访问参数列表, 等等.

在我们深入实践之前, 再多讲一点理论. 要创建自己的处理器, 你需要遵循以下步骤:

  1. 在项目中创建一个模块, 例如 navigation:generator(必须是 jvm-module, 而不是 android-module).
  2. 你还需要创建一个单独的模块navigation:library, 用于放置主合约和辅助方法(该模块应为android-module, 并部分依赖于 Compose).
  3. 在相应的软件包中创建自己的 SymbolProcessSymbolProcessorProvider 实现.
  4. 最后但并非最不重要的一点是, 在 resources/META-INF/services 文件夹中创建一个文件, 并在其中标明 SymbolProcessorProvider 的完整路径.

因此, 你应该有以下结构:

picture.image

起初, 这曾有一个注解…

当然, 如果没有注解, 用 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)
    }

我们已经在上文讨论了参数问题, 因此在此我想对几个要点发表一下意见, 即 parcelableCreatorgetNavArguments. 遗憾的是, 我还没有找到一种无需反射就能获得 Creator 的方法, 因此需要注意的是, 这并不是编译时的方法. 此外, 整个 Parcelable 方法需要手动实现相应的合约或使用 kotlinx-parcelize 生成. 在我的使用案例中, 依赖这类插件是没有问题的, 但在你的场景中, 你可能需要做一些调整.

下一步是SymbolProcessor

在我们深入研究代码之前, 我想先概述一下SymbolProcessor中的分步过程:

  1. 我们需要找到所有标注了@Destination注解的符号.
  2. 对于每个注解符号, 我们将收集必要的信息, 如名称, 包以及是否有参数.
  3. 根据收集到的数据, 我们将生成代表导航目的地和路线的 NavDestinationNavRoute 实例.
  4. 根据生成的 NavDestination 实例所属的图谱对其进行分组. 这将允许我们创建一个分层导航结构.
  5. 创建一个 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()
}

要生成目的地和路线, 我需要获取以下信息:

  1. 包名和函数名称.
  2. 参数类型或空值.
  3. baseRoute, 我们稍后将在导航中使用它.
  4. 该目的地是否是其图中的起始目的地.
  5. 应添加目的地的图形(或为空).
  6. 一个可选的 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!

0
0
0
0
评论
未登录
暂无评论