ZonedDateTime — immutable klasa z pakietu java.time, która przechowuje datę i czas wraz z informacją o strefie czasowej (ZoneId). W przeciwieństwie do LocalDateTime, ZonedDateTime jednoznacznie identyfikuje moment na osi czasu. Według specyfikacji Oracle Java 17 (2024), klasa poprawnie obsługuje przejście na czas letni (DST) poprzez reguły strefy z bazy danych IANA Time Zone Database.
Główne
ZonedDateTime — jeden z kluczowych klas pakietu java.time, reprezentujący datę i czas z pełną informacją o strefie czasowej. Łączy trzy komponenty: LocalDateTime (data i czas), ZoneId (identyfikator strefy) i ZoneOffset (przesunięcie względem UTC).
W przeciwieństwie do LocalDateTime, który przechowuje tylko czas ścienny (wall-clock time) bez przypisania do strefy, ZonedDateTime jednoznacznie identyfikuje moment. Dwa identyczne LocalDateTime w różnych strefach czasowych reprezentują różne momenty czasu. Dwa identyczne ZonedDateTime — ten sam moment.
Klasa jest w pełni immutable i thread-safe. Wszystkie operacje arytmetyczne zwracają nowy obiekt. ZonedDateTime implementuje interfejs ChronoZonedDateTime i może być używany wszędzie, gdzie wymagana jest praca z czasem strefowym w Javie.
Według specyfikacji Oracle Java 17, ZonedDateTime obsługuje pracę z dowolną strefą z IANA Time Zone Database, która obejmuje ponad 600 stref czasowych.
Główna różnica — ZonedDateTime zawiera strefę czasową, a LocalDateTime — nie. To fundamentalne odróżnienie określa zakres zastosowania każdej klasy.
LocalDateTime jest używany do lokalnych wydarzeń: czas koncertu, harmonogram zajęć, data urodzenia. Jeśli wydarzenie ma miejsce w Moskwie o 15:00, LocalDateTime zarejestruje 15:00 bez przypisania. Jeśli przeniesiesz serwer do Nowego Jorku, czas pozostanie 15:00 — ale będzie to już inny fizyczny moment.
ZonedDateTime stosuje się do danych globalnych: logi serwera, znaczniki czasu w API, międzynarodowe spotkania. Jeśli spotkanie jest umówione na 15:00 MSK, ZonedDateTime zachowa zarówno czas, jak i strefę. W Nowym Jorku będzie poprawnie wyświetlane jako 8:00 EST. Według Baeldung (2024), wybór między LocalDateTime a ZonedDateTime to najczęstsza decyzja architektoniczna przy pracy z datami.
Praktyczna zasada: jeśli dane są przechowywane dla jednego regionu — używaj LocalDateTime. Jeśli dane przekraczają granice stref czasowych — używaj ZonedDateTime. Jeśli potrzebujesz przekazać absolutny moment — używaj Instant.
Strefa czasowa w java.time jest reprezentowana przez klasę ZoneId. ZoneId to identyfikator strefy w formacie „continent/region”, na przykład „Europe/Moscow”, „America/New_York”, „Asia/Tokyo”. ZoneId uzyskuje się poprzez statyczną metodę of(String zoneId) lub przez systemową strefę czasową domyślną.
ZoneId dzieli się na dwa typy: fixed offset (stałe przesunięcie, na przykład „+03:00”) i region-based (strefy regionalne, na przykład „Europe/London”). Strefy regionalne zawierają reguły przejścia na czas letni i historyczne zmiany. Fixed offset to tylko stałe przesunięcie.
Aby uzyskać aktualne przesunięcie ZoneId w konkretnym momencie, używa się metody getRules(), która zwraca ZoneRules. ZoneRules zawiera wszystkie przejścia i przesunięcia dla danej strefy. To kluczowy mechanizm do poprawnej obsługi DST.
Wszystkie strefy czasowe są dostarczane z JDK poprzez pliki tzdata (IANA Time Zone Database) i regularnie aktualizowane. Na Android wersja tzdata zależy od aktualizacji systemu poprzez Google Play Services.
Można utworzyć ZonedDateTime na kilka sposobów. Najprostszy — now(), który zwraca aktualny czas w systemowej strefie czasowej. Wariant now(ZoneId) umożliwia uzyskanie aktualnego czasu w określonej strefie.
Metoda of(LocalDateTime, ZoneId) tworzy ZonedDateTime z czasu lokalnego i strefy. Wariant of(int year, int month, int dayOfMonth, int hour, int minute, int second, int nanoOfSecond, ZoneId zone) — z komponentów.
LocalDateTime można przekonwertować na ZonedDateTime poprzez metodę atZone(ZoneId). Instant — poprzez Instant.atZone(ZoneId). Date — poprzez Date.toInstant().atZone(ZoneId).
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)
Główna metoda konwersji — withZoneSameInstant(ZoneId). Przekształca ZonedDateTime w inną strefę czasową, zachowując ten sam moment czasu. Na przykład 15:00 MSK → 8:00 EST. Metoda withZoneSameLocal(ZoneId) zmienia strefę, zachowując czas lokalny — daje to inny moment.
Aby uzyskać przesunięcie względem UTC, używa się metody getOffset(), zwracającej ZoneOffset. ZoneOffset to dziedzic ZoneId, który reprezentuje stałe przesunięcie w formacie „+HH:mm” lub „-HH:mm”.
Konwersja na Instant jest wykonywana poprzez metodę toInstant(). Instant to absolutny moment czasu, niezależny od strefy czasowej. Odwrotna konwersja — Instant.atZone(ZoneId).
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"))
Przejście na czas letni tworzy dwa problemy: luki (gap) i nakładania (overlap). Luka powstaje wiosną, gdy zegary są przesuwane do przodu — określony czas nie istnieje. Nakładanie — jesienią, gdy czas jest cofany — ten sam czas występuje dwukrotnie.
ZonedDateTime obsługuje te sytuacje poprzez strategię resolve. Podczas tworzenia obiektu w czasie luki java.time automatycznie przesuwa czas o wielkość przesunięcia. Podczas tworzenia w czasie nakładania wybierany jest pierwszy wariant (przed zmianą). Zachowanie można zmienić poprzez withZoneSameInstant.
Sprawdzić, czy czas znajduje się w strefie DST, można poprzez zone.getRules().isDaylightSavings(instant). Metoda getOffset() pokazuje aktualne przesunięcie dla danego momentu, a getRules().getDaylightSavings(instant) — wielkość korekty DST w milisekundach.
fun checkDST(zdt: ZonedDateTime) {
val rules = zdt.getZone().getRules()
val instant = zdt.toInstant()
if (rules.isDaylightSavings(instant)) {
val dstAmount = rules.getDaylightSavings(instant)
Log.d("DST", "Przesunięcie DST: $dstAmount")
}
}
Do formatowania ZonedDateTime używa się DateTimeFormatter. Standardowy format ISO obejmuje datę, czas i przesunięcie: „2026-07-21T15:30:00+03:00[Europe/Moscow]”. Predefiniowane formaty: ISO_ZONED_DATE_TIME, ISO_OFFSET_DATE_TIME, ISO_INSTANT.
Do zlokalizowanego formatowania użyj DateTimeFormatter.ofLocalizedDateTime(FormatStyle). FormatStyle może być SHORT, MEDIUM, LONG, FULL. LONG zawiera nazwę strefy („MSK”), FULL — pełną nazwę („Moscow Standard Time”).
Ważne: podczas parsowania ciągu z ZonedDateTime format musi zawierać informację o strefie lub przesunięciu. Jeśli strefa nie jest określona, użyj LocalDateTime.parse(), a następnie atZone().
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
)
Pierwszy przykład — wyświetlanie czasu spotkania dla użytkownika w jego strefie czasowej. Serwer zwraca ZonedDateTime w UTC, klient konwertuje na lokalną strefę czasową urządzenia.
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)
}
Drugi przykład — obliczanie czasu do następnego wydarzenia z uwzględnieniem strefy czasowej. Używamy ZonedDateTime dla czasu serwerowego i Duration.between() do obliczenia różnicy.
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"
}
Trzeci przykład — praca z API Retrofit. Serwer zwraca ciąg w ISO-8601 ze strefą. Używamy niestandardowego deserializatora do konwersji na ZonedDateTime.
data class EventResponse(
@JsonAdapter(ZonedDateTimeAdapter::class)
val eventTime: ZonedDateTime
)
class ZonedDateTimeAdapter : JsonAdapter<ZonedDateTime>() {
override fun fromJson(reader: JsonReader): ZonedDateTime? {
return ZonedDateTime.parse(
reader.nextString()
)
}
}
Pierwszy błąd — używanie ZoneId.systemDefault() w kodzie serwerowym. Strefa czasowa serwera może się różnić od klienckiej, a używanie systemowej strefy na serwerze prowadzi do nieprawidłowych obliczeń. Zawsze jawnie określaj strefę lub używaj UTC jako punktu odniesienia.
Drugi błąd — ignorowanie DST przy obliczaniu czasu trwania. Duration.between() poprawnie obsługuje przejścia, ale jeśli ręcznie odejmujesz timestampy, przejście na czas letni może dać błąd o 1 godzinę. Używaj metod ChronoUnit.HOURS.between() zamiast ręcznej matematyki.
Trzeci błąd — mylenie withZoneSameInstant z withZoneSameLocal. Pierwszy zmienia strefę, zachowując moment — czas się przesuwa. Drugi zmienia strefę, zachowując czas lokalny — moment się zmienia. Wybór nieprawidłowej metody to jeden z najczęstszych błędów według SonarSource (2024).
Czwarty błąd — założenie, że strefa czasowa urządzenia to zawsze to samo co strefa czasowa użytkownika. Użytkownik może podróżować i oczekiwać, że aplikacja pokaże czas w jego „domowej” strefie, a nie w bieżącej. W takim przypadku należy umożliwić wybór strefy poprzez interfejs.
Często zadawane pytania
ZonedDateTime zawiera regionalny identyfikator strefy (na przykład „Europe/Moscow”) i obsługuje DST. OffsetDateTime przechowuje tylko stałe przesunięcie (+03:00) bez reguł regionalnych. Do przechowywania w bazie danych zaleca się OffsetDateTime.
Użyj ZonedDateTime.now(ZoneOffset.UTC) lub Instant.now().atZone(ZoneOffset.UTC). Oba warianty zwracają bieżący moment z zerowym przesunięciem. Dla prostego znacznika czasu użyj Instant.now() bez przypisania do strefy.
Tak, ale wymagany jest niestandardowy adapter. Gson nie obsługuje ZonedDateTime domyślnie. Moshi — obsługuje przez adapter Rfc3339DateJsonAdapter. Zaleca się używanie Kotlinx Serialization lub biblioteki JavaTimeModule dla Jackson.
java.time automatycznie przesuwa czas do przodu o wielkość przesunięcia. Na przykład, jeśli czas 02:30 nie istnieje przy przejściu na 03:00, ZonedDateTime utworzy obiekt na 03:30. Sprawdzić obecność luki można przez ZoneRules.getTransition(instant).
JDBC 4.2 obsługuje OffsetDateTime, ale nie ZonedDateTime bezpośrednio. ZonedDateTime zawiera regionalną strefę, która nie ma odpowiednika w SQL. Zaleca się przechowywanie OffsetDateTime lub Instant, a strefę przechowywać w osobnej kolumnie.
Podsumowanie
Opracujemy aplikację mobilną pod klucz
IT Sectr tworzy aplikacje na iOS i Androida dla startupów i firm od 2017 roku. Doradzimy Ci i zaproponujemy najlepsze rozwiązanie.
Przeczytaj również