一个 Android 商品页需要展示价格、处理点击、把埋点发给日志服务。写起来全是 val、var、fun 和花括号,难点却在于:谁可以改状态,谁决定筛选规则,回调被谁持有,以及日志依赖从哪里来。用变量声明约束重新赋值,用函数签名交代输入与结果,再让 Lambda 和扩展只承担清晰、局部的便利。
以下语言规则以 Kotlin 2.4.21 为基线;ViewModel、点击监听和生命周期只是 Android 场景,具体 API 契约应以项目采用的 AndroidX 与平台版本为准。
状态先分清"引用可改"还是"对象可改"
val 使变量不能重新赋值,var 才能重新赋值。val cart = mutableListOf(...) 仍允许 cart.add(...),因为固定的是引用,不是对象内容。页面状态对外暴露只读视图、对内保留修改入口,比机械地把所有字段换成 val 更能约束写入位置。局部变量可以推断类型;公开函数和属性的类型写清楚,后续改实现时更容易发现契约漂移。
fun 声明函数。短计算可用表达式函数体,含校验、资源释放或多个分支时,用块体更便于阅读。参数在函数体内不能重新赋值,默认值适合稳定的常用选择;调用点多个同类型参数时,命名参数能减少顺序误读。
kotlin
fun label(name: String, count: Int = 1): String = "$name × $count"
fun main() {
val title = "消息"
var unread = 2
unread += 1
println(label(name = title, count = unread))
}
这里 count 省略时才采用默认值。默认表达式在调用缺省参数时求值,也可引用前面的参数;但"当前用户""当前时间"这类隐含环境若藏进默认值,会让测试难以控制。Java 调用者不会自动获得所有默认参数组合的重载,确有互操作需求再考虑 @JvmOverloads;Kotlin 对 Java 方法调用也不能依赖 Kotlin 式命名参数。
命名参数尤其适合两个相邻的 Boolean 或同为 Int 的参数:load(cache = true, refresh = false) 比只写两个布尔字面量更容易复查。不过参数名会进入 Kotlin 调用者的源码写法,公开 API 改名可能让命名调用处无法编译。设计公开函数时,应把名字当作契约的一部分;若一个函数需要一长串开关,拆成有含义的配置对象往往比继续加默认值清楚。
Lambda 让调用方交出一段行为
商品列表的遍历方式相同,筛选条件却常变。函数类型 (Product) -> Boolean 表示接收一个商品、返回真假;接收这种函数值的 select 是高阶函数。Lambda 是构造函数值的一种写法,也可以被保存或返回。
kotlin
data class Product(val name: String, val priceCents: Long)
fun select(
items: List<Product>,
predicate: (Product) -> Boolean
): List<Product> = items.filter(predicate)
val products = listOf(Product("书", 3000), Product("笔", 500))
val expensive = select(products) { product -> product.priceCents >= 2000 }
predicate 是最后一个函数参数,所以 Lambda 能移到圆括号外,这叫尾随 Lambda;即使不写 inline 也成立。只有一个参数时可用 it,但嵌套了几层 filter、let 后,具名参数更易辨认。函数类型也能带接收者,如 StringBuilder.() -> Unit:块内的 this 就是构建器,这类写法常见于 DSL 和 buildString。
在 Android,Lambda 还牵涉持有关系。若进程级单例保存了捕获 Activity 或 View 的回调,页面销毁后仍可能被引用。应按注册者的生命周期注销监听,或只捕获所需的数据;语言不会因为写了尾随花括号就自动管理回调。Java 单抽象方法接口可通过 SAM 转换接收 Lambda,但具名接口在多个回调角色并存时有时更清楚。
还有一个常被忽略的差别:函数类型只描述输入和输出,不承诺执行线程、执行次数或完成时间。(Product) -> Unit 可能同步调用一次,也可能被保存起来稍后调用;这些必须由高阶函数自己的契约说明。页面事件传给后台任务时,单靠函数类型无法表达取消与错误路径。若代码依赖生命周期结束后不再回调,需要看调用方的注册、取消协议,而非推断 Lambda 的语法。
扩展与作用域函数改变的是写法
扩展函数给已有类型增加调用形式,扩展属性给它增加读取形式;二者都没有修改原类,也不能直接访问它的私有成员。扩展按接收者的声明类型静态解析,同签名成员函数优先。扩展属性没有自己的后备字段,必须通过访问器计算。
kotlin
fun String.isUsefulName(): Boolean = isNotBlank() && length <= 20
val String.firstOrDash: Char get() = firstOrNull() ?: '-'
val caption = StringBuilder().apply {
append("Kotlin")
append(" 入门")
}.toString().takeIf(String::isUsefulName) ?: "未命名"
val initial = caption.firstOrDash
apply 块里用 this 配置构建器,表达式返回构建器;takeIf 根据条件留下对象或返回 null。常见作用域函数可按两件事辨别:块里对象叫 this 还是 it,整体返回对象还是块的结果。apply、also 返回对象;let、run、with 返回块结果。let 常接可空值,also 适合附带日志,但连续嵌套会让 this、it 的指代变得含糊。拆成局部变量通常更好。
扩展可以以可空类型为接收者,如 fun String?.orPlaceholder() = this ?: "---"。即使值为 null,函数体也会执行;这与对非空扩展使用 ?. 不同。view.apply { ... } 只是立即执行一个块,不会自动解绑监听器或取消异步任务。
inline 与共享依赖各有边界
inline 请求编译器在调用点展开函数体及可内联的 Lambda,可减少部分函数对象和调用成本,也让某些 Lambda 使用非局部 return。它可能增大生成代码;noinline 保留某个函数参数为普通函数值,crossinline 禁止该参数非局部返回。若只想退出当前 Lambda,可写 return@forEach 一类标签返回。热点优化应先测量;inline 不是给每个高阶函数都加的性能开关。内联函数还可配合 reified 在调用点使用具体类型,但不能据此断言 List<String> 内每个元素都已经过运行时校验。
日志、格式化等稳定依赖在一段调用链里反复出现时,Kotlin 2.4.21 的稳定 Context Parameters 可以把需要的依赖写进函数契约,并在局部作用域提供:
kotlin
class PriceRules(val currency: String)
context(rules: PriceRules)
fun priceText(cents: Long): String = "$cents ${rules.currency}"
fun main() {
val rules = PriceRules("分")
context(rules) { println(priceText(3000)) }
}
priceText() 仍依赖 PriceRules,调用点由当前作用域按类型寻找值;context(rules) 不负责创建对象。若同层有多个兼容值,解析会歧义。旧实验性 Context Receivers 迁移时要检查调用与接收者语义;构造函数不能声明 Context Parameters,带上下文参数的属性不能有后备字段、初始化器或委托。2.4 中"显式传入上下文实参"仍是单独的实验能力,上例没有使用。逐次选择的请求 ID 或事务句柄,普通参数更便于审查。
它与扩展接收者的用途也不同。扩展的接收者是调用语法里的那个对象,通常围绕它做一项操作;Context Parameter 表达一段调用链共享的外部依赖。若格式规则每次调用都不同,把它直接列在 priceText(cents, rules) 的普通参数中更容易看出选择。Context Parameters 并不提供依赖注入容器、线程隔离或 Android 生命周期管理,这些仍由提供 rules 的代码负责。
页面代码可以据此逐层检查:状态是否有明确写入入口,函数签名是否交代必要输入,Lambda 的持有者会不会长于页面,扩展有没有把业务规则藏到难找的包里。语法简洁是结果;谁提供值、谁持有行为、谁负责清理,仍应从调用处读得出来。
参考资料:属性、函数、Lambda 与高阶函数、扩展、作用域函数、内联函数、Context Parameters、Kotlin 2.4 新特性。