我常用的 5 个 Kotlin 优化小技巧

任何人都能写出计算机可以理解的代码。优秀的程序员写出人类能够理解的代码。

--- Martin Fowler

各位 Kotlin 开发者们,早上好。先说声抱歉,最近鼻炎 + 感冒折腾了我好一阵,所以文章更新迟了一点。

今天我先来分享一下最近几年常用的一些 Kotlin 代码优化小技巧,与君共享。

使用 inline 减少 Lambda 带来的额外开销

高阶函数接收的 Lambda,可能需要以 Function 对象的形式存在。

尤其是捕获了外部变量的 Lambda,在反复调用时可能产生额外的对象分配。

如果类似这样的逻辑位于 RecyclerView 的绑定过程或者逐帧回调中,它就可能执行成千上万次,并给垃圾回收器增加本来可以避免的工作。

Kotlin 的 inline 修饰符专门可以解决这个问题,它会让编译器尝试把函数体及传入的 Lambda 一起展开到调用位置,从而减少函数调用和 Lambda 对象带来的额外开销。

例如:

kotlin 复制代码
fun forEachVisibleChannel(action: (Channel) -> Unit) {
    for (c in visibleChannels) action(c)   // action 可能需要以 Function 对象的形式传递
}

优化:

kotlin 复制代码
inline fun forEachVisibleChannel(action: (Channel) -> Unit) {
    for (c in visibleChannels) action(c)   // action 可以被内联到调用位置
}

不过,真正体现你 awesome 的地方,不是给函数加上 inline,而是知道什么时候不应该加。

内联一个很大的函数,会把整个函数体复制到每个调用位置,导致生成的字节码膨胀。

如果一个函数既没有可内联的 Lambda 参数,也没有具体化类型参数,Kotlin 甚至可能通过 NOTHING_TO_INLINE 告诉你,这里的内联没有带来足够收益。

因此,inline 更适合热点代码中的小型高阶函数,而不是看到函数就加。

实际上有个比较好的技巧,你可以看看 Kotlin 的大量官方源码从而得到一个结论:如果你的函数需要一个 Lambda 表达式,那么你就可以问下自己这个地方是否可以通过添加 inline 来优化呢?(一定要注意字节码膨胀问题)

裸 Int 和 String -> value class

当两个 Int 参数挨在一起时,潜在的问题就已经出现了。

假设某个调用位置不小心颠倒了 channelId 和 frequency,编译器不会给出任何提示,直到用户切换频道时只能看到一片雪花,你才可能发现问题。

@JvmInline value class 可以在常见情况下不引入额外包装对象的同时,为底层值建立不同的类型。在生成的代码中,编译器通常仍可以使用它所包装的 Int。

例如:

kotlin 复制代码
fun tune(channelId: Int, frequencyHz: Int) { /* ... */ }
tune(frequencyHz, channelId)   // 可以编译,但参数传反了,而且不会有任何提示

优化:

kotlin 复制代码
@JvmInline value class ChannelId(val raw: Int)
@JvmInline value class FrequencyHz(val raw: Int)

fun tune(id: ChannelId, freq: FrequencyHz) { /* ... */ }

tune(FrequencyHz(101_700_000), ChannelId(7))   // 无法通过编译

当然,value class 也不是任何情况下都没有运行时成本。当它被用作泛型参数、转换为可空类型或其他需要盒装的类型时,仍可能被包装成真实对象。

所以,可以在 API 边界上使用值类提高类型安全,但不要因此认定对象分配在所有场景下都消失了。

我之前在公司内部开发过一个单位的库,用于解决时间单位和长度单位混用的问题,我就是用的该方法。例如:

kotlin 复制代码
@JvmInline value class Meter(val meter: Int) //米
@JvmInline value class Kolimeter(val km: Int) //千米

有意识地选择 Sequence

对于一个包含 650 个频道的列表,下面这段代码会先为 map 创建一个完整的中间列表,再为 filter 创建第二个列表,最后却只通过 first 取出其中一个元素:

kotlin 复制代码
channels.map { transform(it) }.filter { predicate(it) }.first { matches(it) }

Sequence 采用惰性求值,会让每个元素依次流经整条操作链,一旦 first 找到满足条件的元素,后续处理便会立即停止。

例如:

kotlin 复制代码
val target = channels
    .map { it.toDisplayModel() }   // 创建包含 650 个元素的列表
    .filter { it.isHd }            // 再创建一个列表
    .first { it.number == wanted } // 最后只需要其中一个元素

优化:

kotlin 复制代码
val target = channels.asSequence()
    .map { it.toDisplayModel() }
    .filter { it.isHd }
    .first { it.number == wanted } // 找到后立即停止,也不会创建上述中间列表

不过,这里也很容易出现另一种误区:只要看到连续的集合操作,就立刻调用 asSequence()。

Sequence 的操作通常不会像集合操作那样直接内联整条处理逻辑,而且每一层都需要额外的 Sequence 包装和迭代过程。

具体的 Lambda 是否产生新对象,还取决于它有没有捕获外部状态以及编译器的处理方式,不能简单理解为每一步必然分配一个新对象。

如果列表只有 3 个元素,而且只执行一次 map,立即求值的集合操作通常更快,因为它的操作符可以内联,也不需要引入 Sequence 的额外机制。

可以先记住一个简单的经验:面对较大的集合、较长的操作链,或者存在 first、take 这类能够提前终止的操作时,asSequence() 更容易体现价值;对于小集合和很短的操作链,直接使用集合操作往往更合适。

当然,我之前写过一篇文章,各位也可以再去看看 ------ Sequence 一定比 List 快?。

使用密封接口描述状态

我现在依然能看到这样的页面状态:一个对象里同时保存 isLoading、isPlaying 和可空的 errorMessage。

这样做的问题在于,如果仅从代码来看,这些彼此独立的字段可以组合出许多根本不合法的状态。

比如页面不应该既处于加载状态,又同时表示播放失败,但类型系统并不会阻止你构造出这样的对象。

甚至你有可能遗漏一些页面状态,就是因为你写错了一些组合!

密封类型层次可以让编译器检查 when 是否覆盖了所有分支。以后添加了新的状态,如果忘记处理,问题会在编译阶段暴露,而不是等到线上才发现。

例如:

kotlin 复制代码
data class PlayerState(
    val isLoading: Boolean,
    val isPlaying: Boolean,
    val error: String?   // 哪些组合才是合法状态?
)

优化:

kotlin 复制代码
sealed interface PlayerState {
    data object Loading : PlayerState
    data class Playing(val positionMs: Long) : PlayerState
    data class Failed(val cause: Throwable) : PlayerState
}

fun render(state: PlayerState) = when (state) {   // 不需要 else 分支
    PlayerState.Loading    -> showSpinner()
    is PlayerState.Playing -> showFrame(state.positionMs)
    is PlayerState.Failed  -> showError(state.cause)
}

关于 when (state) 的使用,千万不要随手补上 else,因为不写 else 恰恰是这里的重点。

当你加入第四种状态时,编译器会把所有需要同步更新的 when 表达式指出来。这比上线后遇到一个从未处理过的状态可靠得多。

此事亦有记载!

及时取消协程任务

如果每次切换任务时都启动一个 GlobalScope.launch,用户快速操作就会积累多个任务。

N 次切换任务之后,之前启动的任务可能更晚才结束,然后用错误的结果覆盖了当前结果。

而且,这个任务的生命周期还可能超过启动它的页面。

协程取消是协作式的:只有代码执行到挂起点,或者主动检查取消状态时,任务才会响应取消。

所以,业务协程通常应该放在具有明确生命周期的作用域中。开始新的调谐任务之前,也应该取消上一次已经失效的任务。

例如:

kotlin 复制代码
fun zapTo(id: ChannelId) {
    GlobalScope.launch { player.tune(id) }   // 生命周期失控,也没有取消旧任务
}

优化:

kotlin 复制代码
private var tuneJob: Job? = null

fun zapTo(id: ChannelId) {
    tuneJob?.cancel()   // 取消已经过期的切台任务
    tuneJob = viewModelScope.launch { player.tune(id) }
}

对于始终不挂起的 CPU 密集型循环,取消信号不会自动让代码停下来。你需要在循环中调用 ensureActive(),或者检查 isActive,这样任务被取消以后才会真正停止。

另外三个我在 Code Review 中经常标记的问题

text 复制代码
到处使用 !!             → 参数校验用 requireNotNull,状态校验用 checkNotNull,并提供明确消息
CPU 任务使用 Dispatchers.IO → 解析、解码和计算应优先考虑 Dispatchers.Default
状态 data class 中使用 var → 尽量使用 val,通过 copy() 创建修改后的状态

这里并不是说 requireNotNull 可以机械地替换所有 !!:它表示调用参数不符合要求,会抛出 IllegalArgumentException;checkNotNull 更适合校验对象状态。更重要的是先判断这个可空值为什么会为空,再选择符合语义的处理方式。

一点想法

上面这些优化不需要引入新的库,更不需要重写整个项目。

很多时候,当我们写代码的时候,一开始就需要像编译器一样阅读自己的代码:计算每一个对象分配、每一份中间列表,以及那些迟迟没有被取消的任务。

写的时候多问自己一句:这个 Lambda 会不会被反复调用?这段循环会不会不停地分配临时对象?这个协程用完以后,能被及时取消吗?

这也是一种成本意识------先看清每一行代码背后真正发生了什么;等上线之后再返工,那就可能收到客户投诉了。

相关推荐
三少爷的鞋8 小时前
AI 时代,我们都将成为通才型开发者:只懂 Android,已经不够了
android
Dovis(誓平步青云)12 小时前
家里设备越来越多,如何用一张空间地图控制灯光和温度![
android·java·前端·javascript·人工智能·电脑
晚风叙码14 小时前
MySQL 数据类型详解:从数值到字符串,一篇讲透
android·mysql·adb
传奇开心果编程14 小时前
【Compose Multiplatform 跨端开发学与练】第3课 布局与组件
android·windows·学习·ui·ios·kotlin·composer
事圆则缓16 小时前
Kotlin 入门与面试:从空安全、扩展函数到协程
安全·面试·kotlin
传奇开心果编程17 小时前
【Compose Multiplatform 跨端开发学与练】第8课 资源管理与主题
android·windows·学习·ios·kotlin·web·composer
传奇开心果编程18 小时前
【Compose Multiplatform 跨端开发学与练】第9课 测试与调试
android·学习·macos·ios·kotlin·web·composer
传奇开心果编程19 小时前
【Compose Multiplatform 跨端开发学与练】第4课 导航与路由
android·windows·学习·ui·ios·kotlin·composer
传奇开心果编程19 小时前
【Compose Multiplatform 跨端开发学与练】第6课 状态管理与架构
android·学习·ui·ios·架构·kotlin·composer
事圆则缓19 小时前
Android AOSP 定制常见概念:源码目录、系统镜像与刷机流程
android