第 8 篇|本地存储技术选型 —— SP 与文件读写

13 阅读10分钟

本地存储技术选型 —— SP 与文件读写

掌握轻量级本地存储,实现配置保存、文件读写与数据持久化


哈喽,各位坚持学习的小伙伴们!上一篇我们攻克了 RecyclerView,现在你的 App 已经能展示各式各样的列表了。但列表里的数据从哪来?总不能每次打开 App 都让用户重新输入一遍吧?

这就需要本地存储。无论是登录后的 Token、夜间模式的开关,还是笔记 App 里的草稿,都需要把数据存到手机上,下次打开时还能读到。

今天,我们就来系统学习 Android 本地存储技术,从最基础的 SharedPreferences、文件读写,到进阶的 MMKV、DataStore,再到数据库 Room 的入门与迁移,最后再做一个功能完善的「笔记草稿箱」案例,把这些技术全部串联起来。

全程附 Kotlin 代码 + 避坑指南,我们直接开始!


一、SharedPreferences:轻量级配置存取利器

1.1 SharedPreferences 是什么?

SharedPreferences 是 Android 系统原生提供的轻量级键值对存储类,以 XML 文件形式存储在应用私有目录 data/data/包名/shared_prefs/ 下。它非常适合保存少量简单配置,如:

  • 登录状态与 Token
  • 用户偏好(主题、字体大小)
  • 开关状态(是否开启通知、是否首次启动)

它的读写接口非常简单,支持 String、Int、Boolean、Float、Long、StringSet 等基础类型。

1.2 标准使用流程

获取 SharedPreferences 实例

// 指定文件名,MODE_PRIVATE 是唯一推荐的模式
val sp = getSharedPreferences("app_config", Context.MODE_PRIVATE)

写入数据

sp.edit()
    .putString("user_token", "abc123")
    .putBoolean("is_dark_mode", false)
    .putInt("font_size", 16)
    .putLong("last_sync_time", System.currentTimeMillis())
    .putStringSet("favorite_tags", setOf("Android", "Kotlin"))
    .apply()  // 异步提交,推荐使用

读取数据

val token = sp.getString("user_token", "") ?: ""
val isDarkMode = sp.getBoolean("is_dark_mode", false)
val fontSize = sp.getInt("font_size", 14)
val lastSyncTime = sp.getLong("last_sync_time", 0)
val hasToken = sp.contains("user_token")

删除数据

sp.edit().remove("user_token").apply()
sp.edit().clear().apply() // 清空所有

1.3 适用场景与红线

推荐使用场景:用户登录 Token、主题/字体大小等全局配置、功能开关标记、首次启动引导等少量简单数据。

绝对禁止

  • 存储超过 100KB 的大量数据

  • 存储明文密码、银行卡号等敏感信息

  • 需要高频读写的场景(每秒 1 次以上)

  • 多进程间共享数据

  • 存储复杂结构化数据


二、SP 的三大致命坑点,90% 的新手都踩过

SharedPreferences 虽然使用简单,但内部设计存在严重缺陷,是线上 ANR 和数据丢失的重灾区。

2.1 commit vs apply 的区别(别再乱用了)

特性commit()apply()
执行方式同步阻塞,立即写磁盘先写内存,后异步写磁盘
返回值返回 Boolean(是否成功)无返回值
线程阻塞阻塞调用线程不阻塞调用线程
ANR 风险极高低(但并非完全没有)

最佳实践:99% 的场景使用 apply()。仅当后续逻辑强依赖写入结果时才用 commit(),并且永远不要在主线程调用 commit()。

2.2 多进程共享完全不可靠

SP 内部在内存中维护了一份全局缓存,每个进程都会创建独立的缓存副本。当多个进程同时读写同一个 SP 文件时,会直接导致数据覆盖、丢失甚至 XML 损坏。MODE_MULTI_PROCESS 在 API 23 已被废弃。

解决方案:如果确实需要跨进程共享数据,请使用 MMKV 或 ContentProvider。

2.3 apply() 也会导致 ANR!

很多开发者以为 apply() 是异步的就没有性能问题,但实际上它是线上 ANR 的常见诱因。apply() 会将写入任务提交到 QueuedWork 队列。当 Activity 执行 onStop() 或 onPause() 时,系统会强制等待队列中所有写入任务完成,如果 SP 文件很大或有大量写入任务排队,主线程阻塞时间超过 5 秒就会触发 ANR。

优化方案

  • 单个 SP 文件大小控制在 50KB 以内
  • 批量写入,避免频繁多次调用 putXXX()
  • 高频写入场景迁移到 MMKV 或 DataStore

三、存储目录全解析与分区存储适配

在进行文件读写之前,必须先搞懂 Android 的存储目录体系。

3.1 内部存储 vs 外部存储

内部存储:/data/data/包名/,完全私有,随 App 卸载删除,无需任何权限。

val filesDir = filesDir           // /data/data/包名/files
val cacheDir = cacheDir           // /data/data/包名/cache(系统空间不足时可清理)

外部存储私有目录:/storage/emulated/0/Android/data/包名/,同样私有且随 App 卸载删除,API 19+ 无需权限,空间更大。

val externalFilesDir = getExternalFilesDir(null)
val externalCacheDir = externalCacheDir

外部存储公共目录:所有 App 共享,App 卸载后文件保留,但在 Android 10+ 受到分区存储严格限制。

3.2 分区存储适配指南

从 Android 11 开始,Google 强制要求适配分区存储,WRITE_EXTERNAL_STORAGE 等旧权限在公共目录上已基本失效。

存储需求适配方案所需权限
App 私有数据内部存储 / 外部私有目录
保存图片/视频到相册MediaStore API无(Android 10+)
访问其他 App 的媒体文件MediaStore APIREAD_MEDIA_IMAGES 等
访问其他 App 的文档系统文件选择器(SAF)
文件管理器类 AppMANAGE_EXTERNAL_STORAGE 特殊权限需引导用户授权

基本原则:优先使用 filesDir 或 getExternalFilesDir(),不受分区存储影响且无需权限。


四、Kotlin 文件扩展函数:极简读写

Kotlin 为文件读写提供了极其便捷的扩展函数。

4.1 文本文件读写

val file = File(filesDir, "note.txt")
file.writeText("第一篇笔记")                  // 覆盖写入
file.appendText("\n追加一行")                 // 追加写入
val content = file.readText()                 // 一次性读取
val lines = file.readLines()                  // 按行读取

4.2 字节文件与目录操作

val dataFile = File(filesDir, "data.bin")
dataFile.writeBytes(byteArrayOf(0x01, 0x02))  // 写入字节
val data = dataFile.readBytes()               // 读取字节

// 目录操作
val dir = File(filesDir, "my_dir")
dir.mkdirs()                                  // 创建多级目录
dir.deleteRecursively()                       // 递归删除
file.copyTo(File(filesDir, "copy.txt"), overwrite = true) // 复制

4.3 安全处理大文件:use() 自动关闭流

// 逐行读取大文件,避免一次加载到内存
File(filesDir, "large.txt").bufferedReader().use { reader ->
    var line: String?
    while (reader.readLine().also { line = it } != null) {
        processLine(line)
    }
}
// use 块结束会自动关闭流,避免资源泄漏

五、进阶方案 1:MMKV —— SP 的高性能替代者

MMKV 是腾讯开源的高性能键值对存储框架,基于 mmap 内存映射技术,完全解决了 SP 的性能瓶颈和多进程问题。

5.1 引入与初始化

// 依赖
implementation("com.tencent:mmkv:1.3.1")

// 在 Application 中初始化
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        MMKV.initialize(this)
    }
}

5.2 基本使用

val mmkv = MMKV.defaultMMKV()

mmkv.putString("token", "abc")
mmkv.putInt("count", 10)
val token = mmkv.getString("token", "") ?: ""

// 支持直接存取 Parcelable 对象
mmkv.encode("user", user)
val savedUser = mmkv.decodeParcelable("user", User::class.java)

// 多进程安全实例
val multiMMKV = MMKV.mmkvWithID("shared", MMKV.MULTI_PROCESS_MODE)

5.3 SP 一键迁移

mmkv.importFromSharedPreferences(getSharedPreferences("config", MODE_PRIVATE))
sp.edit().clear().apply() // 迁移后清理旧数据

性能上,MMKV 在写入速度上通常是 SP 的 100 倍以上,并且支持多进程,是当前 Android 开发中的首选键值对存储方案。


六、进阶方案 2:DataStore —— Google 官方替代品

DataStore 是 Google 在 Jetpack 中推出的 SP 替代方案,基于协程和 Flow 实现,支持异步操作和类型安全。

6.1 引入与基本使用

// 依赖
implementation("androidx.datastore:datastore-preferences:1.1.1")

// 创建 DataStore 实例
val Context.dataStore by preferencesDataStore(name = "app_preferences")

// 定义键
object Keys {
    val TOKEN = stringPreferencesKey("token")
    val IS_DARK = booleanPreferencesKey("is_dark")
}

// 写入(在协程中)
lifecycleScope.launch {
    dataStore.edit { prefs -> prefs[Keys.TOKEN] = "abc" }
}

// 读取为 Flow,数据变化时自动更新
lifecycleScope.launch {
    dataStore.data.collect { prefs ->
        val token = prefs[Keys.TOKEN] ?: ""
    }
}

DataStore 适合需要与 ViewModel、Compose 等 Jetpack 组件深度集成的项目。但如果追求极致性能和最低迁移成本,MMKV 仍然是更省心的选择。


七、进阶方案 3:Room 数据库入门与迁移指南

当数据有复杂结构和关系时,键值对存储就不再适用。Room 是 Google 推出的 SQLite 抽象层,提供编译时检查、协程支持和 LiveData 集成。

7.1 核心三件套:Entity、Dao、Database

// Entity:定义表结构
@Entity(tableName = "notes")
data class Note(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,
    val title: String,
    val content: String,
    val createTime: Long = System.currentTimeMillis()
)

// Dao:数据访问接口
@Dao
interface NoteDao {
    @Insert suspend fun insert(note: Note)
    @Query("SELECT * FROM notes ORDER BY createTime DESC") suspend fun getAll(): List<Note>
    @Delete suspend fun delete(note: Note)
}

// Database:数据库实例
@Database(entities = [Note::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun noteDao(): NoteDao
    companion object {
        @Volatile private var instance: AppDatabase? = null
        fun getInstance(ctx: Context) = instance ?: synchronized(this) {
            Room.databaseBuilder(ctx, AppDatabase::class.java, "app_db").build().also { instance = it }
        }
    }
}

7.2 数据库迁移:版本升级必须提供的 Migration

当你修改了表结构(如添加字段),必须递增 version 并提供 Migration,否则 Room 会直接删除旧数据库导致数据丢失。

// 版本1→2:添加 category 字段
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL("ALTER TABLE notes ADD COLUMN category TEXT NOT NULL DEFAULT '默认分类'")
    }
}

// 构建时注册迁移
Room.databaseBuilder(ctx, AppDatabase::class.java, "app_db")
    .addMigrations(MIGRATION_1_2)
    .build()

所有迁移必须经过测试,确保从旧版本升级上来不会崩溃。

--

八、新手必踩坑点清单

坑点 ①:高版本动态权限申请遗漏

Android 6.0+ 存储权限需动态申请,仅在 Manifest 中声明无效。Android 13+ 更是细分为 READ_MEDIA_IMAGES、READ_MEDIA_VIDEO、READ_MEDIA_AUDIO 三种媒体权限。最佳实践:优先使用无需权限的内部存储和外部私有目录。

坑点 ②:SP 存储明文敏感信息

SP 以纯文本 XML 存储,root 设备可轻易读取。密码等敏感信息应使用 EncryptedSharedPreferences(Jetpack Security 库)或 MMKV 加密,更高安全需求时使用 Android Keystore 硬件加密。


九、综合实战:开发笔记草稿箱(MMKV + Room 完整方案)

我们来做一个功能完善的笔记草稿箱,覆盖 MMKV 草稿自动保存、Room 数据库持久化、列表展示等核心技能。

9.1 功能需求

  • 编辑页退出时自动存草稿到 MMKV
  • 点击保存按钮将笔记存入 Room 数据库,并清除 MMKV 草稿
  • 笔记列表页展示所有已保存笔记,点击可编辑
  • 数据库升级迁移示例(添加 category 字段)

9.2 核心代码

NoteEditorActivity(编辑页,MMKV + Room)

class NoteEditorActivity : AppCompatActivity() {

    private lateinit var etTitle: EditText
    private lateinit var etContent: EditText
    private var noteId: Int = -1
    private val mmkv = MMKV.defaultMMKV()
    private lateinit var noteDao: NoteDao

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_editor)

        noteDao = AppDatabase.getInstance(this).noteDao()
        etTitle = findViewById(R.id.et_title)
        etContent = findViewById(R.id.et_content)

        noteId = intent.getIntExtra("note_id", -1)
        if (noteId != -1) {
            lifecycleScope.launch {
                noteDao.getNoteById(noteId)?.let { note ->
                    etTitle.setText(note.title)
                    etContent.setText(note.content)
                }
            }
        } else {
            // 恢复 MMKV 草稿
            etTitle.setText(mmkv.getString("draft_title", ""))
            etContent.setText(mmkv.getString("draft_content", ""))
        }

        findViewById<Button>(R.id.btn_save).setOnClickListener { saveNote() }
    }

    private fun saveNote() {
        val title = etTitle.text.toString().trim()
        val content = etContent.text.toString().trim()
        if (title.isEmpty()) { Toast.makeText(this, "请输入标题", Toast.LENGTH_SHORT).show(); return }

        lifecycleScope.launch {
            if (noteId == -1) {
                noteDao.insert(Note(title = title, content = content))
            } else {
                noteDao.update(Note(id = noteId, title = title, content = content))
            }
            // 清除草稿
            mmkv.remove("draft_title").remove("draft_content")
            Toast.makeText(this@NoteEditorActivity, "保存成功", Toast.LENGTH_SHORT).show()
            finish()
        }
    }

    override fun onPause() {
        super.onPause()
        if (noteId == -1) { // 新建模式才存草稿
            mmkv.putString("draft_title", etTitle.text.toString())
            mmkv.putString("draft_content", etContent.text.toString())
        }
    }
}

9.3 运行效果

新建笔记时,输入内容后退出,草稿自动存入 MMKV,下次进入自动恢复。

点击保存,笔记写入 Room 数据库,草稿同时清除。

从列表页点击笔记可进入编辑,回填已有内容。

数据库升级时通过 Migration 安全添加新字段,不会丢失历史数据。


十、存储方案选型总结

数据类型推荐方案备选方案
简单配置(开关、主题、Token)MMKVDataStore
敏感数据加密 MMKV / EncryptedSP
多进程共享MMKV(MULTI_PROCESS_MODE)ContentProvider
结构化数据(列表、关系型)RoomSQLite
临时缓存内部缓存目录外部缓存目录
需要用户可见的文件MediaStore / SAF外部私有目录

今天我们系统掌握了 Android 本地存储的完整技术栈:

  • ✅ SharedPreferences:轻量级键值对,但注意三大坑点,不宜滥用

  • ✅ 文件读写:Kotlin 扩展函数让操作极简,优先私有目录避开权限烦恼

  • ✅ MMKV:高性能 SP 替代方案,支持多进程、对象存取,迁移成本低

  • ✅ DataStore:官方异步替代品,适合 Jetpack 深度集成项目

  • ✅ Room:结构化数据首选,数据库升级 Migration 是必须掌握的技能

  • ✅ 实战案例:笔记草稿箱结合 MMKV 与 Room,完整落地存储方案

掌握了本地存储,你的 App 就能「记住」用户的数据了。但现代 App 光有本地数据还不够,还得能和服务器通信。下一篇,我们将正式进入网络编程,学习 Android 网络请求基础:从 HttpURLConnection 到 OkHttp,搞定接口调用与 JSON 解析!


✨ 如果本文对你有帮助,欢迎点赞收藏,让更多零基础的小伙伴少走弯路!