Android性能优化之枚举替代(二)

88 阅读7分钟

导读

在日常开发中,我们经常需要定义一组有限且固定的状态。常见做法有两种:

  1. 使用枚举类型(enum)
  2. 使用字符串常量

但这两种方式各有优缺点:枚举类型安全却有额外开销,字符串常量轻量但缺乏约束。那么在实际开发中该如何取舍?

之前在Android性能优化之枚举替代一文中介绍了注解的使用,现在再来说一个更优的解,kotlinvalue 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 有一个无可取代的优势。

我们可以探讨一下这个场景: 假设你在写一个支付系统,状态有 SUCCESSFAILED

  • 如果你用 value class(底层是 Int),别人可能会不小心传进一个 99,而你的代码里并没有定义 99 是什么。
  • 如果你用 enum,编译器会强制只能从你定义的几个选项里选。

和注解以及枚举三者比较

在 Android 开发中,@IntDef@StringDef(被称为 Magic Constants)长期以来一直是 enum 的官方推荐替代方案。

现在有了 value class,我们相当于有了三种选择。它们三者的关系可以这样理解:

1. 三者对比:谁最强?
特性@IntDef / @StringDefvalue classenum
底层类型原始 IntString原始 IntString对象 (Object)
运行时开销极低 (就是原始类型)极低 (内联后等同原始类型)较高 (有对象头和引用开销)
类型检查 (基于 Lint 工具检查) (编译器级强类型检查) (编译器级强类型检查)
代码位置只能配合注解使用真正的独立类型真正的独立类型
可维护性较差 (容易写乱)很好 (可以定义方法/属性)最好 (支持穷举、复杂逻辑)
2. 核心区别:Lint 检查 vs. 编译器检查

这是 @IntDefvalue class 之间最本质的区别。

  • @IntDef 是“马后炮” :它依赖于 Android Studio 的 Lint 扫描。如果你把代码拿到一个普通的编辑器(如 VS Code 或纯命令行编译),或者故意绕过警告,它并不能从语法层面阻止你传错参数。
  • value class 是“铁门” :它是 Kotlin 编译器 强制执行的。如果类型不匹配,代码根本无法通过编译
3. 为什么 value class 往往是更好的选择?

在 Android 项目中,value class 正在逐渐取代 @IntDef,原因如下:

  1. 它不仅仅是数值:你可以在 value class 里写逻辑。例如,给 Color 增加一个 toHexString() 方法。@IntDef 做不到这一点。
  2. API 清晰度:在 IDE 的参数提示里,你会直接看到需要 Color 类型,而不是一个模棱两可的 Int
  3. 支持方法重载:你可以定义两个函数名相同但参数不同的方法,一个收 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 的二进制兼容性时。