20、IO优化:文件读写优化、SharedPreferences替代方案(MMKV)、序列化选择(Parcelable vs Serializable)
说到IO优化,很多同学第一反应就是「不就是读写文件嘛,调个API谁不会?」。嗯,这话没错,但真到了线上环境,IO问题往往是卡顿和ANR的隐形杀手。我见过太多应用因为IO没处理好,在低端机上频繁掉帧,用户早就骂娘了。
今天咱们就掰开揉碎,把IO优化这块讲透。我会从三个维度展开:文件读写怎么搞才高效、SharedPreferences的替代方案MMKV到底强在哪、以及序列化到底该选Parcelable还是Serializable。
一、文件读写优化:别让磁盘拖垮你的UI
文件读写,说白了就是应用和存储设备之间的数据搬运。但搬运工也有讲究——你是用独轮车一趟趟推,还是用大卡车一次拉完?
1. 使用Buffer减少系统调用
我见过不少代码,读文件时一个字节一个字节地读。你想想看,每次读一个字节就要触发一次系统调用,这开销有多大?正确的做法是用BufferedInputStream或BufferedReader包装一下。
// ❌ 错误示范:逐字节读取
FileInputStream fis = new FileInputStream("data.bin");
int b;
while ((b = fis.read()) != -1) {
// 处理b
}
fis.close();
// ✅ 正确做法:使用BufferedInputStream
BufferedInputStream bis = new BufferedInputStream(
new FileInputStream("data.bin"), 8192); // 8KB缓冲区
byte[] buffer = new byte[4096];
int len;
while ((len = bis.read(buffer)) != -1) {
// 处理buffer
}
bis.close();
关键点:缓冲区大小建议设置为4KB或8KB,这是文件系统块大小的整数倍,能最大化IO效率。
2. 避免频繁open/close
每次打开文件都要经过路径解析、权限检查、文件描述符分配等步骤。我在项目中遇到过一个问题:某个日志模块每写一行就打开一次文件,写完立刻关闭。结果在日志量大的时候,光文件打开关闭就占了30%的CPU时间。
正确的做法是:打开一次,多次写入,最后关闭。如果担心数据丢失,可以定期flush。
// ✅ 推荐:复用FileOutputStream
FileOutputStream fos = new FileOutputStream("log.txt", true);
for (int i = 0; i < 10000; i++) {
fos.write((i + "\n").getBytes());
if (i % 100 == 0) {
fos.flush(); // 每100条flush一次
}
}
fos.close();
3. 异步IO + 线程池
文件IO是阻塞操作,千万别在主线程里干这事。我曾经接手过一个项目,启动时在主线程读取一个几百KB的配置文件,结果冷启动时间直接多了200ms。解决方案很简单:用线程池异步执行。
ExecutorService ioExecutor = Executors.newFixedThreadPool(4);
ioExecutor.execute(() -> {
// 在这里执行文件读写
readConfigFile();
});
小技巧:对于频繁的小文件读写,可以考虑使用内存缓存。比如把经常读取的配置项缓存在HashMap里,只有缓存失效时才去读文件。
二、SharedPreferences替代方案:MMKV凭什么更快?
SharedPreferences(以下简称SP)是Android官方提供的轻量级存储方案。但说实话,它在性能上有很多硬伤:
- 全量读写:每次commit或apply都会把整个文件写入磁盘,哪怕你只改了一个键值对。
- 主线程阻塞:commit是同步写,直接卡UI线程;apply虽然是异步,但也会在Activity生命周期回调中等待写入完成。
- 跨进程不安全:SP不支持跨进程读写,多进程场景下数据会错乱。
那MMKV呢?它是腾讯开源的一个基于mmap内存映射的键值存储组件。说白了,它把文件直接映射到内存地址,读写操作就是操作内存,速度比SP快一个数量级。
| 对比项 | SharedPreferences | MMKV |
|---|---|---|
| 底层原理 | XML文件 + 全量读写 | mmap内存映射 |
| 写入性能 | 慢(全量写XML) | 快(直接写内存) |
| 读取性能 | 中等(需解析XML) | 极快(内存访问) |
| 跨进程支持 | 不支持 | 支持(通过文件锁) |
| 多线程安全 | 部分支持 | 完全支持 |
我个人的习惯是:新项目直接上MMKV,老项目逐步迁移。迁移成本很低,因为MMKV的API和SP几乎一样。
// 初始化(建议在Application.onCreate中执行)
MMKV.initialize(this);
// 读写操作
MMKV kv = MMKV.defaultMMKV();
kv.encode("user_name", "张三");
String name = kv.decodeString("user_name", "");
// 跨进程模式
MMKV mmkv = MMKV.mmkvWithID("shared_data", MMKV.MULTI_PROCESS_MODE);
注意:MMKV虽然快,但也不是万能的。它适合存储小数据(几KB以内),如果你要存大文件或二进制数据,还是用文件或数据库更合适。
三、序列化选择:Parcelable vs Serializable
序列化,说白了就是把对象变成二进制流,方便传输或存储。Android里主要有两种方式:Parcelable和Serializable。很多新手图省事直接用Serializable,结果性能惨不忍睹。
1. Serializable:Java的亲儿子,但效率低
Serializable是Java原生的序列化机制,用起来确实方便——只要实现一个接口就行了。但它的性能很差:
- 使用反射机制,序列化时频繁创建临时对象,触发GC。
- 序列化后的数据体积大,包含大量元数据。
- 速度慢,比Parcelable慢10~50倍。
2. Parcelable:Android的亲儿子,专为性能而生
Parcelable是Android特有的序列化方式,它要求你手动实现序列化和反序列化逻辑。虽然代码量多了点,但性能优势非常明显:
- 不使用反射,直接操作内存。
- 序列化后的数据体积小,适合IPC传输。
- 速度快,在Intent传递、Binder通信中表现优异。
// Parcelable实现示例
public class User implements Parcelable {
private String name;
private int age;
protected User(Parcel in) {
name = in.readString();
age = in.readInt();
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(name);
dest.writeInt(age);
}
@Override
public int describeContents() {
return 0;
}
public static final Creator<User> CREATOR = new Creator<User>() {
@Override
public User createFromParcel(Parcel in) {
return new User(in);
}
@Override
public User[] newArray(int size) {
return new User[size];
}
};
}
我的建议:
- 内存间传递(Intent、Binder):必须用Parcelable,这是Android的推荐方式。
- 磁盘持久化(存文件、数据库):可以用Serializable,或者直接用JSON(Gson/Moshi)更灵活。
- 网络传输:别用序列化,用JSON或Protobuf。
我曾经在优化一个列表页面的跳转时,发现Intent传递一个包含100个对象的列表,用Serializable花了80ms,换成Parcelable后直接降到5ms。你想想看,这差距有多大?
避坑指南:如果你用Kotlin,可以考虑用@Parcelize注解自动生成Parcelable代码,省去手动编写的麻烦。
// Kotlin + @Parcelize
@Parcelize
data class User(val name: String, val age: Int) : Parcelable
好了,IO优化这块咱们就聊到这。记住三个核心原则:减少IO次数、降低IO耗时、选对工具。文件读写用Buffer和异步,键值存储用MMKV,序列化用Parcelable。把这些做到位,你的应用在IO方面基本不会出大问题。