三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Kotlin开发实战:高频痛点解析与避坑指南

Kotlin开发实战:高频痛点解析与避坑指南

1. 从“Hello World”到“What the Hell”:Kotlin开发中的高频痛点

干了这么多年安卓和后台开发,从Java全面转向Kotlin也有四五年了。Kotlin这语言,用起来是真香,空安全、扩展函数、协程,哪一样都让人回不去。但香归香,踩的坑也是一个没少。特别是项目大了,团队人多了,或者要跟一些老旧的Java库、特定的构建工具打交道时,各种稀奇古怪的报错就冒出来了。最经典的莫过于那个error: kotlin: module was compiled with an incompatible version of kotlin. the binary version of its metadata is x.x to be expected is y.y,我相信每个Kotlin开发者都在不同场合被它“问候”过。这还只是冰山一角,协程的泄漏、Flow的冷热流混淆、与Java互操作时的类型擦除惊喜、Gradle构建脚本里版本号打架……这些问题不像业务逻辑bug那么有迹可循,往往耗费大量时间排查,最后发现可能只是一个配置项没对齐。

这篇文章,我就想把这些年积累下来的,以及从团队小伙伴那里收集来的高频疑难问题,做一次彻底的梳理和复盘。目标不是讲Kotlin语法(那太基础了),而是聚焦于那些“写的时候好好的,一跑就崩”或者“在A环境正常,到B环境就报错”的实战问题。我会结合具体错误信息、场景,给出根因分析和一劳永逸的解决方案。无论你是刚开始接触Kotlin,还是已经用它做了几个项目,相信这里总有几个坑是你遇到过或即将遇到的。咱们不搞虚的,直接上干货。

2. 环境与构建:万恶之源的版本冲突

绝大多数让人头皮发麻的Kotlin问题,根源都在于环境。构建工具(Gradle)、Kotlin编译器、标准库、乃至IDE插件,任何一个环节版本不匹配,都可能引发连锁反应。

2.1 “元数据版本不兼容”错误的终极解决

Module was compiled with an incompatible version of Kotlin这个错误,堪称Kotlin界的“ClassNotFoundException”。它通常出现在以下几种情况:

  1. 项目依赖的某个库(AAR或JAR)是用更高版本的Kotlin编译器编译的。
  2. 多模块项目中,子模块和根模块的Kotlin版本不一致。
  3. IDE(如IntelliJ IDEA)缓存的编译结果与Gradle实际使用的版本不符。

根因剖析:Kotlin编译器在输出字节码的同时,会生成一份额外的“元数据”(metadata),用来保存Kotlin特有的类型信息(如空安全、内联类等),以便其他Kotlin模块在编译时能正确理解它。这个元数据格式本身有版本号。当你的项目用Kotlin 1.8编译,却尝试依赖一个用Kotlin 1.9编译的库时,前者可能无法解析后者的新版本元数据,于是就报了这个错。

解决方案不是简单地升级或降级,而是一套组合拳:

  1. 统一项目中的Kotlin版本:这是根本。在你的根项目的build.gradle.kts(或build.gradle) 中,使用extbuildscript块定义统一的版本号。

    // build.gradle.kts (根项目) buildscript { extra.apply { set("kotlinVersion", "1.9.23") // 使用一个稳定版本 } } // 然后在所有子模块的build.gradle.kts中引用 // 子模块 build.gradle.kts val kotlinVersion by rootProject.extra dependencies { implementation("org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion") }
  2. 检查并统一Gradle插件版本:Kotlin Gradle插件版本必须与Kotlin标准库版本匹配。通常它们的主版本号一致。

    // 根项目 build.gradle.kts plugins { kotlin("jvm") version "1.9.23" apply false // 注意这里apply false } // 子模块中直接应用插件,无需再指定版本 plugins { kotlin("jvm") }
  3. 处理第三方库的版本冲突:如果冲突来自第三方库(如某些库强制依赖了更高版本的Kotlin),可以使用Gradle的强制依赖解析。但需谨慎,可能引发其他问题。

    // 根项目 build.gradle.kts configurations.all { resolutionStrategy.eachDependency { if (requested.group == "org.jetbrains.kotlin") { useVersion("1.9.23") // 强制所有Kotlin相关依赖使用此版本 } } }
  4. 清理IDE和构建缓存:执行File -> Invalidate Caches and Restart,并命令行执行./gradlew cleanBuildCache

实操心得:我建议使用Kotlin版本目录(Version Catalogs)进行更优雅的依赖管理。在gradle/libs.versions.toml文件中定义版本,一改全改,彻底杜绝手误导致的不一致。

# gradle/libs.versions.toml [versions] kotlin = "1.9.23" [libraries] kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } [bundles] kotlin-common = ["kotlin-stdlib", "kotlin-reflect"]

然后在模块中引用:implementation(libs.bundles.kotlin.common)。这是目前Gradle官方推荐的最佳实践。

2.2 Gradle构建脚本中的Kotlin DSL陷阱

从Groovy切换到Kotlin DSL (.gradle.kts) 是趋势,但语法差异常导致隐蔽错误。

常见问题1:配置作用域混淆。在Groovy中,dependencies块里写implementation ‘com.example:lib:1.0’似乎很随意。但在Kotlin DSL中,你必须清楚当前上下文。例如,在plugins块之后直接写implementation是无效的,因为它不在dependencies配置块内。

正确写法

plugins { kotlin("jvm") version "1.9.23" } // 这里不能直接写 implementation dependencies { // 必须进入dependencies配置块 implementation("com.example:lib:1.0") }

常见问题2:类型安全的代价。Kotlin DSL是类型安全的,这既是优点也是坑。比如,在配置sourceSets时:

sourceSets { main { java { srcDirs("src/main/kotlin") // 正确:srcDirs是一个集合操作 // srcDir("src/main/kotlin") // 错误:在java块内,srcDir方法可能不可用或含义不同 } } }

你需要查阅Gradle的API文档或利用IDE的自动补全,而不是凭Groovy的经验猜测。

常见问题3:委托属性与extra的用法。在根项目定义扩展属性供子模块使用,Kotlin DSL的写法更明确但也更繁琐。

// 根项目 build.gradle.kts extra["sdkVersion"] = 34 // 子模块中读取 val sdkVersion by rootProject.extra android { compileSdk = sdkVersion as Int // 注意类型转换! }

避坑指南:对于复杂的构建逻辑,建议将其封装到buildSrc目录下的Kotlin类中。这样你可以获得完整的IDE支持(代码补全、跳转、重构),远比在.gradle.kts文件里写一大段脚本更可维护。buildSrc本身就是一个标准的Kotlin/JVM模块,可以定义插件、任务和工具函数。

2.3 多平台项目(KMP)的配置难题

Kotlin Multiplatform (KMP) 是未来,但现在的构建配置堪称“迷宫”。一个典型的build.gradle.kts可能包含androidiosjsjvm等多个Target配置,以及commonMainandroidMain等源码集。

高频坑点:依赖作用域错误。给commonMain添加了一个仅JVM可用的库(如java.net.URL相关的),导致编译iOS或JS时失败。必须严格区分commonplatform-specific的依赖。

kotlin { sourceSets { val commonMain by getting { dependencies { // 这里只能放多平台通用库,如 kotlinx-coroutines-core implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") // implementation(“some-jvm-only-lib:1.0”) // 错误!会导致其他平台编译失败 } } val jvmMain by getting { dependencies { implementation(“some-jvm-only-lib:1.0”) // 正确,仅JVM平台使用 } } } }

资源与清单文件处理:在Android Target中,你需要正确配置AndroidManifest.xml和资源目录,它们不会自动从common模块继承。通常需要在android块内单独配置sourceSets

构建缓存与清理:KMP构建缓存问题更突出。经常遇到改了common代码,但某个平台(如iOS)的编译结果没更新。除了清理项目,有时还需要删除~/.gradle/caches~/.konan目录下的相关缓存。

排查技巧:当KMP构建失败时,首先运行./gradlew :your-multiplatform-module:dependencies,查看各源码集的依赖树,确认是否有不兼容的依赖被错误引入。其次,使用./gradlew clean--refresh-dependencies强制刷新依赖。对于iOS模拟器链接失败等问题,检查Xcode命令行工具是否安装,以及cocoapods配置是否正确。

3. 语言特性与运行时:那些“聪明反被聪明误”的瞬间

Kotlin提供了许多简洁强大的语法糖,但理解不透彻就会埋下隐患。

3.1 协程:泄漏、取消与异常处理

协程是Kotlin的杀手锏,也是问题重灾区。

问题1:协程泄漏(Coroutine Leaks)。这比内存泄漏更隐蔽。在Android中,在ActivityViewModel中启动一个协程,如果该协程的生命周期长于组件本身(例如,在GlobalScope中启动,或没有在onCleared/onDestroy中取消),那么即使组件销毁了,协程仍在后台运行,可能继续持有对组件上下文的引用,导致内存泄漏和不可预期的行为。

错误示例

class MyViewModel : ViewModel() { fun fetchData() { // 错误!GlobalScope的生命周期是应用级别的,不会随ViewModel销毁而自动取消 GlobalScope.launch { val data = repository.loadData() // 如果这个操作很耗时... _uiState.value = data } } }

正确做法:使用viewModelScope(Android) 或lifecycleScope,它们会在适当的生命周期自动取消。

class MyViewModel : ViewModel() { fun fetchData() { viewModelScope.launch { // 当ViewModel cleared时,此scope下的所有协程会自动取消 val data = repository.loadData() _uiState.value = data } } }

对于非Android环境,可以自定义CoroutineScope并将其与组件的生命周期绑定,在组件销毁时调用scope.cancel()

问题2:协程取消的协作性(Cooperative Cancellation)。协程的取消是协作式的,意味着协程内部必须定期检查isActive或调用ensureActive()yield()等挂起函数,才能响应取消请求。如果一个协程内部执行一个纯CPU密集型的计算循环,且从不挂起,那么即使你取消了它,它也会继续执行到结束。

错误示例

viewModelScope.launch { var i = 0 while (i < 1_000_000_000) { // 一个极长的循环,没有挂起点 // 做一些计算... i++ } // 即使外部取消了该协程,这个循环也会跑完 }

正确做法:在长循环中定期检查取消状态。

viewModelScope.launch { var i = 0 while (i < 1_000_000_000 && isActive) { // 检查 isActive // 做一些计算... i++ yield() // 或者定期调用 yield() 以让出线程并检查取消状态 } if (!isActive) { // 处理取消后的清理工作 return@launch } // 继续后续逻辑 }

问题3:异常的传播与处理launchasync的异常处理机制不同。launch中未捕获的异常会立即传播,可能导致上层协程作用域崩溃(除非用SupervisorJob)。而async返回的Deferred对象,其异常是延迟的,直到你调用.await()时才会抛出。

scope.launch { val deferred = async { throw RuntimeException("Oops inside async!") } try { deferred.await() // 异常在这里抛出,可以被捕获 } catch (e: Exception) { println("Caught: $e") } } scope.launch(SupervisorJob()) { // 使用SupervisorJob,子协程的失败不会影响兄弟协程和父协程 launch { throw Exception("Brother 1 fails") } // 这个崩溃不会影响下面的兄弟 launch { println("Brother 2 still runs") } // 这个仍然会执行 }

核心原则:牢记“结构化并发”。为每个有明确生命周期的组件创建自己的CoroutineScope,并在这个作用域内启动所有协程。在组件销毁时取消整个作用域。这能自动管理所有子协程的生命周期,是避免泄漏和混乱的根本。

3.2 Flow:冷流、热流与背压

Flow是响应式数据流,概念上类似RxJava的Observable,但设计更简洁。混淆其“冷流”本质是常见错误。

冷流(Cold Flow):像菜谱。每个收集者(collector)都会触发一次独立的执行流程。flow { ... }构建器创建的就是冷流。这意味着,如果你有一个从网络获取数据的Flow,每次调用.collect都会发起一次新的网络请求。这有时是需要的,但有时(如共享最新数据)会导致资源浪费。

热流(Hot Flow):像电视直播。数据生产独立于收集者。StateFlowSharedFlow是热流。它们存储数据,新的收集者会收到最新的数据(或历史数据,取决于配置),而不会触发新的生产逻辑。

一个典型误区:在ViewModel中暴露一个普通的冷Flow,然后在多个Fragment中分别收集它,期望它们收到相同的数据。结果每个Fragment都触发了一次独立的数据加载。

// ViewModel中 val myData: Flow<Data> = flow { emit(repository.fetchData()) // 每次收集都会调用fetchData! } // Fragment A中 viewModel.myData.collect { ... } // 触发一次网络请求 // Fragment B中 (同时显示) viewModel.myData.collect { ... } // 又触发一次网络请求!浪费!

解决方案:使用stateInshareIn操作符将冷流转换为热流。

// ViewModel中 val myData: StateFlow<Data> = flow { emit(repository.fetchData()) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), // 5秒无订阅者后停止上游流 initialValue = Data.Empty ) // 现在多个收集者共享同一个数据源,且只会发起一次网络请求。

背压(Backpressure):当生产者发射数据的速度快于消费者处理的速度时,就会产生背压。Flow默认的处理策略是“缓冲”,但缓冲区满了之后会挂起生产者。你需要根据场景选择合适的操作符:

  • buffer(): 设置缓冲区,让生产者和消费者可以并发运行。
  • conflate(): 只保留最新的数据,丢弃中间来不及处理的数据。适用于UI刷新场景。
  • collectLatest(): 当新数据到来时,如果旧数据的处理还没完,就取消旧的处理,立即开始处理新的。适用于搜索建议等场景。

3.3 空安全与类型系统的“灰色地带”

Kotlin的空安全是编译期的护城河,但并非绝对安全。

平台类型(Platform Types):当调用Java代码时,Kotlin编译器无法确定返回类型是否可空,于是将其标记为平台类型(如String!)。你需要自己决定如何处理它。盲目使用!!非空断言是危险的。

// Java代码 public class JavaClass { public String getValue() { return null; } // 可能返回null } // Kotlin中调用 val value: String = JavaClass().getValue() // 编译警告,运行时可能抛出NPE val safeValue: String? = JavaClass().getValue() // 正确,显式声明为可空

建议:对关键的Java接口,使用@Nullable@NonNull注解,Kotlin编译器会识别这些注解。或者,在Kotlin侧尽早用?.?:进行安全处理。

泛型与可空性List<String>List<String?>是天壤之别。前者保证列表内每个元素非空,后者允许元素为null。在函数设计时要明确。

fun processItems(items: List<String>) { // 调用方保证列表元素非空 items.forEach { it.length } // 安全 } fun processNullableItems(items: List<String?>) { items.forEach { it?.length } // 需要安全调用 }

类型擦除与reified:JVM上的泛型在运行时会被擦除。Kotlin的inline+reified组合是解决此问题的利器,允许你在运行时访问泛型的具体类型。

inline fun <reified T> parseJson(json: String): T { val type = object : TypeToken<T>() {}.type // 这里需要Gson等库的支持,但T是具体的 return Gson().fromJson(json, type) } // 使用 val user = parseJson<User>("{...}") // 可以推断出T是User

4. 互操作性与集成:与Java世界的爱恨情仇

Kotlin与Java互操作性极佳,但细节决定成败。

4.1 SAM转换与函数式接口的陷阱

SAM(Single Abstract Method)转换允许你将一个lambda自动转换为Java函数式接口(如Runnable,OnClickListener)的实例。但在重载方法时可能出问题。

// Java代码 public interface Processor { void process(String input); } public class Worker { public void doWork(Processor processor) { ... } public void doWork(Runnable runnable) { ... } // 重载方法 }
// Kotlin中调用 Worker().doWork { println("Hello") // 编译错误!歧义:这个lambda可以匹配Processor,也可以匹配Runnable }

解决:需要显式指定类型。

Worker().doWork(Processor { println(it) // it 是 String 参数 }) // 或者 Worker().doWork(Runnable { println("Hello") })

4.2 伴生对象、静态成员与@JvmStatic

Kotlin没有静态成员,用伴生对象(companion object)替代。但从Java调用时,需要加Companion后缀,除非使用@JvmStatic注解。

class MyClass { companion object { const val CONSTANT = "value" @JvmStatic fun staticMethod() { ... } fun nonStaticMethod() { ... } } }
// Java中调用 String constant = MyClass.CONSTANT; // 正确,常量可以直接访问 MyClass.staticMethod(); // 正确,@JvmStatic使其像静态方法 MyClass.Companion.nonStaticMethod(); // 需要.Companion // MyClass.nonStaticMethod(); // 错误

对于顶级函数和属性,Kotlin编译器会生成一个以文件名+Kt为名的Java类(如File.kt生成FileKt类)。使用@JvmName可以自定义这个类名。

4.3 异常检查的差异

Kotlin没有受检异常(checked exception)。这意味着在Kotlin中调用可能抛出受检异常的Java方法时,不需要用try-catch包围。反之,Kotlin函数如果可能抛出异常,在Java中调用时,也不需要捕获(尽管实际上可能抛出)。这有时会让习惯了Java受检异常安全的开发者感到不安。你需要通过文档或约定来明确函数可能抛出的异常。

5. 平台特定问题:Android与后端开发的专属难题

5.1 Android开发:Compose、序列化与Parcelable

Jetpack Compose 中的@Stable@Immutable:Compose通过重组来更新UI。为了性能,Compose会跳过不必要的重组。标记@Stable@Immutable是告诉Compose编译器:这个类/属性在值相等时,其内部状态(对于@Stable)或所有属性(对于@Immutable)也被认为是相等的,从而可以安全地跳过重组。错误地标记会导致UI不更新。

  • @Immutable:用于所有属性都是val且类型也是不可变的数据类。
  • @Stable:用于属性可能变化,但Compose可以智能比较的情况(如MutableState)。

序列化库的选择与坑kotlinx.serialization是官方首选,但和Gson/Jackson的注解不兼容。混合使用可能导致字段被忽略或重复序列化。务必统一项目中的序列化方案。如果必须与旧Java代码共用JSON,可能需要配置kotlinx.serializationJson实例,使其行为与Gson兼容(如忽略未知键)。

Parcelable 与@Parcelize@Parcelize注解可以自动生成Parcelable实现,但要求所有属性都是可Parcelable的类型。如果包含自定义类型,该类型也必须实现Parcelable。对于不可Parcelable的类型(如某些第三方类),你需要自己写Parcelable实现,或者考虑其他数据传输方式(如序列化为JSON字符串)。

5.2 后端开发:Spring Boot集成与Coroutine上下文传播

在Spring Boot中使用Kotlin协程,最大的挑战是上下文传播。Web请求的上下文(如安全上下文SecurityContext、事务上下文@Transactional)通常是绑定到ThreadLocal的。而协程可能会在不同线程间切换,导致上下文丢失。

解决方案:使用kotlinx-coroutines-slf4j库中的MDCContext来传播SLF4J的MDC(映射诊断上下文)。对于Spring Security,可以使用ReactorContext(如果项目用了WebFlux)或自定义的协程上下文元素来手动传播SecurityContext

import kotlinx.coroutines.slf4j.MDCContext import org.slf4j.MDC suspend fun someServiceCall(): String { val requestId = MDC.get("requestId") // 这里能正确获取到值,因为上下文被传播了 return withContext(Dispatchers.IO) { // 即使在IO线程,MDC上下文依然存在 performIO(requestId) } } fun controllerMethod() = runBlocking { // 在协程起点,将当前MDC放入协程上下文 val context = MDCContext() withContext(context) { someServiceCall() } }

对于事务,确保@Transactional注解的方法在同一个协程中执行,避免在挂起后切换到另一个可能没有事务的线程上执行数据库操作。通常,将@Transactional注解放在suspend函数上,并确保数据访问操作都在同一个协程上下文中完成,是相对安全的做法。

6. 工具链与调试:让问题无处遁形

6.1 编译器插件与注解处理器(kapt vs ksp)

Kotlin注解处理主要有两种方式:kaptksp

  • kapt:在Kotlin编译成Java字节码后,调用Java注解处理器(APT)。它兼容性好,但速度慢,因为需要生成存根(stub)文件。
  • ksp:Kotlin Symbol Processing,直接处理Kotlin的符号(AST),是Kotlin原生的,速度更快,支持Kotlin特有特性。

迁移建议:如果使用的库(如Dagger、Room、Glide)已经支持KSP,强烈建议从kapt迁移到ksp。这能显著提升构建速度。在build.gradle.kts中:

plugins { id("com.google.devtools.ksp") version "1.9.23-1.0.19" // 版本号需与Kotlin版本对应 } dependencies { ksp("androidx.room:room-compiler:2.6.1") // 用ksp替代kapt } // 删除原有的 kapt 配置

6.2 调试协程与Flow

调试异步代码一直是个挑战。IntelliJ IDEA提供了强大的协程调试工具。

  1. 在调试窗口启用“协程”视图:运行调试时,在Debugger窗口的Frames标签页旁边,有一个“Coroutines”标签页。这里可以看到所有活跃的协程、它们的状态(RUNNING、SUSPENDED)、挂起点和上下文。
  2. 设置协程调试模式:在运行配置中,可以添加-Dkotlinx.coroutines.debugJVM参数。这样,当你在日志或异常堆栈中看到线程名时,会附带协程信息(如@coroutine#1),方便追踪。
  3. 调试Flow:可以使用flow操作符中的onEachonStart来打印日志。对于复杂的流,可以考虑使用kotlinx-coroutines-debug库(注意性能开销,仅用于开发环境)。

6.3 性能分析与内存排查

协程泄漏检测:Android Studio Profiler或独立的JVM Profiler(如YourKit, JProfiler)可以抓取内存快照,分析对象引用链。查找那些本该被回收的ActivityViewModel是否被某个长期运行的协程(通过ContinuationJob对象)持有。

使用-Xmx-XX:+HeapDumpOnOutOfMemoryError:在JVM后端服务中,设置这些参数可以在OOM时自动生成堆转储文件,用MAT或VisualVM分析,找出内存泄漏的根源。

避免在热路径上使用高阶函数和Inline类:虽然Kotlin的inline函数能减少运行时开销,但过度使用或在非常高频的循环中使用复杂的inline函数链,可能会使字节码膨胀,反而影响性能。对于绝对性能关键的代码段,需要进行基准测试(使用kotlinx.benchmark库)。

7. 思维模式与最佳实践:从“能用”到“优雅”

最后,分享几个思维层面的心得,这些问题不解决,代码能跑,但会埋下长期的技术债。

拥抱不可变性:尽可能使用val而非var,使用不可变集合(如List,Map)而非可变集合(MutableList,MutableMap)作为函数参数和返回值。这能极大减少并发问题和状态管理的复杂度。如果需要修改,返回一个新的副本。

善用标准库函数,但不要滥用let,run,with,apply,also这五个作用域函数是利器,但选择哪个有讲究:

  • let:变换对象,返回lambda结果。obj.let { it.transform() }
  • run:在对象上执行操作,返回lambda结果。obj.run { this.property = value; compute() }
  • with:非扩展函数,对一个对象执行多个操作。with(obj) { doSomething(); doAnother() }
  • apply:配置对象自身,返回对象本身。obj.apply { property1 = value1; property2 = value2 }
  • also:对对象执行附加操作,返回对象本身。obj.also { log(it) }

链式调用时,注意可读性。超过两层的嵌套就可能难以理解了。

设计面向领域的API:利用扩展函数为现有类添加领域相关功能。例如,为String添加一个isValidEmail()的扩展,而不是到处写重复的正则表达式检查。这能让业务代码更清晰。

测试策略:协程测试需要使用runTestkotlinx-coroutines-test)来提供可控的虚拟时间,避免测试因真实延迟而变慢或不稳定。对于Flow,使用test收集器来断言发射的值。

@Test fun `test flow emission`() = runTest { // 使用 runTest val flow = flowOf(1, 2, 3) val result = mutableListOf<Int>() val job = launch { flow.collect { result.add(it) } } // runTest 会立即执行协程,无需 advanceUntilIdle job.cancel() assertEquals(listOf(1, 2, 3), result) }

Kotlin是一门在不断进化的语言,社区和工具链也在快速发展。保持学习,关注官方博客和Kotlin Conf的更新,同时建立一个自己的“避坑笔记”,把遇到的问题和解决方案记录下来。很多时候,你遇到的奇怪问题,很可能只是某个版本的小bug,或者是一个最佳实践还没普及的角落。多读源码(特别是标准库和kotlinx.coroutines),理解其设计意图,才能真正驾驭这门语言,写出既安全又优雅的代码。

← 返回列表