前言
在Compose的开发中以及在RecelyView
使用ListAdapter
时会发现将Data类Copy后有点小问题,我修改新Copy的Data类的内部对象时,旧的内部对象的值也改变了!!!
这可让我犯了难,因为这样可能导致一些监听无法起到作用,因为Copy前后的值都一样。
起因
不知道大家在对Data类Copy时有没有遇到这个的问题,比如Copy一个Data对象后,无论是新的Data对象还是旧的Data对象,当我们修改里边的引用类型对象时,新旧两者内部对象都会发生改变。
这是因为Kotlin默认的Data类Copy时是浅拷贝,只是把内部对象复制了过来,它们内部引用的对象没有发生改变,导致你无论改哪一个另一个都会被改动。
这非常难受不是吗?因此,我就想能不能搞一个深拷贝的扩展函数,事实上这完全可以,但是每个数据类都去做一个深拷贝函数这是不是太麻烦了?
解决方案
我突然想到,之前看过一个视频,里边讲的是KSP的一个应用,当时正好也是Data类深拷贝问题。
这个方案简单来讲就是利用代码去生成代码!怎么样?听起来很不错吧?
让我找找,啊哈找到了!
因为我没有写过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,那么可以边看边查阅,因为有一些类我可能解释的不太好:
涉及了这个库的源代码解释:
建议看着源代码阅读本文,如果有用的话欢迎对项目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.google.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
来拿被注解的类是有局限性的,它只能拿到被修改的类。
这样就有一个问题,我们需要被深拷贝的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
的信息,因为它没变化。
那…..要不然我们单独对AData
和BData
生成深拷贝方法,这样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有什么,而是直接去调用BData的deepCopy()
,因为我们也会给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