Временные зоны в Kotlin: как не сойти с ума со временем

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Почему время — это боль (и как 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] — пло

Блоги

Рекомендаций по книгам