Kotlin Android开发实战笔记:高频问题与解决技巧
2026/9/24 23:46:17 网站建设 项目流程

说句实话,做 Android 开发这几年,Kotlin 从“要不要学”变成了“不会就没法干活”,前后也就两三年的事。你如果一路跟着官方文档啃到中后期,大概率会卡在两种地方:一是语言本身那些看似不起眼、但用起来很关键的细节,二是从“写完能跑”到“写得顺手、出了问题能查”之间的那段距离。

这篇学习笔记是系列的第三篇,我打算换个思路,不再按语法章节往下罗列,直接拿这段时间在项目里反复碰到的高频问题做切入点:字符串格式化、数组操作、定时任务、Spinner 的事件处理、.aar 依赖引入、Compose 上手节奏,顺带把“怎么在 Android 上直接跑一段 Kotlin 文件”这种基础但很多人不知道的操作一起讲清楚。适合正在学 Kotlin、或已经转 Kotlin 但经常被小问题绊住的 Android 开发者参考,内容偏实战,你可以直接对照着写进项目里。

1. Kotlin 语言细节:字符串、数组与定时任务的高频用法

1.1 String.format() 与字符串模板,到底用哪个最顺手?

Kotlin 的字符串模板语法非常普及,"我是$name,今年${age}岁"这种写法几乎所有教程都会提到。但真正接手项目后你会发现,很多老代码和第三方库封装得比较死,还是习惯用 Java 那种占位符风格,也就是String.format()

直接用没问题,String.format("当前进度:%d/%d", current, total)在 Kotlin 里完全合法,因为 Kotlin 是基于 JVM 的,Java 的静态方法都能直接调用。但这里有个细节值得注意:String.format()的格式化字符串,如果你直接从资源文件读取,很容易因为占位符类型不匹配在运行时崩掉。比如%d对应的参数传了个字符串,会直接抛IllegalFormatConversionException。我在项目里见过不下三次这种崩溃,每次都是因为同事改了数据模型,但忘了改占位符。

实际项目中我的习惯是:

  • 需要国际化、需要统一格式模板的场景,用String.format(),因为翻译人员在 XML 资源里改占位符比改 Kotlin 代码更容易,也方便复用。
  • 只是临时拼几个变量、不超过两三个参数,直接用字符串模板$var${var},可读性高,而且没有隐式的类型转换风险。
  • 要控制数字精度、补零、千分位这类需求,优先String.format(),它背后是java.util.Formatter,能用的格式符很全,比如"%.2f"保留两位小数、"%02d"补零到两位。

有一点务必记住:String.format()返回的是新字符串,对性能极敏感的大循环里,不要反复调format(),能拼模板就拼模板,能StringBuilderStringBuilder

1.2 Kotlin 给数组增加一项,比 Java 简单但不代表没坑

“给数组增加一项”这个需求,听起来简单,但在 Kotlin 里有好几层可以展开。

先说最简单的表达:array + element

val origin = arrayOf("a", "b", "c") val newArr = origin + "d" println(newArr.contentToString()) // [a, b, c, d]

这个操作符重载底层是创建了一个更大的数组,然后System.arraycopy复制旧值进去,最后再塞入新元素。注意,它返回的是新数组,原数组不变。这是很多人最容易忽略的点:你写了arr += "d",如果arr是用val声明的,就会编译报错;如果arrvar,那实际上是重新赋值了一个新数组引用,不是真的原地追加。

从数据结构的角度看,如果你在一段逻辑里频繁“追加元素”,更合理的做法是用MutableList

val list = mutableListOf("a", "b", "c") list.add("d")

这里我要补充一个容易被忽略的坑:Kotlin 的ArrayListequals行为不一样。两个内容相同的数组用==比较,结果是false,因为数组比较的是引用;而相同内容的两个List==比较,多数情况下是true。所以在写单元测试或业务判断时,如果用数组存数据却直接用equals==去对比,你会莫名踩坑。稳妥做法是contentEquals()比较数组,或直接转List再比较。

1.3 Kotlin 间隔任务的常见写法与取消时机

间隔任务有两种诉求:一种是“每隔一段时间执行一次”,另一种是“延迟到某个时间点执行一次”。后台定时推荐协程,UI 上的周期刷新则要小心生命周期。

推荐写法:

// 在协程里实现周期任务 fun startPeriodicTask(scope: CoroutineScope, intervalMillis: Long = 5000, onTick: () -> Unit) { scope.launch { while (isActive) { onTick() delay(intervalMillis) } } }

这里有几个关键点。isActive是协程协作取消的核心,如果循环里没有检查它,scope.cancel()后协程不会立刻停止,可能还会再跑一次、两次甚至多次,取决于当前是否处于delay()挂起点。还有一点:第一次执行是“立即”,不是“先等一个周期”,如果需要先延迟后执行,把delay()挪到onTick()前面即可。

如果你用Timer类写定时任务,一定要记得在页面销毁时调用timer.cancel(),否则 Timer 线程持有 Activity/Fragment 引用,轻则内存泄漏,重则导致已销毁页面里的 UI 操作崩溃。相比之下协程写周期任务更自然,也更容易和ViewModel集成。

注意:协程里的delay()不会阻塞线程,它只是挂起当前协程。这在 Android 主线程上做周期闪烁、轮播更新时非常重要,不会带来卡顿。

2. Android 里的 Kotlin 事件与视图处理

2.1 Spinner 变化事件:setOnItemSelectedListener 的细节

Spinner 在 Android 里虽然老,但在表单类界面中仍占一席之地。很多初学者直接复制的写法是:

spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { // 处理选中 } override fun onNothingSelected(parent: AdapterView<*>?) { // 未选中处理 } }

这里最常见的坑是:Spinner 在布局加载时会自动回调一次onItemSelected,会选中最开始默认的那一项(通常 position=0)。如果你在这个回调里做“读取选中值并请求网络”之类操作,页面一打开就会多发一次请求。这是 Android 官方控件的默认行为,不是 Bug,但确实很烦。

常见的规避方式:

  • 用一个isInitialized布尔值标记第一次回调,第一次直接 return。
  • 初始化 Adapter 后,用spinner.setSelection(0, false)配合post {}延迟设置监听器。
  • 在需要明确用户必须手动选择时才用该事件,若只是展示数据用TagsetTag方案。

第二个坑:onItemSelected回调里的parent是 AdapterView,不是 Adapter。想拿选中项的数据,最方便的还是parent.getItemAtPosition(position).toString(),或在position基础上从你自己的 List 里取。用 Kotlin 的 type-safe 特性时,建议这样:

val data = myList.getOrNull(position) ?: return

避免越界崩溃。

2.2 自定义 View 里的三角形模糊箭头,怎么画?

“三角形模糊箭头”这个需求,在列表展开收起、气泡提示、流式布局标签里很常见。用图片当然简单,但有些场景的动态颜色、尺寸适配,用自定义 View 画更合适。

一个简单清晰的绘制思路是基于Path画出三角形,再用PaintsetMaskFilter做模糊。如果只是“指示箭头”,直接用drawPath配合BlurMaskFilter

class TriangleArrowView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { private val arrowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL color = Color.parseColor("#333333") } private val path = Path() override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) val halfW = w / 2f path.reset() path.moveTo(halfW - 20f, h * 0.3f) path.lineTo(halfW + 20f, h * 0.3f) path.lineTo(halfW, h * 0.7f) path.close() } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) // 先画一个柔和阴影效果 arrowPaint.setMaskFilter(BlurMaskFilter(12f, BlurMaskFilter.Blur.NORMAL)) canvas.drawPath(path, arrowPaint) arrowPaint.setMaskFilter(null) canvas.drawPath(path, arrowPaint) } }

这段代码的重点是:先画一层带模糊的图形,再画一层清晰的图形,就能实现“边缘柔化箭头”的视觉效果。如果想做纯糊的三角形,也可以把两层合成一层,但视觉上不加底部清晰层会很“脏”,像脏了块墨渍。

如果你需要的是“指向某个控件的模糊小箭头”,建议在onSizeChanged里根据宽高动态计算坐标,并让 View 的尺寸尽量写死,否则不同分辨率下三角形比例会飘。

2.3 如何在 Android 项目里直接运行一个 Kotlin 文件?

这个问题看起来基础,但问的人真不少。很多人写了个 Kotlin 测试类,想在 Android 环境里跑一遍,结果发现没有main入口,也不知道怎么执行。

有几种路径:

  • Android Studio 里的 Kotlin REPL:打开菜单 Tools -> Kotlin -> Kotlin REPL,可以执行简单的 Kotlin 表达式,但它不依赖 Android SDK,也无法访问 Context、Activity,只适合验证纯 Kotlin 语法。
  • Kotlin Scratch File:在项目里新建.kts文件,直接写fun main()然后在文件里点运行按钮。它基于项目环境,可以引用项目里的类,但不建议引用 Android SDK 之外的 UI 组件。
  • JUnit 单元测试:在src/test/java/下写测试类,这才是 Android 项目里验证纯逻辑最靠谱的方式。Kotlin 的main其实可以写在测试里:
class MyLogicTest { @Test fun testSomething() { println("hello from kotlin test") } }
  • 命令行运行:如果你装了 Kotlin 编译器的命令行工具,可以用kotlinc -script test.kts跑脚本文件,也可以在编译后通过java -jar运行打包好的 Jar。这在验证纯算法类逻辑时非常快,但不沾 Android 环境。

归纳一下:想在 Android 环境里验证 UI 相关代码,用 device 上的run或测试。想快速验证语法和算法,用 Scratch File 或 JVM 测试。千万不要在MainActivity里写一堆临时实验代码,然后手动注释清理,那不是学习,是给自己埋坑。

3. 依赖管理:compileOnly 与 .aar 文件的使用心法

3.1 compileOnly(filetree(...)) 到底解决什么问题?

搜索热词里有一条很具体:compileonly filetree(dir: 'libs', include: ['*.aar']),这是 Gradle 依赖配置里的一种写法。要理解它,得先分清楚implementationapicompileOnly的区别。

  • implementation:依赖参与编译,并且打进 APK,但不会把依赖暴露给模块外部编译。
  • api:依赖参与编译,也会把依赖关系暴露出去,外部模块也能直接引用其中的类。
  • compileOnly:依赖只参与编译,不参与打包,不会打进 APK 或 AAR。

compileOnly filetree(dir: 'libs', include: ['*.aar'])整行的意思是:以libs目录下的所有.aar文件为编译期依赖,但最终打包时不带上这些 AAR。这种写法在一些 SDK 对接场景里非常常见,尤其当你的模块只想编译时引用对方的接口,而实际的实现文件会在运行时由宿主工程或另一个组件提供。

举一个典型的场景:你开发一个动态库,库内部要用到某手机厂商的核心服务接口,但发布时又不允许把厂商的 AAR 包进去,因为你希望最终集成方自行提供那个 AAR。这时候你就可以用compileOnly来声明,确保编译能过,但产物里不包含大体积的三方文件。

3.2 引入 .aar 文件的实操细节

如果你只是想把一个.aar放到libs目录里正常使用,在 Gradle 中通常是:

dependencies { implementation fileTree(dir: 'libs', include: ['*.aar']) }

这个写法会把所有 aar 和 jar 都当成依赖打进去。和正常implementation 'com.xxx:yyy:1.0.0'相比,本地 aar 最大的问题是它不会自动传递依赖:aar 本身打包时不会把它的pom依赖信息带进来。如果那个 aar 内部还依赖了appcompatokhttp等常用库,你在主工程里没有显式声明这些库,运行时大概率会NoClassDefFoundError

所以接一个三方 aar 时的标准操作是这样的:

  • .aar文件拷入app/libs
  • build.gradleandroid节点里加:
repositories { flatDir { dirs 'libs' } }
  • dependencies里写:
implementation(name: 'your-lib-name', ext: 'aar')
  • 查一下这个 aar 的AndroidManifest.xml(可以用解压工具打开 aar 查看)和它的源码,把缺失的运行时依赖逐一补进主工程。

注意:compileOnly的类在编译期存在,但运行期没有。如果最终宿主工程或运行时环境真的没有提供对应实现,你会在调用这些类时收到NoClassDefFoundError。这种错误比编译错误难排查得多,因为它不会在构建阶段暴露。

3.3 依赖冲突的排查思路

使用compileOnly filetree后最常见的报错是“Could not find or load main class”或者“ClassNotFoundException”。这时候不要慌,按顺序排查即可。

先确认 libs 目录下的文件名是否有中文或空格,Gradle 对本地文件路径的解析经常因特殊字符出问题。然后确认模块的build.gradle里是否真的声明在dependencies而不是allprojects里。接着用 Android Studio 的 Gradle 面板跑dependencies任务,看看依赖树里有没有重复或冲突的包。

我上次接一个小厂商的扫码 SDK 时,用compileOnly filetree引用 aar 编译毫无问题,但跑到真机上扫码就崩。排查了半天,发现是 aar 里内嵌的某个版本okhttp和主工程用的 4.x 版本冲突,导致HttpUrl类的初始化抛异常。换成implementation后再用exclude group: 'com.squareup.okhttp3'排掉内嵌的旧版本,问题才解决。所以我的建议是:非必要不用compileOnly引用 aar,能用implementation就用implementation,只有明确要求“运行时由宿主提供”时才用compileOnly

4. 想快速掌握 Compose 和 Kotlin,别一上来就啃源码

4.1 Compose 最核心的思维转变

很多 Kotlin 新手问“Compose 和 Kotlin 怎么快速掌握”,我的第一句永远是:先忘掉 XML 布局那套findViewById的思路,再去学 Compose,否则你会非常痛苦。Compose 不是“用代码写 XML”,它是一个完全不同的声明式 UI 框架,界面的更新是基于状态变化自动触发的。

关键就三点:

  • 可组合函数(Composable):普通函数前加@Composable注解,就可以在内部调用其他可组合组件。它没有返回值,只负责描述界面。
  • 状态(State)remembermutableStateOf是记忆状态的基础,状态变化时,使用了该状态的组合函数会自动重组。
  • 重组(Recomposition):所谓界面更新,底层是“局部重跑一遍受影响的组合函数”,不是整棵 UI 树重建。所以不要在组合函数里做耗时操作、不要起线程,否则重组会反复触发。

最简示例:

@Composable fun CounterApp() { var count by remember { mutableStateOf(0) } Button(onClick = { count++ }) { Text("点击次数: $count") } }

这个例子能把“状态驱动 UI”的模型讲明白。count变化后,ButtonText所在的作用域会自动重组展示新值,不需要手写setText

4.2 Compose 学习路线上的具体建议

  • TextButtonColumnRow开始,把布局想成“嵌套函数调用”,不要去想控件树。
  • 在理解了状态是 UI 的唯一数据源之后,再去学ViewModel与 Compose 的配合,比如collectAsState()
  • 不要上来就背 animation API,先把Animatableanimate*AsState的常见用法看了即可,动画后期可以按需补。
  • 多写小案例,比如拆一个页面到 5 个小组件,再从小组件拼回去,比别人二十页笔记都有用。

Compose 的类库迭代速度极快,跟着官方文档和 release note 会更稳,而不是去背网上的旧教程。

5. 常见问题与排查技巧实录

5.1 Kotlin 常见编译错误的对症速查表

报错特征原因解决思路
Only safe (?.) or non-null asserted (!!.) calls are allowed调用了可空类型的属性或方法使用?.安全调用,或业务逻辑确保非空后转!!,并处理空值分支
Type mismatch: inferred type is X but Y was expected类型不匹配检查集合泛型、nullable 类型,必要时显式声明类型
Unresolved reference类或方法找不到检查 import、依赖是否存在、包路径是否被修改
Cannot inline bytecode built with JVM target 1.8JVM 目标版本不一致build.gradle里统一kotlinOptions.jvmTargetcompileOptions的版本
Class 'X' is not abstract and does not implement abstract member接口/抽象类方法未全部实现用 IDE 的快捷生成实现或检查父类的默认方法

这些错误里,JVM 目标版本不一致是最容易被忽略的。项目里如果某个模块用 Java 11,另一个模块用 Java 8,而 Kotlin 的jvmTarget又设置不对,编译期可能不报错,但运行期容易出现NoSuchMethodError

5.2 协程里最容易踩的取消与异常坑

协程用多了,最常见的问题是“任务取消不了”和“子协程抛异常不崩溃”。

先看第一类:同步代码里调用挂起函数前没有检查取消状态。

scope.launch { while (true) { delay(1000) doBlockingWork() // 这个操作无法被取消 } }

delay()是可取消的挂起点,但doBlockingWork()是普通阻塞操作,协程被取消后不会打断它。必要时要在ensureActive()isActive中定期检查取消状态。

再看异常:子协程的异常默认会传播给父协程,导致整个scope崩溃。很多项目在此处用supervisorScopeSupervisorJob来隔离异常,让某个子协程失败不影响兄弟协程。这是从“能用”到“好用”的关键区别,面试和实际项目里都是高频考点。

val parentScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) parentScope.launch { // 子协程A失败不影响 B }

5.3 我踩过几次坑后总结的 Kotlin 实操心得

说几个亲测有用的体会。

第一,data classcopy很好用,但不要滥用。当类里有大集合时,每次copy都是浅拷贝,列表对象还是同一个引用,修改列表内容会影响“原对象”,这在状态管理时会带来不易察觉的 Bug。

第二,顶层函数和扩展函数要控制数量和分组。很多人学 Kotlin 喜欢把所有工具函数写成顶层公开放到某个 utils 包里,久了以后突然想查“这个函数在哪定义”,找半天也找不到。更好的做法是放在关联类的伴生对象或独立的object里,作为显式的工具类使用,可读性和可维护性都好很多。

第三,Kotlin 的by lazy虽然好用,但在 Android 里要小心线程模型。默认用的是LazyThreadSafetyMode.SYNCHRONIZED,多线程场景没问题,但首次调用如果里面有 UI 操作,就可能出现线程告警。需要明确“主线程初始化的 View 引用”时,可以显式指定by lazy(LazyThreadSafetyMode.NONE),然后在主线程里第一次读取。

第四,从 Java 接手的老工程里如果有 Kotlin 混编,注意@JvmStatic和伴生对象的差别。伴生对象的方法默认不是静态方法,Java 侧调用需要先访问Companion,如果你在 Java 类里想直接MyClass.myMethod()调用,必须在方法上加@JvmStatic。别等运行期才想起来。

结尾想多说一句

学习 Kotlin 最忌讳的就是“背语法”。我见过太多人文档翻得飞起,网课刷了好几套,真上手写业务时连java.util.Date转换都理不利索。我自己也是从 Java 转过来,踩过的坑大部分都不在语法本身,而在异步、可空性、生命周期这些“看不见的地方”。这篇笔记挑了我在实际项目中最常被问到和踩过的点,如果你也遇到类似问题,按照上面的思路去处理,基本能绕开大部分雷区。

如果你正好也在学 Kotlin,我建议你就这个问题建立自己的排查清单,遇到一次记一条。等积攒到几十条时,你会发现写代码的速度和信心已经完全不一样了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询