ZonedDateTime — що це, робота з часовими поясами та часом

Автор: IT Sectr Опубліковано: 2026-07-13 Час читання: 10 хв

ZonedDateTime — це незмінний (immutable) клас із пакета java.time, який зберігає дату та час разом з інформацією про часовий пояс (ZoneId). На відміну від LocalDateTime, ZonedDateTime однозначно ідентифікує момент на часовій шкалі. Згідно зі специфікацією Oracle Java 17 (2024), клас коректно обробляє перехід на літній час (DST) через правила зони з бази даних IANA Time Zone Database.

Головне

  • ZonedDateTime — це незмінний клас, який об'єднує дату, час і часовий пояс (ZoneId) в одному об'єкті.
  • На відміну від LocalDateTime, ZonedDateTime однозначно визначає момент на часовій шкалі та підходить для глобальних систем.
  • Клас автоматично обробляє перехід на літній час (DST) відповідно до правил IANA Time Zone Database.
  • Для конвертації між часовими поясами використовується метод withZoneSameInstant(ZoneId).
  • Зберігання ZonedDateTime у базах даних рекомендується через OffsetDateTime або TIMESTAMP WITH TIME ZONE.

Що таке ZonedDateTime?

ZonedDateTime — один із ключових класів пакета java.time, що представляє дату та час із повною інформацією про часовий пояс. Він об'єднує три компоненти: LocalDateTime (дата та час), ZoneId (ідентифікатор зони) та ZoneOffset (зміщення відносно UTC).

На відміну від LocalDateTime, який зберігає лише "настінний годинник" (wall-clock time) без прив'язки до зони, ZonedDateTime однозначно ідентифікує момент. Два однакових LocalDateTime у різних часових поясах представляють різні моменти часу. Два однакових ZonedDateTime — той самий момент.

Клас повністю незмінний (immutable) та thread-safe. Усі арифметичні операції повертають новий об'єкт. ZonedDateTime реалізує інтерфейс ChronoZonedDateTime і може використовуватися скрізь, де потрібна робота із зональним часом у Java.

Згідно з специфікацією Oracle Java 17, ZonedDateTime підтримує роботу з будь-якою зоною з IANA Time Zone Database, яка включає понад 600 часових поясів.

ZonedDateTime vs LocalDateTime: у чому різниця?

Головна відмінність — ZonedDateTime містить часовий пояс, а LocalDateTime — ні. Ця фундаментальна відмінність визначає сферу застосування кожного класу.

LocalDateTime використовується для локальних подій: час концерту, розклад занять, дата народження. Якщо подія відбувається в Москві о 15:00, LocalDateTime зафіксує 15:00 без прив'язки. Якщо перенести сервер до Нью-Йорка, час залишиться 15:00 — але це буде вже інший фізичний момент.

ZonedDateTime застосовується для глобальних даних: логи сервера, мітки часу в API, міжнародні зустрічі. Якщо зустріч призначена на 15:00 MSK, ZonedDateTime збереже і час, і зону. У Нью-Йорку це буде коректно відображатися як 8:00 EST. Згідно з Baeldung (2024), вибір між LocalDateTime і ZonedDateTime — найчастіше архітектурне рішення при роботі з датами.

Практичне правило: якщо дані зберігаються для одного регіону — використовуйте LocalDateTime. Якщо дані перетинають кордони часових поясів — використовуйте ZonedDateTime. Якщо потрібно передати абсолютний момент — використовуйте Instant.

Як влаштований часовий пояс у java.time?

Часовий пояс у java.time представлений класом ZoneId. ZoneId — це ідентифікатор зони у форматі "континент/регіон", наприклад "Europe/Moscow", "America/New_York", "Asia/Tokyo". ZoneId отримують через статичний метод of(String zoneId) або через системний часовий пояс за замовчуванням.

ZoneId поділяється на два типи: fixed offset (фіксоване зміщення, наприклад "+03:00") та region-based (регіональні зони, наприклад "Europe/London"). Регіональні зони містять правила переходу на літній час та історичні зміни. Fixed offset — просто фіксоване зміщення.

Для отримання поточного зміщення ZoneId у конкретний момент використовується метод getRules(), який повертає ZoneRules. ZoneRules містить усі переходи та зміщення для даної зони. Це ключовий механізм для коректної обробки DST.

Усі часові пояси постачаються з JDK через файли tzdata (IANA Time Zone Database) та регулярно оновлюються. На Android версія tzdata залежить від оновлень системи через Google Play Services.

Створення ZonedDateTime

Створити ZonedDateTime можна кількома способами. Найпростіший — now(), який повертає поточний час у системному часовому поясі. Варіант now(ZoneId) дозволяє отримати поточний час у вказаній зоні.

Метод of(LocalDateTime, ZoneId) створює ZonedDateTime із локального часу та зони. Варіант of(int year, int month, int dayOfMonth, int hour, int minute, int second, int nanoOfSecond, ZoneId zone) — із компонентів.

LocalDateTime можна перетворити в ZonedDateTime через метод atZone(ZoneId). Instant — через Instant.atZone(ZoneId). Date — через Date.toInstant().atZone(ZoneId).

kotlin
val moscowZone = ZoneId.of("Europe/Moscow")

val nowInMoscow = ZonedDateTime.now(moscowZone)

val fromComponents = ZonedDateTime.of(
    2026, 7, 21, 15, 30, 0, 0, moscowZone
)

val fromLocal = LocalDateTime.now().atZone(moscowZone)

val fromInstant = Instant.now().atZone(moscowZone)

Конвертація між часовими поясами

Основний метод конвертації — withZoneSameInstant(ZoneId). Він перетворює ZonedDateTime в інший часовий пояс, зберігаючи той самий момент часу. Наприклад, 15:00 MSK → 8:00 EST. Метод withZoneSameLocal(ZoneId) змінює зону, зберігаючи локальний час — це дає інший момент.

Для отримання зміщення відносно UTC використовується метод getOffset(), який повертає ZoneOffset. ZoneOffset — це нащадок ZoneId, який представляє фіксоване зміщення у форматі "+HH:mm" або "-HH:mm".

Перетворення в Instant виконується через метод toInstant(). Instant — це абсолютний момент часу, незалежний від часового поясу. Зворотне перетворення — Instant.atZone(ZoneId).

kotlin
val moscow = ZonedDateTime.of(
    2026, 7, 21, 15, 0, 0, 0,
    ZoneId.of("Europe/Moscow")
)

val newYork = moscow.withZoneSameInstant(
    ZoneId.of("America/New_York")
)

val utcInstant = moscow.toInstant()
val backToMoscow = utcInstant.atZone(ZoneId.of("Europe/Moscow"))

Робота з літнім часом (DST)

Перехід на літній час створює дві проблеми: "дірки" (gap) та "накладення" (overlap). Дірка виникає навесні, коли годинник переводять вперед — певного часу не існує. Накладення — восени, коли час переводять назад — один і той самий час існує двічі.

ZonedDateTime обробляє ці ситуації через стратегію resolve. При створенні об'єкта під час дірки java.time автоматично зсуває час на величину зміщення. При створенні під час накладення вибирається перший варіант (до переведення). Поведінку можна змінити через withZoneSameInstant.

Перевірити, чи знаходиться час у зоні DST, можна через zone.getRules().isDaylightSavings(instant). Метод getOffset() показує актуальне зміщення для даного моменту, а getRules().getDaylightSavings(instant) — величину поправки на DST у мілісекундах.

kotlin
fun checkDST(zdt: ZonedDateTime) {
    val rules = zdt.getZone().getRules()
    val instant = zdt.toInstant()

    if (rules.isDaylightSavings(instant)) {
        val dstAmount = rules.getDaylightSavings(instant)
        Log.d("Літній час", "Зміщення літнього часу: $dstAmount")
    }
}

Форматування ZonedDateTime

Для форматування ZonedDateTime використовується DateTimeFormatter. Стандартний ISO-формат включає дату, час та зміщення: "2026-07-21T15:30:00+03:00[Europe/Moscow]". Попередньо визначені формати: ISO_ZONED_DATE_TIME, ISO_OFFSET_DATE_TIME, ISO_INSTANT.

Для локалізованого виклику використовуйте DateTimeFormatter.ofLocalizedDateTime(FormatStyle). FormatStyle може бути SHORT, MEDIUM, LONG, FULL. LONG включає назву зони ("MSK"), FULL — повну назву ("Moscow Standard Time").

Важливо: при парсингу рядка з ZonedDateTime формат повинен містити інформацію про зону або зміщення. Якщо зона не вказана, використовуйте LocalDateTime.parse() і потім atZone().

kotlin
val zdt = ZonedDateTime.now(ZoneId.of("Europe/Moscow"))

val iso = zdt.format(DateTimeFormatter.ISO_ZONED_DATE_TIME)

val custom = DateTimeFormatter
    .ofPattern("dd.MM.yyyy HH:mm z")
val formatted = zdt.format(custom)

val parsed = ZonedDateTime.parse(
    "2026-07-21T15:30:00+03:00",
    DateTimeFormatter.ISO_OFFSET_DATE_TIME
)

ZonedDateTime в Android: практичні приклади

Перший приклад — відображення часу зустрічі для користувача в його часовому поясі. Сервер віддає ZonedDateTime в UTC, клієнт конвертує в локальний часовий пояс пристрою.

kotlin
fun displayMeetingTime(
    serverUtc: ZonedDateTime
): String {
    val deviceZone = ZoneId.systemDefault()
    val localTime = serverUtc.withZoneSameInstant(deviceZone)
    val formatter = DateTimeFormatter
        .ofPattern("dd.MM.yyyy HH:mm z")
    return localTime.format(formatter)
}

Другий приклад — розрахунок часу до наступної події з урахуванням часового поясу. Використовуємо ZonedDateTime для серверного часу та Duration.between() для обчислення різниці.

kotlin
fun timeUntilEvent(eventTime: ZonedDateTime): String {
    val now = ZonedDateTime.now()
    val duration = Duration.between(now, eventTime)

    val hours = duration.toHours()
    val minutes = duration.toMinutes() % 60
    return "Remaining $hours h $minutes min"
}

Третій приклад — робота з API Retrofit. Сервер повертає рядок в ISO-8601 із зоною. Використовуємо кастомний десеріалізатор для перетворення в ZonedDateTime.

kotlin
data class EventResponse(
    @JsonAdapter(ZonedDateTimeAdapter::class)
    val eventTime: ZonedDateTime
)

class ZonedDateTimeAdapter : JsonAdapter<ZonedDateTime>() {
    override fun fromJson(reader: JsonReader): ZonedDateTime? {
        return ZonedDateTime.parse(
            reader.nextString()
        )
    }
}

Помилки при роботі з часовими поясами

Перша помилка — використання ZoneId.systemDefault() у серверному коді. Часовий пояс сервера може відрізнятися від клієнтського, і використання системного поясу на сервері призводить до невірних розрахунків. Завжди явно вказуйте зону або використовуйте UTC в якості еталона.

Друга помилка — ігнорування DST при розрахунку тривалості. Duration.between() коректно обробляє переходи, але якщо ви віднімаєте timestamp вручну, перехід на літній час може дати помилку в 1 годину. Використовуйте методи ChronoUnit.HOURS.between() замість ручної математики.

Третя помилка — плутанина між withZoneSameInstant та withZoneSameLocal. Перший змінює зону, зберігаючи момент — час зсувається. Другий змінює зону, зберігаючи локальний час — момент змінюється. Вибір неправильного методу — одна з найчастіших помилок за даними SonarSource (2024).

Четверта помилка — припущення, що часовий пояс пристрою — це завжди те саме, що й часовий пояс користувача. Користувач може подорожувати і очікувати, що додаток покаже час у його "домашньому" поясі, а не в поточному. У цьому випадку потрібно надати вибір зони через інтерфейс.

Часті запитання

У чому різниця між ZonedDateTime та OffsetDateTime?

ZonedDateTime містить регіональний ідентифікатор зони (наприклад, "Europe/Moscow") та обробляє DST. OffsetDateTime зберігає лише фіксоване зміщення (+03:00) без регіональних правил. Для зберігання в базі даних рекомендується OffsetDateTime.

Як отримати поточний час в UTC через ZonedDateTime?

Використовуйте ZonedDateTime.now(ZoneOffset.UTC) або Instant.now().atZone(ZoneOffset.UTC). Обидва варіанти повертають поточний момент з нульовим зміщенням. Для простої мітки часу використовуйте Instant.now() без прив'язки до зони.

Чи можна серіалізувати ZonedDateTime через Gson або Moshi?

Так, але потрібен кастомний адаптер. Gson не підтримує ZonedDateTime за замовчуванням. Moshi — підтримує через адаптер Rfc3339DateJsonAdapter. Рекомендується використовувати Kotlinx Serialization або бібліотеку JavaTimeModule для Jackson.

Як обробити ситуацію, коли час потрапляє на "дірку" DST?

java.time автоматично зсуває час вперед на величину зміщення. Наприклад, якщо час 02:30 не існує при переводі на 03:00, ZonedDateTime створить об'єкт на 03:30. Перевірити наявність дірки можна через ZoneRules.getTransition(instant).

Чому ZonedDateTime не рекомендується для SQL баз даних?

JDBC 4.2 підтримує OffsetDateTime, але не ZonedDateTime безпосередньо. ZonedDateTime містить регіональну зону, яка не має аналога в SQL. Рекомендується зберігати OffsetDateTime або Instant, а зону зберігати окремою колонкою.

Підсумки

  • ZonedDateTime — незмінний клас для дати та часу з часовим поясом, який коректно обробляє DST через IANA Time Zone Database.
  • Основна відмінність від LocalDateTime — наявність зони, що робить ZonedDateTime однозначним ідентифікатором моменту часу.
  • Для конвертації між зонами використовуйте withZoneSameInstant(), який зберігає момент, а не withZoneSameLocal.
  • При переході на літній час java.time автоматично вирішує "дірки" та "накладення" через вбудовані правила зон.
  • Для зберігання в базі даних використовуйте OffsetDateTime або зберігайте Instant та ZoneId окремо.
  • В Android для перетворення ZonedDateTime в локальний час пристрою використовуйте ZoneId.systemDefault() в парі з withZoneSameInstant.
  • При серіалізації в JSON потрібен кастомний адаптер — використовуйте Kotlinx Serialization або Jackson JavaTimeModule.

Ми розробимо мобільний застосунок під ключ

IT Sectr створює застосунки для iOS та Android для стартапів і бізнесу з 2017 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.

Обговорити проект

Читайте також