上一篇讲了 R8 深度优化,进入数据存储层——离线优先(Offline-First)。移动端网络不稳定,离线设计不是"边角案例"而是"核心场景"。这一篇看 Realm 和 SQLDelight 两种本地存储方案如何在离线优先架构里发挥作用。
什么是离线优先
离线优先的核心思想:UI 不应该等网络,本地数据库是唯一真相。
[网络] [本地数据库(唯一真相)] [UI]
↓ ↑ ↑
更新数据 → 立即写库 → 推送通知 UI 更新 ← 订阅本地变更
关键:网络是数据源之一,本地是同步中心
对比传统的"网络优先"思路:
三种主流持久化方案
SQLDelight 实战
SQLDelight 把 SQL 作为单一真实来源,自动生成 Kotlin API:
-- src/main/sqldelight/com/example/Notes.sq
CREATE TABLE notes (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER NOT NULL,
synced_at INTEGER DEFAULT NULL
);
CREATE INDEX notes_synced_at_idx ON notes(synced_at);
selectAll:
SELECT * FROM notes ORDER BY created_at DESC;
selectById:
SELECT * FROM notes WHERE id = ?;
insertNote:
INSERT INTO notes(title, content, created_at)
VALUES (?, ?, ?);
markSynced:
UPDATE notes SET synced_at = ? WHERE id = ?;
pendingSync:
SELECT * FROM notes WHERE synced_at IS NULL;
生成的 Kotlin 代码:
// 自动生成 Database 类
val driver: SqlDriver = AndroidSqliteDriver(Database.Schema, context, "notes.db")
val database = Database(driver)
val notesQueries = database.notesQueries
// 类型安全的 API
notesQueries.insertNote("Title", "Content", System.currentTimeMillis())
val allNotes: List<Note> = notesQueries.selectAll().executeAsList()
订阅变更:
notesQueries.selectAll().asFlow().mapToList(Dispatchers.IO)
.onEach { notes ->
_state.value = notes
}.launchIn(viewModelScope)
不用声明变量,运行时自动通知。
Realm 实战
Realm 用对象数据库思路,直接操作对象:
// build.gradle.kts
plugins {
id("io.realm.kotlin") version "2.3.0"
}
// 数据类
class Note : RealmObject {
@PrimaryKey
var id: String = ""
var title: String = ""
var content: String = ""
var createdAt: RealmInstant = RealmInstant.now()
var isSynced: Boolean = false
}
// 写入
val config = RealmConfiguration.Builder(schema = setOf(Note::class))
.name("notes.realm")
.build()
val realm = Realm.open(config)
realm.write {
copyToRealm(Note().apply {
title = "Title"
content = "Content"
})
}
// 订阅变更
realm.query<Note>("isSynced == false")
.asFlow()
.collect { results ->
pendingNotes = results.list
}
注意,Realm 2.x 是 MongoDB 公司重写版本(基于 Realm Core),性能比 Realm 1.x 更好。
离线优先架构
数据模型
// 表示同步状态
sealed class SyncState {
object Pending : SyncState()
object Syncing : SyncState()
object Synced : SyncState()
class Failed(val error: Throwable) : SyncState()
}
data class Note(
val id: String,
val title: String,
val content: String,
val syncState: SyncState = SyncState.Pending
)
仓库层
class NoteRepository(
private val localDb: NoteDatabase,
private val api: NoteApi,
private val networkMonitor: NetworkMonitor
) {
// 写入:先本地,立刻返回
suspend fun createNote(title: String, content: String): Note {
val note = Note(
id = generateId(),
title = title,
content = content,
syncState = SyncState.Pending
)
localDb.insert(note)
// 后台同步
coroutineScope.launch { syncToServer(note) }
return note
}
// 同步单个数据
suspend fun syncToServer(note: Note) {
localDb.updateSyncState(note.id, SyncState.Syncing)
try {
val serverNote = api.uploadNote(note)
localDb.updateSyncState(note.id, SyncState.Synced)
} catch (e: Exception) {
localDb.updateSyncState(note.id, SyncState.Failed(e))
}
}
// 监听网络恢复触发批量同步
fun observeNetwork() {
networkMonitor.isConnected.onEach { connected ->
if (connected) {
syncAllPending()
}
}.launchIn(scope)
}
// 批量同步未发送的
suspend fun syncAllPending() {
val pending = localDb.getAllPending()
pending.forEach { syncToServer(it) }
}
}
ViewModel 层
class NotesViewModel(repo: NoteRepository) : ViewModel() {
val notes = repo.localDb.notesQueries
.selectAll()
.asFlow()
.mapToList(Dispatchers.IO)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
}
UI 层订阅 notes,永远即时显示本地数据。同步状态用单独的 hint 显示:
@Composable
fun NoteListScreen(vm: NotesViewModel = viewModel()) {
val notes by vm.notes.collectAsState()
LazyColumn {
items(notes) { note ->
NoteItem(note, syncHint = {
when (note.syncState) {
SyncState.Pending -> "同步中…"
SyncState.Syncing -> "同步中…"
SyncState.Synced -> "已同步"
is SyncState.Failed -> "⚠ 同步失败,点击重试"
}
})
}
}
}
冲突解决策略
多端编辑同一数据必然冲突。三种策略:
实现 LWW 简单:
data class Note(
val id: String,
val title: String,
val content: String,
val version: Int,
val updatedAt: Long
)
// 服务端接受带 version 的写请求
@POST("/notes/{id}")
suspend fun updateNote(
@Path("id") id: String,
@Body note: NoteWithVersion
): Either<Conflict, Note>
// 客户端比较 version
when (val result = api.update(note)) {
is Either.Left -> if (result.value is Conflict) {
mergeConflict(note, result.value.serverNote)
}
is Either.Right -> localDb.update(note.id, result.value)
}
实际生产环境会用 serverTimestamp(服务端时间)做比较,更鲁棒。
同步的最佳实践
1. 增量同步
// 用 sinceTime 增量拉
suspend fun syncDown(sinceTime: Long): List<Note> {
return api.getNotesSince(sinceTime)
.also { localDb.upsertAll(it) }
}
把 sinceTime 存本地,做增量同步。全量同步只首次跑。
2. 变更追踪
Realm/SQLDelight 都自带变动通知。一旦写库,所有订阅者立刻得到通知,无需手动广播。
3. 幂等性
网络重试可能导致操作重复执行。给写操作加 idempotency key:
@POST("/notes")
suspend fun createNote(
@Header("Idempotency-Key") key: String,
@Body note: Note
): Note
服务端用 key 去重。客户端 key 用 UUID + 时间戳。
4. 离线提醒
class NetworkMonitor(context: Context) {
val isConnected = callbackFlow {
val connectivityManager = context.getSystemService<ConnectivityManager>()!!
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(true)
}
override fun onLost(network: Network) {
trySend(false)
}
}
connectivityManager.registerDefaultNetworkCallback(callback)
awaitClose { connectivityManager.unregisterNetworkCallback(callback) }
}
}
UI 显示离线状态:
@Composable
fun NotesScreen() {
val networkState by networkMonitor.isConnected.collectAsState()
if (!networkState) Banner("离线模式:操作将在网络恢复后同步")
// ...
}
选择 Realm 还是 SQLDelight
简单 App 用 SQLDelight,大 App 用 Realm。看团队 SQL 能力决定。
小结
离线优先架构是现代移动 App 的标配。不是"加个缓存"那么简单,是把本地数据库当成唯一真相、网络当作数据源。Realm 和 SQLDelight 是两个优秀的本地存储选择,关键是设计好同步策略和冲突解决。投入一周搭好底座,应用层的 CRUD 直接起飞。
下一篇进入 MVI 架构实战,Orbit 框架是当下最轻量的选择。