Kotlin 库作者 API 设计指南

写应用代码和写库 API 是两种不同工作。应用代码只服务当前系统,改错后一起发布即可;库 API 一旦被别人依赖,就要考虑源码兼容、二进制兼容、行为兼容、Java 调用方式、文档和废弃策略。Kotlin 官方 API guidelines 专门为库作者整理了可读性、一致性、可预测性、向后兼容等建议,本章把这些建议转成中文实践清单,并补充 Java 对比。

三种兼容性

官方指南把向后兼容分成三类:

  • Binary compatibility:调用方不重新编译,只替换你的新版本库,旧的已编译代码仍能运行。
  • Source compatibility:调用方源码不用改,但可能需要重新编译。
  • Behavioral compatibility:同样输入、同样场景下,语义没有意外变化,除非是修复 bug。

Java 对比:Java 库作者也要关注这三类兼容。Kotlin 的额外复杂度在于默认参数、数据类、顶层函数、属性、协程、inline 函数、值类等特性会生成不总是直观的 JVM 字节码。

一个变化是否安全,不能只看 Kotlin 源码“能不能编译”,还要看老版本调用方是否能在不重新编译时继续运行。

使用 Binary Compatibility Validator

JetBrains 提供 Binary Compatibility Validator Gradle 插件。它的核心任务包括:

  • apiDump:生成描述公开 API 的 .api 文件。
  • apiCheck:把当前构建产物与已保存的 .api 文件比较。

建议库项目把 apiCheck 纳入 CI:

./gradlew apiCheck

当你有意修改公开 API:

./gradlew apiDump

然后把 .api 差异当成代码评审的一部分,而不是自动接受。

这类似 Java 项目使用 japicmp、revapi 等工具,只是 Kotlin 官方指南明确推荐了适配 Kotlin API 的验证工具。

公共 API 显式写返回类型

应用内部代码可以依赖类型推断:

private fun normalize(name: String) = name.trim().lowercase()

但公共 API 应该显式写返回类型:

public fun normalizeName(name: String): String =
    name.trim().lowercase()

原因:

  • 类型推断结果可能因为实现变化而改变。
  • 调用方依赖的是 API 契约,不是你的当前实现。
  • 公开函数、属性、扩展函数、接口方法都应该让签名稳定。

危险示例:

public fun ids() = listOf(1, 2, 3)

如果以后实现改成:

public fun ids() = setOf(1, 2, 3)

源码看起来只是换了集合构造,但公开返回类型可能从 List<Int> 变成 Set<Int>,这就是 API 变化。更稳妥:

public fun ids(): List<Int> = listOf(1, 2, 3)

Java 对比:Java 方法必须写返回类型,所以这类风险较少。Kotlin 的类型推断很方便,但库 API 中便利性不应该压过稳定性。

谨慎修改默认参数

Kotlin 默认参数对应用代码非常舒服:

fun connect(
    host: String,
    port: Int = 5432,
    ssl: Boolean = true,
)

但对公开库 API,默认参数会生成额外调用结构,并影响 Java 调用方式。随意新增、删除、重排参数可能破坏二进制兼容或源码兼容。

不建议:

// v1
fun createClient(host: String, timeoutMillis: Long = 1_000): Client

// v2: 在中间加参数
fun createClient(host: String, retries: Int = 3, timeoutMillis: Long = 1_000): Client

更稳妥:

fun createClient(host: String): Client =
    createClient(ClientConfig(host = host))

fun createClient(config: ClientConfig): Client

配置对象比长参数列表更可演进。新增配置项时可以给 ClientConfig 添加属性,但如果 ClientConfig 是公开 data class,仍然要考虑二进制兼容,见下一节。

Java 对比:Java 常用重载、Builder 或配置对象表达可选参数。Kotlin 默认参数更简洁,但公开 API 不能只考虑 Kotlin 调用方。

不要随意暴露 data class

数据类适合应用 DTO 和内部值对象,但公开库 API 中要谨慎使用。

data class User(
    val id: Long,
    val name: String,
)

数据类会生成:

  • componentN()
  • copy()
  • equals()hashCode()toString()
  • 主构造函数参数对应的属性。

如果你在新版本中新增一个主构造函数属性:

data class User(
    val id: Long,
    val name: String,
    val email: String,
)

调用方可能受到影响:

  • 解构声明数量变化。
  • copy() 签名变化。
  • 构造调用变化。
  • 二进制兼容风险。

公开库里更稳妥的做法:

class User private constructor(
    val id: Long,
    val name: String,
) {
    companion object {
        fun of(id: Long, name: String): User = User(id, name)
    }
}

或者用接口隐藏实现:

interface User {
    val id: Long
    val name: String
}

private data class DefaultUser(
    override val id: Long,
    override val name: String,
) : User

Java 对比:Java record 也会把组件作为公开契约,新增 record component 同样不是小改动。Kotlin data class 与 Java record 都适合值建模,但公开库中要谨慎演进。

Boolean 参数要表达含义

差的 API:

fun render(markdown: String, trueForHtml: Boolean)

调用方看到:

render(text, true)

无法知道 true 是什么意思。

更好的写法:

enum class OutputFormat {
    Html,
    PlainText,
}

fun render(markdown: String, format: OutputFormat): String

或者拆成两个函数:

fun renderHtml(markdown: String): String

fun renderPlainText(markdown: String): String

Kotlin 有命名参数:

render(markdown = text, escapeHtml = true)

但 Java 调用方没有 Kotlin 命名参数,且 Kotlin 调用方也可能在位置参数中传布尔值。公开 API 不要依赖调用者永远写命名参数。

用类型表达领域概念

差的 API:

fun findOrder(id: String): Order

fun findUser(id: String): User

调用方很容易传错 id。

更好的写法:

@JvmInline
value class OrderId(val value: String)

@JvmInline
value class UserId(val value: String)

fun findOrder(id: OrderId): Order

fun findUser(id: UserId): User

值类可以减少包装对象成本,并让类型系统阻止误传。限制是:

  • 对 Java 调用方的体验要验证。
  • 泛型、反射、序列化和框架集成时要确认行为。
  • 公开 API 中引入值类也属于契约,需要兼容性评估。

Java 对比:Java 可以用小类或 record 包装 id,但成本和语法更重。Kotlin value class 让这种建模更轻。

扩展函数适合增强,不适合隐藏核心能力

扩展函数适合给已有类型增加便利方法:

fun String.toUserId(): UserId = UserId(this)

但不要把核心业务能力藏在难以发现的扩展里:

fun HttpClient.syncAllUsers() {
    // 大量业务逻辑
}

更好的方式是公开明确服务:

class UserSyncService(
    private val httpClient: HttpClient,
) {
    fun syncAllUsers() {
        // ...
    }
}

扩展函数的 Java 调用体验也较差。Java 侧会看到静态方法,而不是成员方法。如果你的库有大量 Java 使用者,核心 API 应该优先设计成 Java 也清楚的类和方法。

Inline 与 PublishedApi

inline 函数会把函数体复制到调用点,因此公开 inline API 的实现细节会变成调用方编译产物的一部分。

常见风险:

  • 修改 inline 函数内部调用的 internal API 可能影响二进制兼容。
  • 调用方不重新编译时,旧逻辑可能仍留在调用方字节码中。
  • inline 适合高阶函数性能优化和 reified 泛型,但不应该为了“可能更快”随意使用。

当公开 inline 函数需要调用内部成员时,可能需要 @PublishedApi 暴露给内联代码:

@PublishedApi
internal fun parseInternal(input: String): Parsed =
    Parsed(input.trim())

public inline fun <reified T> parse(input: String): T {
    val parsed = parseInternal(input)
    return convert<T>(parsed)
}

@PublishedApi internal 虽然语法上仍是 internal,但对二进制兼容来说已经接近公开契约。修改前要当成 API 评审。

废弃 API 要给迁移路径

不要直接删除 API。先废弃,再给替代方案,再经过版本周期删除。

@Deprecated(
    message = "Use createClient(ClientConfig) instead.",
    replaceWith = ReplaceWith("createClient(ClientConfig(host = host))"),
    level = DeprecationLevel.WARNING,
)
fun createClient(host: String): Client =
    createClient(ClientConfig(host = host))

升级策略可以分阶段:

  1. WARNING:提示调用方迁移。
  2. ERROR:阻止新代码继续使用,但二进制仍保留。
  3. 删除:只在主版本升级或明确破坏性版本中执行。

Java 对比:Java 的 @Deprecated 可以提示废弃,但 Kotlin 的 ReplaceWith 能让 IDE 提供自动替换,迁移体验更好。

实验性 API 使用 RequiresOptIn

当 API 还不稳定,但你又想让用户试用,可以使用 opt-in:

@RequiresOptIn(
    message = "This API is experimental and may change in future releases.",
    level = RequiresOptIn.Level.WARNING,
)
annotation class ExperimentalSearchApi

@ExperimentalSearchApi
fun semanticSearch(query: String): List<SearchResult> = emptyList()

调用方需要显式选择:

@OptIn(ExperimentalSearchApi::class)
fun runSearch() {
    semanticSearch("kotlin")
}

这比在 README 里写“实验性”更可靠,因为编译器会参与提醒。

API 可组合性

官方可读性指南强调 API 应该易于组合。差的 API 会一次性做太多事:

fun loadValidateTransformAndSend(path: String)

更可组合:

fun load(path: String): RawDocument

fun validate(document: RawDocument): ValidationResult

fun transform(document: RawDocument): Message

fun send(message: Message): SendResult

调用方可以自行组合,也更容易测试。对于应用代码,一站式函数可能方便;对于库 API,分解后的能力更可复用。

DSL 要克制

Kotlin 很适合写 DSL:

client {
    timeoutMillis = 1_000
    retry {
        maxAttempts = 3
    }
}

DSL 的优势是可读性强,适合配置和结构化声明。风险是:

  • IDE 跳转和搜索不如普通函数直观。
  • 嵌套层级太深会隐藏作用域。
  • Java 调用方几乎无法享受同等体验。
  • DSL 设计一旦发布,修改接收者类型和嵌套结构也会影响兼容性。

建议:

  • 配置型 API 可以用 DSL。
  • 核心业务操作优先普通函数。
  • DSL builder 最终生成明确的不可变配置对象。
  • 给 Java 用户提供 builder 或构造函数替代入口。

Java 互操作清单

面向 JVM 发布库时,检查这些点:

  • 顶层函数是否需要 @file:JvmName 给 Java 侧更好的类名。
  • 对象单例中的静态入口是否需要 @JvmStatic
  • 默认参数是否需要 @JvmOverloads
  • 属性暴露给 Java 后 getter/setter 名称是否合理。
  • internal 在 JVM 字节码中不是 Java 意义上的私有边界,不能当安全隔离。
  • suspend fun 是否需要 Java-friendly wrapper。
  • Kotlin List 只读接口传给 Java 后仍可能被 Java 视角修改底层对象,要做防御性复制。
  • 可空性注解是否能被 Java 和 Kotlin 调用方正确理解。

示例:

@file:JvmName("Clients")

package com.example.client

object ClientFactory {
    @JvmStatic
    @JvmOverloads
    fun create(
        endpoint: String,
        timeoutMillis: Long = 1_000,
    ): Client = Client(endpoint, timeoutMillis)
}

Java 调用:

Client client = ClientFactory.create("https://api.example.com");

如果使用顶层函数:

@file:JvmName("Clients")

package com.example.client

fun createClient(endpoint: String): Client =
    Client(endpoint, timeoutMillis = 1_000)

Java 调用:

Client client = Clients.createClient("https://api.example.com");

文档和示例也是 API 的一部分

公开库至少应该提供:

  • README 快速开始。
  • Dokka API 文档。
  • Kotlin 示例。
  • 如果支持 Java,提供 Java 示例。
  • 版本兼容说明。
  • 废弃 API 的迁移说明。
  • 错误处理和线程安全说明。

文档中不要只写“调用此函数”。对库用户有价值的是边界条件:

  • 什么时候返回空。
  • 是否抛异常。
  • 是否阻塞。
  • 是否线程安全。
  • 是否缓存。
  • 是否重试。
  • 是否保持顺序。
  • 是否稳定排序。

发布前检查清单

发布 Kotlin 库前,建议逐项检查:

  • 公共 API 都有显式返回类型。
  • CI 运行 apiCheck
  • 公开数据类经过兼容性评审。
  • 默认参数没有破坏旧调用方。
  • 废弃 API 有 ReplaceWith 或迁移说明。
  • 实验性 API 有 @RequiresOptIn
  • Java 调用入口经过实际编译验证。
  • Dokka 文档可以生成。
  • README 示例可以复制运行。
  • 多平台库检查各 target 的 API 差异。
  • 版本号符合语义化发布策略。

常见误区

误区一:源码兼容就够了

很多用户不会在升级依赖时立刻重新编译所有模块。二进制兼容破坏会在运行期表现为 NoSuchMethodErrorNoSuchFieldError 等问题。

误区二:Kotlin-only 库不用考虑 Java

只要发布到 JVM 生态,就可能被 Java、Groovy、Scala、构建工具、反射框架调用。除非明确声明不支持 Java,否则至少要检查 Java 侧体验。

误区三:internal 就不是 API

Kotlin 的 internal 是模块级可见性,但在 JVM 字节码层仍会有可见成员和名称改写。公开 inline 函数调用的 internal 成员尤其要谨慎。

误区四:API 越 Kotlin 风格越好

Kotlin 风格不是越多 DSL、扩展、操作符越好。好的库 API 应该清楚、稳定、可组合、可测试,并让目标用户容易调用。如果用户大量来自 Java,Java 体验也是设计目标。

官方参考

  • Kotlin API guidelines introduction:https://kotlinlang.org/docs/api-guidelines-introduction.html
  • Backward compatibility guidelines:https://kotlinlang.org/docs/api-guidelines-backward-compatibility.html
  • API readability guidelines:https://kotlinlang.org/docs/api-guidelines-readability.html
  • API consistency guidelines:https://kotlinlang.org/docs/api-guidelines-consistency.html
  • API predictability guidelines:https://kotlinlang.org/docs/api-guidelines-predictability.html