如果你已经开始使用 Room(如果没有,建议使用),你很可能需要对某种类型的日期/时间进行存储和检索。Room 不支持开箱即用,而是提供了可扩展的注解 @TypeConverter,它允许你提供从任意对象到 Room 理解的类型的映射,反之亦然。
Room 官方 API 文档中的 规范示例 实际上是日期/时间:
1 | public class Converters { |
我在项目中使用了这个示例代码,并且也是有效的,但是它存在两个问题。第一个是它使用了 Date 类,而我们应该在几乎所有的项目中避免使用 Date 类。Date 类的主要问题是它完全不支持时区。第二个问题是它将值保存为简单的 Long 类型,它再次无法存储任何时区信息。
所以,如果我们使用了上面的转换器将 Date 实例持久化到数据库,然后再检索它。就不会知道这个时间值来自哪个时区。你能做的最好尝试就是确保所有 Date 实例都使用公共时区,比如 UTC。虽然这允许你将不同的检索值进行比较(即用于排序),但你永远无法找到原始时区。我决定花一个小时尝试修复应用中的时区问题。
1 SQLite + date/time
我调查的第一件事是 SQLite 对日期和时间值的支持,事实上它确实支持它们。当你使用 Room 时,它会将你的数值映射到对应的 SQL 数据类型。例如,将 String
映射到 TEXT
,Int
到 INTEGER
等。但是,我们如何让 Room 映射 Date 对象和时间值呢?答案是不需要。
SQLite 是一个松散类型的数据库系统,它将所有值存储为以下值之一:NULL
,INTEGER
,TEXT
,REAL
或 BLOB
。你会发现和其他数据库系统不一样,在 SQLite 中找不到特殊的日期或时间类型。相反,他们提供了和存储 日期/时间 相关的文档:
SQLite 没有为日期、时间预留存储类。相反,SQLite 的内置日期和时间函数能够将日期和时间存储为 TEXT,REAL 或 INTEGER 值。
正是这些日期和时间函数使我们能够存储高保真无损的日期时间值,特别是使用 TEXT 类型,因为它支持 ISO 8601 字符串。
因此,我们只需将我们的值保存为特殊格式的文本,其中包含我们需要的所有信息。然后,我们可以使用提到的 SQLite 函数将我们的文本转换为 SQL 中的日期/时间即可。我们唯一需要做的就是确保我们的代码使用正确的格式。
2 回到 APP
所以我们知道 SQLite 可以支持我们的需求,但我们需要决定如何在程序中表示它。
我的程序中使用了 ThreeTen-BP,它是 JDK 8 日期和时间库(JSR-310)的移植,仅适用于 JDK 6 以上的版本。该库支持时区,因此我将使用其中一个类来表示程序中的日期+时间:OffsetDateTime。此类是 UTC / GMT 特定偏移量内的时间和日期的不可变表示。
因此,当我们查看程序的一个实体时,我们使用的是 OffsetDateTime 而不是 Date:
1 |
|
这只是实体进行了更新,我们还必须更新 TypeConverters,以便让 Room 知道如何持久化/恢复 OffsetDateTime
值:
1 | object TiviTypeConverters { |
现在我们将 OffsetDateTime
映射到 String
或从 String
映射到 OffsetDateTime
,而不是之前的将 Date
映射到 Long
。
这些方法非常简单:一个将 OffsetDateTime 格式化为 String,另一个将 String 解析为 OffsetDateTime。这里的关键点是如何保证我们使用正确的 String 格式。庆幸的是,ThreeTen-BP 为我们提供了兼容的 DateTimeFormatter.**ISO_OFFSET_DATE_TIME**
。
你可能没有使用此库,所以我们先来看一个格式化字符串的示例:2013-10-07T17:23:19.540-04:00。希望你看到的日期代表了:2013年10月7日, 17:23:19.540 UTC-4。只要你将日期时间格式化/解析为这样的字符串,SQLite 就能理解它。
所以在这一点上,我们差不多完成了。如果你运行应用程序,并使用适当的数据库版本增加+迁移,你会发现一切都应该正常工作。有关 Room 的迁移的更多信息,请参阅 Florina Muntenescu 的帖子。
3 Room 排序
目前尚未解决的一件事是 SQL 中日期列的查询。之前的 Date 和 Long 映射具有隐含的优势,因为数字对排序和查询非常有效。应用到 String 上,这种优势多少会减弱,所以让我们解决它。
假设我们之前有一个查询,它返回按加入日期排序的所有用户。你可能会这样做:
1 |
|
由于 joined_date
是一个 Long 类型的数字,SQLite 会进行简单的数字比较并返回结果。如果你使用新的 Text 类型去执行相同的查询,你可能会发现结果看起来相同,但真是这样吗?
答案是肯定的,并且大多数情况都是如此。通过 Text 实现,SQLite 会对文本进行排序,这对于大多数情况来说都是正确的。让我们看一些示例数据:
1 | id | joined_date |
这里有一个简单的从左到右的 String 排序,因为字符串的所有组成部分都按降序排列(年,月,日,等等)。问题在于字符串的最后一个组成部分,即时区偏移量。让我们稍微调整数据,看看会发生什么:
1 | id | joined_date |
你可以看到第 3 行的时区已从 UTC 更改为 UTC-2。这时它的加入时间实际上是 UTC 中的 09:01:12
,因此它实际上应该被排在第二行。而返回的列表顺序却与以前的相同。这是因为我们仍然在使用字符串排序,这种排序是不考虑时区的。
4 SQLite 日期时间函数
那么我们该如何解决呢?还记得 SQLite 中的日期/时间函数吗?我们只需在与 SQL 中的日期/时间列交互时使用它们即可。SQLite 提供了 5 个方法:
序号 | 方法名 | 描述 |
---|---|---|
1 | date(…) | 只返回日期 |
2 | time(…) | 只返回时间 |
3 | datetime(…) | 返回日期和时间 |
4 | julianday(…) | 返回 Julian Day |
5 | strftime(…) | 返回使用给定格式字符串格式化的值。前四个可以看做是具有预定义格式的 strftime 的变体。 |
由于我们想要对日期和时间进行排序,我们可以使用 datetime(...)
函数。如果我们回到 DAO,查询现在变为:
1 |
|
在我们做出这个改变之后,我们现在得到了正确的排序结果:
1 | id | joined_date |