Перейти к содержимому
Все записи

Room TypeConverters в 2026 году: как хранить enum'ы, даты и списки, не повреждая схему

Практическое руководство по Room TypeConverters на Android — enum'ы, Instant/LocalDate и списки — а также ошибки, которые превращают конвертер в незаметный баг повреждения данных.

MFKAPPS 4 мин чтения

SQLite знает всего пять классов хранения: NULL, INTEGER, REAL, TEXT, BLOB. Почти ничто в модели данных реального приложения на это не похоже. У повторяющейся подписки есть enum Frequency для периодичности оплаты. У продукта в кладовой есть срок годности. Запись бюджета может нести список тегов. @TypeConverter в Room — это мост между двумя мирами, и это настолько небольшой кусок кода, что люди пишут его один раз, перестают о нём думать и в итоге получают конвертер, который спустя два года незаметно меняет смысл столбца.

Я поддерживаю конвертеры в трёх local-first приложениях — даты повторяющихся платежей в Subly, отслеживание сроков годности в Stocky и enum’ы категорий в Granyn — и все баги, которые я реально выпускал в продакшен, происходили из одной и той же горстки ошибок. Вот как их избежать.

Базовая форма

Конвертер — это пара чистых функций, зарегистрированных на базе данных:

class Converters {
    @TypeConverter
    fun fromFrequency(value: Frequency): String = value.name

    @TypeConverter
    fun toFrequency(value: String): Frequency = Frequency.valueOf(value)
}

@Database(entities = [Subscription::class], version = 1)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase()

Это весь контракт: одна функция на направление, на каждый тип. Room вызывает их автоматически всякий раз, когда тип поля сущности не относится к тем, что он понимает нативно. Ловушка — в том, что выглядит как разумная реализация для каждого типа.

Enum’ы: храните имя, никогда не ординал

Enum.ordinal соблазнителен, потому что это Int, и Room хранит его нативно, без единой строчки кода для конвертации. Это также мина замедленного действия: ординал — это просто позиция значения enum’а в исходном файле. Поменяйте порядок case’ов в Frequency местами или вставьте QUARTERLY между MONTHLY и YEARLY — и каждая существующая строка молча начнёт указывать не на то значение. Ничего не выбросит исключение. Баг проявится как подписка с еженедельной оплатой, которая отображается как годовая, — и обнаружит его пользователь, а не тест.

enum class Frequency { WEEKLY, MONTHLY, QUARTERLY, YEARLY }

@TypeConverter
fun fromFrequency(value: Frequency): String = value.name

@TypeConverter
fun toFrequency(value: String): Frequency = Frequency.valueOf(value)

Хранение value.name как TEXT стоит несколько лишних байт на строку и невосприимчиво к изменению порядка. Единственная реальная опасность, которая остаётся, — это переименование case’а: делайте это через ручную миграцию, переписывающую сохранённые строки, точно так же, как вы бы обработали любое другое изменение данных на уровне столбца.

Даты: выберите одно представление и никогда не храните локальное время

Классический баг с датами в Room — не в самом конвертере, а в несогласованности: одна часть кодовой базы конвертирует LocalDateTime, предполагая локальное время устройства, другая предполагает UTC, и дата, корректная для пользователя в Стамбуле, оказывается смещена на несколько часов для того же пользователя после перелёта через часовые пояса. Для всего, что должно корректно сравниваться или сортироваться на разных устройствах и в разных часовых поясах, храните момент времени (instant), а не локальную дату-время:

@TypeConverter
fun fromInstant(value: Instant?): Long? = value?.toEpochMilli()

@TypeConverter
fun toInstant(value: Long?): Instant? = value?.let(Instant::ofEpochMilli)

Long в миллисекундах от эпохи как INTEGER корректно сортируется в сыром SQL (полезно для ORDER BY и запросов по диапазону без загрузки строк в Kotlin), и в сохранённом значении нет никакой неоднозначности с часовым поясом — часовой пояс имеет значение только на этапе отображения, в слое UI, где ему и место. Если поле — это по-настоящему календарная дата без компонента времени, скажем, срок годности продукта в кладовой, — храните её как строку TEXT в формате ISO-8601 (2026-09-14). Она всё так же сортируется как строка и избегает ложной точности временной метки для того, что никогда не было моментом времени.

Списки и коллекции: понимайте, чем вы жертвуете

Хранение List<String> обычно означает сериализацию в JSON внутри конвертера:

@TypeConverter
fun fromTags(value: List<String>): String = Json.encodeToString(value)

@TypeConverter
fun toTags(value: String): List<String> = Json.decodeFromString(value)

Это работает, и для небольших, редко запрашиваемых списков — горстки произвольных тегов на записи бюджета — это прагматичный выбор. Но это осознанный компромисс, а не бесплатное удобство: значение JSON внутри столбца непрозрачно для SQL. Вы не можете сделать WHERE по тегу, не можете его проиндексировать, не можете сделать по нему join. В тот момент, когда “список чего-то” нужно запрашивать, фильтровать или связывать с другими данными, это уже не проблема конвертера — это отсутствующая таблица. Полноценная @Relation с промежуточной таблицей стоит больше настройки заранее, но окупается в первый же раз, когда запросу нужно “все подписки с тегом work” вместо “все подписки, а затем отфильтровать теги в Kotlin”.

Два правила, которые ловят большинство багов конвертеров до релиза

Конвертеры должны быть чистыми и тотальными. Никакого I/O, никакого Clock.System.now(), по возможности никакого исключения на неожиданном вводе — конвертер, который выбрасывает исключение на значении, сохранённом более старой версией приложения, превращает одну повреждённую строку в падение при каждом запуске, затрагивающем эту таблицу. Для всего, что читает существующие данные, предпочитайте безопасный запасной вариант (case enum’а по умолчанию, null-дату) выброшенному исключению.

Формат хранения конвертера — часть вашей схемы, даже если экспорт схемы Room этого не видит. AutoMigration сравнивает типы и имена столбцов — он понятия не имеет, что вы изменили конвертер так, что теперь он хранит Instant не как миллисекунды, а как строку ISO. Это изменение требует такой же ручной миграции и обработки через MigrationTestHelper, как и любое другое преобразование хранимых данных, потому что с точки зрения SQLite столбец TEXT не знает, что раньше означал что-то другое.

Сделайте эти два пункта правильно, храните enum’ы как имена, храните временные метки как UTC-миллисекунды от эпохи и тянитесь к настоящей таблице раньше, чем к JSON, — и слой конвертеров перестанет быть местом, где прячутся баги.

// По теме

Ещё из журнала

MFKAPPS 4 мин чтения

Индексы базы данных Room в 2026 году: находим по-настоящему медленный запрос и исправляем его

Практическое руководство по индексированию базы Room/SQLite на Android — чтение EXPLAIN QUERY PLAN, добавление @Index без угадывания и ошибки, которые незаметно сводят индекс на нет.

#android #engineering #room
MFKAPPS 4 мин чтения

@Relation в Room: запрос данных «один ко многим» на Android без N+1-запросов

Практическое руководство по аннотации @Relation в Room — моделирование данных «один ко многим», таких как категории и записи, без N+1-запросов и ручных join'ов.

#android #engineering #room
MFKAPPS 5 мин чтения

Полнотекстовый поиск в Room: добавляем мгновенный поиск в local-first Android-приложение в 2026

Практическое руководство по поддержке FTS4 в Room на Android — создание виртуальной таблицы поиска, синхронизация через триггеры и почему FTS5 требует ручной миграции.

#android #engineering #room