为什么安卓全面转向 Kotlin
Kotlin 由 JetBrains 开发,2017 年被 Google 宣布为安卓官方首选语言,此后新项目几乎都用它起步。原因不复杂:在完全兼容 Java 生态的前提下,它用更少的代码表达同样的意图,并且把很多运行时错误提前到了编译期。
具体收益可以量化为三点:空指针崩溃大幅减少(空安全是类型系统的一部分);样板代码显著变短(数据类、属性访问器、Lambda);异步代码可读性提升(协程取代回调地狱)。
Kotlin 的价值不在“更简洁”本身,而在“把一整类错误提前到编译期”。写得少只是副产品,编译期就能拦住问题才是核心收益。
如果你还在维护 Java 工程,不必一次性重写:Kotlin 与 Java 可以在同一模块内互调,按功能模块逐步迁移是最现实的路径。
空安全:把崩溃挡在编译期
空指针异常曾是安卓崩溃统计里的头号来源。Kotlin 把可空性写进类型系统:String 表示一定不为空,String? 表示可能为空,编译器强制你在使用前处理。
fun greet(name: String?): String {
// 安全调用 + Elvis 运算符:为空时给默认值
val display = name?.trim()?.takeIf { it.isNotEmpty() } ?: "游客"
return "你好,$display"
}
fun main() {
println(greet(null)) // 你好,游客
println(greet(" 绿码 ")) // 你好,绿码
// val len = name.length // 编译器直接报错:name 可能为空
}
几个高频操作符要记牢:?. 安全调用,?: 提供默认值,!! 强制断言(除非你百分百确定,否则不要用),as? 安全类型转换。工程里可以开启编译告警,把滥用 !! 视为代码异味在评审中拦下来。
数据类、密封类与解构
Kotlin 用几个关键字省掉了大量样板代码,这也是它最直观的收益。
- data class自动生成 equals、hashCode、toString、copy,天生适合做数据模型。
- sealed class子类数量在编译期确定,配合 when 可做穷尽检查,是表达状态机的最佳工具。
- object声明单例,线程安全且延迟初始化,替代 Java 的双检锁写法。
- 解构声明用 val (a, b) = pair 拆解 componentN,也能给自定义类扩展 componentN。
密封类尤其值得强调。用它定义界面状态(加载中 / 成功 / 失败)后,when 表达式会检查你是否处理了所有分支;将来新增一种状态,编译器会报出所有漏处理的位置。这比用枚举加一堆可选字段可靠得多。
扩展函数与作用域函数
扩展函数允许你不修改原类就为它添加方法,非常适合把工具逻辑收敛到调用点附近。
// 扩展函数:给 String 增加领域语义的方法
fun String.toDisplayPhone(): String =
if (length == 11) "${substring(0, 3)}****${substring(7)}" else this
// 扩展属性
val String.isChineseMobile: Boolean
get() = matches(Regex("^1[3-9]\d{9}$"))
fun main() {
println("13800138000".toDisplayPhone()) // 138****8000
println("13800138000".isChineseMobile) // true
}
作用域函数有五个:let、run、with、apply、also。选择方法看两点——你想返回对象本身还是 lambda 结果,以及函数体内用 it 还是 this。
- apply:配置对象后返回自身,最常用于构建与初始化。
- let:对非空对象做变换,返回值是 lambda 结果。
- also:做副作用(打日志、校验)后返回自身。
- run:需要同时做配置与计算,返回 lambda 结果。
不要为了“写得短”而嵌套多层作用域函数,可读性下降的收益是负的。
协程:结构化并发的异步方案
协程让异步代码看起来像同步代码:用 suspend 标记挂起函数,用 lifecycleScope 或 viewModelScope 绑定生命周期,用 withContext(Dispatchers.IO) 切换线程。相比回调与 RxJava,它更易读也更容易取消。
核心概念有三个:挂起(suspend)不是阻塞线程,而是让出线程去做别的事;结构化并发保证父协程不会在子协程完成前结束;异常会沿协程层级向上传播,用 CoroutineExceptionHandler 或 try/catch 处理。
两个关键实践:一是在界面层使用与生命周期绑定的作用域,避免页面销毁后回调更新已销毁的视图;二是耗时任务要能被取消,涉及 IO 的循环要检查 isActive 或使用 ensureActive()。
Kotlin 与 Java 互操作
互操作是无缝迁移的前提,有几个细节必须掌握,否则会出现难以定位的空指针问题。
- Java 方法返回的类型被称为平台类型,Kotlin 无法知道它是否可空,编译期不做检查。稳妥做法是在边界处显式声明类型(如
val s: String? = javaObj.getName())并处理空值。 - 用 @JvmStatic、@JvmField、@JvmOverloads 等注解控制编译产物,方便 Java 侧调用。
- Kotlin 的默认参数在 Java 中不可见,需要 @JvmOverloads 生成重载。
- 伴生对象(companion object)在 Java 中通过 Companion 访问,加 @JvmStatic 可直接静态调用。
团队协作时一个实用约定:Java 侧的公开方法都用注解标注可空性(@Nullable / @NonNull),这样 Kotlin 调用方就能得到正确的类型信息,边界处的空安全才有保障。
实战:协程版网络请求与状态封装
把密封类、协程与状态管理组合起来,写一个可复用的加载状态封装。
sealed interface UiState<out T> {
data object Loading : UiState<Nothing>
data class Success<T>(val data: T) : UiState<T>
data class Error(val message: String, val cause: Throwable? = null) : UiState<Nothing>
}
class UserViewModel(
private val repo: UserRepository = UserRepository(),
) : ViewModel() {
private val _state = MutableStateFlow<UiState<User>>(UiState.Loading)
val state: StateFlow<UiState<User>> = _state.asStateFlow()
fun load(userId: String) {
viewModelScope.launch {
_state.value = UiState.Loading
_state.value = try {
UiState.Success(repo.fetchUser(userId))
} catch (e: IOException) {
UiState.Error("网络不可用,请检查连接", e)
} catch (e: Exception) {
UiState.Error("加载失败:${e.message}", e)
}
}
}
}
// Repository 内部切换 IO 线程
class UserRepository {
suspend fun fetchUser(id: String): User = withContext(Dispatchers.IO) {
api.getUser(id)?.toUser() ?: throw IllegalStateException("用户不存在")
}
}
这段代码体现了 Kotlin 工程实践的几个要点:密封接口穷尽表达状态,界面层用 when 判断无需再猜 null;viewModelScope 让协程随 ViewModel 销毁自动取消;异常在数据边界处分类转换,界面层只负责展示。把这些模式固定下来,团队代码风格会高度统一。
常见坑与工程建议
最后整理五个高频问题与对应的建议。
- 滥用
!!:等于放弃空安全。改为?:默认值、requireNotNull报错,或提前 return。 - 在全局作用域启动协程:GlobalScope 不受生命周期管理,几乎总会泄漏。改用 viewModelScope 或自定义的受控作用域。
- suspend 函数里做阻塞调用:把阻塞 IO 包进
withContext(Dispatchers.IO),否则会卡住主线程。 - 忽略 Java 边界可空性:平台类型不做处理,崩溃会出现在最意想不到的地方。
- 过度使用作用域函数嵌套:可读性优先于行数,必要时写普通语句。
语言特性是用来降低出错概率的,不是用来炫技的。凡是让同事需要停下来想三秒的写法,都值得改回去。
下一步建议读 Jetpack Compose,把声明式 UI 与协程状态流结合起来,那才是现代安卓开发完整的形态。