Разрешение конфликтов в офлайн-первых формах для Kotlin + SQLite
Узнайте, как решать конфликты в офлайн-ориентированных формах: прозрачные правила слияния, простой процесс синхронизации для Kotlin + SQLite и практичные UX-паттерны при редактировании.

Что на самом деле происходит, когда двое редактируют офлайн
Офлайн-ориентированные формы позволяют работать с данными даже при медленном или отсутствующем подключении. Вместо ожидания сервера приложение сначала записывает изменения в локальную базу SQLite, а затем синхронизирует их позже.
Это кажется мгновенным, но создаёт простую реальность: два устройства могут изменить одну и ту же запись, не зная друг о друге.
Типичный конфликт выглядит так: полевой сотрудник открывает наряд на планшете в подвале без сигнала. Он отмечает статус как "Done" и добавляет заметку. В то же время руководитель на другом телефоне обновляет тот же наряд, переназначает исполнителя и меняет дату выполнения. Оба нажимают «Сохранить». Оба сохранения успешно выполняются локально. Никто не сделал ничего плохого.
Когда синхронизация наконец происходит, сервер должен решить, какая запись является «реальной». Если не обрабатывать конфликты явно, обычно получаются следующие варианты:
- Последнее изменение выигрывает: более поздняя синхронизация перезаписывает ранние изменения и кто‑то теряет данные.
- Жёсткая ошибка: синхронизация отклоняет обновление и приложение показывает непонятную ошибку.
- Дубликаты: система создаёт вторую копию, чтобы избежать перезаписи, и отчёты путаются.
- Молчаливое слияние: система объединяет изменения, но смешивает поля так, как пользователи не ожидают.
Конфликты — это не баг. Это предсказуемый результат того, что люди работают без живого соединения, и это и есть смысл офлайн-first.
Цель двойная: защитить данные и сохранить простоту использования приложения. Чаще всего это означает прозрачные правила слияния (часто на уровне полей) и UX, который вмешивается только тогда, когда это действительно важно. Если два изменения затрагивают разные поля, вы можете тихо слить их. Если двое изменили одно и то же поле по‑разному, приложение должно сделать это заметным и помочь выбрать правильный результат.
Выберите стратегию конфликтов, соответствующую вашим данным
Конфликты — это в первую очередь не техническая проблема, а продуктовое решение о том, что считается «правильным», когда двое изменили одну и ту же запись до синхронизации.
Три стратегии покрывают большинство офлайн-приложений:
- Last write wins (LWW): принимается самое новое изменение и перезаписывается старое.
- Ручной обзор: останавливаемся и просим человека выбрать, что сохранить.
- Слияние на уровне полей: объединяем изменения по полям и просим вмешаться только если оба коснулись одного и того же поля.
LWW подходит, когда скорость важнее идеальной точности и стоимость ошибки низка. Подумайте о внутренних заметках, некритичных тегах или статусах черновика, которые можно исправить позже.
Ручной обзор — более безопасный выбор для полей с большим влиянием, где приложение не должно догадываться: юридические тексты, подтверждения соответствия, суммы выплат и выставления счетов, банковские реквизиты, инструкции по приёму лекарств и всё, что может создать ответственность.
Слияние на уровне полей обычно лучший дефолт для форм, где разные роли правят разными частями. Агент поддержки меняет адрес, а отдел продаж — дату продления. Слияние по полям сохраняет оба изменения и не мешает пользователям. Но если оба изменили дату продления, это поле должно вызвать решение.
Прежде чем реализовывать что‑то, зафиксируйте в словах, что для вашего бизнеса значит «правильно». Небольшой чеклист помогает:
- Какие поля всегда должны отражать текущее реальное значение (например, текущий статус)?
- Какие поля исторические и никогда не должны перезаписываться (например, время отправки)?
- Кто может менять каждое поле (роль, владение, согласования)?
- Что является источником правды при расхождении значений (устройство, сервер, одобрение менеджера)?
- Что произойдёт, если вы ошибётесь (незначительное неудобство или финансовая/юридическая потеря)?
Когда правила ясны, код синхронизации имеет одну задачу: соблюдать их.
Определяйте правила слияния по полям, а не по экрану
Когда возникает конфликт, он редко затрагивает всю форму целиком. Один пользователь может обновить телефон, а другой — добавить заметку. Если считать всю запись «всё или ничего», вы заставляете людей переделывать хорошую работу.
Слияние на уровне полей предсказуемее, потому что для каждого поля известно поведение. UX остаётся спокойным и быстродействующим.
Простой способ начать — разделить поля на «обычно безопасные» и «обычно небезопасные» для автоматического слияния.
Обычно безопасно сливать автоматически: заметки и внутренние комментарии, теги, вложения (часто берите объединение), и метки времени вроде last contacted (часто сохраняют последнее).
Обычно небезопасно сливать автоматически: статус/состояние, исполнитель/назначение, итоговые суммы/цены, флаги одобрения и уровни запасов.
Затем выберите правило приоритета для каждого поля. Частые варианты: сервер выигрывает, клиент выигрывает, роль выигрывает (например, менеджер перебивает агента) или детерминированный тайбрейкер вроде самой новой серверной версии.
Ключевой вопрос — что происходит, когда обе стороны изменили одно и то же поле. Для каждого поля выберите одно из поведений:
- Автослияние с чётким правилом (например, теги — это объединение)
- Сохранить оба значения (например, дописывать заметки с указанием автора и времени)
- Пометить для обзора (например, статус и исполнитель требуют выбора)
Пример: два агента поддержки редактируют один тикет офлайн. Агент A меняет status с Open на Pending. Агент B меняет notes и добавляет тег refund. При синхронизации можно безопасно объединить notes и tags, но нельзя молча объединять status. Попросите выбрать только status, а всё остальное уже будет слито.
Чтобы избежать споров позже, документируйте каждое правило в одном предложении на поле:
notes: хранить оба, дописывать новое последним, указывать автора и время.tags: объединение, удалять только если явно удалено с обеих сторон.status: если изменено с обеих сторон, требовать выбора пользователя.assignee: менеджер выигрывает, иначе сервер выигрывает.
Это однострочное правило становится источником правды для кода на Kotlin, SQLite-запросов и UI разрешения конфликтов.
Основы модели данных: версии и поля аудита в SQLite
Если вы хотите, чтобы конфликты выглядели предсказуемо, добавьте небольшой набор метаданных в каждую синхронизируемую таблицу. Без них вы не поймёте, смотрите ли вы на свежую правку, старую копию или две правки, требующие слияния.
Практический минимум для каждой серверно-синхронизируемой записи:
id(стабильный первичный ключ): никогда не переиспользуйтеversion(целое): увеличивается при каждой успешной записи на сервереupdated_at(временная метка): когда запись была в последний раз измененаupdated_by(текст или id пользователя): кто сделал последнее изменение
На устройстве добавьте локальные поля для отслеживания изменений, которые ещё не подтверждены сервером:
dirty(0/1): есть локальные измененияpending_sync(0/1): в очереди на отправку, но не подтвержденаlast_synced_at(временная метка): когда эта строка в последний раз совпадала с серверомsync_error(текст, опционально): последняя причина ошибки для показа в UI
Оптимистичная конкуренция — простейшее правило, предотвращающее молчаливые перезаписи: каждое обновление включает версию, которую вы считаете редактируемой (expected_version). Если серверная запись всё ещё на этой версии, обновление принимается, и сервер возвращает новую версию. Если нет — это конфликт.
Пример: пользователь A и пользователь B скачали version = 7. A синхронизируется первым; сервер увеличивает до 8. Когда B пытается синхронизироваться с expected_version = 7, сервер отвергает с конфликтом, и приложение B выполняет слияние вместо перезаписи.
Для удобного экрана конфликтов сохраняйте общую отправную точку: с какого состояния пользователь начал редактировать. Два распространённых подхода:
- Хранить снимок последней синхронизированной записи (одна колонка JSON или параллельная таблица).
- Хранить журнал изменений (строка на правку или поле-на-правку).
Снимки проще и зачастую достаточны для форм. Журналы изменений тяжелее, но дают точное объяснение, что изменилось по полям.
В любом случае UI должен уметь показывать три значения для каждого поля: правку пользователя, текущее значение сервера и общую отправную точку.
Снимки записи против журналов изменений: выберите подход
При синхронизации офлайн-форм вы можете загружать весь объект (снимок) или список операций (журнал). Оба подходят для Kotlin и SQLite, но они по‑разному ведут себя, когда двое редактируют одну запись.
Вариант A: Снимки полной записи
При снимках каждое сохранение записывает последнее полное состояние (все поля). При синхронизации вы отправляете запись и номер версии. Если сервер видит, что версия старая, возникает конфликт.
Это просто в реализации и быстро для чтения, но часто создаёт более крупные конфликты, чем нужно. Если A изменил телефон, а B — адрес, подход со снимками может трактовать это как один большой конфликт, хотя правки не пересекаются.
Вариант B: Журналы операций
При журналах вы храните не всю запись, а то, что изменилось. Каждая локальная правка превращается в операцию, которую можно воспроизвести поверх актуального серверного состояния.
Операции, которые проще сливать:
- Установить значение поля (set
emailв новое значение) - Дописать заметку (append note)
- Добавить тег (add tag в множество)
- Удалить тег (remove tag из множества)
- Пометить чекбокс выполненным (set
isDonetrue с временной меткой)
Журналы операций уменьшают количество конфликтов, потому что многие действия не пересекаются. Дописывание заметок редко конфликтует с другим дописывающим заметки. Добавления/удаления тегов можно объединять как операции над множеством. Для полей с единичным значением всё равно нужны правила поведения при конкуренции.
Цена — сложность: стабильные ID операций, порядок (локальная последовательность и серверное время) и правила для некоммутативных операций.
Очистка: сжатие после успешной синхронизации
Журналы растут, поэтому планируйте, как их уменьшать.
Обычный подход — сжатие на уровне записи: когда все операции до известной серверной версии подтверждены, сворачивайте их в новый снимок и удаляйте старые операции. Оставляйте небольшой хвост, только если нужен откат, аудит или проще отладка.
Пошаговый поток синхронизации для Kotlin + SQLite
Хорошая стратегия синхронизации — в основном строгость в том, что вы отправляете и что принимаете обратно. Цель простая: никогда случайно не перезаписывать более новые данные и делать конфликты явными, когда безопасно их не объединить.
Практичный поток:
-
Записывайте каждую правку сначала в SQLite. Сохраняйте изменения в локальной транзакции и помечайте запись
pending_sync = 1. Сохраняйтеlocal_updated_atи последнюю известнуюserver_version. -
Отправляйте патч, а не весь объект. Когда подключение возвращается, отправляйте id записи и только поля, которые изменились, вместе с
expected_version. -
Позвольте серверу отклонять несовпадающие версии. Если текущая версия сервера не совпадает с
expected_version, он возвращает полезную нагрузку конфликта (серверную запись, предложенные изменения и какие поля отличаются). Если версии совпадают, он применяет патч, увеличивает версию и возвращает обновлённую запись. -
Сначала применяйте автослияние, затем спрашивайте пользователя. Выполните правила слияния по полям. Обрабатывайте безопасные поля (заметки) иначе, чем чувствительные (статус, цена, исполнитель).
-
Зафиксируйте итог и очистите флаги ожидания. Независимо от того, было ли это автослияние или ручное разрешение, запишите финальную запись обратно в SQLite, обновите
server_version, установитеpending_sync = 0и запишите достаточные аудиторные данные, чтобы потом объяснить, что произошло.
Пример: два торговых представителя редактируют один и тот же заказ офлайн. A меняет дату доставки. B меняет телефон клиента. С патчами сервер может принять оба изменения. Если оба изменили дату доставки, вы показываете одно простое решение вместо того, чтобы заставлять вводить всё заново.
Держите обещание UI последовательным: «Saved» должно означать «сохранено локально». «Synced» — отдельное, явное состояние.
UX-паттерны для разрешения конфликтов в формах
Конфликты должны быть исключением, а не нормальным потоком. Начните с автослияния того, что безопасно, и просите пользователя вмешаться только если это действительно необходимо.
Делайте конфликты редкими благодаря безопасным настройкам по умолчанию
Если двое редактировали разные поля, объединяйте без модального окна. Сохраните оба изменения и покажите небольшую подсказку «Обновлено после синхронизации».
Оставляйте запросы для истинных столкновений: одно и то же поле изменено на двух устройствах, или изменение зависит от другого поля (например, статус и причина статуса).
Когда нужно спросить — сделайте это быстро
Экран конфликта должен отвечать на два вопроса: что изменилось и что будет сохранено. Сравнивайте значения рядом: «Ваша правка», «Их правка» и «Результат сохранения». Если конфликтуют только два поля, не показывайте всю форму. Перейдите прямо к этим полям и сделайте остальное только для чтения.
Ограничьте действия до действительно нужных:
- Сохранить моё
- Сохранить их
- Отредактировать итог
- Просмотреть поле за полем (только если нужно)
Частичные слияния — сложная зона для UX. Выделяйте только конфликтующие поля и явно помечайте источник ("Yours" и "Theirs"). Предвыбирайте наиболее безопасный вариант, чтобы пользователь мог подтвердить и двигаться дальше.
Дайте пользователю понять, что будет, если он уйдёт: например, «Мы сохраним вашу версию локально и повторим попытку синхронизации позже» или «Эта запись останется в состоянии Needs review, пока вы не сделаете выбор». Сделайте это состояние заметным в списке, чтобы конфликты не терялись.
Если вы строите этот поток в AppMaster, тот же подход применим: сначала автослияние безопасных полей, затем фокусированный шаг обзора только при столкновениях конкретных полей.
Сложные случаи: удаление, дубликаты и «пропавшие» записи
Большинство странных проблем синхронизации происходит из трёх ситуаций: кто‑то удаляет запись, пока другой редактирует её; два устройства создают «тот же» объект офлайн; или запись исчезает, а потом появляется снова. Эти ситуации требуют явных правил, потому что LWW часто удивляет пользователей.
Удаление против правки: кто выигрывает?
Решите, сильнее ли удаление, чем правка. Во многих бизнес‑приложениях удаление выигрывает, потому что пользователи ожидают, что удалённый объект останется удалённым.
Практический набор правил:
- Если запись удалена на любом устройстве, считайте её удалённой везде, даже если есть последующие правки.
- Если удаление должно быть обратимым, конвертируйте «удалить» в состояние «архивировано» вместо жёсткого удаления.
- Если приходит правка для удалённой записи, храните её в истории для аудита, но не восстанавливайте запись автоматически.
Коллизии создания офлайн и дублирующиеся черновики
Офлайн часто создаются временные ID (например, UUID) до того, как сервер присвоит окончательный ID. Дубликаты возникают, когда пользователи создают два черновика для одного и того же реального объекта (тот же чек, тот же тикет, тот же элемент).
Если у вас есть стабильный естественный ключ (номер квитанции, штрихкод, email+дата), используйте его для обнаружения коллизий. Если нет — принимайте, что дубликаты будут, и предоставьте простой вариант слияния позже.
Реализационный совет: храните и local_id, и server_id в SQLite. Когда сервер ответит, запишите соответствие и держите его, по крайней мере, до тех пор, пока вы уверены, что ни одна операция в очереди не ссылается на локальный ID.
Предотвращение «воскрешения» после синхронизации
Воскрешение происходит, когда устройство A удаляет запись, но устройство B офлайн и позже загружает старую копию как upsert, воссоздавая её.
Решение — tombstone. Вместо немедленного удаления строки пометьте её как удалённую с deleted_at (часто также deleted_by и delete_version). При синхронизации трактуйте tombstone как реальное изменение, которое может переопределять старые несозданные состояния.
Решите, как долго хранить tombstone. Если пользователи могут быть офлайн неделями, храните их дольше этого периода. Очищайте только когда уверены, что активные устройства синхронизировались позже удаления.
Если поддерживаете отмену, трактуйте её как ещё одно изменение: снимите deleted_at и увеличьте версию.
Частые ошибки, приводящие к потере данных или раздражению пользователей
Многие сбои синхронизации происходят из небольших предположений, которые тихо перезаписывают хорошие данные.
Ошибка 1: доверять времени устройства для упорядочивания правок
Телефоны могут иметь неверные часы, меняются часовые пояса, пользователи могут менять время вручную. Если вы упорядочиваете правки по времени устройства, в конце концов примените изменения в неправильном порядке.
Предпочитайте версии, выданные сервером (монотонно растущий serverVersion), и используйте клиентские метки времени только для отображения. Если нужно время, добавьте защитные меры и согласование на сервере.
Ошибка 2: случайный LWW на чувствительных полях
LWW кажется простым, пока не коснётся полей, которые нельзя просто «выиграть». Статусы, суммы, подтверждения и назначения обычно требуют явных правил.
Контрольный список для рискованных полей:
- Транзакции статусов обрабатывайте как конечный автомат, а не как свободный текст.
- Пересчитывайте итоги из строк, не сливайте сырой итог как число.
- Для счетчиков сливайте изменения, применяя дельту, а не выбирая победителя.
- Для владельцев или исполнителей требуйте явного подтверждения при конфликтах.
Ошибка 3: перезапись новых серверных значений устаревшими кэшированными данными
Это происходит, когда клиент редактирует старый снимок, а затем загружает полный объект. Сервер принимает и новые серверные изменения исчезают.
Починка: измените формат отправки — отправляйте только изменённые поля (или журнал операций) плюс базовую версию. Если базовая версия устарела, сервер отвергает или заставляет выполнить слияние.
Ошибка 4: отсутствие истории «кто что изменил»
При конфликтах пользователи хотят знать: что сделал я, а что сделал другой. Без идентичности редактора и по‑полевой истории ваш экран конфликтов превращается в гадание.
Храните updatedBy, серверное время обновления и хотя бы лёгкий по‑полевой аудит для ясности.
Ошибка 5: UI конфликта, заставляющий сравнивать всю запись
Заставлять людей сравнивать целые записи утомительно. Большинство конфликтов — одно‑три поля. Показывайте только конфликтующие поля, предвыбирайте безопасный вариант и позвольте пользователю автоматически принять остальное.
Если вы строите формы в no-code инструменте вроде AppMaster, стремитесь к тому же: разрешайте конфликты на уровне полей, чтобы пользователи делали один понятный выбор вместо прокрутки всей формы.
Быстрый чеклист и следующие шаги
Если хотите, чтобы офлайн‑правки казались безопасными, рассматривайте конфликты как нормальное состояние, а не как ошибку. Лучшие результаты даёт ясность правил, повторяемые тесты и UX, который объясняет, что произошло простыми словами.
Перед добавлением новых фич убедитесь, что базовое покрыто:
- Для каждого типа записи назначьте правило слияния на уровне поля (LWW, keep max/min, append, union или всегда спрашивать).
- Храните серверно контролируемую версию и
updated_at, и проверяйте их при синхронизации. - Протестируйте два устройства: оба редактируют одну запись офлайн, затем синхронизируйтесь в обоих порядках (A затем B, B затем A). Результат должен быть предсказуем.
- Протестируйте тяжёлые конфликты: удаление против правки и редактирование разных полей.
- Сделайте состояния очевидными: Synced, Pending upload и Needs review.
Прототипируйте полный поток end-to-end на реальной форме, а не на демонстрационном экране. Используйте реалистичный сценарий: полевой техник обновляет заметку о задаче на телефоне, в то время как диспетчер меняет заголовок задачи на планшете. Если они трогают разные поля — автослияние и небольшая подсказка «Обновлено с другого устройства». Если одно и то же поле — простой экран обзора с двумя вариантами и чёткой превью.
Когда будете готовы строить мобильное приложение и backend вместе, AppMaster (appmaster.io) может помочь. В нём можно моделировать данные, определять бизнес‑логику и строить web и native мобильный UI в одном месте, затем развернуть или экспортировать исходный код, когда правила синхронизации будут окончательными.
Вопросы и ответы
Конфликт возникает, когда два устройства изменяют одну и ту же серверную запись пока находятся офлайн (или пока ни одно из них не успело синхронизироваться), и сервер видит, что оба обновления основаны на старой версии. Система должна решить, какое значение будет итоговым для каждого поля, где есть расхождения.
Начните с слияния на уровне полей как с поведения по умолчанию для большинства бизнес-форм: разные роли часто редактируют разные поля, и вы можете сохранить оба изменения, не беспокоя пользователя. Используйте ручный обзор только для полей, где ошибка может привести к серьёзным последствиям (деньги, подтверждения, соответствие требованиям). Применяйте last write wins только для низкорисковых полей, где потеря старого изменения допустима.
Если два изменения затрагивают разные поля, вы обычно можете объединить их автоматически и не показывать пользователю ничего лишнего. Если два изменения меняют одно и то же поле на разные значения, это поле должно вызвать решение, потому что автоматический выбор может кого-то удивить. Ограничьте область решения — показывайте только конфликтующие поля, а не всю форму.
Считайте version монотонным счётчиком записи на сервере и требуйте, чтобы клиент отправлял expected_version с каждым обновлением. Если текущая версия на сервере не совпадает с expected_version, отвергайте обновление с ответом о конфликте вместо молчаливого перезаписывания. Это правило предотвращает «молчаливую потерю данных», даже если два устройства синхронизируются в разном порядке.
Практический минимум: устойчивый id, контролируемая сервером version, и контролируемые сервером updated_at/updated_by, чтобы объяснить, что изменилось. На устройстве отслеживайте, есть ли локальные изменения и ожидает ли строка загрузки (например, pending_sync), и храните последнюю синхронизированную серверную версию. Без этой информации вы не сможете надёжно обнаруживать конфликты или показывать полезный экран их разрешения.
Отправляйте только поля, которые изменились (патч) плюс базовый expected_version. Загрузка полного объекта превращает небольшие не пересекающиеся правки в ненужные конфликты и повышает риск перезаписи более новой серверной версии устаревшими данными на клиенте. Патчи также делают очевиднее, какие поля требуют правил слияния.
Снимок проще: вы храните последнюю полную запись и сравниваете её с серверной позже. Журнал изменений гибче: вы храните операции вроде «установить поле» или «дописать заметку» и воспроизводите их поверх актуального серверного состояния; это чаще даёт корректные слияния для заметок, тегов и других аддитивных обновлений. Выберите снимки для быстрой реализации; журнал изменений — если слияния часты и нужна ясная история «кто что изменил».
Решите заранее, сильнее ли удаление, чем правка, потому что пользователи ожидают последовательного поведения. Для многих бизнес-приложений безопасным по умолчанию будет считать удаление «могильной меткой» (tombstone): отмечать запись как удалённую с deleted_at и версией, чтобы устаревшая офлайн-загрузка не восстановила её случайно. Если нужна обратимость, используйте состояние «архивировано» вместо жёсткого удаления.
Самые частые ошибки — полагаться на время устройства для упорядочивания правок (часы сбиваются), применять LWW к чувствительным полям (статус, назначение, суммы) и перезаписывать новые серверные значения устаревшими локальными снапшотами. Делайте проверку версий на сервере, отправляйте патчи, храните хотя бы лёгкую историю «кто изменил» и показывайте пользователю только конфликтующие поля, а не всю запись.
Соблюдайте обещание, что «Saved» означает сохранено локально, и показывайте отдельный статус «Synced», чтобы пользователи понимали состояние. Если вы строите это в AppMaster, придерживайтесь той же структуры: определите правила слияния на уровне полей как часть продуктовой логики, автоматически объединяйте безопасные поля и показывайте шаг обзора только при реальных конфликтных полях. Протестируйте сценарии с двумя устройствами, которые редактируют одну запись офлайн и синхронизируются в разном порядке, чтобы убедиться в предсказуемости.


