【Android基础】数据存储方案总结

【Android基础】数据存储方案总结

本文为android系统上各种存储数据的方式的总结

ContentProvider

见另一篇文章: 【2022-9-12-四大组件之ContentProvider】

内部存储(Internal Storage)

内部存储用于存储应用私有的数据,其他应用无法访问。数据存储在应用的内部目录中,注意,此处的文件会随应用的卸载而删除。适合存储应用的缓存文件,配置文件等。

路径: /storage/emulated/0/Android/data/${packageName}/files

存储代码示例,以下是一个app管理功能中存储应用图标Drawable文件到内部缓存目录:

/**
 * 存储图标png到app内部目录
 */
private fun saveDrawableToFile(context: Context, drawable: Drawable, fileName: String) {
    val bitmap = drawable.toBitmap()
    val file = File(context.getExternalFilesDir(null), "$fileName.png")
    val fos = FileOutputStream(file)
    infoLog("path:${file.absolutePath}")
    bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos)
    fos.flush()
    fos.close()
}

外部存储(External Storage)

用于存储公共的、可共享的数据,其他应用可以访问。数据存储在设备的外部存储设备(如SD卡)上,即使应用被卸载,数据仍然保留。适合存储用户生成的文件,如照片、视频等。

路径一般为sdcard内。此处的存储操作想要顺利完成,一般需要手动申请运行时权限。

存储代码示例,往sdcard的Pictures目录下写一张图片:

/**
 * 保存一个bitmap到本地sdcard的Pictures目录
 */
fun saveImageToGallery(
    context: Context,
    bitmap: Bitmap,
    filename: String = "FilmSimulation.jpg",
) {
    val values = ContentValues().apply {
        put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
        put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") // 文件类型
        put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
    }
    runCatching {
        context.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
            ?.let {
                context.contentResolver.openOutputStream(it)?.apply {
                    bitmap.compress(Bitmap.CompressFormat.JPEG, 100, this)
                    flush()
                    close()
                }
                context.contentResolver.notifyChange(it, null)
            }
    }.onFailure {
        Log.e(TAG, "save image error:${it.message}")
    }
}

数据库存储

数据库存储用于存储结构化的数据,如用户信息、聊天记录等。数据库存储可以支持复杂的查询和关联操作。

数据库存储的路径一般在

/storage/emulated/0/Android/data/${packageName}/databases

像下面的用以举例的 Demo,数据库路径如下:

emu64xa:/data/data/com.example.roomdemo/databases # ls
camera_database  camera_database-shm  camera_database-wal

Room数据库介绍

SQLite数据库

Android平台选取了SqLlite数据库作为结构化数据库的存储方案。

用于存储结构化的数据,如用户信息、聊天记录等。轻量级的关系型数据库,支持SQL查询,适合存储大量的结构化数据。应用可极大地受益于在本地保留这些数据。最常见的使用场景是缓存相关的数据,这样一来,当设备无法访问网络时,用户仍然可以在离线状态下浏览该内容。

长期以来,SQLite 数据库繁杂的使用体验,也让开发者们感到困惑。

Room 持久性库在 SQLite 上提供了一个抽象层,以便在充分利用 SQLite 的强大功能的同时,能够流畅地访问数据库。具体来说,Room 具有以下优势:

  • 提供针对 SQL 查询的编译时验证。
  • 提供方便注解,可最大限度减少重复和容易出错的样板代码。
  • 简化了数据库迁移路径。

使用Room数据库

首先添加依赖库,app级的 build.gradle.kts 依赖添加:

val room_version = "2.6.1"
implementation("androidx.room:room-runtime:$room_version")

/**
  * KSP (Kotlin Symbol Processing)是以 Kotlin 优先的 kapt 替代方案。
  * KSP 可直接分析 Kotlin 代码,使得速度提高多达 2 倍。
  * 此外,它还可以更好地了解 Kotlin 的语言结构。
  */
ksp("androidx.room:room-compiler:$room_version")

Kapt背景知识: Kapt可以将 Java 注解处理器与 Kotlin 代码搭配使用,即使这些处理器没有特定的 Kotlin 支持也是如此。方法是从 Kotlin 文件生成 Java 桩,然后处理器就可以读取这些桩。生成桩是一项成本高昂的操作,并且对构建速度有很大影响。

而KSP 可以说是Kapt的升级替代方案,它是一个 Kotlin 编译器插件,它可以在编译时读取和分析 Kotlin 代码,然后生成 Java 代码。KSP 可以直接分析 Kotlin 代码,而不需要通过 Java 桩。这使得 KSP 可以更好地了解 Kotlin 的语言结构。

如果没有提前添加ksp插件,上面的依赖引入应该是报红的。

添加KSP插件

  1. 项目顶级build.gradle.kts文件:
plugins {
    id("com.google.devtools.ksp") version "2.0.21-1.0.27" apply false
}
  1. app级的build.gradle.kts文件:
plugins {
    id("com.google.devtools.ksp")
}

Room数据库组件

Room 包含三个主要组件:

  • 数据库类,用于保存数据库并作为应用持久性数据底层连接的主要访问点。
  • 数据实体,用于表示应用的数据库中的表。
  • 数据访问对象 (DAO),为您的应用提供在数据库中查询、更新、插入和删除数据的方法。

也就是说,至少需要定义三个类,才可以使用Room数据库来存储数据。

数据实体

数据实体是数据库中表的映射。数据实体是一个类,需要添加 @Entity 注解。

@Entity
data class Camera (
    @PrimaryKey
    val cameraId: Int,
    @ColumnInfo(name = "brand_name") val brandName: String,
    @ColumnInfo(name = "camera_model") val cameraModel: String,
)

数据访问对象 (DAO)

数据访问对象 (DAO) 是用于在数据库中执行查询和更新的接口。数据访问对象 (DAO) 是一个接口,需要添加 @Dao 注解。

@Dao
interface CameraDao {
    @Query("SELECT * FROM camera")
    fun getAll(): List<Camera>

    @Query("SELECT * FROM camera WHERE cameraId IN (:cameraIds)")
    fun loadAllByIds(cameraIds: IntArray): List<Camera>

    @Query("SELECT * FROM camera WHERE brand_name LIKE :first AND " + "camera_model LIKE :last LIMIT 1")
    fun findByName(first: String, last: String): Camera
    
    @Insert
    fun insertAll(vararg cameras: Camera)

    @Delete
    fun delete(camera: Camera)
}

数据库类

数据库类是应用的入口点,用于访问应用的数据库。

数据库类为应用提供与该数据库关联的 DAO 的实例。反过来,应用可以使用 DAO 从数据库中检索数据,作为关联的数据实体对象的实例。此外,应用还可以使用定义的数据实体更新相应表中的行,或者创建新行供插入。

数据库类是一个抽象类,需要继承它,并且需要添加 @Database 注解。

@Database 注解有两个参数:entities 和 version。entities 是一个数组,用于指定数据库中包含的实体类。version 是一个整数,用于指定数据库的版本号。

@Database(entities = [Camera::class], version = 1)
abstract class CameraDatabase : RoomDatabase() {
    abstract fun cameraDao(): CameraDao
}

数据库使用

数据库类是应用的入口点,用于访问应用的数据库。

使用 Room.databaseBuilder 方法来创建数据库实例。然后,可以使用 CameraDatabase 中的抽象方法获取 DAO 的实例,转而可以使用 DAO 实例中的方法与数据库进行交互。

下面是Demo测试代码,注意要在 IO 线程中进行数据库的创建,读写等操作:

CoroutineScope(Dispatchers.IO).launch {
    val db = Room.databaseBuilder(appContext, CameraDatabase::class.java, "camera_database").build()
    val camereDao = db.cameraDao()
    camereDao.insertAll(
        Camera(1000, "Canon", "EOS R6 II"),
        Camera(1001, "Sony", "A9 II"),
        Camera(1002, "LUMIX", "S5M2K")
    )
    delay(3000L)
    Log.i(TAG, "room database test: ${camereDao.getAll()}")
}

四种流行的键值对存储

前三种方案对比结论,来自扔物线朱凯大佬的测试数据。

SharedPreferences

如果您有想要保存的相对较小键值对集合,则可以使用 SharedPreferences API。SharedPreferences 对象指向包含键值对的文件,并提供读写这些键值对的简单方法。每个 SharedPreferences 文件均由框架进行管理,可以是私有文件,也可以是共享文件。

键值对的存储在移动开发里非常常见。比如深色模式的开关、软件语言、字体大小,这些用户偏好设置,很适合用键值对来存。

SharedPreferences 使用起来很简单,但是有性能问题,容易卡顿,甚至有时候会出现 ANR。

MMKV

腾讯开源了一个叫做 MMKV 的项目。它和 SP 一样,都是做键值对存储的,可是它的性能比 SP 强很多。

MMKV的开发背景:

微信在遇到一些无法处理的字符的时候,会出现崩溃的问题,而微信为了及时地找出导致崩溃的字符或者字符串,所有的对话内容在显示之前,先保存到磁盘再显示。而且防止崩溃之后数据还没存好,必须要在主线程去完成这个写操作,耗时就绝对无法避免。一帧的时间也就 16 毫秒,在16 毫秒里来个写磁盘的操作,用户很可能就会感受到一次卡顿。如果用户点开了一个活跃的群,这个群里有几百条没看过的消息。而如果把这几条消息都记录下来,是不是每条消息的记录都会涉及一次写磁盘的操作?这几次写磁盘行为,是发生在同一帧里的,所以在这一帧里因为记录文字而导致的主线程耗时,也会相比起刚才的例子翻上好几倍,卡顿时间就同样也会翻上好几倍。

最终微信找到了解决方案。使用了一种叫做内存映射(mmap())的底层方法。

它可以让系统为你指定的文件开辟一块专用的内存,这块内存和文件之间是自动映射、自动同步的关系,你对文件的改动会自动写到这块内存里,对这块内存的改动也会自动写到文件里。

有了这一层内存作为中间人,我们就可以用「写入内存」的方式来实现「写入磁盘」的目标了。它在程序崩溃的时候,并不会随着进程一起被销毁掉,而是会继续有条不紊地把它里面还没同步完的内容同步到它所映射的文件里面去。

MMKV缺点:

  • 字符串大数据比较慢
  • 丢数据

MMKV 优势:

  • 写速度极快
  • 支持多进程

SP和DataStore对比

DataStore 被创造出来的目标就是替代 SharedPreferences,而它解决的 SharedPreferences 最大的问题有两点:一是性能问题,二是回调问题。

先说性能问题:SharedPreferences 虽然可以用异步的方式来保存更改,以此来避免 I/O 操作所导致的主线程的耗时。

但在 Activity 启动和关闭的时候,Activity 会等待这些异步提交完成保存之后再继续,这就相当于把异步操作转换成同步操作了,从而会导致卡顿甚至 ANR(程序未响应)。

这是为了保证数据的一致性而不得不做的决定,但它也确实成为了 SharedPreferences 的一个弱点。

但是,SharedPreferences 所导致的卡顿和 ANR,是非常低概率的事件。

读取文件卡顿

其实除了写数据时的卡顿,SharedPreferences 在读取数据的时候也会卡顿。

虽然它的文件加载过程是在后台进行的,但如果代码在它加载完成之前就去尝试读取键值对,线程就会被卡住,直到文件加载完成,而如果这个读取的过程发生在主线程,就会造成界面卡顿,并且数据文件越大就会越卡。

这种卡顿,不是 SharedPreferences 独有的,MMKV 也是存在的,因为它初始化的过程同样也是从磁盘里读取文件,而且是一股脑把整个文件读完,所以耗时并不会比 SharedPreferences 少。

而 DataStore,就没有这种问题。DataStore 不管是读文件还是写文件,都是用的协程在后台进行读写,所有的 I/O 操作都是在后台线程发生的,所以不论读还是写,都不会卡主线程。

DataStore回调更方便

DataStore 解决的 SharedPreferences 的另一个问题就是回调。

SharedPreferences 如果使用同步方式来保存更改commit(),会导致主线程的耗时;

但如果使用异步的方式,给它加回调又很不方便,也就是如果你想做一些「等这个异步提交完成之后再怎么怎么样」的工作,会很麻烦。

而 DataStore 由于是用协程来做的,线程的切换是非常简单的,你就把「保存完成之后做什么」直接写在保存代码的下方就可以了,很直观、很简单。

对比来说,MMKV 虽然没有使用协程,但是它太快了,所以大多数时候并不需要切线程也不会卡顿。

三种方式的总结

区别大概就是这么些区别了,大致总结一下就是:

如果你有多进程支持的需求,可以选择MMKV,也可以选择DataStore(1.1.0版本新增);如果你有高频写入的需求,你也应该优先考虑 MMKV。但如果你使用 MMKV,一定要知道它是可能丢失数据的,不过概率很低就是了,所以你要在权衡之后做好决定:是自行实现数据的备份和恢复方案,还是直接接受丢数据的事实,在每次丢失数据之后帮用户把相应的数据进行初始化。

DataStore 在任何时候都不会卡顿,而 MMKV 在写大字符串和初次加载文件的时候是可能会卡顿的,而且初次加载文件的卡顿不是概率性的,只要文件大到了引起卡顿的程度,就是 100% 的卡顿。

三种方案提取的工具类

在我之前研究AOSP redfin平台的项目的时候,在CommonHelper库里面对这三种存储方式都做了一个很简单的工具类。

SharedPreferences

/**
 * SharedPreference存储工具类
 * 不会丢数据
 * 但是加回调不方便
 */
object SPHelper {

    private lateinit var share: SharedPreferences

    private lateinit var editor: SharedPreferences.Editor

    private const val SHARED_NAME = "SPHelper"

    fun init(context: Context) {
        share = context.getSharedPreferences(SHARED_NAME, Context.MODE_PRIVATE);
        editor = share.edit();
    }

    // 采用同步保存,获取保存成功与失败的result
    fun putString(key: String, value: String?): Boolean {
        infoLog("putString key: $key, value: $value")
        editor.putString(key, value)
        return editor.commit()
    }

    fun getString(key: String): String? {
        val value = share.getString(key, null)
        infoLog("getString key: $key, value: $value")
        return value
    }

    fun getString(key: String, defaultValue: String): String {
        val value = share.getString(key, null)
        infoLog("getString key: $key, value: $value, defaultValue: $defaultValue")
        return value ?: defaultValue
    }

    fun putLong(key: String?, value: Long): Boolean {
        infoLog("putLong key: $key, value: $value")
        editor.putLong(key, value)
        return editor.commit()
    }

    fun putFloat(key: String?, value: Float): Boolean {
        infoLog("putFloat key: $key, value: $value")
        editor.putFloat(key, value)
        return editor.commit()
    }

    fun putInt(key: String?, value: Int): Boolean {
        infoLog("putInt key: $key, value: $value")
        editor.putInt(key, value)
        return editor.commit()
    }

    fun putBoolean(key: String?, value: Boolean): Boolean {
        infoLog("putBoolean key: $key, value: $value")
        editor.putBoolean(key, value)
        return editor.commit()
    }

    fun getLong(key: String?): Long {
        val value = share.getLong(key, -1)
        infoLog("getLong key: $key, value: $value")
        return value
    }

    fun getInt(key: String?, defaultValue: Int): Int {
        val value = share.getInt(key, defaultValue)
        infoLog("getInt key: $key, value: $value")
        return value
    }

    fun getFloat(key: String?, defaultValue: Float): Float {
        val value = share.getFloat(key, defaultValue)
        infoLog("getFloat key: $key, value: $value")
        return value
    }

    fun getBoolean(key: String?, defaultValue: Boolean): Boolean {
        val value = share.getBoolean(key, defaultValue)
        infoLog("getBoolean key: $key, value: $value")
        return value
    }

    fun removeSharedPreferenceByKey(key: String?): Boolean {
        infoLog("removeSharedPreferenceByKey key: $key")
        editor.remove(key)
        return editor.commit()
    }
}

MMKV

/**
 * 最适合同步写入小数据
 * 支持多进程,高频写入性能好
 * 但有可能丢数据
 */
object MMKVHelper {

    private lateinit var mmkv: MMKV

    fun init(context: Context,databaseId: String, isMultiProcess: Boolean) {
        val rootDir = MMKV.initialize(context)
        infoLog("MMKV rootDir: $rootDir")
        mmkv =
            if (isMultiProcess) MMKV.mmkvWithID(databaseId, MMKV.MULTI_PROCESS_MODE)
            else MMKV.mmkvWithID(databaseId)
    }

    fun putString(key: String, value: String?) {
        infoLog("putString key: $key, value: $value")
        mmkv.encode(key, value)
    }

    fun getString(key: String): String? {
        val value = mmkv.decodeString(key)
        infoLog("getString key: $key, value: $value")
        return value
    }

    fun getString(key: String, defaultValue: String): String {
        val value = mmkv.decodeString(key)
        infoLog("getString key: $key, value: $value, defaultValue: $defaultValue")
        return value ?: defaultValue
    }

    fun putLong(key: String?, value: Long) {
        infoLog("putLong key: $key, value: $value")
        mmkv.encode(key, value)
    }

    fun putFloat(key: String?, value: Float) {
        infoLog("putFloat key: $key, value: $value")
        mmkv.encode(key, value)
    }

    fun putInt(key: String?, value: Int) {
        infoLog("putInt key: $key, value: $value")
        mmkv.encode(key, value)
    }

    fun putBoolean(key: String?, value: Boolean) {
        infoLog("putBoolean key: $key, value: $value")
        mmkv.encode(key, value)
    }

    fun getLong(key: String?): Long {
        val value = mmkv.decodeLong(key, -1)
        infoLog("getLong key: $key, value: $value")
        return value
    }

    fun getInt(key: String?, defaultValue: Int): Int {
        val value = mmkv.decodeInt(key, defaultValue)
        infoLog("getInt key: $key, value: $value")
        return value
    }

    fun getFloat(key: String?, defaultValue: Float): Float {
        val value = mmkv.decodeFloat(key, defaultValue)
        infoLog("getFloat key: $key, value: $value")
        return value
    }

    fun getBoolean(key: String?, defaultValue: Boolean): Boolean {
        val value = mmkv.decodeBool(key, defaultValue)
        infoLog("getBoolean key: $key, value: $value")
        return value
    }
}

DataStore

/**
 * 谷歌推荐的最新存储方式
 * 协程实现,可以方便地获取存储的结果回调
 */
object DataStoreHelper {
    // 定义一个 DataStore 实例
    val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "data_store_settings")

    private lateinit var outDataStore: DataStore<Preferences>

    /**
     * 初始化,使get和set不受Context域限制
     */
    fun init(context: Context) {
        outDataStore = context.dataStore
    }

    // 定义一个 suspend 函数,用于从 DataStore 中读取数据
    suspend fun <T> getData(key: Preferences.Key<T>, defaultValue: T): T {
        return (outDataStore.data.first()[key] ?: defaultValue)
    }

    // 定义一个 suspend 函数,用于将数据保存到 DataStore 中
    suspend fun <T> saveData(key: Preferences.Key<T>, value: T) {
        outDataStore.edit { preferences ->
            preferences[key] = value
        }
    }
}

使用:

CoroutineScope(Dispatchers.IO).launch {
    val INT_PREF_KEY = intPreferencesKey("IntKey")
    val FLOAT_PERF_KEY = floatPreferencesKey("FloatKey")
    val STRING_PREF_KEY = stringPreferencesKey("SteringKey")

    DataStoreHelper.saveData(INT_PREF_KEY, 45)
    DataStoreHelper.saveData(FLOAT_PERF_KEY, 45.0f)
    DataStoreHelper.saveData(STRING_PREF_KEY, "45")
    delay(1000L)
    // 拿取刚刚存的值
    Log.i(TAG, "intData: ${DataStoreHelper.getData(INT_PREF_KEY, -1)}")
    Log.i(TAG, "floatData: ${DataStoreHelper.getData(FLOAT_PERF_KEY, -1f)}")
    Log.i(TAG, "stringData: ${DataStoreHelper.getData(STRING_PREF_KEY, "default")}")
}

Setting.System系统数据库

这里在车载上使用的更多,单独写了一篇总结:

【Android系统数据库通信使用方式】