跳到主要内容

Kotlin 密封类实战:用 sealed class 告别 if-else 地狱的状态建模

· 阅读需 7 分钟
重庆 Kotlin User Group

昨天我们聊了枚举类,它非常适合表示"固定几个常量"的场景——比如星期、性别、订单状态。但你有没有遇到过这种情况:状态确实是有限的,但每个状态需要携带的数据完全不同?

来看一个你可能写过的代码:

// 用枚举表示 UI 状态——是不是感觉哪里不对?
enum class UiState {
LOADING,
SUCCESS,
ERROR
}

// 使用时不得不这样:
data class UiModel(
val state: UiState,
val data: List<String>? = null, // 只有 SUCCESS 时有用
val errorMessage: String? = null, // 只有 ERROR 时有用
val errorCode: Int? = null // 只有 ERROR 时有用
)

问题很明显:data 在 LOADING 状态下毫无意义,errorMessage 在 SUCCESS 状态下也是多余的。你不得不写一堆可空字段,然后在用的时候到处判空——这就是典型的"用枚举硬套不同数据结构"的尴尬。

今天的主角 密封类(sealed class),就是来解决这个问题的。

密封类:枚举类的"超级进化版"​

密封类的核心思想很简单:把子类限定在一个固定集合里。编译器知道所有可能的子类,所以能帮你做穷举检查。

// 用密封类重新建模
sealed class UiState {
object Loading : UiState()
data class Success(val data: List<String>) : UiState()
data class Error(val message: String, val code: Int) : UiState()
}

对比一下枚举版:

特性枚举类密封类
每个状态可以有不同数据不行,所有成员结构相同可以,每个子类独立定义
可以有多个实例不行,每个常量只有一个实例可以,Success 可以创建无数个
子类可以是 data class不行可以
子类可以是 object可以(类似枚举)可以
支持 when 穷举检查支持支持

一句话总结:枚举适合"固定几个单例常量",密封类适合"固定几种类型但每种可以有不同的数据"。

密封类 + when:编译器帮你兜底​

密封类最爽的地方,就是和 when 表达式的配合——不需要 else 分支。编译器知道所有子类,如果你漏了一个,直接编译报错:

fun render(state: UiState): String = when (state) {
is UiState.Loading -> "加载中..."
is UiState.Success -> "数据:${state.data}" // 智能转换:这里 state 自动变成 Success 类型
is UiState.Error -> "错误 ${state.code}:${state.message}"
// 不需要 else!如果你新增一个子类,这里编译会报错提醒你来处理
}

注意 is UiState.Success -> 后面的 state.data——你不需要手动 as Success 强转,Kotlin 编译器会自动把 state 智能转换为 Success 类型。这就是密封类的"类型安全"威力。

如果你故意漏掉一个分支试试:

fun render(state: UiState) = when (state) {
is UiState.Loading -> "加载中"
is UiState.Success -> "成功"
// 编译错误:when 表达式没有穷尽所有分支,缺少 UiState.Error
}

编译器直接报错,不给你运行时"惊喜"的机会。

密封类也能用泛型​

实际项目中,Success 状态携带的数据类型经常变化——有时是用户列表,有时是订单详情。用泛型就能搞定:

sealed class Result<out T> {
object Loading : Result<Nothing>()
data class Success<out T>(val data: T) : Result<T>()
data class Error(val message: String, val cause: Throwable? = null) : Result<Nothing>()
}

// 使用
fun fetchUser(): Result<User> {
return try {
val user = api.getUser()
Result.Success(user)
} catch (e: Exception) {
Result.Error("获取用户失败", e)
}
}

// 消费
when (val result = fetchUser()) {
is Result.Loading -> showSpinner()
is Result.Success -> showUser(result.data) // result.data 类型自动推断为 User
is Result.Error -> showError(result.message)
}

这里 out T 是协变标记,Nothing 表示"没有数据"——Loading 和 Error 不携带 T 类型的数据,所以用 Nothing(Kotlin 类型系统的最底部类型)。

sealed interface:更灵活的选择​

从 Kotlin 1.5 开始,你还可以用 sealed interface。接口的好处是一个类可以实现多个接口,不受单继承限制:

sealed interface NavigationEvent {
data class ToDetail(val itemId: String) : NavigationEvent
data class ToProfile(val userId: Long) : NavigationEvent
object Back : NavigationEvent
}

// 一个类可以同时实现多个 sealed interface
data class ToDetailAndLog(val itemId: String) : NavigationEvent, Loggable

实际项目中,优先用 sealed interface,除非你的子类需要共享属性或方法。

实战:给 RecyclerView 建一个事件模型​

来看一个更实际的例子——用密封类建模用户交互事件:

sealed class UserEvent {
data class Click(val itemId: Long) : UserEvent()
data class LongPress(val itemId: Long, val position: Int) : UserEvent()
data class Swipe(val direction: Direction, val itemId: Long) : UserEvent()
object ScrollEnd : UserEvent()

enum class Direction { LEFT, RIGHT }
}

fun handleEvent(event: UserEvent) = when (event) {
is UserEvent.Click -> openDetail(event.itemId)
is UserEvent.LongPress -> showContextMenu(event.itemId, event.position)
is UserEvent.Swipe -> {
when (event.direction) {
UserEvent.Direction.LEFT -> archive(event.itemId)
UserEvent.Direction.RIGHT -> markRead(event.itemId)
}
}
UserEvent.ScrollEnd -> loadMore()
}

每个事件携带不同的数据,处理逻辑清晰分明。新增一种事件类型?编译器会告诉你 handleEvent 里需要加一个新分支——再也不会出现"忘了处理某种情况"的 Bug。

密封类 vs 普通继承​

你可能会问:普通抽象类也能实现类似的效果啊,为什么要用密封类?

关键区别在于封闭性:

// 普通抽象类——任何人都可以在任何地方新增子类
abstract class Shape
class Circle(val radius: Double) : Shape()
class Rectangle(val width: Double, val height: Double) : Shape()
// 别人可以随时加一个 Triangle,你的 when 就漏了

// 密封类——子类集合是固定的,编译器全知道
sealed class Shape
data class Circle(val radius: Double) : Shape()
data class Rectangle(val width: Double, val height: Double) : Shape()
// 别人无法在外部新增子类,when 穷举检查永远可靠

密封类 = 编译器保证的穷举检查 + 类型安全的智能转换。这是普通继承做不到的。

Kotlin 空安全实战:用类型系统告别 NullPointerException 的崩溃噩梦

· 阅读需 7 分钟
重庆 Kotlin User Group

Tony Hoare(快速排序的发明者)曾在 2009 年公开道歉,说他发明 null 引用是一个"十亿美元的错误"。在 Java 世界里,NullPointerException(NPE)几乎是每个程序员的日常噩梦——你以为对象不为空,结果运行时直接崩溃,堆栈信息还告诉你崩溃发生在第 3 层调用之后。

// Java:经典的 NPE 陷阱
String city = user.getAddress().getCity().getName();
// 如果 user 为 null?NPE!
// 如果 getAddress() 返回 null?NPE!
// 如果 getCity() 返回 null?NPE!

为了防 NPE,你不得不写一堆 if (xxx != null) 的防御性代码。而 Kotlin 的解决方案是:在类型系统层面就区分"可以为空"和"不能为空",让编译器帮你把关,把 NPE 从运行时提前到编译时。

Kotlin 序列化实战:用 @Serializable 告别手写 JSON 解析的噩梦

· 阅读需 6 分钟
重庆 Kotlin User Group

你一定经历过这种痛苦:后端甩给你一个 JSON,你得手动解析每个字段,稍有不慎就 NullPointerException 或者类型转换错误。就算用 Gson 或 Moshi,也得写一堆 TypeAdapter,或者忍受反射带来的性能开销。

// 用 Gson 解析 JSON 的"经典"写法
val user = Gson().fromJson(jsonString, User::class.java)
// 如果 JSON 里多了个字段?Gson 默认忽略,你完全不知道
// 如果字段类型不对?运行时直接炸
// 如果要用 ProGuard 混淆?还得加 keep 规则

Kotlin 官方的 kotlinx.serialization 库,用一个 @Serializable 注解就能解决这些问题——编译期生成序列化代码,零反射,类型安全,还跨平台(JVM/JS/Native 通吃)。

Kotlin 枚举类实战:用 enum class 告别魔法数字和散落的常量

· 阅读需 6 分钟
重庆 Kotlin User Group

你写过这样的代码吗?

// 订单状态:0=待支付,1=已支付,2=已发货,3=已完成,4=已取消
fun handleOrder(status: Int) {
if (status == 0) {
println("等待支付...")
} else if (status == 1) {
println("已支付,准备发货")
} else if (status == 2) {
println("已发货")
} else if (status == 3) {
println("订单完成")
} else if (status == 4) {
println("已取消")
}
// 如果传进来一个 5 呢?静默忽略了...
}

这段代码有三个问题:第一,0, 1, 2, 3, 4 是"魔法数字"——读代码的人根本不知道它们代表什么;第二,if-else 链又臭又长;第三,如果有人传了个 5,代码会静默忽略,不报任何错。

Kotlin 的 enum class(枚举类) 可以一次性解决这三个问题,而且远比你想象的强大。

Kotlin 数据类实战:用 data class 告别 JavaBean 的百行模板代码

· 阅读需 6 分钟
重庆 Kotlin User Group

如果你学过 Java,一定对下面这种代码深恶痛绝——一个简单的用户类,要写 getter、setter、equals()、hashCode()、toString(),加上构造函数,轻轻松松上百行:

// Java:一个"简单"的数据类
public class User {
private final int id;
private final String name;
private final int age;

public User(int id, String name, int age) {
this.id = id;
this.name = name;
this.age = age;
}

// 下面是 30 行你复制粘贴了无数遍的代码...
public int getId() { return id; }
public String getName() { return name; }
public int getAge() { return age; }

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return id == user.id && age == user.age && Objects.equals(name, user.name);
}

@Override
public int hashCode() {
return Objects.hash(id, name, age);
}

@Override
public String toString() {
return "User(id=" + id + ", name=" + name + ", age=" + age + ")";
}
}

而在 Kotlin 里,同样的功能只需要 一行:

data class User(val id: Int, val name: String, val age: Int)

没错,就这一行。equals()、hashCode()、toString()、copy()、解构方法——编译器全自动生成。

Kotlin Flow 实战:用数据流管道告别回调地狱

· 阅读需 6 分钟
重庆 Kotlin User Group

你写过这样的代码吗?

// 监听用户输入 → 请求搜索建议 → 过滤结果 → 更新 UI
searchBox.addTextChangedListener { text ->
api.searchSuggestions(text.toString()) { result ->
if (result.isSuccessful) {
val filtered = result.body()?.filter { it.isNotEmpty() }
if (filtered != null) {
runOnUiThread {
adapter.submitList(filtered)
}
}
}
}
}

回调套回调,if 嵌 if——经典的"回调地狱"。更头疼的是,你还得手动处理防抖、取消请求、异常恢复……代码越写越像一团乱麻。

Kotlin 的 Flow(数据流) 就是来终结这种痛苦的。它让你像搭流水线一样,把异步数据一个操作符接一个操作符地串起来,代码从上到下读,逻辑清晰得像白开水。

Kotlin 扩展函数实战:告别 Utils 工具类的优雅姿势

· 阅读需 5 分钟
重庆 Kotlin User Group

你有没有翻过自己半年前写的 Kotlin 代码?我最近就翻了一次,发现了一个 StringUtils.kt 文件,里面躺着一堆这样的函数:

object StringUtils {
fun isEmail(s: String): Boolean {
return s.matches(Regex("^[\\w.-]+@[\\w.-]+\\.\\w+$"))
}

fun isEmpty(s: String?): Boolean {
return s == null || s.isBlank()
}

fun truncate(s: String, maxLen: Int): String {
return if (s.length > maxLen) s.take(maxLen) + "..." else s
}
}

// 调用时
if (StringUtils.isEmail(input)) { ... }
val name = StringUtils.truncate(user.name, 20)

每次调用都要先找到对应的 XxxUtils 对象,再调方法——这浓浓的 Java 味儿,让我意识到:我写了两年 Kotlin,居然一直在写 Java。

Kotlin 作用域函数实战:用 apply/let/run/also 告别临时变量地狱

· 阅读需 6 分钟
重庆 Kotlin User Group

你有没有写过这样的代码?

// 场景:创建一个用户对象并设置各种属性
val user = User()
user.name = "Alice"
user.age = 20
user.email = "alice@example.com"
user.city = "Beijing"

// 或者更糟:为了一个中间结果声明临时变量
val result = computeSomething()
val processed = process(result)
val finalValue = transform(processed)

这种写法能跑,但总让人觉得"不够优雅"。临时变量满天飞,对象初始化代码长得像火车一样。

今天我们来聊聊 Kotlin 的五个作用域函数——apply、let、run、also、with,它们能让你告别临时变量地狱,写出更简洁、更 Kotlin 风格的代码。

Kotlin 2.5 Unicode 代码点 API 实战:用 CodePoint 告别代理对(Surrogate Pair)的噩梦

· 阅读需 6 分钟
重庆 Kotlin User Group

你遇到过这种场景吗?统计一个 emoji 字符串的"字符数",结果数字对不上;或者遍历一个包含罕见汉字的文本,发现一个字被拆成了两个 Char。

来看一段"经典翻车"代码:

val text = "Hello 🌍"
println(text.length) // 7?还是 8?
println(text[6]) // 期望拿到 🌍,实际是一个代理对的高位
println(text[7]) // 这才是代理对的低位

Char 在 Kotlin 中表示一个 UTF-16 编码单元,而像 🌍(U+1F30D)这样的字符超出了 BMP(基本多文种平面),需要两个 Char(即代理对)来表示。直接按 Char 遍历或统计,就会把一个字符拆成两半。

Kotlin 2.5 未使用返回值检查器实战:让编译器帮你揪出那些被静默吞掉的关键结果

· 阅读需 6 分钟
重庆 Kotlin User Group

你有没有遇到过这种场景:代码跑得好好的,测试也通过了,但生产环境就是偶尔出现数据对不上的问题?最后排查了半天,发现罪魁祸首是一个被默默忽略的函数返回值。

来看一段看似无害的代码:

fun processOrder(order: Order) {
val cart = order.cart
// 移除库存不足的商品
cart.items.filter { it.inStock }
// 应用优惠券
cart.total.applyDiscount(order.coupon)
// 保存到数据库
repository.save(cart)
}

问题在哪?filter 不会修改原列表,它返回一个新列表;applyDiscount 如果返回的是新对象而非原地修改,那折扣根本没生效。两行代码的返回值都被静默丢弃了,但编译器一言不发。