Реалізація офлайн-режиму роботи мобільного додатка
Офлайн-режим — не просто «показувати кеш коли немає сіті». Це архітектурне рішення, яке торкається всіх шарів додатка: як зберігати дані, показувати актуальність, що дозволяти робити користувачу без інтернету, ставити дії в очередь та як синхронізувати після відновлення зв'язку.
Мобільний інтернет рвється в метро, в ліфті, при поганому покритті. Додаток, який просто висить спіннер та чекає — втрачає користувачів.
Архітектурний фундамент: local-first
Принцип простий: локальна база — джерело правди для UI. Мережа — це синхронізація, а не обов'язкова умова для відображення даних.
UI → ViewModel → Repository
├── LocalDataSource (Room/SQLite) ← UI читає звідси
└── RemoteDataSource (API) ← фонова синхронізація
UI ніколи не робить прямих сітьових запитів. Все через Repository, який спочатку видає локальні дані, а у фоні оновлює їх з сервера.
class ArticleRepository(
private val localDao: ArticleDao,
private val api: ArticleApi,
private val syncManager: SyncManager
) {
// UI підписаний на цей Flow — отримує дані сразу з БД
fun observeArticles(categoryId: String): Flow<List<Article>> =
localDao.observeByCategory(categoryId)
.map { entities -> entities.map { it.toDomain() } }
// Викликається при старті, pull-to-refresh, відновленні мережі
suspend fun refresh(categoryId: String) {
try {
val remote = api.getArticles(categoryId)
localDao.upsertAll(remote.map { it.toEntity() })
} catch (e: NetworkException) {
// Не пробрасуємо — UI просто видить старі дані
syncManager.scheduleSyncWhenOnline(SyncTask.RefreshArticles(categoryId))
}
}
}
Визначення стану мережі
На Android — ConnectivityManager з NetworkCallback:
class NetworkMonitor(context: Context) {
private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val isOnline: StateFlow<Boolean> = callbackFlow {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { trySend(true) }
override fun onLost(network: Network) { trySend(false) }
}
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
}.stateIn(
scope = CoroutineScope(Dispatchers.IO),
started = SharingStarted.WhileSubscribed(5000),
initialValue = connectivityManager.isCurrentlyConnected()
)
}
NET_CAPABILITY_INTERNET не означає реального інтернету — captive portal (WiFi в готелі без авторизації) проходить цю перевірку. Для надійності додайте NET_CAPABILITY_VALIDATED.
На iOS — NWPathMonitor з Network framework:
let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { path in
let isConnected = path.status == .satisfied
DispatchQueue.main.async {
self.networkState = isConnected ? .online : .offline
}
}
monitor.start(queue: DispatchQueue.global(qos: .background))
Офлайн-дії: очередь операцій
Користувач натиснув «Відправити» без інтернету. Не можна просто видати помилку. Правильно — поставити дію в очередь:
@Entity(tableName = "pending_operations")
data class PendingOperation(
@PrimaryKey val id: String = UUID.randomUUID().toString(),
val type: String, // "CREATE_ORDER", "UPDATE_PROFILE", "DELETE_ITEM"
val payload: String, // JSON
val createdAt: Long = System.currentTimeMillis(),
val retryCount: Int = 0,
val status: String = "PENDING" // PENDING, PROCESSING, FAILED
)
При відновленні мережі — WorkManager обробляє очередь:
class OfflineSyncWorker(
context: Context,
params: WorkerParameters,
private val operationDao: PendingOperationDao,
private val api: AppApi
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val pending = operationDao.getPendingOperations()
for (operation in pending) {
try {
operationDao.markProcessing(operation.id)
when (operation.type) {
"CREATE_ORDER" -> {
val order = Json.decodeFromString<CreateOrderRequest>(operation.payload)
api.createOrder(order)
}
"UPDATE_PROFILE" -> {
val update = Json.decodeFromString<UpdateProfileRequest>(operation.payload)
api.updateProfile(update)
}
}
operationDao.delete(operation.id)
} catch (e: Exception) {
operationDao.incrementRetry(operation.id)
if (operation.retryCount >= 3) {
operationDao.markFailed(operation.id)
notifyUser(operation) // показати помилку користувачу
}
}
}
return Result.success()
}
}
// Реєстрація WorkManager з умовою наявності мережі
val syncRequest = OneTimeWorkRequestBuilder<OfflineSyncWorker>()
.setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.SECONDS)
.build()
WorkManager на Android — правильний інструмент для відкладених операцій. Переживає перезапуск додатка та пристрою. Не використовуйте корутини напрямі для цього — вони живуть тільки поки живий процес.
На iOS — Background Tasks framework (BGTaskScheduler):
BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.app.offline-sync",
using: nil) { task in
self.handleOfflineSync(task: task as! BGProcessingTask)
}
UX: що показувати користувачу
Звичайний toast «Немає інтернету» — погано. Користувачу важливо розуміти:
- Дані актуальні чи застаріли (та на скільки)
- Що він може робити офлайн
- Що встане в очередь та виконається пізніше
Показуємо timestamp останньої синхронізації в шапці екрана. Кнопка «Відправити» офлайн — змінює текст на «Відправити при підключенні» та змінює стиль. Pending-операції відображаються в UI як «очікує синхронізації» до підтвердження з сервера.
Типові проблеми
Optimistic update без rollback. Обновили UI сразу (оптимістично), операція в очереди — користувач видит зміну. Сервер вернув помилку — потрібен механізм rollback. Без нього UI показує неіснуючий стан.
Конкурентні записи. Користувач зробив зміни офлайн, паралельно ті ж дані змінилися на іншому пристрої. Потрібна стратегія конфліkt-резолюції — це окрема задача.
Великі обсяги даних. Не потрібно кешувати все. Потрібно кешувати те, що користувач з високою ймовірністю відкриє: поточний екран, дані за останні N днів, вибране.
Реалізація офлайн-режиму з очередею операцій, WorkManager та UX для двох платформ: 3–5 тижнів залежно від складності доменної логіки. Вартість розраховується індивідуально.







