7、类型转换器:@TypeConverter注解、自定义转换器、存储Date/List等复杂类型

Room 数据库能存什么?说白了,它只能存基本类型——Int、Long、String、Boolean 这些。但咱们实际开发中,谁还没个 Date、List、Map 要存呢?

我刚开始用 Room 时,就踩过这个坑。当时想把一个用户收藏的商品 ID 列表存进数据库,心想 Room 这么智能,应该能自动处理吧?结果编译直接报错,提示我「不知道如何把 List 存进 SQLite」。嗯,那时候我才意识到,Room 不是万能的,它需要咱们帮它一把。

这个「帮一把」的工具,就是类型转换器(TypeConverter)。

核心思想:类型转换器就像一座桥,把 Kotlin 的复杂类型「翻译」成 Room 能存的基本类型,读取时再「翻译」回来。

7.1 @TypeConverter 注解:转换器的入口

@TypeConverter 是 Room 提供的一个注解,用来标记「转换方法」。你想想看,一个方法负责把复杂类型转成基本类型(比如 Date → Long),另一个方法负责把基本类型转回复杂类型(Long → Date)。

我个人习惯把转换器定义成静态方法,放在一个单独的类里。这样结构清晰,也好维护。

class Converters {
    @TypeConverter
    fun fromDateToLong(date: Date?): Long? {
        return date?.time
    }

    @TypeConverter
    fun fromLongToDate(timestamp: Long?): Date? {
        return timestamp?.let { Date(it) }
    }
}

注意看,这里用了可空类型。为什么?因为数据库字段可能是 NULL,你总不能让转换器在遇到 null 时崩溃吧。

定义好转换器类之后,还需要告诉 Room 去用它。在 @Database 注解里加上 typeConverters 参数:

@Database(
    entities = [User::class],
    version = 1,
    typeConverters = [Converters::class]
)
abstract class AppDatabase : RoomDatabase() {
    // ...
}

这一步很容易忘。我记得有次重构项目,加了个新转换器,结果忘了注册,Room 直接报错说找不到转换方法。排查了半天才发现是注册漏了。

小技巧:转换器类可以定义在数据库类内部,也可以单独文件。我个人推荐单独文件,因为随着项目变大,转换器会越来越多,单独文件更好管理。

7.2 自定义转换器:存储 Date 类型

Date 类型是咱们最常遇到的复杂类型。存 Date 有几种方案?我简单列一下:

方案 存储方式 优点 缺点
时间戳(Long) 毫秒值 精度高、排序方便 可读性差
字符串(String) yyyy-MM-dd HH:mm:ss 可读性好 排序需转换、有格式问题
ISO 8601 字符串 2025-01-15T10:30:00Z 国际化友好 长度较长

我个人最常用的是时间戳方案。为什么?因为排序快、精度高、没有时区问题。你想想看,如果存字符串,不同时区的用户存进去的时间格式可能不一样,查起来就麻烦了。

上面已经给出了 Date ↔ Long 的转换代码,这里再补充一个带时区处理的版本:

class DateConverters {
    @TypeConverter
    fun fromDateToLong(date: Date?): Long? {
        return date?.time
    }

    @TypeConverter
    fun fromLongToDate(timestamp: Long?): Date? {
        return timestamp?.let {
            // 这里可以加时区转换逻辑
            Date(it)
        }
    }
}

嗯,这里要注意:时间戳是 UTC 的,如果你的业务需要特定时区显示,建议在 ViewModel 或 UI 层做转换,不要在转换器里做。转换器只负责「存」和「取」,不负责「展示」。

7.3 存储 List 类型:最常用的复杂类型

List 怎么存?Room 不认识 List,但认识 String。所以思路很简单:把 List 转成 JSON 字符串存进去,取出来时再解析回 List。

这里需要引入 Gson 或 Moshi 这样的 JSON 库。我个人习惯用 Gson,轻量、够用。

class ListConverters {
    private val gson = Gson()

    @TypeConverter
    fun fromStringList(list: List<String>?): String? {
        return list?.let { gson.toJson(it) }
    }

    @TypeConverter
    fun toStringList(json: String?): List<String>? {
        return json?.let {
            val type = object : TypeToken<List<String>>() {}.type
            gson.fromJson(it, type)
        }
    }
}

这段代码里有个关键点:TypeToken。为什么需要它?因为 Java 的泛型在运行时会被擦除,Gson 不知道你要解析成 List<String> 还是 List<Integer>。TypeToken 就是用来保留这个类型信息的。

我在项目中遇到过一个问题:存 List 时一切正常,但取出来时发现类型不对,报 ClassCastException。排查后发现是 TypeToken 写错了,泛型参数没对上。从那以后,我每次写 List 转换器都会再三检查 TypeToken 的类型。

避坑指南:千万不要在转换器里做复杂的业务逻辑。我曾经见过有人把网络请求也写在转换器里,结果数据库读取时卡了好几百毫秒。转换器应该只做「格式转换」,不做「业务处理」。

7.4 存储 Map 类型:键值对也能存

Map 的存储思路和 List 一样,也是转 JSON。但 Map 有个特殊之处:它的 key 和 value 类型都可能变化。

class MapConverters {
    private val gson = Gson()

    @TypeConverter
    fun fromStringMap(map: Map<String, String>?): String? {
        return map?.let { gson.toJson(it) }
    }

    @TypeConverter
    fun toStringMap(json: String?): Map<String, String>? {
        return json?.let {
            val type = object : TypeToken<Map<String, String>>() {}.type
            gson.fromJson(it, type)
        }
    }
}

如果你要存 Map<Int, String> 或者 Map<String, List<Int>>,只需要改 TypeToken 里的泛型参数就行。但要注意,JSON 的 key 只能是 String,所以如果你的 key 是 Int,Gson 会自动转成字符串,取出来时再转回 Int。

7.5 多个转换器如何组织?

当项目变大,转换器会越来越多。Date 一个、List 一个、Map 一个、自定义实体又一个……全堆在一个类里会变得很乱。

我的做法是按类型分组:

  • DateConverters:处理所有日期相关
  • ListConverters:处理所有列表相关
  • MapConverters:处理所有映射相关
  • CustomTypeConverters:处理自定义实体类

然后在 @Database 注解里一次性注册:

@Database(
    entities = [User::class, Order::class],
    version = 1,
    typeConverters = [
        DateConverters::class,
        ListConverters::class,
        MapConverters::class,
        CustomTypeConverters::class
    ]
)
abstract class AppDatabase : RoomDatabase() {
    // ...
}

这样每个类职责单一,改一个不会影响另一个。而且新同事接手代码时,一眼就能看出有哪些转换器。

7.6 性能与注意事项

类型转换器虽然方便,但也不是没有代价。每次读写数据,Room 都会调用转换器方法。如果转换器里做了耗时操作(比如 JSON 解析大对象),就会拖慢数据库操作。

我建议你注意这几点:

  • 避免大对象:不要在实体里存一个巨大的 List 或 Map。如果数据量超过几百条,建议单独建表。
  • 避免嵌套太深:List<List<String>> 这种嵌套结构,JSON 解析会变慢,而且可读性差。
  • 考虑使用 Room 的 @Embedded:如果复杂类型是固定的几个字段,用 @Embedded 比用转换器更高效。
  • 测试转换器:写单元测试验证转换逻辑,尤其是 null 和空列表的情况。

个人经验:我曾经把一个用户的所有操作日志(上千条)存成 JSON 字符串放在一个字段里。结果查询时卡得不行。后来改成单独建表,用外键关联,性能提升了 10 倍。所以,转换器虽好,但别滥用。

7.7 本章知识体系

下面这张图帮你梳理了类型转换器的核心逻辑:

类型转换器核心逻辑 Kotlin 实体 Date / List / Map 写入 @TypeConverter Date → Long List → JSON String 写入 SQLite INTEGER / TEXT 读取 读取 关键要点 • 转换器只做格式转换,不做业务处理 • 多个转换器建议按类型分文件管理 • 避免在转换器中处理大对象或嵌套过深的数据 • 记得在 @Database 注解中注册所有转换器

类型转换器是 Room 中一个非常实用的功能。它让咱们能用 Kotlin 的丰富类型来建模,同时又不用牺牲 SQLite 的存储效率。只要记住「存时转成基本类型,取时转回复杂类型」这个核心原则,大部分场景都能轻松应对。

嗯,这一章的内容就到这里。如果你在实际项目中遇到什么奇怪的转换问题,欢迎来交流。


公众号:蓝海资料掘金营,微信deep3321