Історія змін у мобільному додатку: реалізація та оптимізація
Уявіть: юрист править договір на планшеті, випадково видаляє абзац і зберігає. Через годину — підписання, а даних немає. Без історії змін — катастрофа. Ми реалізували систему, яка зберігає кожну правку і дозволяє відкотити будь-яку версію за секунди. У проєктах з юридично значущими документами, редакторською роботою або складними таблицями кожна правка має бути відстежена і доступна для відкату. Як спроектувати сховище історії, щоб воно не роздувалося і працювало на мобільному пристрої з обмеженими ресурсами? Розбираємо ключові підходи, їх сильні та слабкі сторони, а також даємо готові схеми та код. Наш досвід — понад 7 років у мобільній розробці, ми реалізували версіонування в десятках проєктів.
Два підходи: snapshot vs event sourcing
Snapshot — при кожному збереженні створюємо повну копію об'єкта. Простота реалізації та відновлення обертається вибуховим зростанням бази. Якщо документ важить 50 КБ, а користувач зберігає його 50 разів на день, за рік накопичиться 50 × 50 × 365 ≈ 912 МБ тільки для одного документа. Для додатку з тисячами користувачів це неприйнятно.
Delta (event sourcing) — зберігаємо тільки різницю між версіями. Це компактно, але відновлення вимагає відтворення всіх дельт від початкового стану. Для тексту можна використовувати diff-match-patch (безкоштовна бібліотека, доступна на iOS та Android). Однак складність зростає при паралельному редагуванні.
Гібридний підхід — комбінація: снепшоти через кожні 10 версій або кожні 7 днів, між ними дельти. Відновлення: беремо найближчий снепшот і застосовуємо дельти. Такий метод дає до 90% економії місця порівняно з чистими снепшотами і прискорює відновлення в 5–10 разів відносно чистих дельт.
Нижче — порівняння трьох підходів.
| Характеристика | Snapshot | Delta | Гібрид |
|---|---|---|---|
| Об'єм сховища | Величезний | Мінімальний | Середній |
| Швидкість відновлення | Миттєво | Повільно | Швидко |
| Складність реалізації | Низька | Висока | Середня |
| Стійкість до конфліктів | Висока | Низька | Середня |
Схема даних
Для SQLite-бази на Android або CoreData на iOS ми використовуємо таку таблицю:
@Entity(tableName = "document_versions") data class DocumentVersion( @PrimaryKey(autoGenerate = true) val id: Long = 0, val documentId: String, val versionNumber: Int, val deltaJson: String?, // null якщо snapshot val snapshotJson: String?, // null якщо delta val authorId: String, val deviceId: String, val createdAt: Long = System.currentTimeMillis(), val comment: String? = null // "Автозбереження" / "Збережено вручну" ) Індекс по (documentId, versionNumber) обов'язковий — інакше вибірка історії стане гальмом. На Android використовуємо @Index в Room, на iOS — складений індекс в CoreData.
Як обрати стратегію зберігання версій?
Вибір залежить від типу даних і частоти змін. Для документів з рідкісними правками (1–2 рази на день) чистий snapshot — просто і надійно. Для текстових редакторів, де правки відбуваються кожні секунди, краще гібрид. Завжди оцінюйте навантаження: якщо база росте швидше, ніж користувачі створюють контент, змінюйте підхід.
Обмеження глибини історії
Зберігати всі версії вічно — недоцільно. Пропонуємо три стратегії:
| Стратегія | Опис | Приклад ліміту |
|---|---|---|
| Фіксована кількість | Зберігати останні N версій | 50 версій |
| Часове вікно | Версії не старші M днів | 30 днів |
| Розумне проріджування | Змішаний підхід | Повні за добу, одна на день за тиждень |
Приклад видалення старих версій на Android з WorkManager:
@Transaction suspend fun pruneVersions(documentId: String, keepCount: Int) { val versions = getVersionsByDocument(documentId) if (versions.size > keepCount) { val toDelete = versions.drop(keepCount) deleteVersions(toDelete.map { it.id }) } } Чому варто обмежувати глибину історії?
Без обмежень база даних може роздутися до гігабайтів на пристрої. Користувач рідко повертається до версій старше місяця. Наш досвід показує, що розумний ліміт — 50 версій або 30 днів — покриває 99% потреб, а сховище залишається під контролем. Економія на зберіганні — до 90% об'єму БД, що прямо знижує витрати на інфраструктуру.
UI історії версій
Список версій з датою, автором і типом (автозбереження/ручне). Тап — попередній перегляд, дві кнопки: «Відновити» і «Порівняти з поточною». Diff-view з підсвіткою: зелений для доданого, червоний для видаленого. На мобільному використовуємо inline diff через SpannableString (Android) або NSAttributedString (iOS) — компактно та інтуїтивно.
Автозбереження та дебаунс
Автозбереження не повинно створювати версію на кожен символ. Дебаунс 2–3 секунди після останньої зміни:
private var saveTask: Task<Void, Never>? func textDidChange(_ text: String) { saveTask?.cancel() saveTask = Task { try? await Task.sleep(nanoseconds: 2_000_000_000) guard !Task.isCancelled else { return } await saveVersion(text, type: .auto) } } Що входить у роботу
- Аналіз даних і вибір стратегії (snapshot / delta / гібрид) — оцінюємо навантаження і гарантуємо продуктивність.
- Проектування схеми даних з індексами та зовнішніми ключами.
- Реалізація автозбереження з дебаунсом.
- Розробка UI списку версій з метаданими.
- Створення diff-view для порівняння версій.
- Налаштування фонового очищення застарілих версій за розкладом.
Приклад повної реалізації очищення з WorkManager (Android)
class VersionPruneWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { val dao = AppDatabase.getInstance(applicationContext).documentVersionDao() dao.pruneAllVersions(keepCount = 50) return Result.success() } } Терміни
Базова реалізація (snapshot + простий UI) — 1,5–2 дні. Повноцінний гібрид з дельтами, diff-view і розумним проріджуванням — 4–5 днів. Вартість розраховується індивідуально виходячи зі складності даних і вимог до глибини історії. Реалізуємо під ключ: від проектування до публікації в сторах. Отримайте консультацію — напишіть нам для оцінки вашого проєкту. Замовте впровадження історії змін — ми запропонуємо оптимальне рішення.







