导读
在日常开发中,我们经常需要定义一组有限且固定的状态。常见做法有两种:
- 使用枚举类型(enum)
- 使用字符串常量
但这两种方式各有优缺点:枚举类型安全却有额外开销,字符串常量轻量但缺乏约束。那么在实际开发中该如何取舍?
之前在Android性能优化之枚举替代一文中介绍了注解的使用,现在再来说一个更优的解,kotlin 的 value class。
value class
在 Kotlin 中,value class(值类)是一种特殊的类,旨在提高代码的可读性和类型安全,同时又不损失性能。
使用
基本使用
// 定义
@JvmInline
value class Password(val value: String)
// 使用
fun login(p: Password) {}
在大多数情况下,编译器会将 Password 类型替换为 String。这意味着它不会在堆上创建一个新的对象,而是直接操作那个字符串。
但是相比普通的字符串,又多了个类型安全,当你给login方法传入普通字符串的时候就会报错。
模拟枚举使用
@JvmInline
value class Color(val rgb: Int) {
companion object {
val Red = Color(0xFF0000)
val Green = Color(0x00FF00)
val Blue = Color(0x0000FF)
}
}
// 使用时看起来很像枚举
val myColor = Color.Red
定义很简单,但是性能却比枚举好很多,因为不同的值不会生成很多个实例,会被转换成原始类型 Int。
比较
和枚举比较
虽然 value class 赢在了性能,但在逻辑严密性上,enum 有一个无可取代的优势。
我们可以探讨一下这个场景: 假设你在写一个支付系统,状态有 SUCCESS 和 FAILED。
- 如果你用
value class(底层是Int),别人可能会不小心传进一个99,而你的代码里并没有定义99是什么。 - 如果你用
enum,编译器会强制只能从你定义的几个选项里选。
和注解以及枚举三者比较
在 Android 开发中,@IntDef 和 @StringDef(被称为 Magic Constants)长期以来一直是 enum 的官方推荐替代方案。
现在有了 value class,我们相当于有了三种选择。它们三者的关系可以这样理解:
1. 三者对比:谁最强?
| 特性 | @IntDef / @StringDef | value class | enum |
|---|---|---|---|
| 底层类型 | 原始 Int或 String | 原始 Int或 String | 对象 (Object) |
| 运行时开销 | 极低 (就是原始类型) | 极低 (内联后等同原始类型) | 较高 (有对象头和引用开销) |
| 类型检查 | 弱 (基于 Lint 工具检查) | 强 (编译器级强类型检查) | 强 (编译器级强类型检查) |
| 代码位置 | 只能配合注解使用 | 真正的独立类型 | 真正的独立类型 |
| 可维护性 | 较差 (容易写乱) | 很好 (可以定义方法/属性) | 最好 (支持穷举、复杂逻辑) |
2. 核心区别:Lint 检查 vs. 编译器检查
这是 @IntDef 和 value class 之间最本质的区别。
@IntDef是“马后炮” :它依赖于 Android Studio 的 Lint 扫描。如果你把代码拿到一个普通的编辑器(如 VS Code 或纯命令行编译),或者故意绕过警告,它并不能从语法层面阻止你传错参数。value class是“铁门” :它是 Kotlin 编译器 强制执行的。如果类型不匹配,代码根本无法通过编译。
3. 为什么 value class 往往是更好的选择?
在 Android 项目中,value class 正在逐渐取代 @IntDef,原因如下:
- 它不仅仅是数值:你可以在
value class里写逻辑。例如,给Color增加一个toHexString()方法。@IntDef做不到这一点。 - API 清晰度:在 IDE 的参数提示里,你会直接看到需要
Color类型,而不是一个模棱两可的Int。 - 支持方法重载:你可以定义两个函数名相同但参数不同的方法,一个收
UserId,一个收OrderId。用@IntDef是做不到的(因为它们底层都是Int)。
注意
虽然 value class 的初衷是零成本抽象,但在某些特定场景下,它会发生 “装箱”(Boxing) ,也就是从原始类型变回一个真正的包装对象。
一旦发生装箱,它的性能消耗就变得和普通 class 一样了,甚至在某些极端情况下,因为频繁的拆箱/装箱操作,开销会比直接用原始类型略大。
以下是 value class 消耗变大的四大典型场景:
1. 当它被当作“泛型类型”使用时 🧩
这是最常见的“破功”场景。在 JVM 中,泛型(如 List<T>)必须是引用对象,不能直接存储原始类型。
@JvmInline value class UserId(val id: Long)
val list = listOf(UserId(1L), UserId(2L))
// 这里的 Long 会被包装成 UserId 对象存入 List
- 后果:如果你有一个包含百万级数据的
List<UserId>,内存中会存在一百万个对象实例,这和List<Long>(自动装箱为Long对象)的开销基本持平。
2. 当它被赋值给“接口类型”时 接口转换 🛡️
如果你的 value class 实现了一个接口,当你把它向上转型为接口时,它必须装箱以携带接口的虚函数表信息。
interface Identifiable
@JvmInline value class UserId(val id: Long) : Identifiable
val myId = UserId(100L) // 此时是内联的 Long
val provider: Identifiable = myId // 发生装箱!为了匹配接口类型
3. 使用“可空类型” (Nullable) 时 ❓
原始类型(如 long)在 Java 中是不能为 null 的。为了表示 null,Kotlin 必须将其包装。
fun process(id: UserId?) { ... }
process(UserId(1L)) // 传入时会装箱,因为参数声明可以接收 null
- 这就像
int变成Integer一样,UserId会变成一个真正的对象来允许null值的存在。
4. 在数组中的表现差异 数组
Array<UserId>:会发生装箱,因为它对应 Java 的UserId[]对象数组。LongArray:不会装箱,对应 Java 的long[]。- 目前遗憾的是:你不能直接创建一个“内联的 UserIdArray”,这导致在处理大量数据集合时,
value class往往不如原始类型的专属数组(如IntArray)快。
总结:消耗对比表
| 场景 | 是否内联 | 内存消耗 |
|---|---|---|
| 局部变量 / 函数参数 | ✅ 是 | 极低(原始类型) |
| 作为对象属性 | ✅ 是 | 极低(原始类型) |
| 作为泛型参数 (List, Map) | ❌ 否 | 较高(装箱对象) |
| 向上转型为接口 | ❌ 否 | 较高(装箱对象) |
| 声明为可空类型 (?) | ❌ 否 | 较高(装箱对象) |
建议
在 Android 中,如果你在 RecyclerView 的数据列表中大量使用 value class(作为 List<T> 的泛型),虽然它提供了类型安全,但并没有帮你节省内存。
在这种情况下,如果你追求极致性能,可能需要直接使用 LongArray 或者在计算逻辑内部使用 value class,而在存储层使用原始数组。
最佳实践
如果又想使用极致性能,又想在 when 中枚举的穷举能力,可以考虑使用sealed interface。
sealed interface UIState
@JvmInline value class Success(val data: String) : UIState
@JvmInline value class Error(val code: Int) : UIState
object Loading : UIState
fun handleState(state: UIState) {
// 这里的 when 是可以穷举检查的!
when (state) {
is Success -> println("拿到数据: ${state.data}")
is Error -> println("错误代码: ${state.code}")
Loading -> println("加载中...")
}
}
如果没穷举完就会报错:
⚠️ 一个必须要避开的“坑”
如上文的注意:虽然上面的代码很优雅,但你要记住:一旦 value class 被赋值给接口类型(如 val s: UIState = Success("Done") ),它就会发生“装箱” 。
- 内联状态:直接调用函数
handle(Success("...")),编译器会尝试内联。 - 装箱状态:放入
List<UIState>时,它会变成一个真正的对象。
总结建议
- 用
enum:如果你的选项很少(如 3-5 个),且需要复杂的逻辑或必须在when中强制穷举处理,且不在乎那丁点内存开销。 - 用
value class: (推荐方案) 如果你追求高性能,且希望代码拥有极强的类型安全(尤其是 ID、颜色值、单位换算等场景)。 - 用
@IntDef或@StringDef:仅当你还在维护老的 Java 代码,或者必须保持与现有 Java API 的二进制兼容性时。