这篇笔记记录到第三篇了。前面两篇把 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 和 TimerTask:
val 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.22,B 模块用 1.9.10,编译时轻则警告,重则直接报 Kotlin 版本不一致的异常。
我自己的做法是在根项目 gradle.properties 里定义公共版本号:
kotlin.version=1.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 的 shader:
paint.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 里到处是 lambda,Button 的 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 的官方文档都很全面,但没有人能从头到尾读完。带着问题去查,查到就做笔记,这是最有效率的方式。我这个系列笔记就是按这个逻辑积累下来的,第三篇写完,回头翻前两篇,发现有不少当时觉得很难的点,现在已经是肌肉记忆了。
最后,如果你也在学习过程中琢磨上面这些知识,不用追求一次全记住。把这篇笔记收藏起来,等真正写代码遇到这些问题时,再回来看一遍,收获会比硬记大得多。