ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Kotlin工程实践全景:空安全、Flow与多平台开发要点

Kotlin工程实践全景:空安全、Flow与多平台开发要点 这两年团队从 Java 迁移到 Kotlin 之后最直观的感受不是代码变短了而是很多以前靠规范文档和 code review 才能拦住的低级错误被编译器直接挡在了门外。这篇文章不是那种一句话一个语法点的 Kotlin 快速教程而是我在带新人、准备技术和做项目复盘时觉得真正值得系统讲一遍的 Kotlin 基础全景。内容从最核心的变量与空安全开始一路延伸到 Gradle Kotlin DSL、Flow、SQL Server 连接、Kotlin/Native 生成 C 头文件以及在 Eclipse 里跑 Kotlin 这些偏门但确实有人问的场景。无论你刚准备学 Kotlin还是用了一段时间想查缺补漏都可以按章节挑着看。后面所有代码默认基于 Kotlin 1.9 以上版本JDK 8 到 17 的环境都能直接跑。遇到和 Java 对比的地方我会顺手点一下差异因为面试基本都会从这里切入。1. Kotlin入门第一课变量、空安全与扩展函数背后的设计取舍1.1 val/var 不是常量和变量这么简单val name Kotlin var count 0 count 1 val list mutableListOf(1, 2, 3) list.add(4)很多初学者会把val理解成 Java 里的常量这个理解在工程上会出问题。val的准确含义是只读引用它保证的是这个引用不能被重新赋值并不保证引用指向的对象内部状态不可变。所以上面代码里val list声明完还能执行list.add(4)这完全合法。真正要做到对象不可变应该用listOf(1, 2, 3)这类只读集合接口而不是把希望寄托在val上。这个区别在面试里很常考val 和 const 的区别是什么答案是const只能修饰基本类型和 String且必须在编译期就能确定值而val可以是运行时计算出来的结果。Kotlin 推荐val优先本质是在用语言层面引导你减少可变状态。可变状态往往是并发 bug 和复杂度的来源Java 里做不可变设计靠的是开发者自觉Kotlin 则在语法层面把可重新赋值和只读区分开了。这不是限制自由而是把工程约束前置到编译期。1.2 空安全把万一为空写进类型系统var name: String kotlin // name null // 编译错误 var nullableName: String? null val length nullableName?.length ?: 0在 Java 里任何引用类型都可以是 null运行时才发现NullPointerException。Kotlin 则把可空性直接写进类型系统String表示一定不为空String?表示可能为空。编译器会在你写出nullableName.length这种代码时直接报错逼你先处理空的情况。?.是安全调用如果接收者为 null 整个表达式返回 null?:是 Elvis 运算符左侧为空时用右侧兜底。上面的length在nullableName为 null 时会得到 0不会抛异常。还有一个!!非空断言运算符它的含义是我确定这里不为空如果为空就抛异常给我看。实际项目中!!越少越好因为一旦你依赖它就说明你绕过了类型系统的保护。但也不能完全不用比如在某些 Java 互操作或者数据完整性由外部保证的场景!!是必要时的补丁。很多从 Java 转过来的人一开始不习惯空安全觉得写起来麻烦。但用两周之后你会发现自己写 Java 代码时心里发虚因为 Java 里到处都是潜在的空指针风险全靠人脑记忆这个函数到底会不会返回 null。1.3 扩展函数不是魔法接收者只是第一个参数fun String.isEmail(): Boolean contains() infix fun Int.times(str: String): String str.repeat(this) fun main() { println(testexample.com.isEmail()) println(3 times abc) }扩展函数是 Kotlin 最具代表性的特性之一。它允许你给已有的类增加方法看起来像是这个类自己定义的方法但实际上并没有修改原始类。它的本质是一个静态方法接收者对象作为第一个参数传入所以你无法通过扩展函数访问接收者的 private 成员。这个理解在阅读协程源码时特别重要你会发现flow { }、map { }、filter { }这些操作符本质上都是扩展函数它们的接收者就是FlowT本身。理解这一点你就不会觉得协程 API 是某种黑魔法而只是普通的扩展函数组合。扩展函数也不是万能的。Java 类、Kotlin 类、甚至接口都能扩展但它不是继承不能实现多态。如果父类和子类都定义了同名扩展函数调用时看的是静态类型而不是运行时类型。这个细节在团队代码 review 中经常引发讨论。2. 面试必问data class、sealed class 与对象声明的设计逻辑2.1 data class相等性、复制与解构data class User( val id: Long, val name: String ) val user User(1, kotlin) val copyUser user.copy(name renamed) val (id, name) copyUser println($id $name)data class会自动生成toString()、equals()、hashCode()、copy()以及componentN()系列函数。这意味着两个结构体只要字段值一样比较就相等这在 Java 里需要手写一坨代码才能实现。有一个很关键的实践经验data class的构造参数应该全部或大部分声明为val。如果构造参数不是属性data class 的语义就弱化了。copy()是浅拷贝如果类里有一个ListUser类型的字段copy 之后这个 List 还是原来那个对象的引用修改它会影响原对象。componentN()函数是解构声明的基础val (id, name) user实际上调用了component1()和component2()。自定义类也可以手动定义 component 系列函数但日常开发完全没必要直接用 data class 就好。data class 最常见的应用是 DTO、接口返回值、数据库查询结果映射。因为它足够无侵入适合表达纯数据。2.2 sealed class用类型穷尽状态分支sealed class UiState { data class Loading(val percent: Int) : UiState() data class Success(val data: ListString) : UiState() object Error : UiState() } fun render(state: UiState) { when (state) { is UiState.Loading - println(loading ${state.percent}%) is UiState.Success - println(state.data) UiState.Error - println(error) } }sealed class的核心价值是受限的继承层级所有子类必须出现在同一个包Kotlin 1.5 之前甚至必须在同一个文件内。这种限制换来的是when表达式的穷尽性检查——当你对所有子类型写完分支后编译器知道没有遗漏不需要写else如果以后新增一个子类所有处理这个 sealed class 的when都会被编译器提示缺少分支。用 sealed class 建模页面状态是我最推荐的做法。比如上面的UiState网络请求过程中无非是加载中、成功、失败几种状态用 sealed class 可以让状态合法组合全部显式地出现在代码里。Java 里想达到类似效果要么用枚举无法携带更多上下文数据要么用继承加手工检查都不够干净。sealed class 与 enum 的区别也需要在面试中能说清enum 更适合固定数量的常量sealed class 每个子类可以有自己的字段和不同结构。像加载进度这种带百分比的状态enum 根本表达不了。2.3 object、companion object 与顶层函数object AppConfig { const val BASE_URL https://api.example.com } class UserService { companion object { JvmStatic fun create(): UserService UserService() } } fun topLevelUtil(): String top-levelobject声明会创建一个线程安全的单例在第一次访问时才初始化通常用来放全局配置、无状态工具类或者事件总线之类的组件。companion object很容易被误当成 Java 的static。准确地说它是伴随类的单例对象存放在包含它的类内部。如果不用JvmStaticJava 那边访问它得到的实际是内部对象的方法而不是类的静态方法。为了让 Java 调用方获得和static完全一致的体验需要对目标方法或属性加JvmStatic注解。顶层函数直接写在.kt文件中的函数编译之后会被放到一个以文件名命名的类里作为静态方法。比如topLevelUtil()写在Utils.ktJava 里要调用就得写UtilsKt.topLevelUtil()。如果你想指定生成的类名可以在文件顶部加file:JvmName(MyUtils)。理解这些机制对 Java 和 Kotlin 混合代码库非常重要。很多互操作的问题比如为什么 Java 里找不到这个 Kotlin 方法为什么伴生对象方法访问不到根源都在这里。3. Gradle Kotlin DSL 与 Groovy DSL 的真实差异构建脚本的迁移经验3.1 动态语言与静态语言在构建脚本上的碰撞Gradle 从诞生起默认使用 Groovy DSL 写构建脚本。Groovy 是动态语言字符串可以用单引号也可以用双引号方法调用可以省略括号闭包非常灵活。这在写简单项目时很爽但项目变大之后问题就来了脚本里的错误要到执行阶段才暴露IDE 也几乎没办法做智能补全。Kotlin DSL 把构建脚本当成 Kotlin 源码来写拥有类型检查、自动补全、编译期报错等能力。它不是换了个语法这么简单而是把构建脚本从配置文件变成静态类型语言程序。对比维度Groovy DSLKotlin DSL类型检查弱执行时才发现强编译期拦截IDE 补全与重构支持有限完整支持语法灵活性高可随意省略括号和引号中必须符合 Kotlin 语法多项目配置动态特性多重构难静态类型重构友好性能表现早期启动快Gradle 8 之后差距明显缩小Gradle 官方其实已经在大力推广 Kotlin DSL新项目默认生成build.gradle.kts所以现在入 Kotlin 坑的新人直接掌握 Kotlin DSL 是绝对正确的选择。3.2 从 Groovy 迁移到 Kotlin DSL 的典型改动Groovy 写法plugins { id org.jetbrains.kotlin.jvm version 1.9.24 } dependencies { implementation org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1 }对应的 Kotlin DSL 写法plugins { id(org.jetbrains.kotlin.jvm) version 1.9.24 } dependencies { implementation(org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1) }表面上看只是把引号换成了括号但本质上是语法模型的改变。Groovy 里implementation是一个方法调用方法参数是一个字符串省略了括号Kotlin DSL 里implementation也是函数但调用必须符合 Kotlin 语法括号不能随便省。除了依赖写法其他常见迁移点包括ext { ... }扩展属性改成 Kotlin DSL 的extra[key] value或者定义val prop by extra(...)。task hello {}这种创建任务的简写在 Kotlin DSL 里建议用tasks.register(hello) { ... }否则可能会出现配置期与执行期混淆的问题。sourceSets { main { java.srcDirs src/main/java } }在 Kotlin DSL 中需要调用方法而不是给属性赋值所以要写成sourceSets.main { java.srcDir(src/main/java) }。3.3 迁移时的坑最大的坑是复制粘贴。很多人把build.gradle的内容直接复制到build.gradle.kts然后期待它自动兼容结果看到几十个编译错误瞬间放弃。正确做法是尽量新建或参考官方迁移文档一行一行检查。另一个坑在自定义任务。Groovy 的task myTask { doLast { ... } }语法在 Kotlin DSL 中也存在但含义和性能不同。Kotlin DSL 中直接写task myTask {}会在配置阶段就创建任务而使用tasks.register则是懒加载只有实际需要执行时才创建。项目里有大量无用任务时懒加载和立即创建的构建性能差异非常大。pluginManagement和settings.gradle.kts的迁移也要单独处理尤其是多项目工程。依赖版本管理方面Gradle 官方推荐用libs.versions.toml配合版本目录这个模式和 Kotlin DSL 配合非常舒服Groovy DSL 虽然也能用但类型安全优势没有 Kotlin DSL 明显。4. Kotlin Flow 面试与实战冷流、热流、背压与回调转 Flow4.1 Flow 的冷流本质fun simpleFlow(): FlowInt flow { println(flow started) for (i in 1..3) { delay(300) emit(i) } } fun main() runBlocking { println(first collect) simpleFlow().collect { println(it) } println(second collect) simpleFlow().collect { println(it) } }Flow 是冷流意思是每次collect时flow块里的代码都会从头执行一遍。上面的代码执行两次collect就会看到两次flow started。这个特性是面试里最容易翻车的地方很多人误以为 Flow 和 RxJava 的 Observable 一样可以共享其实冷流天生是一个消费者一个数据流。冷流的好处在于它是惰性的不收集就不产生数据用来做真实业务的数据源非常安全不会因为创建了 Flow 就开始意外请求。如果想多个订阅者共享同一份数据流就需要把冷流转化为热流常见手段是shareIn和stateIn。它们会把原始 Flow 包装成 SharedFlow 或 StateFlow让多个订阅者拿到同一份数据。4.2 背压buffer、conflate 与 collectLatestflow { for (i in 1..10) { delay(100) emit(i) } } .conflate() .collect { value - delay(200) println(value) }上面这种场景是典型的生产速度比消费速度快。如果什么都不做collect 里的逻辑处理不完数据会在内存里堆积。Kotlin Flow 没有像 RxJava 那样复杂的背压策略而是提供了几个直观操作符buffer()给上游增加一个缓冲区让 emit 不用等 collect 处理完才继续。conflate()只保留最新值中间来不及处理的值直接丢弃适合我只关心最新状态的场景。collectLatest()当新值到达时取消当前正在进行的处理立即处理新值适合旧结果没意义的场景。这三者经常在面试中被放在一起比较。实际项目中更新进度条、列表搜索输入这类场景用conflate或collectLatest能明显减少卡顿。4.3 StateFlow 与 SharedFlow 怎么选private val _uiState MutableStateFlowUiState(UiState.Loading(0)) val uiState: StateFlowUiState _uiState.asStateFlow() private val _events MutableSharedFlowString(replay 0, extraBufferCapacity 16) val events: SharedFlowString _events.asSharedFlow()StateFlow 和 SharedFlow 都是热流但语义差异很明显StateFlow 总是持有一个最新值新订阅者订阅时立刻收到这个值适合 UI 状态这类必须知道当前值的场景。它天然支持比较当新值等于旧值时不会重复发射能有效避免无意义的 UI 刷新。SharedFlow 是一种更通用的热流它通过replay参数控制新订阅者能收到多少历史值。如果把replay设为 0就是一个事件通道事件发射之后没有订阅者收到就永远过去了。这适合一次性提示消息、导航事件这类数据。一个容易犯的错是试图给 SharedFlow 设置类似value的属性它没有。SharedFlow 不负责保存状态保存状态是 StateFlow 的职责。如果两者用混代码维护起来会很别扭。4.4 回调转 FlowcallbackFlow 的经典写法fun sensorFlow(): FlowInt callbackFlow { val listener object : SensorListener { override fun onValueChanged(value: Int) { trySend(value) } } register(listener) awaitClose { unregister(listener) } }很多老项目还是回调风格比如传感器、消息推送、文件监听。把这些回调统一成 Flow 之后业务代码可以用 Flow 的操作符链写清爽很多。callbackFlow的写法有固定套路在flow块里注册回调回调内用trySend往流里发数据最后通过awaitClose清理资源。awaitClose是很关键的一步它相当于生命周期回调保证 Flow 被取消时不会造成监听器泄漏。没有awaitClose的 callbackFlow 在构建时会直接抛异常这不是可选配置。如果要在 Flow 的发送端使用挂起函数可以考虑用channelFlow它比callbackFlow更底层但能力也更强。日常开发 90% 的场景callbackFlow加trySend就足够了。5. Kotlin 连接 SQL Server从 JDBC 直连到连接池与 Exposed5.1 驱动与依赖Kotlin 连接 SQL Server 最常规的方式还是 JDBC因为 JVM 生态里的数据库工具全部构建在 JDBC 之上。SQL Server 的官方 JDBC 驱动叫mssql-jdbc在 Maven Central 上可以找到。implementation(com.microsoft.sqlserver:mssql-jdbc:12.8.1.jre11)这里要注意驱动版本后面跟的jre11后缀它代表该构件适配的 Java 运行时版本。Java 8 环境选jre8Java 11 以上选jre11或jre17选错可能出现奇怪的连接异常。具体版本号以 Maven Central 最新发布为准但 jre 后缀要和本机 JRE 匹配 这个原则不会变。5.2 JDBC 直连写一个可以直接跑的最小示例val url jdbc:sqlserver://localhost:1433;databaseNamedemo;encrypttrue;trustServerCertificatetrue Class.forName(com.microsoft.sqlserver.jdbc.SQLServerDriver) DriverManager.getConnection(url, sa, your_password).use { conn - conn.prepareStatement(SELECT id, name FROM users WHERE id ?).use { ps - ps.setLong(1, 1L) ps.executeQuery().use { rs - while (rs.next()) { println(${rs.getLong(1)} ${rs.getString(2)}) } } } }这段代码在 Kotlin 里能直接运行。use是 Kotlin 标准库提供的扩展函数内部处理了异常和资源关闭相当于自动把Connection、PreparedStatement、ResultSet都在 finally 里 close 掉了比手写 try-finally 干净很多也避免忘关连接导致的连接泄漏。连接 URL 里的encrypttrue是微软官方驱动默认开启的 TLS 加密要求很多人在本地连不上 SQL Server 就是因为这个参数。开发环境想快速验证可以临时加trustServerCertificatetrue告诉驱动信任服务器提供的证书。生产环境应该用正式证书不要轻易信任自签名证书。JDBC 4.0 之后驱动可以通过服务提供者机制自动加载也就是说Class.forName这一行其实可以省略。但保留它有一个好处如果你换了老版本驱动或者环境比较特殊显式加载更稳。5.3 用 HikariCP 管理连接池生产环境不要每次请求都新建连接应该引入连接池。HikariCP 是目前 JVM 后端生态中使用最广泛的连接池。val hikariConfig HikariConfig().apply { jdbcUrl url username sa password your_password driverClassName com.microsoft.sqlserver.jdbc.SQLServerDriver maximumPoolSize 10 minimumIdle 2 connectionTimeout 30_000 } val dataSource HikariDataSource(hikariConfig) dataSource.connection.use { conn - // 查询代码 }minimumIdle是池中保持的最小空闲连接数maximumPoolSize是最大连接数这两个值要根据应用的并发量来定。不是越大越好SQL Server 对并发连接数有限制连接过多反而会拖垮数据库。一般建议从maximumPoolSize 10起步压测后再调。HikariDataSource 实现了javax.sql.DataSource接口所以它不光能自己用还能无缝接入 Spring、Exposed 等框架。5.4 ExposedJetBrains 出品的轻量级 ORM依赖层面额外加一个implementation(org.jetbrains.exposed:exposed-core:0.53.0) implementation(org.jetbrains.exposed:exposed-jdbc:0.53.0)Exposed 的 DSL 风格写法object Users : Table(users) { val id long(id).autoIncrement() val name varchar(name, 255) override val primaryKey PrimaryKey(id) } fun main() { Database.connect(dataSource) transaction { SchemaUtils.create(Users) val userId Users.insert { it[name] kotlin } get Users.id println(userId) } }Exposed 对 Kotlin 开发者很友好因为它写起来像 Kotlin DSL不是拼字符串 SQL类型安全度高。简单项目用它非常顺手但遇到特别复杂的联表查询、窗口函数还是直接写原生 SQL 更直白。Exposed 从来没有承诺要做成 Hibernate 那样什么都管的重型 ORM用它的时候理解这一点就不会被一些语法限制逼疯。5.5 易踩的坑驱动类找不到先查mssql-jdbc依赖是否被正确引入再看jre后缀和当前 Java 版本是否匹配。网络通但连不上SQL Server 默认可能没有开启 TCP/IP 协议或 SQL Server 身份验证模式需要在 SQL Server 配置管理器里检查这个问题最容易忽略。时区问题驱动和数据库时区设置不一致会导致时间字段读取偏差建议在连接 URL 里显式加applicationName等参数便于定位同时统一服务器和代码时区。不要硬编码账号密码本地写死图方便可以理解但提交到代码仓库之前一定要改成环境变量或密钥管理服务这个习惯要养成。6. Kotlin 生成 C 头文件Kotlin/Native 的互操作能力6.1 从 Kotlin 代码导出 C 接口是怎么回事Kotlin 和 C 之间有两种方向相反的互操作从 C 到 Kotlin用cinterop把 C 头文件转换成 Kotlin 可以调用的 API这是最常用的方式。从 Kotlin 到 C把 Kotlin 编译成原生动态库或静态库同时生成对应的 C 头文件让 C/C 代码可以反过来调用 Kotlin 导出的函数。很多人只熟悉第一种看到Kotlin 生成头文件会以为方向写反了。其实在 Kotlin/Native 的跨平台工程里第二种场景很常见。比如团队用 Kotlin 写了一套业务算法希望底层 C/C 客户端也能调用就可以把这段 Kotlin 编成libxxx.so并导出头文件给 C 侧使用。6.2 通过 Gradle 配置 sharedLib 并导出头文件以 Linux x64 目标为例在 Kotlin Multiplatform 模块的build.gradle.kts里plugins { kotlin(multiplatform) } kotlin { linuxX64 { binaries { sharedLib { baseName kotlin_core // 把指定模块的公共 API 导出到生成的 C 头文件 export(project(:shared-core)) } } } }执行构建任务之后产物会出现在build/bin/linuxX64/debugShared或releaseShared目录下里面包含.so文件和对应的.h头文件。不同 Kotlin 版本对产物的目录结构和头文件命名存在差异最稳妥的办法是执行./gradlew build后到产物目录里查看实际生成的文件名。export(project(:shared-core))这一行是重点。只有被 export 的模块 API 才会出现在生成的 C 头文件里没有导出的 Kotlin 类、函数对 C 侧是不可见的。这个设计避免把内部实现细节暴露出去。6.3 使用限制与注意事项不要在工程一开始就假设任何 Kotlin 代码都能导出给 C 端使用。Kotlin 的数据类、协程、泛型这些概念在 C 语言里没有对等映射直接导出复杂对象会非常吃力。实用做法是在 Kotlin 侧写一个薄薄的适配层把业务函数封装成简单的普通函数只导出这些函数复杂对象用指针或不透明句柄方式传递。协程函数的导出尤其麻烦。C 端不懂挂起函数如果导出的函数是个 suspend 函数C 调用方拿到的是一个带额外参数的函数签名使用体验很差。更合理的做法是在适配层用runBlocking或回调把异步逻辑包成同步函数再导出。头文件生成只是 Kotlin/Native 生态的一部分。如果你想反过来接入现有的 C/C SDK需要学习cinterop的.def文件配置这两者容易搞混一定要先理清方向。7. Eclipse 里的 Kotlin能用但也只是能用7.1 Kotlin for Eclipse 的插件现状Kotlin for Eclipse 是个存在但热度不高的选项。JetBrains 在早期为 Eclipse 开发过官方插件在 Eclipse Marketplace 中通过 Kotlin 就能搜到。但它与 IntelliJ IDEA 里的 Kotlin 支持完全不在一个量级插件更新节奏慢对新版本 Kotlin 特性支持滞后。如果团队因为历史原因只能使用 Eclipse那装这个插件可以解决能不能写 Kotlin的问题但不要期待它有 IDEA 那种智能体验。我接触到的场景多数是培训机构或传统 Java 企业还在用 Eclipse突然需要展示 Kotlin 代码装插件属于救急方案。7.2 安装和创建工程的步骤在 Eclipse 里打开Help - Eclipse Marketplace搜索Kotlin找到 KOTLIN 插件后点击的 Install之后重启 IDE 即可。安装完成后新建项目时选择Kotlin分类下的Kotlin Project。Eclipse 会要求选择 Kotlin runtime 版本默认选项通常可以用。创建好的工程会有一个src目录在里面新建.kt文件写一个带main函数的类然后右键Run As - Kotlin Application就能运行。需要注意的是Eclipse 本身版本和 JDK 版本会影响插件兼容性。旧版 Eclipse 配新版 Kotlin 插件常常出现菜单点不动、工程创建失败的问题。安装前先确认自己的 Eclipse 版本在插件支持范围内。7.3 为什么不推荐作为日常主力实际用下来Eclipse 里的 Kotlin 插件在代码自动补全、重构、类型推导提示上都明显弱于 IntelliJ IDEA。Kotlin 协程里的很多类型是内联类或者带泛型IDE 如果不能准确推断写起来几乎等于盲打。如果只是临时演示安装插件足够了。但如果真要长期在 Kotlin 项目上开发我更推荐把项目改成 Gradle Kotlin DSL 构建然后直接换 IntelliJ IDEA Community 或 Android Studio 打开。Kotlin 本身就是 JetBrains 的语言IDE 体验没有对手。最后说一点我自己的体会。很多人学 Kotlin 的时候喜欢背语法、争论Kotlin 和 Java 谁更好真正上手之后会发现语法只是表面。Kotlin 最值钱的是它逼着你在写代码之前把可空性、可变性、状态边界都想清楚。我带过的团队里同一个服务从 Java 迁到 Kotlin最明显的变化不是代码行数变少而是 code review 时不再花大量时间争论这里会不会空指针这个集合能不能被修改。这种安全感不是靠规范文档给出来的而是靠类型系统给出来的。如果你正打算在团队里推广 Kotlin建议从这两个点切入比单纯喊语法更简洁有说服力得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进