Реализация офлайн-режима работы мобильного приложения
Офлайн-режим — не просто «показывать кэш когда нет сети». Это архитектурное решение, которое затрагивает все слои приложения: как хранить данные, как отображать актуальность, что разрешать делать пользователю без интернета, как ставить действия в очередь и как синхронизировать после восстановления связи.
Мобильный интернет рвётся в метро, в лифте, при плохом покрытии. Приложение, которое просто вешает спиннер и ждёт — теряет пользователей.
Архитектурный фундамент: 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))
Offline-действия: очередь операций
Пользователь нажал «Отправить» без интернета. Нельзя просто выдать ошибку. Правильно — поставить действие в очередь:
@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 показывает несуществующее состояние.
Конкурентные записи. Пользователь сделал изменения оффлайн, параллельно те же данные изменили на другом устройстве. Нужна стратегия конфликт-резолюции — это отдельная задача.
Большие объёмы данных. Не нужно кэшировать всё. Нужно кэшировать то, что пользователь с высокой вероятностью откроет: текущий экран, данные за последние N дней, избранное.
Реализация офлайн-режима с очередью операций, WorkManager и UX для двух платформ: 3–5 недель в зависимости от сложности доменной логики. Стоимость рассчитывается индивидуально.







