开始做 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
而 { 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, 表示
- action 函数是在 StringBuilder 上执行的
- 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 编译
上来就被编译给干懵了。
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 种传参方式:
- lambda 包裹一层 【更推荐!扩展性好,容易理解;在 Jetpack Compose 1.5.3 之前版本里性能更稳定—自动 remember】
::引用
. 去引用函数,在 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)。
解决方法:
- 把
MessageUiModel改为 sealed interface… 这样能解决问题,但是冗余代码一大堆 -
没办法,强转
@Suppress("UNCHECKED_CAST") fun <T : MessageContentUiModel> AnyMessageUiModel.narrow(): MessageUiModel<T> = this as MessageUiModel<T>但担心 typo 导致莫名其妙崩掉…
- 不要
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 不能捕获所有的异常,遗漏了:
database.topicSelectQueries.getTopicById(topicId).asFlow()同步创建 flow 过程中的异常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)
}
}
问来问去,
try-catch最直接,没看到啥副作用,除了 2 个 catch 有点丑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:
{}不合法,必须显式写参数