Почему время — это боль (и как Kotlin её лечит)
Если вы когда-нибудь писали приложение, которое обслуживает пользователей из разных часовых поясов, вы знаете эту боль. Запрос приходит с таймстампом, в базе лежит UTC, пользователь в Нью-Йорке жалуется, что уведомление пришло в 3 часа ночи, а в базе — 8 утра. Знакомо?
Работа с временными зонами — это та область программирования, где кажущаяся простота оборачивается десятками багов. Но Kotlin, унаследовавший лучшие практики Java, даёт нам мощный арсенал для борьбы с хаосом. В этой статье мы разберём, как правильно работать с временными зонами в Kotlin, избежать типичных ошибок и написать код, который не сломается при переходе на летнее время.
Краткий экскурс: что такое временная зона?
Прежде чем нырнуть в код, давайте договоримся о терминах. Временная зона (time zone) — это не просто смещение относительно UTC. Это ещё и набор правил перехода на летнее/зимнее время (DST). Например, Europe/Moscow — это не просто +3 часа, это ещё и исторические изменения (в 2014 году Россия перестала переводить часы).
В мире Java/Kotlin за это отвечают два ключевых понятия:
- ZoneId — идентификатор временной зоны (например, "America/New_York"). Содержит всю историю DST.
- ZoneOffset — просто смещение (например, +05:00). Без правил перехода.
Разница критическая: если вы используете ZoneOffset вместо ZoneId, ваш код сломается в день перевода часов. Запомните это.
Базовые классы java.time в Kotlin
Kotlin полностью поддерживает пакет java.time (Java 8+). Если вы пишете под Android — не забудьте добавить desugaring или используйте ThreeTenABP. Для серверного Kotlin проблем нет.
Instant — момент во времени
Instant — это абсолютный момент на временной шкале. Он не привязан к часовому поясу. Именно его стоит хранить в базе данных.
import java.time.Instant
fun main() {
val now = Instant.now()
println(now) // 2025-03-15T10:30:00.123Z
}
Обратите внимание на букву Z в конце — это Zulu time, то есть UTC. Храните всё в UTC — это золотое правило.
ZonedDateTime — время с учётом зоны
Когда вам нужно показать время пользователю, используйте ZonedDateTime. Он хранит дату, время, смещение и идентификатор зоны.
import java.time.ZonedDateTime
import java.time.ZoneId
fun main() {
val moscowTime = ZonedDateTime.now(ZoneId.of("Europe/Moscow"))
println(moscowTime) // 2025-03-15T13:30:00.123+03:00[Europe/Moscow]
}
Видите квадратные скобки? Это как раз ZoneId, который позволяет системе правильно обрабатывать DST.
OffsetDateTime — время со смещением
Если вам не нужна информация о зоне, а нужно только смещение — используйте OffsetDateTime. Он легче и быстрее.
import java.time.OffsetDateTime
import java.time.ZoneOffset
fun main() {
val offsetTime = OffsetDateTime.now(ZoneOffset.ofHours(5))
println(offsetTime) // 2025-03-15T15:30:00.123+05:00
}
Но помните: если вы сохраните OffsetDateTime для Нью-Йорка, а потом США перейдёт на летнее время — ваше смещение устареет.
Конвертация между временными зонами в Kotlin
Самая частая задача — взять время из базы (UTC) и показать его в часовом поясе пользователя. Давайте разберём, как это делать правильно.
Из UTC в локальную зону
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
fun convertUtcToLocal(utcInstant: Instant, targetZone: String): ZonedDateTime {
return utcInstant.atZone(ZoneId.of(targetZone))
}
fun main() {
val utcNow = Instant.now()
val nyTime = convertUtcToLocal(utcNow, "America/New_York")
println("UTC: $utcNow")
println("NY: $nyTime")
}
Вуаля! atZone автоматически применяет все правила DST для указанной зоны. Никаких ручных вычислений.
Из одной зоны в другую
import java.time.ZonedDateTime
import java.time.ZoneId
fun convertZone(sourceDateTime: ZonedDateTime, targetZone: String): ZonedDateTime {
return sourceDateTime.withZoneSameInstant(ZoneId.of(targetZone))
}
fun main() {
val moscowTime = ZonedDateTime.now(ZoneId.of("Europe/Moscow"))
val tokyoTime = convertZone(moscowTime, "Asia/Tokyo")
println("Москва: $moscowTime")
println("Токио: $tokyoTime")
}
Обратите внимание: withZoneSameInstant сохраняет момент во времени, меняя только отображение. Это именно то, что нам нужно.
Из строки в ZonedDateTime
Часто время приходит в виде строки от клиента или внешнего API. Парсим:
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
fun parseZonedDateTime(dateString: String): ZonedDateTime {
val formatter = DateTimeFormatter.ISO_ZONED_DATE_TIME
return ZonedDateTime.parse(dateString, formatter)
}
fun main() {
val input = "2025-03-15T13:30:00+03:00[Europe/Moscow]"
val parsed = parseZonedDateTime(input)
println(parsed) // 2025-03-15T13:30+03:00[Europe/Moscow]
}
Если строка приходит без зоны (например, "2025-03-15T13:30:00"), лучше сначала распарсить в LocalDateTime, а потом "привязать" зону:
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
fun parseAndAttachZone(dateString: String, zone: String): ZonedDateTime {
val local = LocalDateTime.parse(dateString, DateTimeFormatter.ISO_LOCAL_DATE_TIME)
return local.atZone(ZoneId.of(zone))
}
fun main() {
val dateStr = "2025-03-15T13:30:00"
val moscowTime = parseAndAttachZone(dateStr, "Europe/Moscow")
println(moscowTime)
}
Важный нюанс: если вы используете atZone для даты, которая попадает на переход DST, Java корректно обработает "дырку" или "наложение" во времени. Но будьте внимательны — в некоторых краевых случаях может выброситься исключение.
Форматирование и вывод пользователю
Показывать пользователю 2025-03-15T13:30:00+03:00[Europe/Moscow] — пло