7、IO与存储优化:SharedPreferences替代方案、SQLite性能调优、文件读写异步化
说到IO和存储优化,我估计不少同学都踩过坑。SharedPreferences卡UI、SQLite写入慢、文件读写阻塞主线程——这些场景我几乎在每个项目里都见过。今天咱们就聊聊怎么把这些痛点一个个解决掉。
核心思路:IO操作的本质是磁盘读写,速度比内存慢几个数量级。优化的方向无非三个:减少IO次数、异步执行IO、换更快的存储方案。
7.1 SharedPreferences 的替代方案
SharedPreferences 的问题在哪?说白了,它会在主线程做磁盘IO。你想想看,每次调用 apply() 虽然返回快,但写入是异步的,如果写入量大,还是会卡。更坑的是 commit(),直接同步写磁盘,主线程一卡就是几十毫秒。
我在项目中遇到过这样一个场景:一个配置页面,用户改了十几个开关,每次改完都调 apply()。结果呢?快速滑动页面时明显掉帧。后来一查,SP 的写入队列积压了,主线程被频繁唤醒去处理写入回调。
7.1.1 MMKV 方案
腾讯的 MMKV 是目前最主流的替代方案。它基于 mmap 内存映射文件,写入时直接写内存,由内核负责刷盘。性能比 SP 高一个数量级。
// 初始化
MMKV.initialize(this);
// 读写
MMKV kv = MMKV.defaultMMKV();
kv.encode("key", "value");
String value = kv.decodeString("key");
我的建议:新项目直接上 MMKV,老项目可以逐步替换。注意 MMKV 的多进程支持比 SP 好很多,但跨进程场景还是要小心同步问题。
7.1.2 DataStore 方案
Jetpack 的 DataStore 是官方推荐的替代方案。它基于协程和 Flow,天然支持异步。但要注意,DataStore 的写入是事务性的,频繁写入会有性能开销。
// Preferences DataStore
val Context.dataStore by preferencesDataStore(name = "settings")
val EXAMPLE_COUNTER = intPreferencesKey("example_counter")
suspend fun incrementCounter() {
context.dataStore.edit { preferences ->
val current = preferences[EXAMPLE_COUNTER] ?: 0
preferences[EXAMPLE_COUNTER] = current + 1
}
}
注意:DataStore 的 edit 是挂起函数,必须在协程里调用。如果你还在用线程池那套,建议先迁移到协程再考虑 DataStore。
7.2 SQLite 性能调优
SQLite 是 Android 最常用的本地数据库。但很多人用起来就是一把梭——建表、插入、查询,完全不考虑性能。我见过最夸张的项目,一个页面启动要查 20 次数据库,每次查完还要解析成对象。
7.2.1 索引优化
索引是 SQLite 性能的第一道防线。但索引不是越多越好,写操作会维护索引,索引多了写入反而慢。
| 场景 | 建议 |
|---|---|
| 频繁查询的字段 | 建索引,比如用户ID、订单号 |
| 频繁更新的字段 | 不建索引,比如状态字段 |
| 复合查询 | 建联合索引,注意字段顺序 |
-- 创建索引
CREATE INDEX idx_user_id ON orders(user_id);
-- 联合索引,注意顺序:先查 user_id,再查 status
CREATE INDEX idx_user_status ON orders(user_id, status);
7.2.2 事务批量处理
我曾经接手过一个项目,插入 1000 条数据要 30 秒。一看代码,每条数据都单独开一个事务。改成批量事务后,1 秒就搞定了。
db.beginTransaction();
try {
for (item in items) {
db.execSQL("INSERT INTO table VALUES(?,?)", arrayOf(item.a, item.b));
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
关键点:SQLite 每提交一次事务都要刷盘。批量事务把 1000 次刷盘变成 1 次,性能提升是质变的。
7.2.3 预编译语句
如果你反复执行同一条 SQL,预编译能省去 SQL 解析的时间。Room 默认就做了这件事,但如果你用原生 SQLite,记得手动用 compileStatement。
SQLiteStatement stmt = db.compileStatement(
"INSERT INTO table VALUES(?,?)"
);
db.beginTransaction();
for (item in items) {
stmt.bindLong(1, item.a);
stmt.bindString(2, item.b);
stmt.executeInsert();
stmt.clearBindings();
}
db.setTransactionSuccessful();
db.endTransaction();
7.3 文件读写异步化
文件读写是最容易被忽视的性能杀手。你想想看,一个 10MB 的文件,在主线程读出来解析,UI 直接卡死。我见过有人用 BitmapFactory.decodeFile 在主线程加载大图,结果 ANR 了。
7.3.1 协程 + Dispatchers.IO
Kotlin 协程提供了 Dispatchers.IO,专门处理 IO 密集型任务。配合 withContext,可以轻松把文件读写切到后台线程。
suspend fun readFileAsync(path: String): String = withContext(Dispatchers.IO) {
File(path).readText()
}
// 调用
lifecycleScope.launch {
val content = readFileAsync("/sdcard/data.txt")
textView.text = content
}
我的习惯:所有文件读写、网络请求、数据库操作,一律用 Dispatchers.IO。主线程只做 UI 更新和轻量计算。
7.3.2 缓冲区与内存映射
大文件读写时,缓冲区大小直接影响性能。默认的 8KB 缓冲区对于大文件来说太小了,建议根据文件大小动态调整。
// 使用 BufferedInputStream,缓冲区设为 64KB
val input = BufferedInputStream(FileInputStream(file), 64 * 1024)
input.use { stream ->
// 读取数据
}
对于超大文件(比如几百 MB),可以考虑用 MappedByteBuffer 做内存映射。它把文件映射到虚拟内存,读写就像操作内存一样快。
val channel = FileChannel.open(file.toPath(), StandardOpenOption.READ)
val buffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, channel.size())
// 直接操作 buffer
val data = ByteArray(buffer.remaining())
buffer.get(data)
注意:MappedByteBuffer 会占用虚拟内存,不适合频繁创建和销毁。建议用在长期存活的大文件读取场景,比如视频缓存文件。
7.4 知识体系总览
下面这张图总结了本章的核心知识点,你可以对照着检查自己的项目还有哪些优化空间。
7.5 避坑指南
最后分享几个我踩过的坑,希望能帮你少走弯路。
- 不要在主线程做任何IO操作——哪怕只是读一个几KB的文件。我曾经在启动时读配置文件,结果用户手机IO性能差,启动时间多了200ms。
- MMKV 的跨进程读写要加锁——虽然 MMKV 支持多进程,但并发写入还是会有问题。建议用
MMKV.mmkvWithID指定互斥锁模式。 - SQLite 的 WAL 模式——开启 WAL(Write-Ahead Logging)模式后,读写可以并发执行,性能提升明显。Room 默认就开了,但原生 SQLite 需要手动设置。
- 文件读写记得用 try-with-resources——忘了关闭流会导致文件句柄泄漏,严重时应用会崩溃。Kotlin 的
use函数就是干这个的。
我的经验:IO优化没有银弹。最好的办法是用 Profiler 工具先定位瓶颈,再针对性地优化。别一上来就上 MMKV、改数据库,先看看是不是真的慢在 IO 上。
公众号:蓝海资料掘金营,微信deep3321