最近在带新人时,我经常被问到同一个问题:Kotlin到底应该怎么学?很多人Java写得不错,一碰Kotlin就总觉得代码别扭,空安全警告多到怀疑人生,协程用起来更是云里雾里。我前段时间把《Kotlin从零到精通Android开发》这本书从头到尾重新过了一遍,不是当小说看,而是边看边把书里的例子改成自己手头的项目代码。这篇笔记算是我在第二个阅读阶段里沉淀下来的东西,主要面向已经能写简单Kotlin代码、但还没有形成系统认识的Android开发者。如果你还在犹豫要不要把Kotlin作为主力语言,也可以拿这篇当作起点参考。
1. 环境搭建与工程结构:先解决“代码能跑但不知道跑在哪层”的问题
1.1 版本组合决定了一半的启动成功率
很多新手把环境问题归结为“运气不好”,其实绝大多数配置类报错都指向同一个根源:Kotlin、AGP、Gradle、JDK 这几样东西的版本互相不认识。我见过最典型的例子是某同学的工程里 JDK 是 17,AGP 却是比较老的版本,Gradle 同步直接报错,他却盯着代码看了半天。
我整理了一套比较稳妥的组合思路,不一定是最新,但至少能让你少踩一半的坑:
| 组件 | 建议状态 | 对应关系 |
|---|---|---|
| JDK | 17 或 11 | 新版 Android Studio 自带 JBR 通常够用 |
| Gradle | 与 AGP 匹配 | 每个 AGP 版本都有对应的最低 Gradle 版本要求 |
| AGP | 跟随项目模板 | 模板生成的就是经过验证的组合 |
| Kotlin 插件 | 与 AGP 兼容 | 主版本号建议跟着 IDE 提示走 |
| compileSdk / minSdk | compileSdk 比 targetSdk 高即可 | 两者不直接相等,很多人理解错了 |
页面显示“Unresolved reference: kotlinx”这种错误时,80% 不是代码问题,而是 Kotlin 插件版本和依赖库版本不一致。我现在的习惯是先看一眼 build.gradle.kts 里的版本号,再去查官方兼容列表,最后才怀疑代码。这个顺序能省下大量无意义的改代码时间。
1.2 第一个工程的启动链路排查
我在讲解的时候喜欢把问题分层:先看构建工具能不能跑,再看依赖能不能拉,最后才是代码能不能编译。具体到操作上,可以按这样的顺序排查:
- 先看构建输出面板的完整报错,而不是只看红字第一行。很多报错真正的原因被长堆栈盖住了,需要往上翻几屏才能找到关键行。
- 检查 local.properties 里的 sdk.dir 是否指向了实际存在的 SDK 路径。换了电脑之后这个文件经常被忽略。
- 尝试“文件 > 使缓存失效并重启”,这能解决大量 Gradle 缓存损坏导致的奇奇怪怪问题。
- 如果依赖下载卡住,先把 Gradle 的离线模式关掉,确认网络正常后再试。
提示:新装 Android Studio 后创建项目,最忌讳的是手动乱改版本号。官方模板给出的版本组合是经过大量用户验证的,没必要为了“追新”而强行升级。
把这些环境问题解决了之后,另一个容易让人困惑的点是工程本身的目录结构。刚接触 Android 工程的人常会把 kotlin 目录、java 目录、res 目录搞混。要理解,kotlin/java 目录里放的是逻辑代码,res 目录里放的是资源文件,build.gradle 文件则负责告诉构建工具这些代码和资源怎么打包。当这条链路在脑子里清晰了,很多“代码没错但运行不对”的问题就自然能找到方向。
2. 语言进阶:把 Java 写法转换为 Kotlin 思维,关键在这四件事
2.1 空安全:从“处处判空”到“类型系统替你判空”
Java 里写判空,最常见的形态是满屏的 if (x != null)。业务一复杂,判空逻辑之间还能相互嵌套,阅读起来极为痛苦。Kotlin 的做法是把空状态直接编入类型系统:一个变量能不能为 null,在声明的时候就定死了。
我经常用价格计算来举例。Java 版本可能是:
if (price != null && discount != null) { double result = price * discount; textView.setText(String.valueOf(result)); }换成 Kotlin,可以写成:
val price: Double = getPrice() val discount: Double = getDiscount() textView.text = (price * discount).toString()如果这两个值确实可能为空,就需要显式使用可空类型和安全调用:
val price: Double? = getPriceNullable() val discount: Double? = getDiscountNullable() val result: Double? = price?.times(discount ?: 1.0) textView.text = result?.toString() ?: "--"这里最该警惕的是 !!。它的意思是“我确信这里不为空”,一旦被运行期打脸,立刻抛空指针异常。我在 review 代码时,看到 !! 几乎都会停下来问一句:这个值真的不可能为空吗?能推理出来才允许保留,推理不出来就改成安全调用。工程上最好的状态是让“是否为空”这件事在类型层面被约束住,而不是靠记忆和勇气。
2.2 扩展函数与作用域函数:用对了是语法糖,用错了是代码噪音
扩展函数是 Kotlin 非常能提升表达力的特性。比如 dp 转 px,Java 里一般写一个工具方法,每处调用都要传 context 参数。Kotlin 可以给 Number 类型做扩展:
fun Number.dp2px(): Int { val density = Resources.getSystem().displayMetrics.density return (this.toFloat() * density).toInt() }之后在布局代码里直接写16.dp2px(),可读性立刻上一个台阶。不过扩展函数不是银弹,如果一个扩展函数只在某一个页面里用,我倾向于把它定义为 private 放在该页面顶部,而不是丢进全局工具类。这样既享受了扩展的便捷,又不会让全局命名空间变得杂乱。
作用域函数 apply、run、let、also 四个长得像,但语义侧重点完全不同。我用一句话总结:apply 适合配置对象,run 适合计算返回值,let 适合处理可空对象并传递结果,also 适合在链式调用里插入副作用。写代码时如果要在四者之间纠结超过十秒,就果断放弃作用域函数,直接写普通语句。过度使用作用域函数比不用更糟,因为阅读者需要额外花精力去判断你到底在做什么。
2.3 data class 与 sealed class:数据载体和有限状态的正确打开方式
data class 大概是 Kotlin 里被用得最顺手、也最容易用错的一个特性。它自动生成 equals、hashCode、toString、copy,还能支持解构声明。我项目里传递参数、接口返回值、数据库实体都会优先考虑 data class。但要注意,data class 不能无脑继承,也不适合做复杂业务对象。它更适合充当“装数据的盒子”,而不是“有行为的角色”。
真正让状态管理变得清晰的是 sealed class(以及它的兄弟 sealed interface)。比如一个网络请求的状态,用枚举表达不了“附带数据”的需求,用普通类表达又无法穷举分支。sealed class 可以这样写:
sealed class LoadState { data object Loading : LoadState() data class Success(val data: List<String>) : LoadState() data class Failed(val message: String) : LoadState() }当我在 when 表达式里处理 LoadState 时,编译器会强制我思考所有可能的分支,漏掉任何一个都无法通过编译。这比运行期才发现状态没处理要可靠得多。我之前用枚举加字段的做法,后来几乎全面替换成了 sealed class,因为它在表达“有限状态 + 不同负载”的场景下有着天然优势。
2.4 伴生对象与顶层函数怎么选
Kotlin 没有 static 关键字,很多人会用 companion object 来替代静态方法。这确实能解决需求,但伴生对象本质上还是对象,不是真正的静态成员。我建议遵循这条规律:
- 需要访问外部传入参数的“伪静态方法”,写成顶层函数更合适。
- 需要保存常量列表或枚举式定义,放到 companion object 里。
- 需要被 Java 代码直接调用,配合 @JvmStatic 注解。
- 想实现真正的全局单例状态,考虑 object 关键字而不是 companion object。
我自己经常会遇到过一种情况:某工具类里的方法全部无状态,最后整个类直接改成顶层文件,类名直接去掉,调用时连类名前缀都省了,代码简洁不少。不过顶层函数也不宜泛滥,如果只是某个模块内部使用的工具,我仍然建议保持私有顶层函数或者普通类方法,避免污染工程级命名空间。
3. 界面实战:从 XML 布局到声明式 UI,我这样把知识点串成闭环
3.1 传统 RecyclerView 体系中,Adapter 设计最容易被忽视的内层逻辑
很多教材在讲 RecyclerView 时会重点讲 Adapter 怎么重写、ViewHolder 怎么创建,但很少有人解释为什么这套体系这么设计。实际上 RecyclerView 面对的核心难题是“屏幕内可见的 item 有限,但数据可能无限”。它的解决办法是回收复用 itemView,而 ViewHolder 则是“缓存下来的视图引用容器”。
明白了这个本质,再写 Adapter 时就会注意一些细节:
- 不要在 bind 里做耗时操作,比如网络请求、复杂计算,这会让列表滚动变卡。
- 尽量用 DiffUtil 来做局部刷新。
notifyDataSetChanged()虽然简单,但会导致整个列表重绘、闪烁、焦点丢失。 - ViewHolder 里尽量保存创建好的控件引用,不要在 onBind 里反复
findViewById。
我给新人示范时,经常让他们先写一个什么都不做、只显示文本的列表页,然后逐步加需求:点击、加载更多、空态、错误态。很多人一开始会把所有状态逻辑都堆在 Activity 里,等需求变多才开始后悔。正确做法是从头就分好三层:数据层负责准备列表数据,ViewModel 负责持有状态,Adapter 只负责把状态渲染到视图上。
3.2 Jetpack Compose 的声明式思维:状态驱动界面而不是手动改界面
Compose 对传统 View 体系最大的冲击,是把“手动找控件然后改属性”变成“根据状态自动生成界面”。很多从 XML 转过来的人会习惯性想找 findViewById,但在 Compose 里根本没有这个概念。你需要接受一个事实:界面不是被“修改”的,而是被“重新生成”的。
一个最简单的例子,点击按钮让计数加一:
@Composable fun CounterPage() { var count by remember { mutableIntStateOf(0) } Button(onClick = { count++ }) { Text(text = "点击次数:$count") } }每次 count 变化,Button 和 Text 所在的 Composable 会重新执行,从而展示新界面。这个写法的好处是,你永远不需要操心“某个控件当前显示的文案是否和状态同步”,因为文案永远由状态唯一决定。扯皮状态不一致的 bug,从此可以大幅减少。
但要提醒的是,Compose 的重组粒度是函数级别,不是 View 级别。如果某个 Composable 函数里做了大量计算,状态一变整个函数都会跟着重新执行。所以写 Compose 时要习惯把大函数拆成小函数,并用 a 实现的关系把“读状态的函数”和“不读状态的函数”分开,否则很容易出现无谓的性能损耗。
3.3 一个练习页面的完整组织方式
我曾经让一个模拟项目把新闻列表做成练习页,要求同时支持加载中、加载成功、加载失败、点击跳转详情。他没有先从 UI 入手,而是先定义了一个新闻数据模型,再定义了一个加载状态模型,然后写 ViewModel 提供状态,最后才去写 Compose 页面。整个过程大概花了一个下午,但完成之后,他再看到书上那些零散知识点,就能自动归类到对应层里了。
这个练习的价值不在于页面本身,而在于理解“数据流单向流动”:界面事件发生,事件交给 ViewModel 处理,ViewModel 更新状态,状态驱动界面。如果哪一天遇到 bug,排查路径非常清晰——先看状态对不对,再看界面是否正确响应了状态。绝大多数 UI 问题都能在五分钟内定位到层。
4. 协程与结构化并发:让异步代码回归同步写法,但要懂背后的调度逻辑
4.1 为什么我坚持在主工程里放弃回调嵌套
Java 时代的异步代码,最经典的是回调嵌套。比如先请求用户信息,再根据用户信息请求订单列表,最后更新界面。代码一层套一层,缩进逐渐失控,错误处理还容易漏。协程最大的贡献不是“更快”,而是让代码在写法上回归同步风格,同时保持异步性能。
看一段对比。传统回调式写法大概是:
fetchUser(onSuccess = { user -> fetchOrders(user.id, onSuccess = { orders -> updateUI(orders) }, onFailure = { showError() }) }, onFailure = { showError() })协程写法则清晰得多:
lifecycleScope.launch { try { val user = fetchUser() val orders = fetchOrders(user.id) updateUI(orders) } catch (e: Exception) { showError(e) } }重点要理解的是:挂起函数并不会阻塞主线程。fetchUser 内部通过挂起让出线程,网络请求结束后再回到原来的调用点继续执行。这种“暂停”和“恢复”的机制,就是协程中文名字的由来。
4.2 作用域选择:viewModelScope、lifecycleScope 与 GlobalScope 的区别
协程作用域是最容易用错的地方。GlobalScope 如果在普通 app 里被滥用,协程的生命周期就和整个应用绑定,ViewModel 销毁后任务还在跑,状态更新就可能落到已销毁的界面上,轻则内存泄漏,重则崩溃。我的原则是:能不用 GlobalScope 就不用。
日常开发中,我基本只用两种作用域:
- viewModelScope,适合做业务请求,跟随 ViewModel 生命周期。
- lifecycleScope,适合做界面层短任务,跟随 Activity/Fragment 生命周期。
ViewModel 里发起请求,写成下面这样很稳:
fun loadData() { viewModelScope.launch { val result = repository.fetchData() _state.value = result } }还要理解 SupervisorJob 和普通 Job 的差别。普通 Job 中,一个子协程失败会连带取消同一个父作用域里的其他子协程;SupervisorJob 则不会。如果页面里需要并发请求三个接口,其中一个失败不应该取消另外两个,这时 SupervisorJob 就是正确的选择。这个细节书里不一定写得很透,但工程里几乎必然会遇到。
4.3 我实际踩过的三个协程坑
第一个坑是跨线程改 UI。刚接触协程时,我习惯在 withContext(Dispatchers.Default) 里直接更新界面控件,结果在调试时偶尔闪退。原因是网络请求和解析数据放在了子线程,UI 更新必须在主线程执行。后来我统一了分工:请求加上下文切到 IO 线程,回到主线程后再碰 UI,或者只用 viewModelScope 里的状态驱动界面,这样反而不用关心线程切换了。
第二个坑是并发请求被重复触发。我之前做了一个页面进入就加载数据的逻辑,但没考虑到旋转屏幕时 ViewModel 是否重建。后来发现重复的 init 请求导致列表被刷新两次,用户还会看到数据闪烁。解决方法是把加载逻辑放进 ViewModel 的初始化块,并用状态判断是否已经加载过。这个思路比到处加标志位清晰很多。
第三个坑是异常被静默吞掉。协程的异常如果不处理,有时候并不会直接崩溃,而是被默认的异常处理器处理掉,界面上没有任何反应。我在联调阶段遇到过接口报错,但界面一直停在加载中的情况。后来规范做法是在协程体内显式捕获异常,并为此设计错误状态。宁可界面报错,也不允许静默失败,这是开发体验里一条很重要的原则。
5. 从书里到工程里:我的代码习惯与复盘清单
5.1 命名和封装:把“以后能看懂”变成现在的硬要求
书里的小例子通常很短,变量名随便写 temp、data 也能读完,但工程代码动辄几万行,命名就成了最影响可读性的因素。我现在给自己定了一个标准:如果一个变量名需要注释才能解释清楚,那多半是名字起得不够好。好的命名读代码的人基本可以不用看注释就明白含义。
以列表页为例,与其命名为getList(),不如叫getArticleListForHome();与其使用data,不如使用articleList或homeFeed。这些看起来是小改动,但对维护者来说是巨大的效率差距。另外,布尔变量用 is 开头,回调参数名带动词含义,资源 ID 的命名跟着模块和类型走,这些规则都很基础,但能把“能跑的代码”提升为“能维护的代码”。
5.2 学习顺序的建议:先啃透一套,再横向铺开
读过一遍《Kotlin从零到精通Android开发》之后,我对学习顺序有了更明确的判断。如果时间有限,先不要一上来就追各种流行库。把语言特性、Activity/Fragment 生命周期、布局与列表、ViewModel、协程这几条主线吃透,就已经能应对大部分业务需求了。
我正在做的第二遍阅读,重点完全不一样。第一遍是在学语法,第二遍是在学设计。看到书里任何一个封装好的类,我都会追问一句:作者为什么把这段逻辑放在这里?如果是我,会拆成几个类?很多之前觉得“高级”的用法,站在这个角度重新看,其实都是很朴素的工程决策。
5.3 把书里的例子翻译成自己的项目,我遇到的三点差距
第一点最大的差距是数据来源。书里的例子往往是写死的数据,跑起来很顺畅,但项目里需要从网络请求拿数据,这就多出了状态管理、错误处理、缓存策略。第二点是边界条件,书里不会太多地讨论空态、网络超时、用户快速点击等极端情况,项目里必须主动补齐。第三点是版本演变,书中代码只能代表它出版时的状态,运行环境变了以后,一些 API 可能需要调整。所以每读到一个知识点,我都会到官方文档里再确认一遍最新写法,而不是直接复制书里的代码。
我把这套思路整理下来之后,最大的感受是:Kotlin 不是 Java 的简化版,而是一套愿意改变你思考方式的语言。如果你也正在读这本《Kotlin从零到精通Android开发》,建议不要迷信书里的最终代码,而是把每个例子改成自己能讲得出“为什么”的版本。这样读完一遍,收获会比想象中的大得多。