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

本地存储技术选型 ------ 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 实例

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

写入数据

kotlin 复制代码
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()  // 异步提交,推荐使用

读取数据

kotlin 复制代码
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")

删除数据

kotlin 复制代码
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 卸载删除,无需任何权限。

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

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

kotlin 复制代码
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 API READ_MEDIA_IMAGES 等
访问其他 App 的文档 系统文件选择器(SAF)
文件管理器类 App MANAGE_EXTERNAL_STORAGE 特殊权限 需引导用户授权

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


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

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

4.1 文本文件读写

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

4.2 字节文件与目录操作

kotlin 复制代码
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() 自动关闭流

kotlin 复制代码
// 逐行读取大文件,避免一次加载到内存
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 引入与初始化

kotlin 复制代码
// 依赖
implementation("com.tencent:mmkv:1.3.1")

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

5.2 基本使用

kotlin 复制代码
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 一键迁移

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

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


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

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

6.1 引入与基本使用

kotlin 复制代码
// 依赖
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

kotlin 复制代码
// 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 会直接删除旧数据库导致数据丢失。

kotlin 复制代码
// 版本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)

kotlin 复制代码
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) MMKV DataStore
敏感数据 加密 MMKV / EncryptedSP ---
多进程共享 MMKV(MULTI_PROCESS_MODE) ContentProvider
结构化数据(列表、关系型) Room SQLite
临时缓存 内部缓存目录 外部缓存目录
需要用户可见的文件 MediaStore / SAF 外部私有目录

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

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

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

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

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

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

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

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


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

相关推荐
码农哈丁1 天前
从 JVM 到 Cargo:把整个 Kotlin 项目移植到 Rust 的踩坑实录
jvm·rust·kotlin
Kapaseker1 天前
写过 4000 行 ViewModel 后,我开始这样拆 Compose 页面
android·kotlin
新时代牛马2 天前
cyclictest 毛刺从哪来?从ftrace、latencytop、perf到IRQ/调度延迟定位
android·开发语言·python·kotlin
光电的一只菜鸡2 天前
为什么调整LSC会对AF产生影响
android·开发语言·kotlin
hai_android2 天前
FusibleFlow:Kotlin Flow 的融合机制原理
android·kotlin
alexhilton2 天前
从零开始的架构测试套件
android·kotlin·android jetpack
YB13752 天前
kotlin 关键字data
kotlin
挂科边缘2 天前
Android Studio 保姆级安装教程,Windows 电脑上安装 Android Studio
android·windows·android studio
Danny_姜2 天前
Android Studio Quail 4 稳定版发布:本地 Gemma 4 + Android Skills
android·ide·android studio