跳至正文
Ende Sun
返回博客

移动开发

移动端本地持久化:Room、Content Provider 与 Core Data 对比

阅读需 11 分钟

移动端本地持久化:Room、Content Provider 与 Core Data 对比

读移动开发硕士期间,我经常需要在同一周里分别在 Android 和 iOS 上做本地数据存储。把两边放在一起整理之后我发现,两个平台要解决的其实是同一批问题(数据结构、线程、迁移、数据共享),只是工具不同;而大多数 bug 也都出在两边相同的几个地方。

先选对存储层级

并不是所有数据都需要数据库。在 Android 上,设置项、"是否已登录"这类小型键值数据应该放在 SharedPreferences 或 DataStore 里;iOS 也有对应的用户偏好设置。只有当你需要对结构化记录进行查询、关联和更新时,数据库才真正有用武之地。

Android 上选 SQLite 还是 Room

Android 系统自带 SQLite,不需要额外引入任何库就能直接使用。但代价很快就会显现:

  • 大量样板代码:ContentValues、游标(Cursor),以及从行到对象的手动映射。
  • SQL 只在运行时才被检查。像 SELETC 这样的拼写错误可以顺利编译,等用户打开那个页面时应用才会崩溃。

Room 是构建在 SQLite 之上的抽象层,恰好解决了这两个问题:它在编译期校验查询语句,自动把数据行映射为对象,并且与 Flow 和 LiveData 集成。对于 Android 上几乎所有的本地数据库场景,它都是合理的默认选择。

Room 需要三个组成部分:实体(Entity,即数据表)、DAO(查询)以及数据库类(入口)。

@Entity(tableName = "notes")
data class Note(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    @ColumnInfo(name = "text") val text: String
)

@Dao
interface NoteDao {
    @Query("SELECT * FROM notes ORDER BY id DESC")
    fun observeAll(): Flow<List<Note>>

    @Insert
    suspend fun insert(note: Note): Long
}

DAO 是一份契约:它规定了可以对数据做什么,而不关心具体怎么做。它不包含任何业务逻辑,只有方法定义,由 Room 负责翻译成 SQL。

只有一个数据库实例,且绝不在主线程上查询

有两条规则帮我避开了绝大多数问题。

第一,数据库只打开一次。如果应用的多个地方各自创建实例,而两个线程又同时通过不同的连接写入,就会出现锁冲突,甚至更糟的情况。在 companion object 里实现单例即可解决:

@Database(entities = [Note::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun noteDao(): NoteDao

    companion object {
        @Volatile private var instance: AppDatabase? = null

        fun get(context: Context): AppDatabase =
            instance ?: synchronized(this) {
                instance ?: Room.databaseBuilder(
                    context.applicationContext,
                    AppDatabase::class.java,
                    "app.db"
                ).build().also { instance = it }
            }
    }
}

第二,绝不在主线程上执行查询。Room 默认就禁止这样做,而 allowMainThreadQueries() 这个"后门"在快速做原型时很诱人。不要用它:一旦数据表变大,界面就会在读取时卡住,最终 Android 会弹出"应用无响应"(ANR)对话框。把 DAO 方法声明为 suspend 并配合协程,几乎不需要额外代码就能把工作移出 UI 线程。

在应用之间共享数据:Content Provider

数据库是应用私有的。当其他应用需要访问你的数据时,Android 的标准机制是 Content Provider:它位于你的存储与外部之间,通过 URI 对外暴露数据,并负责权限控制。

Content URI 的格式是 content://authority/path/id:authority 用于标识 provider(通常就是包名),path 标识数据表,可选的 id 则指向某一条具体记录。

我认为不能省略的是 Contract 类:一个只包含常量(authority、基础 URI、表名和列名、MIME 类型)的对象,由 provider 和客户端共同使用。这样双方对数据结构的约定是一致的,不必在各处复制字符串。

object NotesContract {
    const val AUTHORITY = "com.example.notes.provider"
    val BASE_URI: Uri = Uri.parse("content://$AUTHORITY")

    object NoteEntry : BaseColumns {
        const val TABLE = "notes"
        const val COLUMN_TEXT = "text"
        val CONTENT_URI: Uri = BASE_URI.buildUpon().appendPath(TABLE).build()
    }
}

Provider 继承自 ContentProvider,需要实现 onCreate、getType、query、insert、update 和 delete,并借助 UriMatcher 区分列表 URI 与单条记录 URI。插入数据后要调用 notifyChange,让观察者及时刷新。

客户端则通过 ContentResolver 访问:

contentResolver.query(
    NotesContract.NoteEntry.CONTENT_URI,
    arrayOf(BaseColumns._ID, NotesContract.NoteEntry.COLUMN_TEXT),
    null, null, null
)?.use { cursor ->
    while (cursor.moveToNext()) {
        val text = cursor.getString(1)
    }
}

有两个细节让我花了不少时间。一是要在 manifest 中用 readPermission 和 writePermission 保护 provider;如果只想临时共享某一个 URI,就使用 grantUriPermissions。二是从 Android 11 开始,客户端应用必须在 <queries> 中声明要访问的 provider,否则它根本找不到 provider,而且不会有任何明确的报错。

iOS 上的 Core Data

Core Data 不是数据库,而是一个对象图管理与持久化框架,底层通常使用 SQLite(对于很容易重新生成的数据,也可以使用内存存储)。日常开发中主要接触三样东西:数据模型(.xcdatamodeld)、负责搭建整套栈的 NSPersistentContainer,以及 NSManagedObjectContext——所有读写操作都要经过它。查询使用 NSFetchRequest,并通过 NSPredicate 过滤;CONTAINS[cd] 会忽略大小写和变音符号,正适合用在搜索框背后。

并发

和 Android 一样的原则:耗时操作不应放在主线程上。在 Core Data 中,这意味着要使用多个上下文,每个上下文绑定在自己的队列上:

container.performBackgroundTask { context in
    let request: NSFetchRequest<Note> = Note.fetchRequest()
    request.predicate = NSPredicate(format: "text CONTAINS[cd] %@", term)
    let notes = (try? context.fetch(request)) ?? []
    // 在这里做耗时工作:导出、上传……
}

托管对象隶属于各自的上下文。如果要在主上下文中使用后台获取的对象,可以传递它的 objectID 再在主上下文中重新加载;或者监听 NSManagedObjectContextDidSave 通知,并在主上下文中调用 mergeChanges(fromContextDidSave:)。

迁移

每次修改数据结构都意味着要创建一个新的模型版本。轻量级迁移可以自动处理很多变化,但有一个很容易被忽略的陷阱:如果你重命名了某个属性或实体,Core Data 会把它理解为删除旧的、创建新的,旧数据随之丢失。你必须明确告诉它这是一次重命名。对于它无法自动推断的转换,则需要使用重量级迁移,描述如何把旧数据映射为新格式。

Android 在另一个地方也有同样的陷阱:在 SQLiteOpenHelper.onUpgrade 中,如果为了"从头开始"而执行 DROP TABLE,下一次版本更新就会把所有用户的数据删掉。

查看实际存储了什么

在 Android 上,Android Studio 的 Database Inspector 可以实时显示数据表,并允许直接对运行中的应用执行查询。在 iOS 上,SQLite 文件位于应用沙盒的 Library/Application Support 目录下,可以用 sqlite3 打开。Core Data 会给表名加上 Z 前缀,而最近的写入可能还停留在 .wal 文件中——这也解释了为什么直接查看数据库文件时,有些数据"不见了"。

我的总结

按需选择存储层级;数据库只保留一个入口;查询不要放在主线程;数据跨应用共享时定义一份公共契约;把每一次数据结构变更都当作可能丢失数据的迁移来对待。API 各不相同,但出错的方式是一样的。