• 首页 首页 icon
  • 工具库 工具库 icon
    • IP查询 IP查询 icon
  • 内容库 内容库 icon
    • 快讯库 快讯库 icon
    • 精品库 精品库 icon
    • 问答库 问答库 icon
  • 更多 更多 icon
    • 服务条款 服务条款 icon

KSP实现Kotlin的Data类深拷贝库 | Compose番外

武飞扬头像
萌新杰少
帮助1

前言

在Compose的开发中以及在RecelyView使用ListAdapter时会发现将Data类Copy后有点小问题,我修改新Copy的Data类的内部对象时,旧的内部对象的值也改变了!!! 这可让我犯了难,因为这样可能导致一些监听无法起到作用,因为Copy前后的值都一样。

起因

不知道大家在对Data类Copy时有没有遇到这个的问题,比如Copy一个Data对象后,无论是新的Data对象还是旧的Data对象,当我们修改里边的引用类型对象时,新旧两者内部对象都会发生改变。

这是因为Kotlin默认的Data类Copy时是浅拷贝,只是把内部对象复制了过来,它们内部引用的对象没有发生改变,导致你无论改哪一个另一个都会被改动。

这非常难受不是吗?因此,我就想能不能搞一个深拷贝的扩展函数,事实上这完全可以,但是每个数据类都去做一个深拷贝函数这是不是太麻烦了?

解决方案

我突然想到,之前看过一个视频,里边讲的是KSP的一个应用,当时正好也是Data类深拷贝问题。

这个方案简单来讲就是利用代码去生成代码!怎么样?听起来很不错吧?

让我找找,啊哈找到了!

# Kotlin 元编程:从注解处理器(KAPT)到符号处理器(KSP

因为我没有写过KSP,霍佬讲的比较深入,当时没有听懂视频中太多的东西,比较遗憾,但是通过视频我知道了什么是KSP,KSP可以做什么,我用的一些库为什么能做到这样的功能,这也是一大收获。

想到这里,我就准备自己做一个KSP库尝试一下。

开发构想

我要什么?

这事实上是一个比较重要的问题,我得明白我自己需要什么?

首先,我想要对Data类实现深拷贝的能力,并且这个深拷贝的写法还得支持DSL,这样写才好看

现在我们来看看我想要的代码结构长什么样子:

下面这段我的两个数据类

data class AData(
    val name: String,
    val title: String,
    val bData: BData,
)

data class BData(
    val doc: String,
    val content: String,
)

那么我要如何去深拷贝AData?

val aData = AData("name", "title", BData("doc", "content"))
val newAData = aData.deepCopy {
    name = ""
    bData = BData("newDoc", "newContent")
}

怎么样,我们在lambda里直接传递参数,这个写法还不错吧!

现在我们看到的是需要的结果,但是我们现在需要逆向推导出它背后的扩展函数。

扩展deepCopy函数

我们先看看,为了深拷贝,我们需要把Data的内部对象也给他new出来。

fun AData.deepCopy(
    name : String = this.name,
    title : String = this.title,
    bData : BData = this.bData,
): AData {
    return AData(name, title, BData(doc = bData.doc, content = bData.content))
}

没错,假如这个字段不是标准类型,那么我们就需要new出它,然后把原本的值复制进去,假如内部还不是标准类型,那就继续实例化对象并且把值传进去,最后,我们重新new一个AData,把值加进去,这样看假如有传入值就会覆盖原来的对象,无论如何新产生的对象都不会影响旧的对象。

让deepCopy函数支持DSL

如果只是像上面一样,那么写出来就和copy差别不大了,但是我当时想的是要有个lambda来传值。

因此我就想到下面的格式:

fun AData.deepCopy(
    copyFunction:AData.()->Unit
): AData{
    val copyData = AData(name, title, bData)
    copyData.copyFunction()
    return this.deepCopy(copyData.name, copyData.title, copyData.bData)
}

copyFunction这个高阶函数就像是AData的扩展函数一样,在内部可以调用AData的变量,但是这样有个新的问题产生了。

AData类的每个属性不可能都是var,compose时大部分都是val,这样我们就不能像刚刚这样通过AData.()->Unit来拷贝值。

有了!我们可以弄一个中间的Data类,让它有和AData一样的字段,但是可以为var,相当于这个类起到一个中转作用。

最后我们看看代码变成了什么样:

data class _ADataCopyFun(
    var name : String,
    var title : String,
    var bData : BData,
)

fun AData.deepCopy(
    copyFunction:_ADataCopyFun.()->Unit
): AData{
    val copyData = _ADataCopyFun(name, title, bData)
    //拷贝copyFunction()内的属性值到copyData中
    copyData.copyFunction()
    //调用前面写的函数完成深拷贝
    return this.deepCopy(copyData.name, copyData.title, copyData.bData)
}

其中_ADataCopyFun就是那个中间类,它的命名有一些特殊,以_开头,大家平时调用就不会调出来,这样也避免和其他类冲突,毕竟我们的Data类可是很多的。

业务开发实践

哈哈,刚刚这么多只是我们设想的样子,接下来才是硬骨头,那就是编写生成这些扩展方法的代码。 如果你没有写过KSP,那么可以边看边查阅,因为有一些类我可能解释的不太好:

KSP 快速入门 · Kotlin 官方文档 中文版 (kotlincn.net)

涉及了这个库的源代码解释:

1250422131/DeepReCopy: DeepReCopy是针对Kotlin的Data类所开发的深度拷贝功能库,利用KSP可以生成Data类的深度拷贝扩展方法,支持DSL写法。 (github.com)

建议看着源代码阅读本文,如果有用的话欢迎对项目Star。

注解类模块编写

欸?怎么注解就来了,哈哈,我们可不能把自己所有的数据类都加一个扩展,要有目的的去加,因此,我们需要有注解来限定需要被深拷贝的类。

起什么名字好呢?我想要这个库服务于Data类,那么就把他叫做EnhancedData吧,增强Data,虽然现在它只能进行深拷贝,但是也许以后我还想维护新的功能呢?

还有一个问题,那就是Data类里的引用类型,它们不一定都要被深拷贝,那么就再起一个注解吧?就叫DeepCopy,意味着这个类需要被深拷贝。

这里我给模块起名叫core模块,有下面两个注解。

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class EnhancedData

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class DeepCopy

目前来说它们只能对类起作用,事实上这有一些限制,我后面会提到。

注解处理类模块编写

就如同标题,我们需要对注解处理模块进行开发,这里才是KSP的用武之地,下面我创建了一个以compiler为名的模块,用来存放KSP的关键代码。

依赖引入

想要用KSP做注解处理器就必须要引入KSP的API,我们通过这个东西来操作。

implementation(project(":core"))
implementation("com.谷歌.devtools.ksp:symbol-processing-api:version")

不过别忘记引入我们的core模块,因为注解类在里边,离开了它我们可就不好判断是不是我们的注解类了。

编写KSP关联类

我们想要处理注解,就需要暴露一个对象出去,让Gradle知道,这个类是处理注解类的入口类。 那么SymbolProcessorProvider事实上就承担了这个作用,我们先建立一个SymbolProcessorProvider吧!

class EnhanceDataSymbolProcessorProvider : SymbolProcessorProvider {
    override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor =
        EnhanceDataSymbolProcessor(environment)
}

哈哈,名字很朴素吧,为了处理EnhanceData注解所以起了这个名字。 欸嘿,注意到了吗?我们把environment传递给了SymbolProcessorEnvironment。 而SymbolProcessorEnvironment提供了各种接口,可以获取元信息和注解信息。 而EnhanceDataSymbolProcessor类则负责过滤有用的类集合,让我们更专注的去操作我们关注的类。

编写SymbolProcessor

还记得吗?前面我们有了关联类,让Gradle可以找到我们的注解处理器,现在我们要实现注解处理的业务代码了。

class EnhanceDataSymbolProcessor(private val environment: SymbolProcessorEnvironment) :
   SymbolProcessor {

   override fun process(resolver: Resolver): List<KSAnnotated> {
       // 获取由EnhancedData注解类
       val enhancedDataSymbols =
           resolver.getSymbolsWithAnnotation(
               EnhancedData::class.qualifiedName
                   ?: "",
           )
       // 获取由DeepCopy注解类
       val deepCopySymbols = ..........

       // 干掉无法处理的类
       val ret = mutableListOf<KSAnnotated>()
       ret.addAll(enhancedDataSymbols.filter { !it.validate() })
       ret.addAll(deepCopySymbols.filter { !it.validate() })
       
       enhancedDataSymbols
           .filter { it is KSClassDeclaration && it.validate() } 
           .forEach {
               //处理注解
               it.accept(EnhanceVisitor(environment, deepCopySymbols), Unit)
           }

       return ret
   }

}

我们首先获取了EnhancedData,以及DeepCopy所注解的类集合,并且传递给EnhanceVisitor来处理注解了。

实践遇到的问题

理想很充实,但是现实很骨感。

我们忽略了一个重要的事实,那就是KSP为了提高处理速度,提出了增量更新,简单来讲,就是我们在上面这个类的process方法中调用getSymbolsWithAnnotation来拿被注解的类是有局限性的,它只能拿到被修改的类。

增量处理 · Kotlin 官方文档 中文版 (kotlincn.net)

这样就有一个问题,我们需要被深拷贝的Data类被EnhancedData注解,而Data类内部的引用对象类型需要被DeepCopy来注解,这样当我们更新Data类,而没有改变被DeepCopy注解的类时,就会发现deepCopySymbols里边没有东西,因为KSP发现,被DeepCopy注解的类没有更新。

deepCopySymbols假设为空,我们就不能通过deepCopySymbols来获取到被DeepCopy所注解类的对象信息,更不要提通过被注解类的构造函数来new一个新对象。

那么这会影响到哪一步的代码生成呢?

fun AData.deepCopy(
    name : String = this.name,
    title : String = this.title,
    bData : BData = this.bData,
): AData {
    return AData(name, title, BData(doc = bData.doc, content = bData.content))
}

还记得我们的深拷贝函数吗?看看这个,BData是被@DeepCopy注解的,第一次代码生成时我们可以拿到DeepCopy注解的信息,因为一切都是从零开始生成。但是,假设你给AData新增或者删除一个字段,再次build,也就是让ksp任务执行,你会发现上面函数中的deepCopySymbols为空,可是明明BData是被@DeepCopy注解了呀,事实上这就是增量更新的问题,AData变了所以enhancedDataSymbols可以拿到东西,但是BData虽然被注解了,但是没变,所以不会传进来。

这不是KSP的问题,这样反而对性能有帮助,你不想每次都生成一遍全部文件吧?显然我们希望有更改后再去更新。

当然,假如你以后想要关闭这个增量更新,那就设置属性ksp.incremental=false,这个在官方文档里有提出。

但是我并不想这样做,我们得换个思路了。

实践问题分析

分析原因

刚刚是因为两种情况采用了不同注解,导致我们需要对两个注解都处理,这样就有一些割裂了,因为我们不可能同时更新需要深拷贝的Data类,以及这个类里引用对象的类。

解决思路

不行的话~让我们试试看换成一个注解?事实上本质还不是注解的问题,而是增量更新让我们没办法拿到更多未改变类的信息。

而如果我们拿不到被深拷贝类AData中的引用对象类BData的信息,就没办法知道BData构造函数中的信息,更不可能生成BData(doc = bData.doc, content = bData.content) 这样的代码出来,因为我们压根不知道BData里有什么,前面的做法是使用两个注解,但这只是让我们可以拿到它的信息罢了。

那就都使用@EnhancedData,就像是这样:

@EnhancedData
data class AData(val name: String, val title: String, val bData: BData)

@EnhancedData
data class BData(val doc: String, val content: String)

但是这问题还没有解决,如果你更新AData,比如新增一个字段,但是BData又没有改变,这样就导致了如果用上面的方式enhancedDataSymbols仍然拿不到BData的信息,因为它没变化。

那.....要不然我们单独对ADataBData生成深拷贝方法,这样AData深拷贝处理时就只需要调用BData的深拷贝方法就可以了,就像是下面这样。

//原来的写法
fun AData.deepCopy(
    name : String = this.name,
    title : String = this.title,
    bData : BData = this.bData,
): AData {
    return AData(name, title, BData(doc = bData.doc, content = bData.content))
}
//新的写法
fun AData.deepCopy(
    name : kotlin.String = this.name,
    title : kotlin.String = this.title,
    bData : BData = this.bData,
): AData {
    return AData(name, title, bData.deepCopy())
}

我们不再关注BData有什么,而是直接去调用BDatadeepCopy(),因为我们也会给BData生成deepCopy(),当然这就要求BData注解了@EnhancedData。 我们看看BData

fun BData.deepCopy(
    doc : String = this.doc,
    content :String = this.content,
): BData {
    return BData(doc, content)
}

因为当处理到BData,我们完全可以知道BData有什么,这样只需要给BData也生成deepCopy()方法就好。

实践问题解决

调整SymbolProcessor类

我们对上面看见的SymbolProcessor类进行调整,下面我们就只获取enhancedDataSymbols了。

class EnhanceDataSymbolProcessor(private val environment: SymbolProcessorEnvironment) :
    SymbolProcessor {

    override fun process(resolver: Resolver): List<KSAnnotated> {
        // 获取由EnhancedData注解类
        val enhancedDataSymbols =
            resolver.getSymbolsWithAnnotation(
                EnhancedData::class.qualifiedName
                    ?: "",
            )

        // 干掉无法处理的类
        val ret = mutableListOf<KSAnnotated>()
        ret.addAll(enhancedDataSymbols.filter { !it.validate() })

        generateDeepCopyClass(enhancedDataSymbols)

        return ret
    }

    private fun generateDeepCopyClass(
        symbols: Sequence<KSAnnotated>,
    ) {
        symbols
            .filter { it is KSClassDeclaration && it.validate() } 
            .forEach {
                it.accept(EnhanceVisitor(environment), Unit)
            }
    }
}

我们发现最终我们过滤掉不能被解析的类,但是却又调用了it.accept(EnhanceVisitor(environment), Unit) 事实上这是把注解处理交给了下一层,也就是EnhanceVisitor,当然我们完全可以在这里处理注解了,但是这样不够,我们需要细化处理,比如我只关系在类上的注解信息,那么就需要用实现一个KSVisitorVoid,注意我给EnhanceVisitor传递了environment,我们需要用这个对象来生成创建的kt文件。

实现KSVisitorVoid类

让我们覆写visitClassDeclaration方法,就像是下面这样,因为我们要处理的注解是注解在类上的。

override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) {
    //........
    // 检查是否能找到主构造函数
    val primaryConstructor = classDeclaration.primaryConstructor
        ?: throw Exception("error no find primaryConstructor")

    // 获取类名,当前包名
    val params = primaryConstructor.parameters
    val className = classDeclaration.simpleName.asString()
    val packageName = classDeclaration.packageName.asString()

    // 创建KSP生成的文件
    val file = environment.codeGenerator.createNewFile(
        Dependencies(false, classDeclaration.containingFile!!),
        packageName,
        "${className}Enhance",
    )
    // 生成扩展函数的代码
    val extensionFunctionCode = generateCode(packageName, className, params)
    // 写入生成的代码
    file.write(extensionFunctionCode.toByteArray())
    // 释放内存
    file.close()

}

我们要生成前面提到的扩展函数,就需要知道这些信息,比如构造函数对象,当前包名,以及这个被注解类的类名。 environment.codeGenerator.createNewFile则就是用来创建文件的,这就是为什么要传environment进来的原因,同时我们要注意到,这个文件的名字叫${className}Enhance,例子就是ADataEnhance

然后我们看看,我们调用了一个generateCode方法,这个方法返回的就是这个文件最终的内容,我们需要生成的就是它,最后我们通过file.close()关闭文件就完成啦。

实现代码生成核心业务

private fun generateCode(
    packageName: String,
    className: String,
    params: List<KSValueParameter>,
): String {
    // 生成临时类
    val complexClassName = "_${className}CopyFun"
    val extensionFunctionCode = buildString {
        // 添加包声明
        appendLine("package $packageName\n\n")

        // 新增为DSL写法支持的Data类
        appendCopyFunDataClassCode(complexClassName, params)

        // 新增深拷贝扩展函数代码
        appendDeepCopyFunCode(className, params)

        // 新增DSL写法的深拷贝扩展函数代码
        appendDSLDeepCodyFunCode(className, complexClassName, params)
    }
    return extensionFunctionCode
}

我们看看,我们先构建了一个临时的类名,它就是为了给DSL写法做准备的,由于我不希望其他人使用这个类,就加了下划线。

接下来,我们先看到的是声明包,这是必须的,我们把获取到的包名放进来,就像是这样: package com.imcys.deeprecopy.demo 再看看appendCopyFunDataClassCode,它就是生成DSL临时拷贝类代码的方法。

appendDeepCopyFunCode则用来生成深拷贝的扩展函数,appendDSLDeepCodyFunCode则负责生成DSL语法的深拷贝扩展函数。

下面我们一点一点看!

实现appendCopyFunDataClassCode

data class _ADataCopyFun(
    var name : kotlin.String,
    var title : kotlin.String,
    var bData : com.imcys.deeprecopy.demo.BData,
    var mList : kotlin.collections.MutableList<com.imcys.deeprecopy.demo.BData>,
)

上面这代码我们已经见过面了,就是在文章开头,只不过这里我们写全了属性类型的包名,这是因为生成导包的代码要花精力,不如直接这样生成方便。

下面我们看看这段代码是如何被生成出来的:

private fun StringBuilder.appendCopyFunDataClassCode(
    complexClassName: String,
    params: List<KSValueParameter>,
) {
    appendLine("data class $complexClassName(")
    appendParams(params)
    appendLine(")\n\n")
}

我们拿到了构造函数中的属性信息,通过appendLine构造了这个函数的开头和结尾,但是缺少了中间属性的构建,事实上它由appendParams方法完成。

appendParams构建

appendParams就是去构建 var name : kotlin.String,这样的东西,我们仔细看看吧?

private fun StringBuilder.appendParams(params: List<KSValueParameter>) {
    params.forEach {
        val paramName = it.name?.getShortName() ?: "Erro"
        val typeName = generateParamsType(it.type)
        appendLine("    var $paramName : $typeName,")
    }
}

首先我们遍历属性集合params,拿到每个属性的名字和类型,通过appendLine就可以拼凑出我们上面看见的效果啦。 但是我们发现typeName可不是这么好获取的,我们又写了一个函数来完成它。

generateParamsType函数代码多就不粘了,可以直接在源码里看,它就是实现了对属性类型的获取,另外如果是可空类型或者泛型,也会被写进去,确保生成的类型符合原来Data类里对象的类型。 学新通

这样我们就把的一块代码写好了。

appendDeepCopyFunCode函数实现

appendDeepCopyFunCode负责生成深拷贝的核心功能,它会负责创建新的内部对象,并且将原来的值赋回去,如果有新的,那就用新传入的值替换,这个函数前面也解析过了。

fun AData.deepCopy(
    name : kotlin.String = this.name,
    title : kotlin.String = this.title,
    bData : com.imcys.deeprecopy.demo.BData = this.bData,
    mList : kotlin.collections.MutableList< com.imcys.deeprecopy.demo.BData> = this.mList,
): AData {
    return AData(name, title, bData.deepCopy(), mList)
}

我们看看,是通过什么样的方式来生成它的:

private fun StringBuilder.appendDeepCopyFunCode(
    className: String,
    params: List<KSValueParameter>,
) {
    appendLine("fun $className.deepCopy(")
    appendParamsWithDefaultValues(params)
    appendLine("): $className {")
    appendLine("    return $className(${getReturn(params)})")
    appendLine("}\n\n")
}

我们首先生成函数头,这个头的名字就是深拷贝类的类名.deepCopy,这是事实上是扩展函数对吧? 其中appendParamsWithDefaultValues方法生成的就是name : kotlin.String = this.name,这部分代码。 而getReturn方法是生成return AData(name, title, bData.deepCopy(), mList)这部分代码。

下面我们一点一点看:

appendParamsWithDefaultValues函数实现

我们仔细看看要生成的部分name : kotlin.String = this.name,,大家发现了吗?前面这部分(name : kotlin.String)和刚刚上面生成临时DSL函数的Data类时用的东西一模一样,那自然就通过generateParamsType函数获取到了,后面的话更好办,就是this.属性名称

OK下面这段代码就是完成了上面的行为。

    private fun StringBuilder.appendParamsWithDefaultValues(params: List<KSValueParameter>) {
        params.forEach {
            val paramName = it.name?.getShortName() ?: "Erro"
            val typeName = generateParamsType(it.type)
            appendLine(
                "    $paramName : $typeName = this.$paramName,",
            )
        }
    }
getReturn函数实现

这个函数也长就不写了 学新通 return AData(name, title, bData.deepCopy(), mList) 我们无非就要这一段,事实上前面已经写好了,我们只需要关心传入的参数。

首先我们将params遍历,并且在每一次遍历结果字符串后加入",",同样的我们需要得到属性名字和它的类型,我们通过类型先获取一下这个类型有没有被注解上EnhancedData,因为只有注解了它才会有deepCopy方法,假如不注解就代表不需要对这个属性进行深拷贝。

接下来我们看看这个属性是不是Kotlin的标准属性或者说是不是没有注解EnhancedData,假如是,那就直接拼接这个属性的名字,就像是上面的name,但假如不满足条件,那么就会拼接属性名.deepCopy,例如bData.deepCopy()

最后我们再来看看DSL是如何完成的。

appendDSLDeepCodyFunCode函数实现

private fun StringBuilder.appendDSLDeepCodyFunCode(
    className: String,
    complexClassName: String,
    params: List<KSValueParameter>,
) {
    appendLine("fun $className.deepCopy(")
    appendLine("    copyFunction:$complexClassName.()->Unit): $className{")
    appendLine("    val copyData = $complexClassName(${getReturn(params, "")})")
    appendLine("    copyData.copyFunction()")
    appendLine("    return this.deepCopy(${getReturn(params, "copyData.")})")
    appendLine("}")
}

这个函数实际上就比较简单了,我们先定义一个函数参数copyFunction,而它的类型就是我们生成的临时Data的扩展函数属性,这样我们就可以直接操作里边的值了。

fun AData.deepCopy(
    copyFunction:_ADataCopyFun.()->Unit
): AData{
    val copyData = _ADataCopyFun(name, title, bData)
    copyData.copyFunction()
    return this.deepCopy(copyData.name, copyData.title, copyData.bData)
}

这个就是生成的最终结果,我们发现这里用了一个不一样的getReturn,它接受两个参数。

private fun getReturn(params: List<KSValueParameter>, prefix: String = ""): String {
    return params.joinToString(", ") { param ->
        val paramName = param.name?.getShortName() ?: "Error"
        "$prefix$paramName"
    }
}

我想大家可能都能猜到这个写法了,事实上它就是自定义字符串 属性的名字。

至此这个库就写完了

暴露KSP关联类

但任务还没有完成,我们需要在这暴露出SymbolProcessorProvider,否则KSP无法正常工作。

学新通

文末

我也是第一次去做KSP的东西,可能有些内容有错误理解,欢迎大家指出。

欢迎对项目Star

1250422131/DeepReCopy: DeepReCopy是针对Kotlin的Data类所开发的深度拷贝功能库,利用KSP可以生成Data类的深度拷贝扩展方法,支持DSL写法。 (github.com)

这篇好文章是转载于:学新通技术网

  • 版权申明: 本站部分内容来自互联网,仅供学习及演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,请提供相关证据及您的身份证明,我们将在收到邮件后48小时内删除。
  • 本站站名: 学新通技术网
  • 本文地址: /boutique/detail/tanhgeciee
系列文章
更多 icon
同类精品
更多 icon
继续加载