添加链接
link之家
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接
·  阅读 KMM 求生日记四:使用 kotlinx.serialization 对 SQLite 数据库反序列化

上个月发布的文章: 《干货|携程机票 App KMM 跨端生产实践》 中比较详细的阐述了我们团队在开始尝试使用 KMM 半年的过程中做了哪些工作。其中有一个原则比较重要:能复用现有 Native 代码的就尽量复用,以最小的改动量先完成 KMM 工程基础架构的搭建。

公司项目与许多 App 一样,使用 SQLite 作为本地复杂的数据库存储方案,目前基础架构团队提供的 API 对于我们业务团队来说使用方便:输入 SQL 语句与期待反序列化对象的 Class,得到要查询的对象。

当前 Java/Objective-C 两套 SQLite 查询 API 设计的高度相似,与之前提到过的网络 API 类似,在封装方面问题不大。但是在反序列化机制上出现了问题,原有的查询方法需要将希望反序列化的类的 Class(Java、Objective-C 都有 Class)作为参数传入,然后利用 Java 反射/Objective-C Runtime 机制生成对象,并依托类型信息匹配类字段与数据库表的列名为反序列化生成的对象字段赋值后返回。在 KMM 中,如果在 Android 平台,Kotlin 可以利用反射的方式实现类似的反序列化,但 Kotlin 反射暂不支持 Kotlin/Native, 且在 KMM 工程中无法获取 Kotlin 类的 Objective-C Class 对象(虽然在 Xcode 工程中可见 Kotlin 类均继承自 NSObject),因此在数据库 API 的 KMM 化改进过程中反序列化就是需要我们自己定制的。

kotlinx.serialization

无论是 kotlinx.serialization-json 还是 kotlinx.serialization-protobuf 我们都需要将需要序列化/反序列化的 class 打上 @Serializable 注解,该注解通过 KCP 处理最终生成包含类字段信息的工具对象 serializer(类型为 KSerializer )。KSerializer 对象与具体数据类型如 JSON、Protobuf 无关,只保存类型信息以及进行序列化与反序列化。要完整的支持反序列化,我们只需定义每种数据类型对应的 Decoder,调用它的 decodeSerializableValue 函数并将待反序列化对象的 KSerializer 传入即可(序列化同理)。回到最初的问题,我们要将 SQLite 内存储的表数据反序列化为 KMM 对象,那么自定义 Decoder 即可。

我们来看看现有的 Java/Objective-C API 的设计:

为了完成 KMM 的序列化机制,我们要把原本的 SQLite API 封装修改一下,新增一个完成 SQL 查询后直接返回游标的 API,游标在 Android 上的体现是 android.database.Cursor 类,在 iOS 上是 sqlite3_stmt 结构体。对这两者进行 KMM 化封装后实现 common 层的 KMMCursor 抽象,然后基于 KMMCursor 编写 common 层的 CursorDecoder 即可大功告成,新的设计思路图是:

对现有 SQLite API 封装的改动这里跳过,总之 Android/iOS 两边各定义一个新的方法,Android 这边返回 Cursor ,iOS 这边返回 sqlite3_stmt

接下来定义游标在 common 层的抽象:

interface KMMCursor {
    fun getInt(columnIndex: Int): Int    
    fun getLong(columnIndex: Int): Long    
    fun getFloat(columnIndex: Int): Float    
    fun getDouble(columnIndex: Int): Double    
    fun getString(columnIndex: Int): String
    fun getColumnIndex(columnName: String): Int
    fun forEachRow(block: () -> Unit)
    fun close()

由于 KMMCursor 的创建是平台相关的,所以我们无需使用 expect-actual 机制来完成 KMM 化,只需在 common 层定义接口,在 Android 和 iOS 各自的 source set 实现该接口即可。

KMMCursor 内除了定义 getXXX 函数用于 get 数据外,还提供了三个函数,getColumnIndex 用于使用列(column)名去查询列的下标,forEachRow 用于遍历行,cl 用于释放资源。

然后是 Android 端 KMMCursor 的实现:

class CursorImpl(private val cursor: Cursor) : KMMCursor {
    override fun getInt(columnIndex: Int): Int = cursor.getInt(columnIndex)    
    override fun getLong(columnIndex: Int): Long = cursor.getLong(columnIndex)    
    override fun getFloat(columnIndex: Int): Float = cursor.getFloat(columnIndex)    
    override fun getDouble(columnIndex: Int): Double = cursor.getDouble(columnIndex)       
    override fun getString(columnIndex: Int): String = cursor.getString(columnIndex) ?: ""
    override fun getColumnIndex(columnName: String): Int = cursor.getColumnIndex(columnName)
    override fun forEachRow(block: () -> Unit) {        
        cursor.moveToFirst()        
        while (cursor.position < cursor.count) {            
            block()            
            cursor.moveToNext()        
    override fun close() = cursor.close()

构造函数中须传入 android.database.Cursor 对象 ,getXXX 函数全部委托给 Cursor 的相应方法就好。forEachRow 函数同样依赖 Cursor 的 API,先将其下标置于初始位置,然后在每次迭代的末尾调用一下 moveToNext 方法。close 函数的实现内调用 Cursor 的 close 方法释放资源。

然后看看 iOS 的实现:

class CursorImpl(private var stmt: CPointer<sqlite3_stmt>?) : KMMCursor {
    override fun getInt(columnIndex: Int): Int = sqlite3_column_int(stmt, columnIndex)    
    override fun getLong(columnIndex: Int): Long = sqlite3_column_int64(stmt, columnIndex)    
    override fun getFloat(columnIndex: Int): Float = sqlite3_column_double(stmt, columnIndex).toFloat()    
    override fun getDouble(columnIndex: Int): Double = sqlite3_column_double(stmt, columnIndex)
    @Suppress("UNCHECKED_CAST")    
    override fun getString(columnIndex: Int): String = memScoped {        
        sqlite3_column_text(stmt, columnIndex)?.let {            
            (it as CPointer<ByteVar>).toKString()        
        } ?: ""    
    override fun getColumnIndex(columnName: String): Int = indexNameMap[columnName] ?: COLUMN_ERROR_INDEX
    override fun forEachRow(block: () -> Unit) {          
        while (sqlite3_step(stmt) == SQLITE_ROW)            
            block()    
    private val indexNameMap: Map<String, Int> by lazy { buildMap {        
        memScoped {            
            val count = sqlite3_column_count(stmt)            
            for (i in 0 until count)                
                sqlite3_column_name(stmt, i)                    
                    ?.pointed?.ptr                    
                    ?.toKString()                    
                    ?.takeIf { it.isNotBlank() }                    
                    ?.let { this@buildMap[it] = i }        
    override fun close() {        
        sqlite3_finalize(stmt)        
        stmt = null    

getXXX 函数的实现是调用 sqlite3_column_xxx C 函数来获取数据。getColumnIndex 的实现要注意一下,由于 sqlite3 C 函数没有直接提供根据列名来获取列索引的函数,但是提供了 sqlite3_column_name 函数通过下标获取列名,那么我们遍历 index 生成一个 map 提供 name -> index 的映射,在执行 getColumnIndex 函数的时候使用这个 map 来获取 name。close 函数内通过 sqlite3_finalize 函数释放 stmt,为了避免不同语言之间内存回收机制不同的潜在问题,这里将 stmt 指针的引用置空(当然,没有证据表明不置空就会发生内存泄漏等问题)。对了,还有一点需要注意,sqlite3 C 函数查询出来的字符串都是 char 数组,可用 toKString 将其转化为 Kotlin 字符串。

有了双平台游标的完整实现,现在可以实现自定义的反序列化中 kotlinx.serialization 所需的 Decoder 了。可参考的资料包括官方文档(参考链接 1),以及 kotlinx.serialization-json 的 Decoder(StreamingJsonDecode r 参考链接 2)。

自定义 Decoder 需要实现 Decoder、CompositeDecoder 两个接口,但根据官方文档及 SteamingJsonDecoder 的实现,我们可以直接继承自 AbstractDecoder 抽象类,它提供了许多默认实现,简化了不少工作,CursorDecoder 的完整实现如下:

@OptIn(ExperimentalSerializationApi::class)
class CursorDecoder(
    private val cursor: KMMCursor,
) : AbstractDecoder() {
    private var elementIndex = 0    
    private var elementName = ""
    override val serializersModule: SerializersModule = EmptySerializersModule
    override tailrec fun decodeElementIndex(descriptor: SerialDescriptor): Int =      
        if (elementIndex == descriptor.elementsCount)           
            CompositeDecoder.DECODE_DONE        
        else {            
            elementName = descriptor.getElementName(elementIndex)            
            val resultIndex = elementIndex++            
            if (cursorColumnIndex >= 0)                
                resultIndex            
                decodeElementIndex(descriptor)        
    override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder = CursorDecoder(cursor)
    private inline val cursorColumnIndex        
        get() = cursor.getColumnIndex(elementName)
    private inline fun <T> deserialize(block: (Int) -> T): T = cursorColumnIndex.let {        
        if (it >= 0) block(it) else throw SerializationException("The Cursor don't have this column")    
    override fun decodeBoolean(): Boolean = deserialize { cursor.getInt(it) == 1 }    
    override fun decodeByte(): Byte = deserialize { cursor.getInt(it).toByte() }    
    override fun decodeShort(): Short = deserialize { cursor.getInt(it).toShort() }    
    override fun decodeInt(): Int = deserialize { cursor.getInt(it) }    
    override fun decodeLong(): Long = deserialize { cursor.getLong(it) }    
    override fun decodeChar(): Char = deserialize { cursor.getString(it).first() }    
    override fun decodeString(): String = deserialize { cursor.getString(it) }    
    override fun decodeFloat(): Float = deserialize { cursor.getFloat(it) }    
    override fun decodeDouble(): Double = deserialize { cursor.getDouble(it) }
    override fun decodeNotNullMark(): Boolean = cursorColumnIndex >= 0
    // override fun decodeEnum(enumDescriptor: SerialDescriptor): Int {}    
    // override fun decodeCollectionSize(descriptor: SerialDescriptor): Int {}

它的构造函数需要一个 KMMCursor 对象用于 get 数据。

kotlinx.serialization 反序列化的基本工作方式是通过迭代 KSerializer 中的字段列表,每次迭代会调用一次 decodeElementIndex 函数,该函数返回的 Int 值就是当前需要被反序列化的字段在 KSerializer 中维护的字段列表中的索引,但索引值的更新逻辑需要我们自己编写维护,AbstractDecoder 并不提供默认逻辑,所以可以说 decodeElementIndex 函数是 CursorDecoder 的核心函数,它驱动着整个解析流程的运转。我们定义 elementIndex 字段用于标识当前元素 index,当 elementIndex 的值等于字段总数时标志着字段迭代过程的结束,这时 decodeElementIndex 函数返回 CompositeDecoder.DECODE_DONE。当不等于字段总数时表示反序列化仍在继续,我们先使用 descriptor.getElementName 获取本次迭代正在反序列化的字段名(通常与数据库列名相同),然后使用 elementName 成员变量将其保存,接着调用内联幕后属性 cursorColumnIndex (使用 elementName 获取该列名对应的列索引)。如果列索引大于等于 0,说明我们可以获取到该列下的值,decodeElementIndex 函数返回 elementIndex 的值然后 elementIndex 自增加 1 完成本次迭代,接着 decodeXXX 函数会被调用,通过调用 cursor.getXXX 函数获取值并给该字段赋值;否则则认为我们期待反序列化的类中的某个字段在本次数据查询中没有对应的列,那么仍然让 elementIndex 自增加 1 跳过该字段的反序列化,并递归调用 decodeElementIndex 函数进入下一次迭代(decodeElementIndex 函数已声明为尾递归函数,当然这个操作也可以使用循环实现),如果该字段有默认值则反序列化后的对象内该字段的值会是其默认值,如果没有,例如该字段以构造函数参数的形式定义,那么将会 crash,当然,这是业务代码编写者需要注意的问题。

为了保险,所有的 decodeXXX 函数都在此对列索引值进行了判断,如果不合法则抛出 SerializationExcepetion(见内联函数 deserialize)。

CursorDecoder 的核心实现就这些,其他非核心的细节可参考官方文档(参考链接 1)。

通常我们的查询出来的对象不止一个,也就是说游标内包含很多个行(Row),那么我们其实需要的是反序列化出一个 object list,最终的反序列化调用如下:

@OptIn(ExperimentalSerializationApi::class)
fun <T> KMMCursor.decodeToModelList(deserializer: DeserializationStrategy<T>): List<T> =    
    buildList {        
        val decoder = CursorDecoder(this@decodeToModelList)         
        forEachRow {             
            add(decoder.decodeSerializableValue(deserializer))         
        close()    

通过 KMMCursor 的 forEachRow 函数遍历行,每遍历一行调用一次 decodeSerializableValue 函数解析出一个对象,然后装入 List 返回。

序列化与反序列化类似,自定义 Encoder 即可。

友情提示,本文基于 Kotlin 1.6.10,kotlinx.serialization 1.3.1,自定义 Decoder、Encoder 功能属于实验性特性,生产环境使用请慎重,并且不排除 API 在未来有破坏性变更。

kotlinx.serialization 不仅仅可以序列化或反序列化某种数据格式,从本文的实践来看我们可以在任意需要根据类型信息来生成对象的场景使用 kotlnix.serialization。

KMM 的建设是一步一步走的,逐渐改进对于项目的稳定性来说至关重要,所以应将精力集中在当前最迫切的问题上,最迫切的问题通常指会阻断 KMM 建设进度推进的问题。在当前这个阶段最大限度的复用已有的 Java/Objective-C 代码还是比较正确的做法。KMM 的推进不仅意味着 Native 代码跨平台化的推进,迁移过程也给我们提供了利用新思想重构旧代码的机会,在后续的数据库建设中我们计划尝试使用 SQLiter(参考链接 3)等开源社区作品继续优化数据库的实现,最终尽量与已有的 Java/Objective-C 底层代码脱钩。

  • 自定义 kotlinx.serialization Decoder 的官方文档:github.com/Kotlin/kotl…
  • StreamingJsonDecode:github.com/Kotlin/kotl…
  • SQLiter:github.com/touchlab/SQ…
  • 分类:
    Android
    标签: