如何处理 JodaTime 和 Android 的时区数据库差异?

How to handle JodaTime's and Android's timezone database differences?

我想扩展我在 Reddit Android 开发社区 yesterday 上开始的一个新问题:如何使用具有过时时区信息的设备上的 JodaTime 库?

问题

手头的具体问题与特定时区有关,“Europe/Kaliningrad”。我可以重现这个问题:在 Android 4.4 设备上,如果我手动将其时区设置为上述时区,调用 new DateTime() 会将此 DateTime 实例设置为前一小时的时间phone 状态栏上显示的实际时间。

我创建了一个示例 Activity 来说明问题。在其 onCreate() 上,我调用以下内容:

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    ResourceZoneInfoProvider.init(getApplicationContext());
    
    ViewGroup v = (ViewGroup) findViewById(R.id.root);
    addTimeZoneInfo("America/New_York", v);
    addTimeZoneInfo("Europe/Paris", v);
    addTimeZoneInfo("Europe/Kaliningrad", v);
}

private void addTimeZoneInfo(String id, ViewGroup root) {
    AlarmManager am = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
    am.setTimeZone(id);
    //Joda does not update its time zone automatically when there is a system change
    DateTimeZone.setDefault(DateTimeZone.forID(id));
    
    View v = getLayoutInflater().inflate(R.layout.info, root, false);
    
    TextView idInfo = (TextView) v.findViewById(R.id.id);
    idInfo.setText(id);
    
    TextView timezone = (TextView) v.findViewById(android.R.id.text1);
    timezone.setText("Time zone: " + TimeZone.getDefault().getDisplayName());
    
    TextView jodaTime = (TextView) v.findViewById(android.R.id.text2);
    //Using the same pattern as Date()
    jodaTime.setText("Time now (Joda): " + new DateTime().toString("EEE MMM dd HH:mm:ss zzz yyyy"));
    
    TextView javaTime = (TextView) v.findViewById(R.id.time_java);
    javaTime.setText("Time now (Java): " + new Date().toString());
    
    
    root.addView(v);
}

ResourceZoneInfoProvider.init()joda-time-android 库的一部分,用于初始化 Joda 的时区数据库。 addTimeZoneInfo 覆盖设备的时区并在其中显示更新时区信息的新视图。这是结果的示例:

请注意“加里宁格勒”,Android 如何将其映射到“GMT+3:00”,因为 2014 年 10 月 26 日之前都是如此(参见 Wikipedia article)。甚至一些网站仍将此时区显示为 GMT+3:00,因为此更改相对较新。然而,正确的是 JodaTime 显示的“GMT+2:00”。

有缺陷的可能解决方案?

这是一个问题,因为无论我如何尝试规避它,最后我都必须格式化时间以在他们的时区显示给用户。当我使用 JodaTime 执行此操作时,时间格式会不正确,因为它与系统显示的预期时间不匹配。

或者,假设我用 UTC 处理所有事情。当用户在日历中添加事件并选择提醒时间时,我可以将其设置为 UTC,像那样将其存储在数据库中并完成它。

但是,我需要用 Android 的 AlarmManager 设置提醒,而不是在 UTC 时间我转换了用户设置的时间,而是在相对于他们想要的时间提醒触发。这需要时区信息才能发挥作用。

例如,如果用户在 UTC+1:00 的某个地方并且他或她设置了 9:00am 的提醒,我可以:

我错过了什么?

我可能想多了,恐怕我可能遗漏了一些明显的东西。我是吗?除了向应用程序添加我自己的时区管理并完全忽略所有内置 Android 格式化功能之外,有什么方法可以让我在 Android 上继续使用 JodaTime?

你做错了。如果用户添加一个上午 9 点的日历条目 明年他的意思是早上 9 点。时区数据库无关紧要 在用户的设备更改或政府决定更改 夏令时的开始或结束。重要的是什么 时钟在墙上说。您需要在数据库中存储“上午 9 点”。

这通常是一个不可能的问题。您永远无法在给定的挂钟时间为未来事件存储增量时间(例如纪元之后的毫秒数),并确信它会保持正确。考虑到对于任意时间,一个国家可能会签署一项法律,规定夏令时在该时间前 30 分钟生效,并且时间向前跳 1 小时。可以缓解此问题的解决方案是,不是要求 phone 在特定时间醒来,而是定期醒来,检查当前时间,然后检查是否应激活任何提醒。这可以通过调整唤醒时间来有效地完成,以反映下一次提醒设置的大致时间。例如,如果下一次提醒不是 1 年,则在 360 天后醒来,并开始更频繁地醒来,直到您非常接近提醒的时间。

由于您使用的是 AlarmManager,并且在 UTC 中显示 it requires you to set the time,那么您是正确的,因为您需要将时间投射到 UTC 才能安排它。

但您不必坚持那样。例如,如果您有一个每天重复发生的事件,则存储该事件应该触发的本地时间。如果事件发生在特定时区(而不是设备的 当前 时区),则还要存储该时区 ID。

使用 JodaTime 将本地时间投影为 UTC 时间 - 然后将该值传递给 AlarmManager

定期(或至少,每当您对 JodaTime 的数据应用更新时),重新评估计划的 UTC 时间。根据需要取消并重新建立事件。

注意 DST 转换期间安排的时间。您可能有一个在特定日期无效的当地时间(可能应该提前),或者您可能有一个在特定日期不明确的本地时间(您可能应该选择 first 两个实例)。

最终,我认为您担心的是 Joda Time 数据可能比设备数据准确。因此,闹钟可能会在正确的当地时间响起——但它可能与设备上显示的时间不匹配。我同意最终用户可能会感到困惑,但他们可能会感谢您做了正确的事情。无论如何,我认为您无能为力。

您可能会考虑的一件事是检查 TimeUtils.getTimeZoneDatabaseVersion() 并将其与您加载到 Joda Time 中的数据的版本号进行比较。如果它们不同步,您可以向您的用户显示一条警告消息,以更新您的应用程序(当您落后时),或更新他们设备的 tzdata(当设备落后时)。

快速搜索发现 these instructions and this app 用于更新 Android 上的 tzdata。 (我自己没有测试过。)

我认为其他答案没有抓住要点。是的,在持久化时间信息时,您应该仔细考虑您的用例以决定如何最好地做到这一点。但即使你做了,这个问题提出的问题仍然存在。

考虑一下 Android 的闹钟应用程序,它有源代码 freely available。如果您查看它的 AlarmInstance class,这是它在数据库中的建模方式:

private static final String[] QUERY_COLUMNS = {
        _ID,
        YEAR,
        MONTH,
        DAY,
        HOUR,
        MINUTES,
        LABEL,
        VIBRATE,
        RINGTONE,
        ALARM_ID,
        ALARM_STATE
};

要知道何时触发警报实例,您可以调用 getAlarmTime():

/**
 * Return the time when a alarm should fire.
 *
 * @return the time
 */
public Calendar getAlarmTime() {
    Calendar calendar = Calendar.getInstance();
    calendar.set(Calendar.YEAR, mYear);
    calendar.set(Calendar.MONTH, mMonth);
    calendar.set(Calendar.DAY_OF_MONTH, mDay);
    calendar.set(Calendar.HOUR_OF_DAY, mHour);
    calendar.set(Calendar.MINUTE, mMinute);
    calendar.set(Calendar.SECOND, 0);
    calendar.set(Calendar.MILLISECOND, 0);
    return calendar;
}

请注意 AlarmInstance 如何存储 确切的 它应该触发的时间,而不考虑时区。这确保每次调用 getAlarmTime() 时,您都会获得正确的时间以在用户的​​时区触发。这里的问题是如果不更新时区,getAlarmTime() 无法获得正确的时间变化,例如,当 DST 开始时。

JodaTime comes in handy in this scenario because it ships with its own time zone database. You could consider other date time libraries such as date4j 为了方便更好地处理日期计算,但这些通常不处理它们自己的时区数据。

但是拥有自己的时区数据会给您的应用带来限制:您不能再依赖 Android 的时区。这意味着您不能使用它的 Calendar class 或它的格式化功能。 JodaTime 也提供格式化功能,使用它们。如果您必须转换为 Calendar,而不是使用 toCalendar() 方法,请创建一个类似于上面的 getAlarmTime() 的方法,您可以在其中传递您想要的确切时间。

或者,您可以检查是否存在时区不匹配,并像 Matt Johnson 在 中建议的那样警告用户。如果您决定继续使用 Android 和 Joda 的功能,我同意他的观点:

Yes - with two sources of truth, if they're out of sync, there will be mismatches. Check the versions, show a warning, ask to be updated, etc. There's probably not much more you can do than that.

除了您还可以做一件事:您可以自己更改 Android 的时区。您可能应该在这样做之前警告用户,然后您可以强制 Android 使用与 Joda 相同的时区偏移量:

public static boolean isSameOffset() {
    long now = System.currentTimeMillis();
    return DateTimeZone.getDefault().getOffset(now) == TimeZone.getDefault().getOffset(now);
}

检查后,如果不一样,你可以将Android的时区更改为你创建的"fake"时区与Joda正确时区信息的偏移量:

public static void updateTimeZone(Context c) {
    TimeZone tz = DateTimeZone.forOffsetMillis(DateTimeZone.getDefault().getOffset(System.currentTimeMillis())).toTimeZone();
    AlarmManager mgr = (AlarmManager) c.getSystemService(Context.ALARM_SERVICE);
    mgr.setTimeZone(tz.getID());
}

请记住,您将需要 <uses-permission android:name="android.permission.SET_TIME_ZONE"/> 权限。

最后,更改时区会更改系统当前时间。不幸的是,只有系统应用程序可以设置时间,所以您能做的最好的事情就是打开用户的日期时间设置并提示 him/her 手动将其更改为正确的时间:

startActivity(new Intent(android.provider.Settings.ACTION_DATE_SETTINGS));

您还必须添加更多控件以确保在 DST 开始和结束时更新时区。正如您所说,您将添加自己的时区管理,但这是确保两个时区数据库之间一致性的唯一方法。

您需要覆盖 Joda Time,尤其是 Timezone Provider,并使用系统的时区而不是 IANA 数据库。 让我们展示一个基于系统 TimeZone class:

的 DateTimezone 示例
public class AndroidOldDateTimeZone extends DateTimeZone {

    private final TimeZone mTz;
    private final Calendar mCalendar;
    private long[] mTransition;

    public AndroidOldDateTimeZone(final String id) {
        super(id);
        mTz = TimeZone.getTimeZone(id);
        mCalendar = GregorianCalendar.getInstance(mTz);
        mTransition = new long[0];

        try {
            final Class tzClass = mTz.getClass();
            final Field field = tzClass.getDeclaredField("mTransitions");
            field.setAccessible(true);
            final Object transitions = field.get(mTz);

            if (transitions instanceof long[]) {
                mTransition = (long[]) transitions;
            } else if (transitions instanceof int[]) {
                final int[] intArray = (int[]) transitions;
                final int size = intArray.length;
                mTransition = new long[size];
                for (int i = 0; i < size; i++) {
                    mTransition[i] = intArray[i];
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public TimeZone getTz() {
        return mTz;
    }

    @Override
    public long previousTransition(final long instant) {
        if (mTransition.length == 0) {
            return instant;
        }

        final int index = findTransitionIndex(instant, false);

        if (index <= 0) {
            return instant;
        }

        return mTransition[index - 1] * 1000;
    }

    @Override
    public long nextTransition(final long instant) {
        if (mTransition.length == 0) {
            return instant;
        }

        final int index = findTransitionIndex(instant, true);

        if (index > mTransition.length - 2) {
            return instant;
        }

        return mTransition[index + 1] * 1000;
    }

    @Override
    public boolean isFixed() {
        return mTransition.length > 0 &&
               mCalendar.getMinimum(Calendar.DST_OFFSET) == mCalendar.getMaximum(Calendar.DST_OFFSET) &&
               mCalendar.getMinimum(Calendar.ZONE_OFFSET) == mCalendar.getMaximum(Calendar.ZONE_OFFSET);
    }

    @Override
    public boolean isStandardOffset(final long instant) {
        mCalendar.setTimeInMillis(instant);
        return mCalendar.get(Calendar.DST_OFFSET) == 0;
    }

    @Override
    public int getStandardOffset(final long instant) {
        mCalendar.setTimeInMillis(instant);
        return mCalendar.get(Calendar.ZONE_OFFSET);
    }

    @Override
    public int getOffset(final long instant) {
        return mTz.getOffset(instant);
    }

    @Override
    public String getShortName(final long instant, final Locale locale) {
        return getName(instant, locale, true);
    }

    @Override
    public String getName(final long instant, final Locale locale) {
        return getName(instant, locale, false);
    }

    private String getName(final long instant, final Locale locale, final boolean isShort) {
        return mTz.getDisplayName(!isStandardOffset(instant),
               isShort ? TimeZone.SHORT : TimeZone.LONG,
               locale == null ? Locale.getDefault() : locale);
    }

    @Override
    public String getNameKey(final long instant) {
        return null;
    }

    @Override
    public TimeZone toTimeZone() {
        return (TimeZone) mTz.clone();
    }

    @Override
    public String toString() {
        return mTz.getClass().getSimpleName();
    }

    @Override
    public boolean equals(final Object o) {
        return (o instanceof AndroidOldDateTimeZone) && mTz == ((AndroidOldDateTimeZone) o).getTz();
    }

    @Override
    public int hashCode() {
        return 31 * super.hashCode() + mTz.hashCode();
    }

    private long roundDownMillisToSeconds(final long millis) {
        return millis < 0 ? (millis - 999) / 1000 : millis / 1000;
    }

    private int findTransitionIndex(final long millis, final boolean isNext) {
        final long seconds = roundDownMillisToSeconds(millis);
        int index = isNext ? mTransition.length : -1;
        for (int i = 0; i < mTransition.length; i++) {
            if (mTransition[i] == seconds) {
                index = i;
            }
        }
        return index;
    }
}

我还创建了一个 Joda Time 的分支,它使用系统的时区,它是 available here。作为奖励,它在没有时区数据库的情况下重量更轻。

两句话什么是最好的方法: 使用 LocalDate 和 LocalDateTime classes 来存储和计算提醒的时间。但在调用 alarmManager.setExact 方法之前,将其转换为默认时区的 DateTime。但是用 try-catch 运算符包围它,因为 IllegalInstantException 可能会发生。因为那天不可能有这样的时间。在catch块中,根据夏令时调整时间。