我常用的 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 参数挨在一起时,潜在的问题就已经出现了。

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

@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 的额外机制。

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

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

使用密封接口描述状态

我现在依然能看到这样的页面状态:一个对象里同时保存 isLoadingisPlaying 和可空的 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 可以机械地替换所有 !!:它表示调用参数不符合要求,会抛出 IllegalArgumentExceptioncheckNotNull 更适合校验对象状态。更重要的是先判断这个可空值为什么会为空,再选择符合语义的处理方式。

一点想法

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

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

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

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

相关推荐
恋猫de小郭1 小时前
Flutter 多窗口支持类型和 API 介绍
android·前端·flutter
张风捷特烈1 小时前
当 AI 遇见 Flutter | 打造 500+ Widget 专属Logo
android·前端·flutter
名剑走天下1 小时前
Kotlin_LiveData
开发语言·kotlin
mengge.cloud2 小时前
云计算&服务器基础小白教程
android·linux·运维·服务器·网络·云计算
天空之城--2 小时前
Android Flutter行业最新动态与实用参考(2026年8月第3周)
android·人工智能·flutter·ai编程
zh73144 小时前
typephp的编译核心phpx原理解析
android·java·开发语言
胖大师11 小时前
Android Audio-FW
android
kayyoo13 小时前
CoordinatorLayout解析(二): 结合CollapsingToolbarLayout实现Toolbar折叠效果
android
数据知道15 小时前
移动端渗透测试流程:Android APK 逆向与动态调试
android·web安全·网络安全