Странное поведение класса java.util.GregorianCalendar в разных версиях Android

Я создаю календарь в своем приложении для Android. Первый день календаря — воскресенье или понедельник. Это зависит от локали. Странное поведение класса java.util.GregorianCalendar в разных версиях Android:

public class CurrentMonth extends AbstractCurrentMonth implements InterfaceCurrentMonth {

    public CurrentMonth(GregorianCalendar calendar, int firstDayOfWeek) {
        super(calendar, firstDayOfWeek);
    }

    @Override
    public List<ContentAbstract> getListContent() {
        int year = calendar.get(Calendar.YEAR);
        int month = calendar.get(Calendar.MONTH);

        GregorianCalendar currentCalendar = new GregorianCalendar(year, month, 1);

        List<ContentAbstract> list = new ArrayList<>();
        int weekDay = getDayOfWeek(currentCalendar);
        currentCalendar.add(Calendar.DAY_OF_WEEK, - (weekDay - 1));

        while (currentCalendar.get(Calendar.MONTH) != month) {
            list.add(getContent(currentCalendar));
            currentCalendar.add(Calendar.DAY_OF_MONTH, 1);
        }

        while (currentCalendar.get(Calendar.MONTH) == month) {
            list.add(getContent(currentCalendar));
            currentCalendar.add(Calendar.DAY_OF_MONTH, 1);
        }
        currentCalendar.add(Calendar.DAY_OF_MONTH, - 1);

        while (getDayOfWeek(currentCalendar) != 7) {
            currentCalendar.add(Calendar.DAY_OF_MONTH, 1);
            list.add(getContent(currentCalendar));
        }

        Log.i("text", "yaer: " + list.get(0).getYear());
        Log.i("text", "month: " + list.get(0).getMonth());
        Log.i("text", "day of month: " + list.get(0).getDay());
        Log.i("text", "day of week: " + list.get(0).getDayOfWeek());

        return list;
    }

    private int getDayOfWeek(GregorianCalendar currentCalendar) {
        int weekDay;
        if (firstDayOfWeek == Calendar.MONDAY) {
            weekDay = 7 - (8 - currentCalendar.get(Calendar.DAY_OF_WEEK)) % 7;
        }
        else weekDay = currentCalendar.get(Calendar.DAY_OF_WEEK);
        return weekDay;
    }

    private GraphicContent getContent(GregorianCalendar cal) {
        GraphicContent content = new GraphicContent();
        content.setYear(cal.get(Calendar.YEAR));
        content.setMonth(cal.get(Calendar.MONTH));
        content.setDay(cal.get(Calendar.DAY_OF_MONTH));
        content.setDayOfWeek(cal.get(Calendar.DAY_OF_WEEK));
        return content;
    }
}

public class GraphicContent extends ContentAbstract {
    private int year;
    private int month;
    private int day;
    private int dayOfWeek;

    @Override
    public int getYear() {
        return year;
    }

    @Override
    public void setYear(int year) {
        this.year = year;
    }

    @Override
    public int getMonth() {
        return month;
    }

    @Override
    public void setMonth(int month) {
        this.month = month;
    }

    @Override
    public int getDay() {
        return day;
    }

    @Override
    public void setDay(int day) {
        this.day = day;
    }

    @Override
    public int getDayOfWeek() {
        return dayOfWeek;
    }

    @Override
    public void setDayOfWeek(int dayOfWeek) {
        this.dayOfWeek = dayOfWeek;
    }
}

Задайте конструктор класса (новый GregorianCalendar(1994, 3, 1), Calendar.SUNDAY). В Android 4.4, 5.0 результат Logcat:

10-12 14:32:28.332 27739-27739/*** I/text: yaer: 1994
10-12 14:32:28.332 27739-27739/*** I/text: month: 2
10-12 14:32:28.332 27739-27739/*** I/text: day of month: 26
10-12 14:32:28.332 27739-27739/*** I/text: day of week: 7

В Android 8.0 результат Logcat:

2018-10-12 11:50:59.549 6565-6565/*** I/text: yaer: 1994
2018-10-12 11:50:59.549 6565-6565/*** I/text: month: 2
2018-10-12 11:50:59.549 6565-6565/*** I/text: day of month: 27
2018-10-12 11:50:59.549 6565-6565/*** I/text: day of week: 1

Как видите результат - разные дни (26 и 27), что соответствует разным дням недели. НО ЕСЛИ ВЫ ИЗМЕНИТЕ ИНИЦИАЛИЗАЦИЯ объекта календаря:

@Override
    public List<ContentAbstract> getListContent() {
        int year = calendar.get(Calendar.YEAR);
        int month = calendar.get(Calendar.MONTH);

        GregorianCalendar currentCalendar = (GregorianCalendar) Calendar.getInstance();
        currentCalendar.set(year, month, 1);

РЕЗУЛЬТАТ БУДЕТ ИСТИНЕН во всех версиях Android:

10-12 15:12:56.400 28914-28914/*** I/text: yaer: 1994
10-12 15:12:56.400 28914-28914/*** I/text: month: 2
10-12 15:12:56.400 28914-28914/*** I/text: day of month: 27
10-12 15:12:56.400 28914-28914/*** I/text: week day: 1

В тестах junit результат правильный во всех случаях (27 и ВОСКРЕСЕНЬЕ). Удалите логи из кода и проверьте:

 public class TestCurrentMonth {

    @Test
    public void testGetListContent() {
        GregorianCalendar calendar = new GregorianCalendar(1994, 3, 1);
        int firstDay = Calendar.SUNDAY;
        CurrentMonth currentMonth = new CurrentMonth(calendar, firstDay);
        List<ContentAbstract> list = currentMonth.getListContent();
        Assert.assertEquals(27, list.get(0).getDay());
        Assert.assertEquals(Calendar.SUNDAY, list.get(0).getDayOfWeek());
    }
}

Также поведение за апрель 1993, 1992. Почему? Я уже сломал себе мозги.


person SportAtomDroid    schedule 12.10.2018    source источник
comment
Вы должны научиться как создать минимальный, полный и проверяемый пример. Я уверен, что ваша проблема может быть продемонстрирована максимум в 10 строках кода. Это значительно облегчило бы задачу тем, кто заинтересован в том, чтобы помочь вам, и увеличило бы шансы, что они в конечном итоге это сделают.   -  person Ole V.V.    schedule 13.10.2018
comment
Спасибо за ответ. Он похож на правду. Я знаю класс LokalData, он не подходит для min SdK. Спасибо за совет по поводу ThreeTenAbP. Я изучу это. Но все равно непонятно, почему тесты корректны при любой инициализации объекта GregorianCalendar, а на устройствах (настоящих и эмуляторах) корректны только при вызове getInstance()? И почему на новых версиях андроида результат в любом случае правильный?   -  person SportAtomDroid    schedule 13.10.2018
comment
Как создать минимальный, полный и проверяемый пример — я изучу это. Спасибо   -  person SportAtomDroid    schedule 13.10.2018


Ответы (2)


Java.время

Хорошее решение — пропустить классы Calendar и GregorianCalendar и вместо этого использовать LocalDate из java.time, современного API даты и времени Java. Calendar и GregorianCalendar давно устарели и имеют плохой дизайн. С современным API гораздо приятнее работать. И LocalDate - это дата без времени и без часового пояса, поэтому, если подозрение, что я веду трансляцию ниже, верно, это гарантированно оставит позади проблему вашего часового пояса / летнего времени. Чтобы использовать его на более старых версиях Android, см. ниже.

Что пошло не так? Спекулятивное объяснение

Следующее объяснение чисто теоретическое, но лучшее, что я смог придумать. Он основан на нескольких предположениях, которые я не смог проверить:

  • Вы находитесь (или одно из ваших устройств) в часовом поясе, в котором летнее время (DST) началось в последние дни марта 1994 года.
  • В GregorianCalendar в Android 4.4 и 5.0 может быть ошибка, из-за которой currentCalendar.add(Calendar.DAY_OF_WEEK, - (weekDay - 1)); просто добавляет столько раз 24 часа.

Это чистая спекуляция, но если есть такая ошибка, ваш GregorianCalendar закончится в 23:00 вечера накануне целевой даты, что объясняет ваши результаты. Например, в странах ЕС летнее время начинается в последнее воскресенье марта. То же самое было и в 1994 году. Это очень хорошо подходит для вашей целевой даты воскресенья, 27 марта 1994 года, а также объясняет ваши неправильные результаты для 1992 и 1993 годов. Android GregorianCalendar и не нашел ничего, что бы его поддерживало.

Для моего подозрения, чтобы объяснить ваши наблюдения, нам нужно еще пару штук:

  1. Я подозреваю, что ошибка будет только в некоторых версиях Android (4.4, 5.0) и исправлена ​​​​в более поздних версиях (8.0) (в качестве альтернативы ваше устройство Android 8.0 будет работать в другом часовом поясе). Кроме того, среда, в которой вы запускаете свои тесты, либо не имеет ошибки, либо имеет другой часовой пояс по умолчанию (любой из них объясняет, почему тесты проходят).
  2. В GregorianCalendar, которое вы получаете от getInstance, указано время суток. И сохраняет его после того, как вы установите дату. Чтобы объяснить разницу между двумя способами установки даты: скажем, вы запускаете свой код в 9:05. new GregorianCalendar(1994, Calendar.APRIL, 1) даст вам 1 апреля 1994 года в 00:00. Calendar.getInstance(), за которым следует currentCalendar.set(year, month, 1);, дает вам 1 апреля 1994 года в 09:05. Между ними разница чуть более 9 часов. В последнем случае предполагаемая ошибка приведет к тому, что вы нажмете 8:05 27 марта, что все еще является правильной датой, поэтому вы не видите ошибку. Если вы запустите свой код, скажем, в 0:35 ночи, вы достигнете 23:35 26 марта, так что вы увидите ошибку и в этом случае.

Как я уже сказал, LocalDate, java.time и ThreeTenABP станут хорошим решением. Если вы решите не полагаться на внешнюю библиотеку, а пробиваться через устаревшие классы, я считаю, что следующее поможет:

    GregorianCalendar currentCalendar = new GregorianCalendar(TimeZone.getTimeZone("UTC"));
    currentCalendar.set(year, month, 1);

TimeZone — это еще один старый и плохо спроектированный класс, в частности метод getTimeZone, который я использую, имеет несколько неприятных сюрпризов, но я считаю, что вышеизложенное работает (скрестим пальцы). Идея состоит в том, чтобы указать Calendar использовать время UTC. В UTC нет летнего времени, что позволяет избежать проблемы.

Еще одна и более хакерская вещь, которую вы можете попробовать, это:

    currentCalendar.set(year, month, 1, 6, 0);

Это устанавливает час дня на 6, а это означает, что когда вы вернетесь через переход на летнее время, вы достигнете 5:00 утра, что по-прежнему будет правильной датой (приведенный выше вызов не установить секунды и миллисекунды; за один прогон я получил 1 апреля 1994 года в 06:00:40.213 UTC).

Вопрос: Могу ли я использовать java.time на Android?

Да, java.time отлично работает на старых и новых устройствах Android. Просто требуется как минимум Java 6.

  • В Java 8 и более поздних версиях, а также на более новых устройствах Android (начиная с уровня API 26) современный API встроен.
  • В Java 6 и 7 используйте ThreeTen Backport, бэкпорт новых классов (ThreeTen для JSR 310; см. ссылки внизу).
  • На (старом) Android используйте версию ThreeTen Backport для Android. Он называется ThreeTenABP. И убедитесь, что вы импортируете классы даты и времени из org.threeten.bp с подпакетами.

Ссылки

person Ole V.V.    schedule 14.10.2018
comment
Спасибо за подробный ответ и потраченное на меня время. Я изучу ваши предложения. Как я уже писал, следующий код работает нормально: GregorianCalendar currentCalendar = (GregorianCalendar) Calendar.getInstance(); currentCalendar.set(год, месяц, 1); На всех версиях андроида. - person SportAtomDroid; 15.10.2018
comment
Не работает в старых версиях Android (проверены не все версии): GregorianCalendar currentCalendar = new GregorianCalendar(год, месяц,1); - person SportAtomDroid; 15.10.2018
comment
Вот что странно. Я думал, что: new GregorianCalendar(год, месяц, 1) == (GregorianCalendar) Calendar.getInstance() и set(год, месяц, 1). Но нашел непонятную разницу. В равных условиях всегда работает один код, не всегда другой. - person SportAtomDroid; 15.10.2018
comment
Я отредактировал и попытался объяснить свой пункт 2. более подробно. Надеюсь, это поможет понять разницу между new GregorianCalendar(year, month, 1) с одной стороны и (GregorianCalendar) Calendar.getInstance() и set(year, month,1) с другой. - person Ole V.V.; 15.10.2018

Здесь нет ничего странного. getInstance будет возвращать данные в зависимости от вашей локали и часового пояса, в отличие от конструктора. Не уверен насчет более новых версий Android, возможно, здесь что-то изменилось или вы тестировали с разными часовыми поясами/регионами?

person user1209216    schedule 12.10.2018
comment
В новых версиях андроида поведение другое. Много раз пользовался конструктором в разных версиях андроида, без проблем. А почему апрель 1992, 1993, 1994. Спасибо - person SportAtomDroid; 12.10.2018
comment
Приложение тестировалось с разными локалями, его поведение отличается в разных версиях Android. Только с новым GregorianCalendar и только в этом сегменте кода - person SportAtomDroid; 12.10.2018
comment
new GregerianCalendar() и Calendar.getInstance() возвращают GregorianCalendar с локалью. Кроме того, тесты во всех случаях проходят корректно, а вот на Android-устройствах результаты разные. - person SportAtomDroid; 12.10.2018