如何在不加载区域的情况下将 LocalDateTime 转换为 UTC 并返回?

How to convert LocalDateTime to UTC and back, without loading zones?

背景

我正在使用 threetenbp backport for Android (here),来处理各种与时间相关的数据操作。

其中之一是将时间转换为不同的时区(当前为 UTC 并返回)。

我知道如果你使用类似的东西这是可能的:

LocalDateTime now = LocalDateTime.now();
LocalDateTime nowInUtc = now.atZone(ZoneId.systemDefault()).withZoneSameInstant(ZoneId.of("UTC")).toLocalDateTime();

这很好用,反过来也很容易。

问题

我试图避免库的初始化,它会加载相当大的区域文件。我已经弄清楚如何在没有这个的情况下处理各种 date/time 相关操作,除了这种转换为 UTC 并返回的情况。

我得到的结果与正确的转换相差整整 1 小时。

我试过的

这是我发现并尝试过的:

// getting the current time, using current time zone: 
Calendar cal = Calendar.getInstance();
LocalDateTime now = LocalDateTime.of(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH), cal.get(Calendar.HOUR_OF_DAY),
            cal.get(Calendar.MINUTE), cal.get(Calendar.SECOND), cal.get(Calendar.MILLISECOND) * 1000000);

//the conversion itself, which is wrong by 1 hour in my tests: 
LocalDateTime alternativeNowInUtc = now.atZone(ZoneOffset.ofTotalSeconds(TimeZone.getDefault().getRawOffset() / 1000)).withZoneSameInstant(ZoneId.ofOffset("UTC", ZoneOffset.ofHours(0))).toLocalDateTime();

问题

我写的到底有什么问题?如何在不初始化库的情况下获得转换时间的替代代码?

给定一个 LocalDateTime 实例作为输入,我如何将它从当前时区转换为 UTC,以及从 UTC 转换为当前时区?

这可能是因为您的 JVM 的默认时区是夏令时 (DST)。

要获得正确的偏移量,您应该检查时区是否为夏令时并将其添加到偏移量中:

Calendar cal = Calendar.getInstance();

TimeZone zone = TimeZone.getDefault();
// if in DST, add the offset, otherwise add zero
int dst = zone.inDaylightTime(cal.getTime()) ? zone.getDSTSavings() : 0;
int offset = (zone.getRawOffset() + dst) / 1000;
LocalDateTime alternativeNowInUtc = now.atZone(ZoneOffset.ofTotalSeconds(offset))
    .withZoneSameInstant(ZoneId.ofOffset("UTC", ZoneOffset.ofHours(0)))
    .toLocalDateTime();

nowInUtc 创建为 LocalDateTime 的另一种方法是从 Calendar:

创建 Instant
LocalDateTime nowInUtc = Instant.ofEpochMilli(cal.getTimeInMillis())
    .atOffset(ZoneOffset.ofHours(0)).toLocalDateTime();

实际上,你根本不需要 Calendar,只需使用 Instant.now() 来获取当前时刻:

LocalDateTime nowInUtc = Instant.now().atOffset(ZoneOffset.ofHours(0)).toLocalDateTime();

或者,甚至更短,直接使用 OffsetDateTime

LocalDateTime nowInUtc = OffsetDateTime.now(ZoneOffset.ofHours(0)).toLocalDateTime();

不确定是否有任何加载时区数据,由您自行测试。

我认为可以使用常量ZoneOffset.UTC代替ZoneOffset.ofHours(0),因为它也不会加载tz数据(但我还没有测试过)。

最终解决方案

假设默认时区在以色列(TimeZone.getDefault()Asia/Jerusalem):

// April 11th 2018, 3 PM (current date/time in Israel)
LocalDateTime now = LocalDateTime.of(2018, 4, 11, 15, 0, 0);

TimeZone zone = TimeZone.getDefault();
// translate DayOfWeek values to Calendar's
int dayOfWeek;
switch (now.getDayOfWeek().getValue()) {
    case 7:
        dayOfWeek = 1;
        break;
    default:
        dayOfWeek = now.getDayOfWeek().getValue() + 1;
}
// get the offset used in the timezone, at the specified date
int offset = zone.getOffset(1, now.getYear(), now.getMonthValue() - 1,
                            now.getDayOfMonth(), dayOfWeek, now.getNano() / 1000000);
ZoneOffset tzOffset = ZoneOffset.ofTotalSeconds(offset / 1000);

// convert to UTC
LocalDateTime nowInUtc = now
                // conver to timezone's offset
                .atOffset(tzOffset)
                // convert to UTC
                .withOffsetSameInstant(ZoneOffset.UTC)
                // get LocalDateTime
                .toLocalDateTime();

// convert back to timezone
LocalDateTime localTime = nowInUtc
    // first convert to UTC
    .atOffset(ZoneOffset.UTC)
    // then convert to your timezone's offset
    .withOffsetSameInstant(tzOffset)
    // then convert to LocalDateTime
    .toLocalDateTime();

carlBjqsd 的回答还可以,只是比较笨拙,应该更清楚一些。

为什么相差一小时

查看@carlBjqsd 的最终解决方案:它使用表达式

int offset = zone.getOffset(1, now.getYear(), now.getMonthValue() - 1, now.getDayOfMonth(), dayOfWeek, now.getNano() / 1000000);

而不是

getRawOffset()

这导致您观察到的时间相差一小时。应用程序通常不需要只计算原始偏移量,它在一年中的某些时期省略了 dst-offset。 只有总偏移量在从本地时间戳到 UTC 和返回的任何转换中才重要。 fine-granular 区分原始偏移量或 dst 偏移量等部分偏移量的主要目的是正确命名该区域(我们是否称其为标准时间?)。

问题的误导性标题:"without loading zones"

,如果您想使用区域在本地时间戳和 UTC 之间进行转换,则永远无法避免加载区域。您真正的问题是:如何避免加载 ThreetenABP 的区域并改为 use/load Android 平台的区域。 您的动机似乎是:

I'm trying to avoid initialization of the library, which loads quite a large file of zones into it

嗯,我还没有测出哪些区域数据对性能的影响更大。根据我对所涉及库源代码的研究和了解,我只能说 java.time 和 ThreetenBP 将整个文件 TZDB.dat 加载到内存中的二进制数组缓存中(作为第一步),然后挑选出来单个区域的相关部分(即通过反序列化将二进制数据数组的一部分解释为一组区域规则,最后是单个 ZoneId)。旧 Java 平台改为使用一组不同的 zi-files(每个区域一个),我怀疑 Android 区域的行为方式类似(但如果你知道请纠正我越详细越好)。

如果只使用一个区域,那么使用单独区域文件的传统方法可能会更好,但是一旦您想遍历所有可用区域,那么最好只使用一个区域文件。

个人认为性能方面可以忽略不计。如果您使用 Android 区域,您也不可避免地会有一些加载时间。 如果你真的想加快ThreetenABP的初始化时间,你应该考虑在后台线程中加载它。

Android 区域和 ThreetenABP 区域是否等同?

一般不会。两个时区存储库可能会为具体时区提供相同的偏移量。他们经常这样做,但有时会出现不在您控制范围内的差异。 虽然两个timezone版本库在最终结果中都使用了iana.org/tz的数据,但差异主要是由于tzdb-data.的不同版本造成的,而你无法控制哪个版本的zone Android 平台上存在数据,因为这取决于移动 phone 用户 he/she 更新 Android OS 的频率。 ThreetenABP的数据也是如此。您可以提供最新版本的应用程序,包括最新版本的 ThreetenABP,但您无法控制移动设备用户是否真的更新了应用程序。

为什么要关心选择合适的 tz 存储库的其他原因?

除了性能和初始化时间之外,确实有一个特殊的场景可能会引起选择的兴趣。如果 Android OS 有点旧并且使用了过时版本的时区规则,那么一些移动 phone 用户不会更新他们的操作系统,而是操纵设备时钟以补偿错误的时区数据。这样,他们仍然可以在手机上获得正确的当地时间 phone(在所有应用程序中)。

在这种情况下,ThreetenABP 没有提供好的解决方案,因为将正确的区域数据与错误的设备时钟相结合会导致错误的本地时间戳(使用户烦恼)。例如,在不久前更改 dst-rules 的土耳其就是一个问题。

仅使用 Android 的旧日历和时区 API(在包 java.util 中)可以解决该问题,因此可以创建正确的本地时间戳。但是,如果应用程序与其他主机(例如服务器)通信 UTC-times(例如自 1970-01-01T00Z 以来的毫秒数),那么错误的设备时钟仍然是个问题。

我们可以说为什么要这么麻烦,因为用户已经完成 "nonsense" 设备配置,但​​我们也生活在现实世界中,应该考虑如何让这些用户满意。因此,在考虑我至少在我的日历库 Time4A methods like SystemClock.inPlatformView() 中引入的解决方案时,它使用(可能)最实际的区域数据并基于用户至少会观察到正确的本地设备的假设获得正确的 UTC 时钟时间(无论 he/she 通过更新 OS 或 clock/zone 配置来实现此目标所做的一切)。我很高兴以这种方式完全避免使用旧日历和区域 API。我的 API 甚至允许同时使用两个 zon存储库:

  • Timezone.of("java.util.TimeZone~Asia/Jerusalem") // 使用 Android 数据
  • Timezone.of("Asia/Jerusalem") // 使用 Time4A 数据

也许您可以从这些想法中获益,以 find/develop 合适的助手 类 来使用 ThreetenABP。 Time4A 是开源的。