开始做 ImLog Android,要用 Kotlin,快速入门下,感觉有些东西挺有意思的。

变量声明:var 与 val

一开始觉得容易眼花—知道 val 是 value 后才恍然大悟。

?: Elvis Operator

这个奇怪的名字,原来是来自 Elvis Aaron Presley,上个世纪 icon 型的人物,他标志性的发型,从侧边看,就可这个符号一样。 哈哈,有点意思。

Unit

即是类型,也是值。多用在函数型参数定义的地方,表示 void 的含义。

尾随 Lambda

一开始接受不了,感觉把一个很规则化的东西搞得复杂了,但是看下 DS 给的例子,又觉得这东西确实挺精妙的。

// 测试框架(这看起来像英语)
describe("Calculator") {
    it("should add correctly") {
        expect(2 + 2).toBe(4)
    }
}

// HTML 构建器(看起来像 HTML 本身)
html {
    body {
        h1 { +"Hello" }
        p { +"World" }
    }
}

// 如果用传统写法,这些 DSL 会非常丑陋

委托语法

入门 Jetpack Compose, 看到这个

var count by remember { mutableStateOf(0) }

by 将后面的 MutableState 对象的 getValue, setValue 委托给 count var.

而 { mutableStateOf(0) } 就是尾随 Lambda, remember 的第一个参数,省略了圆括号。

带接收者的函数类型 (Function literal with receiver )

fun buildString(action: StringBuilder.() -> Unit): String {
    val sb = StringBuilder()
    sb.action()  // 在lambda内部,this指向sb
    return sb.toString()
}

val html = buildString {
    append("<html>")
    append("</html>")
}

这段代码是天书,我真是看不懂。看了 DS 的解释,才大概知道。这里的 StringBuilder. 就是所谓的 receiver, 表示

  1. action 函数是在 StringBuilder 上执行的
  2. action 函数体内部,自动获得 this 指向 StringBuilder, 也就是内部也在 StringBuilder 作用域下

所以可以认为,这个 Function literal with receiver 是给框架作者用的!用户通过 lambda 尾随函数指定 receiver 要干的的事项,框架作者通过这个 Function 设置串联的逻辑。高阶函数。

最好理解等效的版本(函数版):

fun buildStringTraditional(action: (StringBuilder) -> Unit): String {
    val sb = StringBuilder()
    action(sb)  // 要把 sb 作为参数传进去
    return sb.toString()
}

val html2 = buildStringTraditional { sb ->
    sb.append("<html>")
    sb.append("</html>")
}

给类增加临时函数版:

fun StringBuilder.buildStringTemporaryMethod() {
    append("<html>")
    append("</html>")
}

val sb = StringBuilder()
sb.buildStringTemporaryMethod()

DSL

上面有点学习成本的语法,都属于是 DSL. 其实就是语言自己的一些语法糖或者设定。

还有个定义路由的例子:

// 没有 DSL(传统写法)
fun setupRoutes(router: Router) {
    router.get("/users", handler::listUsers)
    router.post("/users", handler::createUser)
    router.get("/users/{id}", handler::getUser)
}

// 有 DSL(Ktor 框架实际用法)
routing {
    get("/users") {
        call.respond(listUsers())
    }
    post("/users") {
        call.respond(createUser())
    }
    get("/users/{id}") {
        call.respond(getUser(id))
    }
}
  • 传统代码:告诉计算机怎么做(创建对象、调用方法、转换类型)
  • DSL 代码:告诉计算机做什么(构建字符串、定义路由、描述 UI)

Kotlin 文化

简洁优于显式: 代码要短,假设读者懂

Gradle 编译

上来就被编译给干懵了。

  1. libs.versions.toml 本质上就是一种官方推荐的“最佳实践和设计模式”,它在 Gradle 里被称作 Version Catalog(版本目录)。它和 Python 的 pyproject.toml 有着本质的区别。在 Gradle 中,这个 .toml 文件只是为了解决“当项目有几十个模块时,改一个版本号要改几十遍”的痛点而引入的一个全局变量本子。Gradle 只是在后台默默地帮你生成了一个名为 libs.versions.kotlin 的代码变量。这个变量现在静静地躺在内存里,如果你不在 build.gradle.kts 里去调用它,它对整个构建过程产生不了任何实质影响。 Gradle 只认 build.gradle.kts, libs.versions.toml 只是版本定义的一种最佳实践而已。

Kotlin 包可见性

同 package 内部是互相可见的,即使跨文件,也不需要 import.

package 是由文件头部的 package xxx 决定的,而非文件路径!!当然一般来说,对应起来最好。

类的定义

// 在这里就定义好构造函数的参数;
// 其中带 val/var 的,自动成为 class 的属性
// 不带的,就只是参数
class Person(val name: String, inputAge: Int) {
    // 属性、方法默认都是 public
    // 可用 private/protected/internal 来修饰
    private val saveAge = inputAge - 1
    // 可以定义延迟初始化的 var 属性;但访问未初始化的属性要崩溃;一般避免
    lateinit var job: String

    // 初始化方法
    init { 
        println("Person ${name}")
    }
    // 公开方法;一般这种属性设置值只用用 setter
    fun setJob(job: String) {
        this.job = job;
    }
}

太多的隐式规则了。

sealed class, 嵌套定义, class, data class, object, data object

sealed class, 密封类,类似增强版 Enum:可以听一不同类型的子类,然后用在 when 的地方,作用类似枚举。

可以嵌套定义,就是子类在父类的内部直接继承定义,也可以外部定义, 一般都是嵌套定义:

sealed class State {
    object Idle : State()
    data object Loading : State()
}

data class Success(val data: String): State()
class Error(val msg: String) : State()

object, 表示定义的是单例;

class 就是类型,有多实例。

data object 对 toString 等方法有优化,推荐用;

data class 表示是数据类,预定义好了 hash 等方法,类似 Python 里的 dataclass.

而基于 sealed class 下面可以有各种不同类型的子类,这就比普通 Enum 强太多了。

写到下面这个实际的 case,才一下子理解到 Sealed class + When 的优雅:

data class AppInitData(
    val userId: UserId,
    val storageUriSelected: Boolean,
    val firstTopicCreated: Boolean,
    val WelcomeShown: Boolean,
)
// 基于不同阶段,可以把后续阶段依赖的参数放进去;when 可以保证字段是符合预期的
private fun determineInitStep(initData: AppInitData?): AppInitStep = when {
    initData == null -> AppInitStep.SignInUp
    !initData.storageUriSelected -> AppInitStep.SelectMediaStorageUri(initData.userId)
    !initData.WelcomeShown -> AppInitStep.Welcome(
        userId = initData.userId, needCreateFirstTopic = !initData.firstTopicCreated
    )

    else -> AppInitStep.Finished
}
// 使用的时候,配合 when 也好做区分
when (val step = uiState.initStep) {
    AppInitStep.Loading -> Splash()
    AppInitStep.SignInUp -> SignInUpNavigation()
    is AppInitStep.SelectMediaStorageUri -> SharedStorageSelectScreen(
        currentUserId = step.userId,
    )
    ...
}

如果不用这个,在外部结构里塞一个 UserId? 的类型,when 就爱莫能助,只能写 !! 这种吓人代码了。

实际遇到了,才明白它是真的优雅。

子类返回类型会自动 Import 进来

在继承关系中,子类不需要显式 import 父类中使用的类型。

背景:

@HiltWorker
class MediaFileProcessWorker @AssistedInject constructor(
    @Assisted context: Context,
    @Assisted workerParams: WorkerParameters,
    private val fileManager: FileManager,
    private val messageRepository: MessageRepository,
) : CoroutineWorker(context, workerParams) {

  override suspend fun doWork(): Result {}
}

这里的 Result 并非 kotlin.Result, 而是 androidx.work.ListenableWorker.Result — 但它并没有通过 import 引入。 在我单独一个文件里,我没有 import , 则时用默认的 kotlin.Result. 还挺容易出错的…

经典问题:函数传参,为啥不能用 obj.fun

在 Kotlin 中,不带括号的 obj.fun 代表去获取一个属性—变量。而 fun 是函数,不是变量!

要引用函数,要用 ::,也就是 obj::fun.

所以,一般 2 种传参方式:

  1. lambda 包裹一层 【更推荐!扩展性好,容易理解;在 Jetpack Compose 1.5.3 之前版本里性能更稳定—自动 remember】
  2. :: 引用

. 去引用函数,在 Kotlin 里是语法错误!

Smart Cast 局限性:用 when 检查了属性类型,但父对象却不能再 smart cast!

Example

// 内容:多态类型
sealed interface MessageContentUiModel {
    data class Text(val text: String) : MessageContentUiModel
    data class Image(val width: Int, val height: Int) : MessageContentUiModel
    data class Video(...) : MessageContentUiModel
}

// 消息壳:通用字段 + 协变泛型内容
data class MessageUiModel<out T : MessageContentUiModel>(
    val id: MessageId,
    val sender: MessageSenderUiModel,
    val content: T,
    val createdAt: Instant,
)

// 列表/通用场景下的顶层类型
typealias AnyMessageUiModel = MessageUiModel<MessageContentUiModel>

// 业务下游:严格要求必须是图片消息
data class ImageFullScreenUiModel(
    val message: MessageUiModel<MessageContentUiModel.Image>,
    val path: AbsolutePathModel,
)

// 要放进容器,必须得用 AnyMessageUiModel 做类型擦除
val messages: List<AnyMessageUiModel> = listOf(...)

// 渲染时,各个函数要求接受具体的类型,这时 AnyMessageUiModel 不能再自动 narrow
@Composable 
fun MessageBubble(message: AnyMessageUiModel) {

when (val content = message.content) {
    is MessageContentUiModel.Image -> {
        //  编译器成功把 content 推导为 MessageContentUiModel.Image
        println(content.width)

        // ❌ 编译报错!Type mismatch:
        // Required: MessageUiModel<MessageContentUiModel.Image>
        // Found: AnyMessageUiModel 
        //      => content smart cast 成功,但是父类型不能转换,
        //         永远是传入的 AnyMessageUiModel
        val fullScreen = ImageFullScreenUiModel(
            message = message, // 这里报错!
            path = path
        )
    }
}
}

既然 message 是一个不可变对象(所有属性都是 val),而它的 content 已经证实是 Image 了,为什么编译器不能顺理成章地将 message 逆向推导为 MessageUiModel?

Smart Cast 局限
  • 局部流敏感分析(Flow-Sensitive Analysis):Kotlin 的智能类型转换是针对被检查的独立局部变量生效的。当执行 content is Image 时,编译器只在当前作用域内收窄了 content 的类型符号表。
  • 缺乏“跨属性逆向推导”(No Cross-Property Reverse Smart Cast):编译器不会因为内部某个 val 属性的类型收窄,就自动推导其封闭类(Enclosing Class)的泛型实参。
  • 泛型擦除与不可逆性:从面向对象类型系统来看,MessageUiModel<Image> 是 MessageUiModel<MessageContentUiModel> 的子类型(由于 out 协变)。Kotlin 现存规则不会因为子对象的属性匹配而自动将父引用向下转型(Downcast)。

解决方法:

  1. 把 MessageUiModel 改为 sealed interface… 这样能解决问题,但是冗余代码一大堆
  2. 没办法,强转

    @Suppress("UNCHECKED_CAST")
    fun <T : MessageContentUiModel> AnyMessageUiModel.narrow(): MessageUiModel<T> =
     this as MessageUiModel<T>
    

    但担心 typo 导致莫名其妙崩掉…

  3. 不要 MessageUiModel 了,拆分成 2 个小类型:上述问题能解决,但传参麻烦。

最后选了强转,寄希望于测试能发现问题吧… 好在 Kotlin 有 target-typing, 不用强制写 <T>,所以 typo 的问题概率还是很低的。

when (val content = item.message.content) {
    is MessageContentUiModel.Image -> {
        // 利用 target-typing 目标类型推导,无需显式写出泛型参数
        val fullScreen = ImageFullScreenUiModel(
            message = item.message.narrow(),
            path = path
        )
        ImageBubble(message = item.message.narrow())
    }
    ...
}

Q: 捕获 sqldelight 创建 flow 时异常,究竟该用 try-catch 还是再包一层 flow?

原代码:

    override fun observeTopicOrNull(topicId: TopicId): Flow<Topic?> =
        database.topicSelectQueries.getTopicById(topicId)
            .asFlow()
            .mapToOneOrNull(dispatcher)
            .map { it?.toDomain() }
            .catch { e ->
                Timber.w(e, "No Topic found for id=${topicId}, emit null")
                emit(null)
            }

这个 Flow.catch 不能捕获所有的异常,遗漏了:

  1. database.topicSelectQueries.getTopicById(topicId).asFlow() 同步创建 flow 过程中的异常
  2. CancellationException 自动抛出该异常,符合协程规范

为了 100% 稳定,我们要把同步构建的异常捕获,该怎么做? Gemini 3.6 flash 和 Grok 给出了不同的意见:

Gemini 3.6 flash: 用 flow 包裹一层
override fun observeTopicPreference(
    userId: UserId,
    topicId: TopicId,
): Flow<TopicPreference?> = flow {
    // 将 getPreference 的调用移到 flow 内部,延迟到 collect 时执行
    val queryFlow = database.topicSelectQueries
        .getPreference(topic_id = topicId, user_id = userId)
        .asFlow()
        .mapToOneOrNull(dispatcher)
        .map { it?.toDomain() }
    
    emitAll(queryFlow)
}.catch { e ->
    Timber.i(e, "Observe TopicPersonalState failed on id=${topicId}, emit null")
    emit(null)
}

思路就是包一层,同步阶段的异常也成 flow 的异常了,可以被 Flow.catch 捕获了。

Grok: 用 try-catch 捕获,不推荐用 flow 包裹
override fun observeTopicPreference(
    userId: UserId,
    topicId: TopicId,
): Flow<TopicPreference?> {
    return try {
        database.topicSelectQueries.getPreference(
            topic_id = topicId,
            user_id = userId
        )
            .asFlow()
            .mapToOneOrNull(dispatcher)
            .map { it?.toDomain() }
            .catch { e ->
                Timber.i(e, "Observe TopicPreference failed on id=$topicId, emit null")
                emit(null)
            }
    } catch (e: Exception) {
        if (e is CancellationException) throw e   // 必须重新抛出
        // 捕获「构建阶段」的同步异常(极少见)
        Timber.i(e, "Observe TopicPreference construction failed on id=$topicId, emit null")
        flowOf(null)          // 或者 flow { emit(null) }
    }
}

不推荐:

return flow {
    try {
        emitAll(数据库查询链路)
    } catch (e: Exception) {
        emit(null)
    }
}

问来问去,

  1. try-catch 最直接,没看到啥副作用,除了 2 个 catch 有点丑
  2. flow {} 其实有点 trick, 但它改变了构建 flow 执行的时机;性能上存疑,Grok 说下游多次 collect 时查询构建的部分会被重复执行(Flow -> StateFlow, stateIn 订阅中断、继续的时机),但 Gemini 反驳说大家都一样—重新订阅都要执行一遍监听,差别只在 Query 对象的创建,那个开销忽略不计。哈哈,看着两个交锋还挺有意思的,俺不懂,选择简单直接的吧。

语法强化

函数参数以 lambda 传参时,如果函数参数大于 1 个,lambda 就不能省略参数了

fun testFun(dualProcess: (Int, Int) -> Unit) {}

// Valid Example
testFun(add = {_, _ ->})
  • 参数数量为 0:{} 合法
  • 参数数量为 1:{} 也合法,因为那个唯一的参数会被隐式命名为 it,你不用它也没关系
  • 参数数量 ≥ 2:{} 不合法,必须显式写参数