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

时间:2015-04-11 22:10:08

标签: android date datetime timezone jodatime

我想在Reddit Android Dev社区yesterday上开始讨论一个新问题:如何使用设备上的JodaTime库管理应用附带的最新时区数据库这已经过时的时区信息?

问题

手头的具体问题涉及特定的时区,"欧洲/加里宁格勒"。我可以重现这个问题:在Android 4.4设备上,如果我手动将其时区设置为上述时间,则调用new DateTime()会将此DateTime实例设置为显示实际时间前一小时的时间手机的状态栏。

我创建了一个示例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覆盖设备的时区并为显示更新的时区信息的新视图充气。以下是结果示例:

Same time at different time zones using Java

注意"加里宁格勒",Android如何将其映射到" GMT + 3:00"因为直到2014年10月26日才是这种情况(见Wikipedia article)。甚至一些网站仍然将这个时区显示为格林尼治标准时间+3:00,因为这种变化相对较近。然而,正确的是" GMT + 2:00"由JodaTime显示。

有缺陷的解决方案?

这是一个问题,因为无论我如何绕过它,最后,我必须格式化时间,以便在时区中向用户显示它。当我使用JodaTime执行此操作时,时间将被错误地格式化,因为它将与系统显示的预期时间不匹配。

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

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

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

  • 在用户的时区为09:00 am创建一个新的DateTime实例,并将其毫秒存储在数据库中。我也可以使用AlarmManager;
  • 直接使用相同的毫秒数
  • 在UTC上午09:00创建一个新的DateTime实例,并将其毫秒存储在数据库中。这更好地解决了与此问题不完全相关的一些其他问题。但是在使用AlarmManager设置时间时,我需要在用户的时区上午09:00计算其毫秒值;
  • 完全忽略Joda DateTime并使用Java Calendar处理提醒设置。这将使我的应用程序在显示时间时依赖于过时的时区信息,但至少在使用AlarmManager进行计划或显示日期和时间时不会出现不一致。

我缺少什么?

我可能在想这个,我担心我可能会遗漏一些明显的东西。我呢?有没有什么方法可以继续在Android上使用JodaTime,而不是将我自己的时区管理添加到应用程序并完全忽略所有内置的Android格式化功能?

5 个答案:

答案 0 :(得分:4)

我认为其他答案都没有提到。是的,在持久保存时间信息时,您应该仔细考虑您的使用案例,以决定如何做到最好。但即使你已经这样做了,这个问题所带来的问题仍然存在。

考虑Android的闹钟应用,其源代码为freely available。如果你查看它的AlarmInstance类,这就是它在数据库中的建模方式:

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()无法获得正确的时间更改,例如,当夏令时开始时。

JodaTime在这种情况下派上用场,因为它附带了自己的时区数据库。您可以考虑其他日期时间库,例如date4j,以便更好地处理日期计算,但这些通常不会处理他们自己的时区数据。

但是拥有自己的时区数据会对您的应用产生约束:您不能再依赖Android的时区了。这意味着您无法使用其Calendar类或其格式化函数。 JodaTime也提供格式化功能,使用它们。如果您必须转换为Calendar,而不是使用toCalendar()方法,请创建一个类似于上面getAlarmTime()的方法,然后传递您想要的确切时间。

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

  

是的 - 有两个事实来源,如果它们不同步,就会有   不匹配。检查版本,显示警告,要求更新,   你可能没有比这更多的东西了。

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

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

检查后,如果不一样,您可以使用您根据Joda的正确时区信息偏移创建的“假”区域更改Android的时区:

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"/>的许可。

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

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

您还需要添加一些控件以确保在DST开始和结束时更新时区。就像你说的那样,你将添加自己的时区管理,但这是确保两个时区数据库之间一致性的唯一方法。

答案 1 :(得分:1)

你做错了。如果用户在上午9点添加日历条目 明年他的意思是上午9点。时区数据库并不重要 在用户的设备更改或政府决定改变 夏令时的开始或结束。重要的是什么 时钟在墙上说。你需要存储&#34;上午9点&#34;在你的数据库中。

答案 2 :(得分:1)

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

答案 3 :(得分:1)

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

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

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

定期(或至少在您对JodaTime的数据应用更新时),重新评估预定的UTC时间。根据需要取消并重新建立活动。

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

最终,我认为您担心的是Joda Time数据可能比设备的数据更准确更准确。因此,警报可能会在正确的本地时间关闭 - 但它可能与设备上显示的时间不匹配。我同意这对最终用户来说可能是令人费解的,但是他们可能会感谢你做了正确的事情。我认为你无论如何都无法做到这一点。

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

快速搜索these instructionsthis app以更新Android上的tzdata。 (我自己没有测试过它们。)

答案 4 :(得分:0)

您需要覆盖Joda Time,尤其是Timezone Provider,并使用系统的时区而不是IANA数据库。 让我们展示一个基于系统的TimeZone类的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类存储和计算提醒时间。但是,在调用alarmManager.setExact方法之前,请将其转换为可以计算默认时区的DateTime。但是用try-catch运算符将其括起来,因为可能发生IllegalInstantException。因为那天可能没有这样的时间。在catch块中,根据DST偏移调整时间。