Kotlin Multiplatform (KMP) 中使用 Protobuf

3 阅读4分钟

引言

本文介绍了在 Kotlin Multiplatform 项目中集成和使用 Protobuf 的方法,重点通过 Wire 库实现数据序列化。

对于 Protobuf 的介绍和原理,可以参考前文 juejin.cn/post/757536…


一、KMP 工程中集成

1.1 wire 介绍

在 KMP 开发中,推荐使用 Wire 库来处理 Protobuf 序列化任务。Wire 是由 Square 公司开发的,专为 Android 和 Java 平台设计,在 Kotlin 生态中也有广泛应用。Wire 在 GitHub 上拥有超过 4.4k stars,比较可靠。

Wire 的 GitHub 主页:github.com/square/wire

此外,腾讯在 kuikly 文档的 Protobuf 部分也明确采用了 Wire,进一步证明了它在实际项目中的实用性。

kuikly Protobuf 使用文档:kuikly.tds.qq.com/DevGuide/pr…


1.2 KMP 中集成 wire

wire 开发文档:square.github.io/wire/

首先,我们需要在项目的 libs.versions.toml 文件里声明 Wire 的依赖版本和库引用。添加以下内容:

[versions]
wire = "4.9.2" # 指定我们使用的 Wire 版本

[libraries]
# 声明运行时需要的库
wire = { module = "com.squareup.wire:wire-runtime", version.ref = "wire" }

[plugins]
# 声明 Wire Gradle 插件
wire = { id = "com.squareup.wire", version.ref = "wire" }

接下来,在公共模块(common module)的 build.gradle.kts 文件中,我们需要做三件事:应用插件、添加运行时依赖和配置 Wire。

//添加插件 在文件顶部或 plugins 块内启用 Wire Gradle 插件。
plugins {
    alias(libs.plugins.wire)
}

//运行时依赖 确保公共模块的代码能访问 Wire 的运行时库
kotlin {
    sourceSets {
        val commonMain by getting {
            dependencies {
                api(libs.wire)
            }
        }
    }
}

//配置
wire {
    sourcePath {
        srcDir("src/commonMain/kotlin") // 指定 .proto 文件所在的目录
    }
    kotlin {
        includes = listOf("com.example.mylibrary.proto.*") // 指定要生成代码的 .proto 包路径
        out = "${buildDir}/generated/wire" // 指定生成的 Kotlin 代码的输出目录
    }
}

使用wire的好处:

  1. 自动生成: 配置好之后,Wire Gradle 插件会在构建过程中自动处理 .proto 文件,为你生成对应的 Kotlin 数据实体类。不需要手动运行额外的脚本或命令。
  2. 简化流程: 通过直接在 Gradle 中配置源目录和输出目录,整个开发流程变得更加简单和集成。
  3. IDE 支持: 添加了 Wire 插件后,像 Android Studio 或 IntelliJ IDEA 这样的 IDE 通常会自动识别 .proto 文件并提供语法高亮显示,这能显著改善你编辑这些文件的体验。


二、KMP 中使用

2.1 实体类生成

在配置好 Wire 后,我们可以在指定的 proto 源目录下创建 .proto 文件。这些文件定义了我们的数据结构协议。

这里是一个简单的 proto 文件示例,它定义了一个账户消息类型:

syntax = "proto3"; // 指定使用proto3版本

package com.example.mykuikly.proto;// 确保包名与配置匹配

message Account {
  int32 id = 1;
  string name = 2;
  string email = 3;
  AccountType accountType = 4;

  enum AccountType {
    TYPE_1 = 0;
    TYPE_2 = 1;
    TYPE_3 = 2;
    TYPE_4 = 3;
  }
}

在这个示例中,我们使用最成熟的 proto3 语法。

要确保 package 包名声明必须与 Gradle 配置中的 package 路径完全一致,如果包名错误,Protobuf 编译器可能无法生成对应的实体类文件。

构建项目后,Wire 会自动在配置的输出目录生成对应的 Kotlin 实体类。我们不需要手动执行任何额外命令。

生成的实体类大致如下(已简化,关键部分):

public class KAccount(
  @field:WireField(tag = 1,adapter = "com.squareup.wire.ProtoAdapter#INT32",label = WireField.Label.OMIT_IDENTITY,schemaIndex = 0,)
  public val id: Int = 0,
  @field:WireField(tag = 2,adapter = "com.squareup.wire.ProtoAdapter#STRING",label = WireField.Label.OMIT_IDENTITY,schemaIndex = 1,)
  public val email: String = "",
  @field:WireField(tag = 3,adapter = "com.squareup.wire.ProtoAdapter#STRING",label = WireField.Label.OMIT_IDENTITY,schemaIndex = 2,)
  public val mainEmail: String = "",
  @field:WireField(tag = 4,adapter = "com.netease.mail.mmsharedkmp.proto.KAccount${'$'}AccountType#ADAPTER",label = WireField.Label.OMIT_IDENTITY,schemaIndex = 3,)
  public val accountType: AccountType = AccountType.NETEASE_FREE,
  unknownFields: ByteString = ByteString.EMPTY,
) : Message<KAccount, Nothing>(ADAPTER, unknownFields) {

public companion object {
    @JvmField
    public val ADAPTER: ProtoAdapter<KAccount> = object : ProtoAdapter<KAccount>(
      FieldEncoding.LENGTH_DELIMITED, 
      KAccount::class, 
      "type.googleapis.com/com.netease.mail.mmsharedkmp.proto.KAccount", 
      PROTO_3, 
      null, 
      "com/netease/mail/mmsharedkmp/proto/account.proto"
    ) {
      override fun encodedSize(`value`: KAccount): Int {//省略}

      override fun encode(writer: ProtoWriter, `value`: KAccount) {//省略}

      override fun encode(writer: ReverseProtoWriter, `value`: KAccount) {//省略}

      override fun decode(reader: ProtoReader): KAccount {//省略}

      override fun redact(`value`: KAccount): KAccount = //省略
    }
  }
  //其他部分省略
}

生成完成后,我们就可以直接使用这些实体类进行数据序列化和反序列化操作:

 // 创建账户对象
val account = UserAccount(
    id = 1001,
    name = "张三",
    email = "zhangsan@example.com",
    accountType = UserAccount.AccountType.PREMIUM
)

// 序列化为字节数组
val encodedData = UserAccount.ADAPTER.encode(account)

// 从字节数组反序列化
val decodedAccount = UserAccount.ADAPTER.decode(encodedData)

2.2 跨平台调用

在理解了 Wire 如何生成实体类并进行编解码后,我们会遇到一个跨平台开发的常见问题:KMP 共享模块生成的 Kotlin ByteArray 数据需要被 Android 和 iOS 主工程使用,Android 可以直接使用,而 iOS 使用的是 NSData 类型,这里有两种实现策略:


  • 方法一:iOS 主工程处理转换

在 KMP 共享模块中直接返回 ByteArray,由 iOS 主工程负责转换:

// KMP 共享模块编码函数
fun encodeAccountData(): ByteArray {
    val account = Account(
        id = 1,
        name = "test",
        email = "test@example.com"
    )
    return Account.ADAPTER.encode(account)
}

iOS 主工程需要添加转换工具:

// 工具函数:NSData → MmsharedkmpKotlinByteArray
+ (MmsharedkmpKotlinByteArray *)kotlinByteArrayFromData:(NSData *)data {
    MmsharedkmpKotlinByteArray *arr = [MmsharedkmpKotlinByteArray arrayWithSize:(int32_t)data.length];
    const uint8_t *bytes = (const uint8_t *)[data bytes];
    for (int32_t i = 0; i < data.length; i++) {
        [arr setIndex:i value:(int8_t)bytes[i]];
    }
    return arr;
}


// 工具函数:MmsharedkmpKotlinByteArray → NSData
+ (NSData *)dataFromKotlinByteArray:(MmsharedkmpKotlinByteArray *)arr {
    NSMutableData *data = [NSMutableData dataWithLength:arr.size];
    uint8_t *buffer = (uint8_t *)[data mutableBytes];
    for (int32_t i = 0; i < arr.size; i++) {
        buffer[i] = (uint8_t)[arr getIndex:i];
    }
    return data;
}

这种方法实现简单,但需要在每个 iOS 调用点手动处理类型转换。


  • 方法二:使用 KMP 的 expect/actual 机制统一处理平台差异

首先,在公共模块中定义 expect 接口,作为跨平台的统一契约。这包括一个抽象字节数组类型和转换函数,通过扩展方法简化调用。

expect class PlatformByteArray


expect object PlatformByteArrayConverter {
    fun fromByteArray(byteArray: ByteArray): PlatformByteArray
    fun toByteArray(data: PlatformByteArray): ByteArray
}

fun PlatformByteArray.toByteArray(): ByteArray {
    return PlatformByteArrayConverter.toByteArray(this)
}

fun ByteArray.toPlatformByteArray(): PlatformByteArray {
    return PlatformByteArrayConverter.fromByteArray(this)
}

在 Android 平台上,由于原生支持 ByteArray,实现非常简单:使用类型别名直接映射,转换函数直接返回输入值,避免额外开销。


/**
 * Android 平台的ByteArray类型实现
 * 在 Android 平台直接使用 ByteArray
 */
actual typealias PlatformByteArray = ByteArray

actual object PlatformByteArrayConverter {
    actual fun fromByteArray(byteArray: ByteArray): PlatformByteArray {
        return byteArray
    }

    actual fun toByteArray(data: PlatformByteArray): ByteArray {
        return data
    }
}

在 iOS 平台上,需要处理 NSData 的转换。这里使用 pinned 内存操作来安全拷贝数据,确保跨平台兼容性。

/**
 * iOS 平台的ByteArray类型实现
 * 在 iOS 平台使用 NSData
 */
actual typealias PlatformByteArray = platform.Foundation.NSData


actual object PlatformByteArrayConverter {
    @OptIn(ExperimentalForeignApi::class)
    actual fun fromByteArray(byteArray: ByteArray): PlatformByteArray {
        return byteArray.usePinned { pinnedBytes ->
            platform.Foundation.NSData.create(
                bytes = pinnedBytes.addressOf(0),
                length = byteArray.size.toULong()
            )
        }
    }

    @OptIn(ExperimentalForeignApi::class)
    actual fun toByteArray(data: PlatformByteArray): ByteArray {
        return ByteArray(data.length.toInt()).apply {
            usePinned { pinnedBytes ->
                memcpy(pinnedBytes.addressOf(0), data.bytes, data.length)
            }
        }
    }
}

这种方法通过类型别名和转换器封装了平台差异,避免了平台侧重复编写转换逻辑。

提升代码可维护性和跨平台一致性,这样业务逻辑代码可以保持简洁统一,Android 和 iOS 主工程都不需要额外干预。


三、总结

总之,Protobuf 结合 Wire 库在 KMP 开发中提供了简洁高效的数据序列化方案。其自动生成代码和跨平台兼容机制显著降低了开发复杂度,是一种实用且可靠的技术选择,能有效优化多平台项目的性能和维护性。