Kotlin实战高频坑:字符串格式化、协程定时任务与Android UI细节
发布时间:2026/9/24 23:46:17来源:尧图网络
这篇笔记记录到第三篇了。前面两篇把 Kotlin 的基础语法、空安全、集合、函数式写法过了一遍到了实际写 Android 业务的时候我发现真正卡住人的往往不是语法本身而是一些“看着会、用起来踩坑”的细节字符串格式化到底该用模板字符串还是 format、数组怎么加一项、定时任务用协程还是 Handler、Gradle 里那行 compileOnly filetree 到底什么意思。这篇文章就是把这些高频问题按场景重新整理一遍顺便把最近在 UI 控件和 Compose 学习上的一些经验也沉淀下来。适合 Kotlin 语法已经过了一遍、正在写真实业务代码时遇到具体问题来查阅的同学。1. Kotlin 语言层的高频特性盘点这一章挑的是日常业务代码里出现频率极高的三个语言层知识点。它们本身都不难但正因为不难很多教程都是一笔带过实战里真碰到反而要停下来查半天。我把它们的适用场景、写法对比和容易忽略的坑集中记下来。1.1 String.format()模板字符串和格式化并不冲突很多人写 Kotlin 之后字符串拼接基本都换成了 ${} 模板字符串这确实是 Kotlin 比 Java 舒服的地方。比如展示一个用户昵称和积分val tip 用户 $name 当前积分$score这种纯拼接场景模板字符串完全碾压 format。但只要涉及数字格式化模板字符串就有点力不从心了比如保留两位小数、整数补零、金额千分位、百分比展示。这些场景用 String.format() 反而更直接val price 1234.5 val text String.format(Locale.CHINA, %.2f, price) // 1234.50 val percent 0.125 val text2 String.format(Locale.CHINA, %.1f%%, percent * 100) // 12.5% val clock String.format(Locale.CHINA, %02d:%02d, 9, 5) // 09:05网上很多 Kotlin 示例写 String.format() 时不带 Locale 参数这在国内绝大多数项目里确实跑不出什么问题但有一个隐藏风险默认 Locale 在部分系统或区域设置下小数点可能变成逗号日期格式也会跟着变。做海外项目或者金融类项目时这里就是一颗雷所以我现在习惯统一传 Locale.CHINA 或 Locale.US。另外说一点性能上的体感。String.format() 底层走的是 java.util.Formatter比模板字符串要重一些如果在循环里高频拼接比如一秒钟几百次的日志输出会有可感知的耗时差异。普通业务展示完全无感但如果写在对性能敏感的基础库里就要慎重。我的习惯是展示层随便用基础库和循环内优先用模板字符串或者 StringBuilder。1.2 数组增加一项先想清楚你到底要什么数据结构Kotlin 里的 arrayOf(1, 2, 3) 本质上是 JVM 数组长度固定没有 add 方法。所以很多人第一次写“给数组增加一项”时会被编译器的红色波浪线搞得一头雾水。最常见的做法是用 plus 操作符val old arrayOf(a, b) val new old c // 返回一个新数组old 本身不变这里要注意的是plus 并没有修改原数组而是创建一个新数组并拷贝元素。这在只加一两次的场景下没问题但如果你在循环里反复做 old old item 这种操作每次都要新建数组和拷贝时间复杂度会变成 O(n²)数据量一大立刻卡顿。所以写这个操作之前先想清楚你要的到底是什么结构场景推荐写法往副本数组追加一项不改变原数组oldArr item 或 oldArr.plus(item)自己维护的动态列表频繁增删mutableListOf() 或 ArrayList用 add()一次性构建大量数据ArrayList能估算长度就指定初始容量只读使用的集合直接用 List 类型别用数组项目里最常见的实际场景是从接口拿到一个数组你想在最前面插入一个“全部”选项。这种时候用 arrayListOf(全部).also { it.addAll(originList) } 或者 listOf(全部) originList 都很清晰。不过要小心这行代码会生成一个新列表如果对内存特别敏感比如超大列表还是尽量用可变列表原地操作。还有一个细节数组和 List 之间的转换也很常用。数组转 List 用 toList()List 转数组用 toTypedArray()网上搜 Kotlin 示例时这两个方法出现频率非常高值得记一下。1.3 间隔任务从 Timer 到协程的路线图业务里“每隔一段时间做一次”的需求特别多轮询订单状态、倒计时、心跳上报、广告自动轮播。我从老项目到新项目基本把间隔任务的写法都试了一遍算是一个演进路线。最早的项目里用的是 Timer 和 TimerTaskval timer Timer() timer.schedule(object : TimerTask() { override fun run() { // 执行任务 } }, 1000, 2000)Timer 的问题在于它跑在后台线程run() 里不能直接更新 UI需要 post 到主线程而且 timer.cancel() 之后同一个实例不能再 schedule否则会抛 IllegalStateException。这里要特别注意很多崩溃就是这么来的。再后来用的是 Handler.postDelayed 递归val handler Handler(Looper.getMainLooper()) val runnable object : Runnable { override fun run() { // 执行任务 handler.postDelayed(this, 1000) } } handler.postDelayed(runnable, 1000)这种方式的好处是天然跑在主线程适合做倒计时、UI 轮播这类操作。但缺点也很明显页面销毁时一定要 removeCallbacks不然就是一个隐性的内存泄漏。我早年踩过这个坑一个轮询任务在 Fragment 销毁后还在跑导致 Activity 一直被持有后来排查半天才发现是忘了清理 Runnable。现在的项目基本都用协程了写法简洁太多lifecycleScope.launch { while (isActive) { // 执行任务 delay(1000) } }在 ViewModel 里用 viewModelScope在 Fragment 或者 Activity 里用 lifecycleScope组件销毁时协程自动取消不再需要手动清理这解决了我过去最头疼的生命周期管理问题。delay() 只挂起当前协程不阻塞线程所以 1000ms 的间隔不会卡 UI。不过用协程写间隔任务也有一个容易忽略的坑如果循环体里的任务执行时间超过了间隔时间任务就会重叠。比如轮询接口时网络超时 3 秒而 delay 只有 1 秒就会出现上一次请求还没回来下一次又发出去的情况。我的习惯是在循环体里用一个 state flag 或者 Mutex 做互斥while (isActive) { if (!isRunning) { isRunning true try { doRequest() } finally { isRunning false } } delay(1000) }简单说新代码优先选协程老代码里看到 Handler 递归写法也别急着全部推翻先把生命周期处理好功能稳定优先。2. 工程配置Kotlin 项目里的依赖管理与本地库如果说语言层是“怎么写”工程配置就是“怎么跑起来”。这一章要拆解的是一些 Gradle 相关的知识点尤其是 compileOnly 和本地 aar 的用法。很多 Kotlin 项目的编译问题最后都定位到依赖配置上。2.1 compileOnly 和 filetree 拆开讲网上有一类搜索词特别有意思compileonly filetree(dir: libs, include: [*.aar])。这其实是 Gradle 里一行配置但混在一起写确实容易让人一头雾水。我先拆开解释。compileOnly filetree(dir: libs, include: [*.aar])filetree(dit: libs, include: [*.aar])创建一个文件集合对象指向当前模块下的 libs 目录include 表示只匹配后缀为 .aar 的文件。compileOnly依赖作用域表示编译期可见运行期不打进产物。组合起来的含义就是编译的时候我要引用 libs 目录下这些 aar 里的类但最终生成的 apk/aar 包里不要包含它们。那什么场景会用到最常见的是 SDK 开发。比如你在写一个广告 SDK接口和实现分离编译时需要调用宿主 App 提供的某个类但你的 SDK 又不能在包里重复打进这个类因为宿主里已经有了重复了会冲突。这时候用 compileOnly 就对了。另外插件化开发、或者在运行期才动态加载的模块也经常这么用。放一张配置对比表更直观配置编译期可见运行期打进包典型场景implementation是是绝大多数依赖api是且向依赖方传递是公共底层库compileOnly是否SDK 开发、宿主提供类runtimeOnly否是基本用不到2.2 本地 aar/jar 的引入与避坑工程里有时候会使用一些不在中央仓库的私有库或者厂商给的 SDK直接放 libs 目录下。aar 和 jar 的处理方式不一样jar 直接把文件丢进 libs 就能用aar 还得先声明 flatDir 仓库repositories { flatDir { dirs libs } } dependencies { implementation(name: mylib, ext: aar) }也可以配合前面说的 filetree 一起用implementation fileTree(dir: libs, include: [*.jar])这样 libs 目录下所有 jar 都会被引用。如果同时存在 aar 和 jar可以用两组 include 分开声明也可以直接写 fileTree(dir: libs)。本地库踩过的坑我整理一下都是真实项目里碰到的多个 module 同时依赖同一个 aar编译时会报 Duplicate class。解决办法是让其中一个 module 改成 compileOnly只留一个真正的 implementation或者在依赖里用 exclude 排除重复的模块。aar 内部的传递依赖不会自动带过来。aar 不像远程依赖那样有 pom 文件它自己依赖了哪些第三方库不会告诉你的构建系统。所以如果运行期报 ClassNotFoundException多半是少了某个传递依赖需要手工补上。META-INF 下文件冲突。比如多个 aar 里都有 META-INF/xxx构建时会包重复文件错误需要在 android 块里配置 packagingOptions 或 packaging 块来 exclude。2.3 多模块下的 Kotlin 版本统一项目模块一多每个 module 的 build.gradle 里都写一遍 kotlin_version 是很容易翻车的。比如 A 模块用 1.8.22B 模块用 1.9.10编译时轻则警告重则直接报 Kotlin 版本不一致的异常。我自己的做法是在根项目 gradle.properties 里定义公共版本号kotlin.version1.9.10然后各 module 引用implementation org.jetbrains.kotlin:kotlin-stdlib:${rootProject.ext.kotlin_version}新版 Android Studio 的项目模板更推荐用 gradle/libs.versions.toml 这个版本目录文件Gradle 会自动生成类型安全的引用写起来更舒服[versions] kotlin 1.9.10 [libraries] kotlin-stdlib { group org.jetbrains.kotlin, name kotlin-stdlib, version.ref kotlin }然后模块里直接引用implementation(libs.kotlin.stdlib)这个方式的好处是版本集中在文件顶部升级时一眼就能看到哪些地方需要同步调整。老项目如果没有用版本目录可以先把公共的 plugin 和依赖版本抽到 buildSrc 里统一管理逐步迁移。3. Android UI 与 Kotlin 的实战组合这一章记录的是 UI 开发中几个和 Kotlin 写法结合比较紧密的点。有老的 View 系统也有新的 Compose放在一起是因为它们背后都涉及同一个问题状态变化如何触发 UI 更新。3.1 Spinner 选择事件初始化就触发的那个坑Spinner 是 Android 里非常经典的下拉选择控件在 Kotlin 里监听选中事件一般这么写spinner.onItemSelectedListener object : AdapterView.OnItemSelectedListener { override fun onItemSelected(parent: AdapterView*?, view: View?, position: Int, id: Long) { // 处理选中逻辑 } override fun onNothingSelected(parent: AdapterView*?) { // 没有选中任何项 } }这里有一个非常经典的坑onItemSelected 在 Spinner 完成初始化或者 setAdapter 之后会立刻触发一次且 position 是 0。如果你在这个回调里写了“根据当前选择加载数据”的逻辑页面一打开就会莫名其妙多一次请求而且默认选中第一项的行为甚至不被用户感知。我自己处理这个坑的方法有两个第一种是用标志位跳过首次触发private var isFirstSelect true override fun onItemSelected(...) { if (isFirstSelect) { isFirstSelect false return } // 真正的业务逻辑 }第二种是记录上一次选中的 position只有当 position 发生变化时才执行逻辑。这种方式更健壮因为有些操作虽然不是首次但重复设置同一个 item 也会触发回调如果业务上不需要响应直接丢掉即可。还有一点要注意调用 adapter.notifyDataSetChanged() 之后onItemSelected 也可能会再次触发。如果有类似“选中的城市变了刷新列表”的逻辑要注意避免重复刷新。这个问题在数据量大的列表里尤其隐蔽。3.2 三角形模糊箭头一个典型的自定义 View 思考路径有个搜索词是“android kotlin 三角形模糊箭头”这种需求在实际项目里其实很常见气泡弹窗、下拉提示、功能引导都需要在弹窗上方加一个小箭头。别小看这个小图形它集合了自定义 View 的几个基本功Path 绘制、模糊效果、尺寸转换。先分析需求。“三角形模糊箭头”其实包含两个要素三角形本身以及边缘的柔和过渡或半透明渐变。如果只是纯色小箭头用 shape 资源就够了。但要做模糊效果就得写自定义 View。一个最基础的自定义三角形箭头可以这样写class TriangleArrowView JvmOverloads constructor( context: Context, attrs: AttributeSet? null, defStyleAttr: Int 0 ) : View(context, attrs, defStyleAttr) { private val paint Paint(Paint.ANTI_ALIAS_FLAG).apply { color Color.parseColor(#333333) } private val path Path() private fun dp2px(value: Float): Float TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value, resources.displayMetrics) override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) val middle w / 2f path.reset() path.moveTo(middle - dp2px(8f), 0f) path.lineTo(middle dp2px(8f), 0f) path.lineTo(middle, dp2px(10f)) path.close() } override fun onDraw(canvas: Canvas) { canvas.drawPath(path, paint) } }模糊效果可以通过给 Paint 设置 BlurMaskFilter 实现paint.maskFilter BlurMaskFilter(dp2px(2f), BlurMaskFilter.Blur.NORMAL)注意使用 BlurMaskFilter 时 View 需要开启软件层否则在某些设备上不会有任何效果setLayerType(LAYER_TYPE_SOFTWARE, null)如果想要箭头颜色有从上到下的渐变可以用 LinearGradient 作为 Paint 的 shaderpaint.shader LinearGradient( 0f, 0f, 0f, height.toFloat(), Color.TRANSPARENT, Color.parseColor(#333333), Shader.TileMode.CLAMP )把 blur 和 gradient 结合就是一个边缘柔和的渐变箭头。这种问题在求职面试和开源项目里经常出现算是自定义 View 的入门题但这个思路可以延伸到很多复杂控件上。3.3 Compose 和 Kotlin 怎么快速掌握搜索词里还有一个高频问题compose 和 kotlin 怎么快速掌握。首先要澄清一个概念Compose 不是 Kotlin 的一部分Kotlin 是编程语言而 Compose 是 Android 的声明式 UI 框架。两者之所以经常放在一起讲是因为 Compose 几乎把 Kotlin 的函数式特性用到了极致代码写起来像在构建一棵 UI 树。如果理解了这层关系学习重点就清楚了。第一条线是补 Kotlin 里面与 Compose 强相关的语言特性Lambda 和高阶函数Compose 里到处是 lambdaButton 的 onClick、Column 的内容 block本质上都是函数参数。状态与可变性mutableStateOf 和 remember 是 Compose 状态管理的核心理解起来需要一点闭包和状态的概念。Data Class 和 Sealed Class写 UI 状态、事件类型的时候非常常用。协程Compose 的副作用 API 如 LaunchedEffect 底层就是协程不熟悉协程的话这部分会卡住。第二条线是转变 UI 开发思维。传统 Android 开发是命令式findViewById 找到控件setText 修改内容。Compose 的核心心智模型只有一句UI 是状态的函数。状态变了UI 自动重组。所以写 Compose 时不要老想“改这个控件”而是想“状态应该怎么设计”。快速上手的路径我自己的经验是按这个顺序来Kotlin 基础语法速览 - 状态管理remember / rememberSaveable / mutableStateOf - 布局系统Column / Row / Box / Modifier - 列表LazyColumn - 导航 - 与 ViewModel 集成 - 自定义 Composable如果时间有限就做一个连续两周的计划每天写一个小页面登录页、列表页、详情页、设置页。比对着文档看两个月有用得多。官方开源项目 Now in Android 是一个非常好的参考代码分层、状态管理、依赖注入都写得很规范适合直接拿来抄。4. 常见问题与排查技巧实录写代码最花时间的往往不是写而是排错。这一章把我自己在 Kotlin 项目里遇到频率最高的几类问题以及排查思路记录下来。大部分问题都不是语法不会而是编译环境、依赖配置或反射时机这些系统性问题。4.1 编译期错误Unresolved reference 与版本矩阵Unresolved reference 恐怕是 Kotlin 编译报错的 Top 1。遇到这个错误第一步不是去改代码而是按照下面的顺序排查依赖是否引入。最常见的是忘了在 build.gradle 里添加对应库的依赖。比如用了 compose 但没加 compose 的 implementation编译器说什么 reference 都认不出来。Kotlin 版本与 AGP 版本是否匹配。Kotlin 和 Android Gradle Plugin 是有兼容矩阵的版本差太多会报各种看不懂的错误。比如 Kotlin 1.9 配合 AGP 7.x 大概率没问题但 Kotlin 2.0 配老 AGP 就可能遇到编译插件加载失败。Gradle 缓存是否脏了。依赖明明加了代码看着也没问题但还是 unresolved。这种时候先试 File - Invalidate Caches 清理缓存或者./gradlew clean。实测下来很多奇奇怪怪的问题都出在缓存上。还有一个常见的 JVM target 相关报错提示 bytes built with JVM target 1.8 being built with JVM target 1.6。这是 compileOptions 和 kotlinOptions 的 jvmTarget 不一致导致的在 app 模块的 build.gradle 里统一一下android { compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { jvmTarget 17 } }最好所有模块保持一致。这个报错在引入新的第三方库时特别容易触发因为第三方库可能是更高的 target 编译的。4.2 运行期问题Kotlin 文件怎么才能跑起来有一个搜索词是“如何在 android 上运行 kotlin 文件”这个问题看起来基础但实际背后有三种完全不同的场景很多人会混淆场景一纯 Kotlin 脚本不依赖任何 Android API。这种可以脱离 Android 环境运行。在 Android Studio 里新建一个包含 main 函数的 Kotlin 文件右键选择 Run MainKt 就可以了。本质上它跑的是 JVM 上的 Kotlin不是 Android 机的代码。场景二类里面有 Android 组件比如 Activity、Fragment或者调用了 Context、Intent 等 Android API。这种文件单独跑是跑不起来的必须放进 Android 工程编译打包成 APK然后在模拟器或真机上运行。很多初学者在这里被卡住明明有 main 函数但一运行就崩溃原因就是代码依赖了 Andorid 运行时。场景三写测试代码。如果你只是想验证某个纯逻辑函数不要启动整个 App用本地单元测试是最合适的。在 src/test 下写一个测试类右键 Run几秒种就能看到结果。所以判断一个 Kotlin 文件能不能“单独运行”就一句话看它依赖不依赖 Android 运行时。不依赖的可以跑 JVM依赖的只能进工程。4.3 混淆配置与 Kotlin 反射的注意事项发布 release 包时混淆是必须过的坎。Kotlin 项目比纯 Java 多几个注意点。第一个是反射。Kotlin 的反射功能在 kotlin-reflect 库中默认不会打进包里需要单独加依赖implementation org.jetbrains.kotlin:kotlin-reflect:$kotlin_version而且混淆之后靠字符串类名反射查找肯定崩。如果代码里有 Class.forName(com.example.MainActivity) 这样的逻辑必须在混淆规则里 keep 住-keep class com.example.MainActivity { *; }第二个是 data class 配合 JSON 序列化。Gson、Moshi、Kotlinx Serialization 这类库在混淆后如果没配置好字段名会被改成 a、b 这类短名导致序列化结果对不上。解决办法是 keep 数据类的字段名或者干脆在混淆规则里对实体类所在的包整体 keep-keep class com.example.entity.** { *; }很多新项目在 debug 包一切正常一上 release 包接口就解析不出来十有八九就是这个原因。4.4 Gradle 依赖冲突的排查思路再补充一个运行期和编译期都会遇到的依赖冲突问题。比如项目里同时引入了两个库它们各自传递依赖了不同版本的 Kotlin stdlib构建时就会看到冲突提示。Gradle 默认会选择最高版本但这不一定是正确选择可能会引入破坏性变更。我的排查习惯是使用 dependencies 命令看完整依赖树./gradlew :app:dependencies --configuration debugRuntimeClasspath输出非常长但能清楚看到每个依赖从哪个库传递进来。定位到冲突来源后在 build.gradle 里用 exclude 或 resolutionStrategy 指定版本。不建议一上来就写 force先看清楚依赖关系再说。5. 我的几点学习建议笔记写到这里最后聊几句自己的体会。我学 Kotlin 的过程并不是线性推进的语法看一遍很快真正掌握全靠写业务和踩坑。比如 Spinner 那个首次触发的问题我是在线上反馈“页面打开就多调了一次接口”才发现并追查的。所以我的第一个建议是遇到问题先自己复现再去看源码和文档印象会比任何教程都深。第二个建议把官方文档当字典不要当书读。Kotlin 和 Android 的官方文档都很全面但没有人能从头到尾读完。带着问题去查查到就做笔记这是最有效率的方式。我这个系列笔记就是按这个逻辑积累下来的第三篇写完回头翻前两篇发现有不少当时觉得很难的点现在已经是肌肉记忆了。最后如果你也在学习过程中琢磨上面这些知识不用追求一次全记住。把这篇笔记收藏起来等真正写代码遇到这些问题时再回来看一遍收获会比硬记大得多。
网站建设高端定制企业官网