Skip to content

Kotlin 编码规范

Kotlin 提供了大量语法糖,用得好能让代码简洁优雅,用得滥则适得其反。

本规范不是 Kotlin 语法教程,而是约定在 Android 项目中哪些特性该用、怎么用、哪些要避免。

如果你打算学习 Kotlin

理解 Kotlin 的关键,在于领会它将 面向对象函数式编程 有机结合的设计哲学——这不是机械的拼凑,而是互为解药的化学反应:以面向对象稳固组件结构,以函数式编程净化数据逻辑

如果你想系统入门或重塑心智:

start-kotlin-tour.svg

变量声明

默认优先使用 val,只有变量在生命周期内确实需要重新赋值时,才使用 var

  • 大部分局部变量在创建后都不会再次赋值,直接使用 val 更符合实际情况。
  • 变量是否需要修改应该从声明处就能看出来。
  • 减少无意义的可变状态,避免后续维护过程中出现意外重新赋值的问题。

推荐:

kotlin
// 集合引用不会改变,使用 val
val devices = repository.loadDevices()

val protocol = ProtocolType.MODBUS_RTU

// 需要重新赋值,使用 var
var retryCount = 0
while (retryCount < MAX_RETRY_COUNT) {
    retryCount++
}

避免:

kotlin
// 滥用 var,导致变量状态不可控
var devices = repository.loadDevices() 
// ... 在后续冗长的业务逻辑中,极易被意外重新赋值
devices = emptyList() 

空安全

优先非空设计

声明变量时,默认应设计为非空类型。只有当空值具有真实的业务含义时,才声明为可空类型。

kotlin
// 不要把所有变量都声明成可空的
var userName: String? = null
var deviceId: String? = null

// 非空是默认,可空是例外
var userName = ""
var deviceId = ""

例外:

kotlin
// 设备未连接时确实为 null,可空合理
var currentDevice: Device? = null

安全处理可空类型

安全调用与 Elvis 操作符

优先使用安全调用 ?. 和 Elvis 操作符 ?: 处理可空类型,避免使用 !! 强行断言非空。

正例:

使用 Kotlin 提供的空安全机制处理空值。

kotlin
// 安全调用
val length = text?.length

// Elvis:提供默认值
val name = user?.name ?: "未知用户"

// Elvis:提前返回
fun process(data: ByteArray?) {
    val payload = data ?: return
    
    parsePayload(payload)
}

反例:

滥用 !!,主动放弃空安全保护。

kotlin
val name = user!!.profile!!.name!!

使用原则

  • !! 表示:"我保证这里不会为 null,如果为 null 就崩溃"
  • 如果连续出现多个 !! 往往意味着设计存在问题

只有在明确保证非空时才用,并加上注释说明原因。

kotlin
// 此处 Fragment 由当前方法刚刚 add,必然存在
val fragment = supportFragmentManager.findFragmentByTag(TAG)!!

数据类

纯数据载体

data class 用于纯数据载体,自动生成 equals()hashCode()toString()copy(),适合作为数据模型。

kotlin
data class DeviceInfo(
    val id: String,
    val name: String,
    val isOnline: Boolean,
    val signalStrength: Int = 0
)

data class 使用原则

  • 只放数据字段,不写业务逻辑方法
  • 字段尽量用 val(不可变),需要局部修改时用 copy()
  • data class 默认是 final,不能被继承
  • 通常也不应继承其他具有复杂状态和行为的父类

密封类与枚举

Kotlin 提供了 enum classsealed class 两种有限状态建模方式。

固定常量集合

enum class 用于固定常量集合,当所有成员都是等价的无参常量时,使用枚举。

例如:

kotlin
/**
 * 表示通信链路的固定状态
 */
enum class CommunicationState {
    DISCONNECTED,
    CONNECTING,
    CONNECTED,
    ERROR
}

使用 when 时,编译器能够检查所有枚举值是否被覆盖。

kotlin
when (device.communicationState) {
    CommunicationState.DISCONNECTED -> showOffline()
    CommunicationState.CONNECTING -> showConnecting()
    CommunicationState.CONNECTED -> showOnline()
    CommunicationState.ERROR -> showError()
}

状态建模

sealed class 用于状态建模,当不同状态需要携带不同数据时,使用密封类。

例如:

kotlin
/**
 * 用于表示数据加载的状态
 * 
 * @author 新一
 * @date 2024/9/24 21:10
 */
sealed class Resource<out T> {

    /**
     * 表示正在加载状态
     */
    data object Loading : Resource<Nothing>()

    /**
     * 表示加载成功,携带成功返回的值
     *
     * @param value 成功加载的数据
     */
    data class Success<out T>(val value: T) : Resource<T>()

    /**
     * 表示加载失败,携带异常信息
     *
     * @param ex 加载过程中发生的异常
     */
    data class Fail(val ex: Throwable) : Resource<Nothing>()
}

不同状态可以拥有各自的数据结构。

kotlin
when (result) {
    is Resource.Loading -> showLoading()
    is Resource.Success -> {
        showData(result.value)
    }
    is Resource.Fail -> {
        showError(result.ex)
    }
}

使用建议

选择 enum class 还是 sealed class 的关键不在于状态数量,而在于状态是否需要携带不同数据。

  • 固定常量集合:enum class
  • 不同状态需要不同数据:sealed class

优先让类型表达业务含义,而不是通过额外字段或魔法值区分状态。

扩展函数

虽然扩展函数能够优雅的为既有类扩展新能力,但由于它脱离了传统面向对象的定义与边界,所以极易演变为游离于架构之外的无序代码片段。

核心克制点

扩展函数应作为类的行为延伸,而非包罗万象的通用工具箱。

引入扩展函数的前提,是能让调用栈更加直观;而维持其可读性的关键,在于清晰的文件组织与严格的可见性边界。

组织方式

按扩展对象组织分类:

text
// View 相关扩展
extensions.ViewExtension.kt      
// Context 相关扩展
extensions.ContextExtension.kt   
// String 相关扩展
extensions.StringExtension.kt

按业务模块组织分类:

text
// 设备模块相关扩展
DeviceExtension.kt
// 分析仪模块相关扩展
AnalyzerExtension.kt
// 协议模块相关扩展
ProtocolExtension.kt

按功能领域组织分类:

text
// 数据存储相关扩展
DatabaseExtension.kt             
// 网络通信相关扩展
NetworkExtension.kt              
// 协程相关扩展
CoroutineExtension.kt

实际项目中通常会组合使用多种组织方式,应根据扩展函数的职责和所属领域进行分类,重点是归属明确、便于查找。

避免无归属集中

避免将各种函数在一个文件里无序堆积。

例如:

kotlin
Extensions.kt
UtilsExtensions.kt
CommonExtensions.kt

问题不在 “不能用”,而在于内容都在同一个文件里,扩展函数没有 “可定位性”。

可见性控制

放任默认的可见性,会导致局部的业务扩展无差别地入侵整个工程的自动补全列表,造成比较严重的语义污染。

可见性控制原则

  • 如果扩展函数仅在当前模块内使用,必须追加 internal 修饰符。
  • 如果扩展函数仅在当前文件内使用,必须追加 private 修饰符。
  • 只有确信在全工程通用的核心底层工具属性,才允许保持默认的全局可见性。

反例:

缺少可见性约束,导致特定业务逻辑向全工程无序扩散。

kotlin
// 放在 CommonExtensions.kt 中,因缺少修饰符而暴露给全局
// 这会导致与该业务无关的 String 对象,也能在任意模块调用此方法
fun String.toDeviceProtocolType(): ProtocolType {
    return ProtocolType.fromRawString(this)
}

正例:

显式限制边界,只将能力赋予到需要它的地方。

kotlin
// 仅在设备模块内可见,切断跨模块的语义污染
internal fun String.toDeviceProtocolType(): ProtocolType { 
    return ProtocolType.fromRawString(this) 
} 

// 或在具体业务文件顶部声明为 private,仅供当前文件消费
// 仅用于当前界面的信号强度 UI 图标转换,不暴露给外部
private fun Int.toSignalIcon(): Int {
    return when (this) {
        in 0..30 -> R.drawable.ic_signal_weak
        in 31..70 -> R.drawable.ic_signal_medium
        else -> R.drawable.ic_signal_strong
    }
}

高阶函数

高阶函数(以函数作为参数或返回值的函数)是 Kotlin 极其强大的特性,但过度使用或嵌套过深会导致代码难以阅读。

作用域函数

作用域函数是 Kotlin 标准库提供的几个高阶函数,它们的唯一目的是在对象的上下文中执行代码块。

当对一个对象调用这些函数并提供一个 lambda 表达式时,会形成一个临时作用域。在该作用域内,可以访问该对象而无需其名称,因此这些函数称为作用域函数。

以下是 Kotlin 标准库中,最常用的五个作用域函数,它们各有适用场景,不要混用。

选择指南:

函数接收者返回值典型用途
letitLambda 结果可空对象的非空处理,链式转换
runthisLambda 结果对象初始化 + 计算结果
applythis对象本身对象属性配置(Builder 风格)
alsoit对象本身副作用操作(日志、调试等)
withthisLambda 结果对非空对象执行多个操作(非链式场景)
kotlin
// let:处理可空对象 / 链式转换
val nameLength = device.name?.let {
    it.length
} ?: 0

// run:初始化 + 计算结果
val deviceInfo = device.run {
    "$name-$id-$status"
}

// apply:配置对象属性,返回对象本身
val intent = Intent(context, MainActivity::class.java).apply {
    putExtra("device_id", deviceId)
    flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}

// also:执行副作用,不影响链式调用
val devices = repository.getDevices()
    .also { Log.d(TAG, "Loaded ${it.size} devices") }

// with:对同一对象执行多个操作
with(binding) {
    tvTitle.text = device.name
    tvStatus.text = device.statusText
}

使用技巧

先看是否可空,其次看是否需要返回值,最后再决定是否影响原对象。

警惕隐式 this 嵌套污染

严禁多层嵌套使用显式或隐式包含 this 的作用域函数(如 apply / run / with)。

当多个 this 在同一个代码块中交织时,上下文语义会被严重混淆。你以为你在配置子对象的属性,编译器可能正悄悄把它赋值给父对象,从而埋下极其隐蔽的 BUG

反例:

kotlin
// 根本分不清哪个属性属于哪个对象,极易发生隐式赋值错误
mDialog.apply { // 这层的 this 是 Dialog
    title = "设备配置"
    binding.btnSubmit.run { // 这层的 this 是 Button
        text = "激活设备"
        isEnabled = isConnected // 惊悚:这里的 isConnected 到底是 Dialog 的、Button 的、还是外层 Activity 的?
    } 
} 

正例:

若需嵌套,必须将其中一层拆解为具名参数 it,显式划清边界。

kotlin
// 边界清晰,语义各司其职
mDialog.apply { // this 是 Dialog
    title = "设备配置"
    // 使用 also/let 显式命名,将 Button 的上下文剥离出来
    binding.btnSubmit.also { button ->
        button.text = "激活设备"
        button.isEnabled = this@MainActivity.isConnected // 显式指定外部 Activity 的状态
    } 
} 

Lambda

Lambda 是一把双刃剑,运用得当是提升代码表达力的利器;一旦过度嵌套或滥用,便会成为摧毁可读性的凶器。

总纲

Lambda 的核心克制点只有一个:保持单一语义边界

后面三条规则都是这个原则的具体情况。

参数语义

在多层 Lambda 嵌套或链式调用中,禁止继续使用默认参数名 it,也严禁使用 “编号式命名”(如 it1 / it2 / it3)。

反例一

完全使用 it,语义丢失

  • it 在嵌套作用域中无法区分指代对象
  • 没有可读性,需要不断回溯上下文才能判断当前层级含义
kotlin
orders.map {
    it.items.map {
        it.products.filter {
            it.price > 100
        }.map {
            it.name
        }
    }
}

反例二

编号式命名,伪语义

  • it1 / it2 / it3 仅表达“层级顺序”,不表达 “领域含义”
  • 没有维护性,一旦结构调整,编号体系立即失效,维护成本高
kotlin
orders.map { it1 ->
    it1.items.map { it2 ->
        it2.products.filter { it3 ->
            it3.price > 100
        }.map { it4 ->
            it4.name
        }
    }
}

此二反例,其本一致。

两个反例的问题一样:都没有表达业务语义,只是不同形式的匿名化。

正例:

使用具名语义参数

  • 无需关注 Lambda 的嵌套层级,仅通过参数名称即可理解当前处理的对象。
kotlin
orders.map { order ->
    order.items.map { item ->
        item.products
            .filter { product ->
                product.price > 100
            }
            .map { product ->
                product.name
            }
    }
}

链式复杂度

链式调用适用于表达 “单一数据流上的连续转换”,但当链路中出现多个语义阶段(过滤、展开、再过滤、再映射等),链式结构会掩盖中间语义,降低可读性与可维护性。

当无法用一个连续语义描述整个链路时,必须拆分为中间步骤或具名变量。

反例:

链式调用过长且语义混杂

  • 隐式数据流难以拆解
  • 调试也困难(无法单独观察中间状态)
  • 修改某一阶段逻辑需要整体理解链路
kotlin
list.filter { it.isOnline }
    .map { it.sensors }
    .flatMap { it }
    .filter { it.isActive }
    .map { it.value }

正例:

拆分为具名语义步骤,每个阶段仅负责一个明确的数据处理动作。

kotlin
val onlineDevices = list.filter { it.isOnline }

val sensors = onlineDevices.flatMap { device ->
    device.sensors
}

val activeSensors = sensors.filter { sensor ->
    sensor.isActive
}

val result = activeSensors.map { sensor ->
    sensor.value
}

例外情况

当链式调用语义单一且转换步骤不多时,可以保持链式写法,无需强制拆分。

kotlin
// 语义单一,只有 2 步,保持链式写法即可
val activeDeviceNames = devices.filter { it.isOnline }.map { it.name }

拆分原则

  • 当中间结果具备独立业务含义时,应拆分并命名,不要按步骤数量机械拆分
  • 判断标准:能否为中间结果赋予有业务意义的变量名

嵌套控制

嵌套过深会导致逻辑层级不清晰,应将内层逻辑提取为具名函数。

以下是一个贴近真实 Repository 层的场景:

将 API 响应中的 DTO 映射为 UI 展示模型。

kotlin
// 嵌套过深,内层映射逻辑淹没在链式结构中,难以阅读
fun getDisplayItems(response: ApiResponse): List<DisplayItem> { 
    return response.data
        .filter { it.isVisible } 
        .map { dto ->
            dto.tags.filter { tag ->
                tag.isEnabled 
            }.map { tag ->
                DisplayItem(dto.id, tag.label) 
            } 
        }.flatten() 
} 

// 拆分后:职责分离,每一步意图一目了然
fun getDisplayItems(response: ApiResponse): List<DisplayItem> { 
    return response.data
        .filter { it.isVisible } // 筛选可展示的数据
        .flatMap { dto ->
            // 将 DTO 转换为展示模型
            toDisplayItems(dto) 
        } 
} 

private fun toDisplayItems(dto: DataDto): List<DisplayItem> { 
    return dto.tags 
        .filter { it.isEnabled } // 过滤可用标签
        .map { tag ->
            // 转换为展示模型
            DisplayItem(dto.id, tag.label) 
        } 
} 

嵌套拆分原则

  • 出现多层链式调用时,按数据处理阶段拆分为具名变量或函数
  • 内层 Lambda 包含独立映射逻辑时,必须提取为具名函数
  • 判断标准:无法快速读懂内层意图时,就该提取

协程

协程是 Kotlin 推荐的异步编程方案。合理使用协程可以极大地简化异步逻辑,但如果不加限制地滥用,也容易导致内存泄漏和状态混乱。

谨慎使用 Global

GlobalScope 的生命周期与应用进程一致,不受 ActivityFragmentViewModel 等组件生命周期管理。

在 Android 开发中,大多数异步任务都应与特定生命周期绑定,避免任务失控、资源泄漏或无效状态更新。

反例:

使用 GlobalScope 执行界面相关任务

  • 任务无法随页面或 ViewModel 生命周期自动取消
  • 容易导致内存泄漏或无效 UI 更新
kotlin
// Activity 销毁后,协程仍可能继续运行
GlobalScope.launch { 
    val data = repository.fetchData()
    updateUI(data)
}

正例:

使用与生命周期绑定的 Scope

kotlin
viewModelScope.launch {
    val data = repository.fetchData()
    updateUI(data)
}

使用原则

  • UI 相关任务使用 viewModelScopelifecycleScope
  • 业务任务应绑定到明确的 CoroutineScope
  • 除应用级后台任务外,不应默认选择 GlobalScope

例外情况

当任务确实需要独立于页面、组件或业务生命周期长期存在时,可以使用 GlobalScope

典型场景:

  • 日志上报
  • 崩溃信息收集
  • 应用级监控任务

使用前应明确说明其脱离生命周期管理的原因。

结构化并发

协程应遵循结构化并发原则,确保任务生命周期清晰可控。

父协程会自动管理其所有子协程:

  • 父协程结束前,会等待所有子协程执行完成
  • 父协程取消时,所有子协程会一并取消

因此,不要在协程内部创建新的独立 CoroutineScope,否则会脱离父协程管理。

反例:

在协程内部创建独立作用域

  • 这会破坏结构化并发,子任务不再受父协程控制
  • ViewModel 销毁后任务仍可能继续执行,无端增加状态异常风险等隐性 BUG
kotlin
viewModelScope.launch {
    // 创建了新的协程作用域,切断与父协程的生命周期绑定
    CoroutineScope(Dispatchers.IO).launch { 
        // 这里的任务不受 viewModelScope 控制,ViewModel 销毁后不会停止
        repository.syncData()
    }
}

正例:

使用子协程继承父协程上下文

kotlin
viewModelScope.launch {
    // 子协程自动继承 viewModelScope 生命周期
    launch(Dispatchers.IO) {
        repository.syncData()
    }
}

并行执行多个独立请求时,使用 async + awaitAll,创建结构化子协程:

kotlin
viewModelScope.launch {
    // 并行请求设备数据和告警数据
    val (devices, alerts) = awaitAll(
        async { repository.fetchDevices() },
        async { repository.fetchAlerts() }
    )

    // 所有任务完成后统一更新界面状态
    _uiState.update {
        it.copy(
            devices = devices,
            alerts = alerts
        )
    }
}

使用原则

  • 优先使用父协程提供的 launchasync 等子协程
  • 不要在协程内部随意创建新的 CoroutineScope
  • 子任务应始终纳入父协程生命周期管理
  • 并发任务优先使用 async + awaitAll

异常处理

协程异常必须在协程作用域内处理,不要尝试在 launch 外层使用 try-catch 捕获协程内部异常。

反例:

launch 外层捕获异常

kotlin
try {
    viewModelScope.launch {
        repository.fetchDevices() 
    }
} catch (ex: Exception) { 
    // 无法捕获协程内部异常
}

launch 是异步启动,协程异常发生时,早已脱离当前调用栈,因此外层 try-catch 无法捕获。

正例:

在协程内部处理异常

kotlin
viewModelScope.launch {
    try {
        val devices = repository.fetchDevices()

        _uiState.update {
            it.copy(devices = devices)
        }
    } catch (ex: CancellationException) {
        // 协程取消属于正常控制流程
        throw ex
    } catch (ex: Exception) {
        // 业务异常统一处理
        _uiState.update {
            it.copy(errorMessage = ex.message)
        }
    }
}

补充说明

如果需要统一处理异常时,建议使用 CoroutineExceptionHandler

集合操作

Kotlin 标准库提供提供的集合 API 非常丰富,大多数情况下无需手写循环或复杂状态变量。

声明式操作优先

优先使用标准库表达数据处理意图,而不是手写循环。

反例:

手动维护结果集合

kotlin
val onlineDevices = mutableListOf<Device>()

for (device in devices) {
    // 判断是否在线
    if (device.isOnline) {
        onlineDevices.add(device)
    }
}

正例:

kotlin
// 筛选在线设备
val onlineDevices = devices.filter { it.isOnline }

常见操作:

kotlin
val onlineDevices = devices.filter { it.isOnline }       // 过滤:只保留在线设备
val deviceNames = devices.map { it.name }                // 转换:提取所有设备名称
val totalSignal = devices.sumOf { it.signalStrength }    // 求和:计算信号强度总和
val hasOffline = devices.any { !it.isOnline }            // 判断:是否存在离线设备
val allOnline = devices.all { it.isOnline }              // 判断:是否全部在线
val grouped = devices.groupBy { it.type }                // 分组:按设备类型分组

不可变集合优先

对外暴露的集合属性应使用不可变类型,防止外部意外修改。

反例:

调用方可随意修改数据,会导致数据状态来源难以追踪

kotlin
// 对外直接暴露可变集合
val devices = mutableListOf<Device>()

正例:

可变性应限制在最小作用域内,对外优先暴露不可变接口。

kotlin
private val _devices = mutableListOf<Device>()

val devices: List<Device>
    get() = _devices // 对外仅暴露只读视图

按需使用 Sequence

普通集合链式操作(如 filter 后接 map)时,会在每一步都创建一个新的临时列表。

当数据量较大或存在短路操作时,可使用 Sequence(序列)可以实现 惰性求值,元素会逐个穿过整个处理链,从而避免创建中间集合。此外,如果配合 takefirst 等短路操作,Sequence 还能提前结束遍历,极大提升性能。

反例:

kotlin
val result = largeList
    .filter { it.isOnline } // 遍历所有元素,创建中间集合 1
    .map { it.toDisplayItem() } // 遍历集合 1,创建中间集合 2

正例:

Sequence 惰性求值,不创建中间集合,内存更友好

kotlin
val result = largeList.asSequence()
    .filter { it.isOnline }
    .map { it.toDisplayItem() }
    .toList() // 只有遇到终端操作(如 toList)时才开始真正执行

短路场景:

如果存在短路操作(如 take(20)),Sequence 的优势会更明显。

kotlin
val result = largeList
    .asSequence()
    .filter { it.isOnline }
    .map { it.toDisplayItem() }
    // 获取前 20 条结果
    .take(20)
    .toList()

找到 20 个满足条件的元素后就会立即停止,而不会处理集合内剩余的元素。

Sequence 的使用边界

数据量小(几百条以内)的场景不需要 Sequence,普通集合操作的开销可以忽略。过度使用 Sequence 反而增加代码复杂度。

Kotlin 的设计哲学

learning

Kotlin 提供了丰富的语言特性,但这些特性的价值从来不在于减少代码量,而在于提升代码的表达能力。

Kotlin 鼓励简洁,但简洁不等于晦涩;Kotlin 追求表达力,但表达力不意味着炫技。

当某种写法让语法技巧盖过了业务语义时,即使代码看起来再 “高级”,也已经偏离了 Kotlin 的设计初衷。

基于 MIT 许可发布