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

前言

在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,这样写才好看

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

下面这段我的两个数据类

kotlin 复制代码
data class AData(
    val name: String,
    val title: String,
    val bData: BData,
)

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

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

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

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

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

扩展deepCopy函数

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

kotlin 复制代码
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来传值。

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

kotlin 复制代码
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,相当于这个类起到一个中转作用。

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

kotlin 复制代码
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模块,有下面两个注解。

kotlin 复制代码
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class EnhancedData

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

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

注解处理类模块编写

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

依赖引入

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

kts 复制代码
implementation(project(":core"))
implementation("com.google.devtools.ksp:symbol-processing-api:version")

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

编写KSP关联类

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

kotlin 复制代码
class EnhanceDataSymbolProcessorProvider : SymbolProcessorProvider {
    override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor =
        EnhanceDataSymbolProcessor(environment)
}

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

编写SymbolProcessor

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

kotlin 复制代码
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一个新对象。

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

kotlin 复制代码
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,就像是这样:

kotlin 复制代码
@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的深拷贝方法就可以了,就像是下面这样。

kotlin 复制代码
//原来的写法
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

kotlin 复制代码
fun BData.deepCopy(
    doc : String = this.doc,
    content :String = this.content,
): BData {
    return BData(doc, content)
}

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

实践问题解决

调整SymbolProcessor类

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

kotlin 复制代码
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方法,就像是下面这样,因为我们要处理的注解是注解在类上的。

kotlin 复制代码
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()关闭文件就完成啦。

实现代码生成核心业务

kotlin 复制代码
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

kotlin 复制代码
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>,
)

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

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

kotlin 复制代码
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,这样的东西,我们仔细看看吧?

kotlin 复制代码
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负责生成深拷贝的核心功能,它会负责创建新的内部对象,并且将原来的值赋回去,如果有新的,那就用新传入的值替换,这个函数前面也解析过了。

kotlin 复制代码
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)
}

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

kotlin 复制代码
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下面这段代码就是完成了上面的行为。

kotlin 复制代码
    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函数实现

kotlin 复制代码
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的扩展函数属性,这样我们就可以直接操作里边的值了。

kotlin 复制代码
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,它接受两个参数。

kotlin 复制代码
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)

相关推荐
FunnySaltyFish14 小时前
什么?Compose 把 GapBuffer 换成了 LinkBuffer?
算法·kotlin·android jetpack
Kapaseker20 小时前
Compose 进阶—巧用 GraphicsLayer
android·kotlin
Kapaseker2 天前
实战 Compose 中的 IntrinsicSize
android·kotlin
A0微声z4 天前
Kotlin Multiplatform (KMP) 中使用 Protobuf
kotlin
alexhilton4 天前
使用FunctionGemma进行设备端函数调用
android·kotlin·android jetpack
lhDream5 天前
Kotlin 开发者必看!JetBrains 开源 LLM 框架 Koog 快速上手指南(含示例)
kotlin
RdoZam5 天前
Android-封装基类Activity\Fragment,从0到1记录
android·kotlin
Kapaseker5 天前
研究表明,开发者对Kotlin集合的了解不到 20%
android·kotlin
糖猫猫cc5 天前
Kite:两种方式实现动态表名
java·kotlin·orm·kite
如此风景6 天前
kotlin协程学习小计
android·kotlin