ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Kotlin Android开发实战笔记:高频问题与解决技巧

Kotlin Android开发实战笔记:高频问题与解决技巧 说句实话做 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()能拼模板就拼模板能StringBuilder就StringBuilder。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声明的就会编译报错如果arr是var那实际上是重新赋值了一个新数组引用不是真的原地追加。从数据结构的角度看如果你在一段逻辑里频繁“追加元素”更合理的做法是用MutableListval list mutableListOf(a, b, c) list.add(d)这里我要补充一个容易被忽略的坑Kotlin 的Array和List的equals行为不一样。两个内容相同的数组用比较结果是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会选中最开始默认的那一项通常 position0。如果你在这个回调里做“读取选中值并请求网络”之类操作页面一打开就会多发一次请求。这是 Android 官方控件的默认行为不是 Bug但确实很烦。常见的规避方式用一个isInitialized布尔值标记第一次回调第一次直接 return。初始化 Adapter 后用spinner.setSelection(0, false)配合post {}延迟设置监听器。在需要明确用户必须手动选择时才用该事件若只是展示数据用Tag或setTag方案。第二个坑onItemSelected回调里的parent是 AdapterView不是 Adapter。想拿选中项的数据最方便的还是parent.getItemAtPosition(position).toString()或在position基础上从你自己的 List 里取。用 Kotlin 的 type-safe 特性时建议这样val data myList.getOrNull(position) ?: return避免越界崩溃。2.2 自定义 View 里的三角形模糊箭头怎么画“三角形模糊箭头”这个需求在列表展开收起、气泡提示、流式布局标签里很常见。用图片当然简单但有些场景的动态颜色、尺寸适配用自定义 View 画更合适。一个简单清晰的绘制思路是基于Path画出三角形再用Paint的setMaskFilter做模糊。如果只是“指示箭头”直接用drawPath配合BlurMaskFilterclass 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 依赖配置里的一种写法。要理解它得先分清楚implementation、api和compileOnly的区别。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 内部还依赖了appcompat、okhttp等常用库你在主工程里没有显式声明这些库运行时大概率会NoClassDefFoundError。所以接一个三方 aar 时的标准操作是这样的把.aar文件拷入app/libs。在build.gradle的android节点里加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注解就可以在内部调用其他可组合组件。它没有返回值只负责描述界面。状态Stateremember和mutableStateOf是记忆状态的基础状态变化时使用了该状态的组合函数会自动重组。重组Recomposition所谓界面更新底层是“局部重跑一遍受影响的组合函数”不是整棵 UI 树重建。所以不要在组合函数里做耗时操作、不要起线程否则重组会反复触发。最简示例Composable fun CounterApp() { var count by remember { mutableStateOf(0) } Button(onClick { count }) { Text(点击次数: $count) } }这个例子能把“状态驱动 UI”的模型讲明白。count变化后Button和Text所在的作用域会自动重组展示新值不需要手写setText。4.2 Compose 学习路线上的具体建议从Text、Button、Column、Row开始把布局想成“嵌套函数调用”不要去想控件树。在理解了状态是 UI 的唯一数据源之后再去学ViewModel与 Compose 的配合比如collectAsState()。不要上来就背 animation API先把Animatable、animate*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.jvmTarget和compileOptions的版本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崩溃。很多项目在此处用supervisorScope或SupervisorJob来隔离异常让某个子协程失败不影响兄弟协程。这是从“能用”到“好用”的关键区别面试和实际项目里都是高频考点。val parentScope CoroutineScope(SupervisorJob() Dispatchers.Main) parentScope.launch { // 子协程A失败不影响 B }5.3 我踩过几次坑后总结的 Kotlin 实操心得说几个亲测有用的体会。第一data class的copy很好用但不要滥用。当类里有大集合时每次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我建议你就这个问题建立自己的排查清单遇到一次记一条。等积攒到几十条时你会发现写代码的速度和信心已经完全不一样了。
返回列表