Administrator
发布于 2026-08-25 / 33 阅读
0
0

系列文章《Android 工程师的 100 篇进阶笔记》第 63 篇:离线优先架构:Realm 与 SQLDelight 实战

上一篇讲了 R8 深度优化,进入数据存储层——离线优先(Offline-First)。移动端网络不稳定,离线设计不是"边角案例"而是"核心场景"。这一篇看 Realm 和 SQLDelight 两种本地存储方案如何在离线优先架构里发挥作用。

什么是离线优先

离线优先的核心思想:UI 不应该等网络,本地数据库是唯一真相。

[网络]             [本地数据库(唯一真相)]         [UI]
  ↓                       ↑                         ↑
更新数据 → 立即写库 → 推送通知 UI 更新 ← 订阅本地变更

关键:网络是数据源之一,本地是同步中心

对比传统的"网络优先"思路:

场景

网络优先

离线优先

弱网下打开 App

加载失败、空白页

加载本地数据

用户发送消息

等服务端 ACK

立即显示已发送

用户编辑内容

实时同步

本地立即生效,后台同步

网络断开

所有操作失败

继续工作,恢复后同步

三种主流持久化方案

方案

类型

优势

劣势

Room(SQLite)

关系型 ORM

Google 官方、生态成熟

编译时注解处理慢、Schema 迁移痛苦

SQLDelight

类型安全 SQL

编译时类型检查、KMP

字符串拼接可能慢

Realm/MongoDB

嵌入式对象数据库

实时通知、移动友好

体积大、迁移需要学 Mongo

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 -> "⚠ 同步失败,点击重试"
                }
            })
        }
    }
}

冲突解决策略

多端编辑同一数据必然冲突。三种策略:

策略

例子

适用场景

Last-Write-Wins

简单粗暴

文档类、计数类

Operational Transform

Google Docs

富文本协作

CRDT

Figma、自动驾驶

高实时协作

实现 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

维度

Realm

SQLDelight

上手难度

中等

较低(熟悉 SQL 就行)

类型安全

运行时类型检查

编译时类型检查

性能

大量读

复杂查询

多平台

有限支持(MongoDB Atlas)

KMP 友好

体积

~2MB

小(基本是 Kotlin)

学习曲线

较缓(思维模型不同)

平缓(SQL 知识迁移)

简单 App 用 SQLDelight,大 App 用 Realm。看团队 SQL 能力决定。

小结

离线优先架构是现代移动 App 的标配。不是"加个缓存"那么简单,是把本地数据库当成唯一真相、网络当作数据源。Realm 和 SQLDelight 是两个优秀的本地存储选择,关键是设计好同步策略和冲突解决。投入一周搭好底座,应用层的 CRUD 直接起飞。

下一篇进入 MVI 架构实战,Orbit 框架是当下最轻量的选择。


评论