第4章:文件存储优化:大文件读写策略与序列化方案
说到文件存储优化,我脑子里立刻浮现出几年前的一个深夜。那天线上反馈说用户上传的日志文件超过100MB时,App直接卡死。我盯着崩溃堆栈看了半天,最后发现是传统的FileInputStream在作祟。嗯,今天我们就来聊聊大文件读写那些事儿。
4.1 大文件读写:NIO vs MappedByteBuffer
传统的Java IO(BIO)处理大文件时,说白了就是「一次读一点,慢慢搬砖」。数据从磁盘到内核空间,再从内核空间拷贝到用户空间,中间还涉及多次上下文切换。文件一大,效率就直线下降。
我个人习惯把NIO和MappedByteBuffer看作两种不同的「搬砖策略」:
| 方案 | 核心原理 | 适用场景 | 内存占用 |
|---|---|---|---|
| 传统BIO | 逐字节/逐行读取 | 小文件(<1MB) | 低 |
| NIO Channel | 使用Buffer批量传输 | 中等文件(1MB~100MB) | 可控 |
| MappedByteBuffer | 内存映射文件 | 超大文件(>100MB) | 虚拟内存映射 |
4.1.1 NIO Channel 实战
NIO的核心是Channel + Buffer。数据不再逐字节搬运,而是整块整块地搬。你想想看,这就像用卡车运货,而不是用自行车一趟趟跑。
// 使用FileChannel读取大文件
try (FileChannel channel = new FileInputStream(filePath).getChannel()) {
ByteBuffer buffer = ByteBuffer.allocate(8192); // 8KB缓冲区
while (channel.read(buffer) != -1) {
buffer.flip(); // 切换为读模式
// 处理buffer中的数据
byte[] data = new byte[buffer.remaining()];
buffer.get(data);
// ... 业务处理
buffer.clear(); // 清空准备下一次读取
}
}
4.1.2 MappedByteBuffer 的正确姿势
MappedByteBuffer把文件直接映射到虚拟内存。说白了,就是让操作系统帮你管理「什么时候读、什么时候写」。读取时就像访问内存数组一样快。
// MappedByteBuffer示例
try (RandomAccessFile file = new RandomAccessFile(filePath, "rw");
FileChannel channel = file.getChannel()) {
MappedByteBuffer mappedBuffer = channel.map(
FileChannel.MapMode.READ_WRITE,
0,
file.length()
);
// 直接像操作数组一样读写
while (mappedBuffer.hasRemaining()) {
byte b = mappedBuffer.get();
// 处理数据
}
}
4.2 缓存策略:LRU Cache 实战
缓存策略说白了就是「用空间换时间」。但Android设备的内存是有限的,你不能无限制地缓存。LRU(Least Recently Used)是我最常用的策略——淘汰最久没被访问的数据。
Android官方提供了LruCache,但它的实现是基于LinkedHashMap的。我建议你直接使用它,而不是自己造轮子。
// 自定义LRU缓存示例
public class FileCache {
private final LruCache<String, byte[]> cache;
public FileCache(int maxSize) {
// maxSize单位:字节
this.cache = new LruCache<String, byte[]>(maxSize) {
@Override
protected int sizeOf(String key, byte[] value) {
return value.length; // 按实际大小计算
}
};
}
public void put(String key, byte[] data) {
if (data.length > cache.maxSize() / 2) {
// 单个文件超过缓存一半大小,不缓存
return;
}
cache.put(key, data);
}
public byte[] get(String key) {
return cache.get(key);
}
}
4.3 序列化方案对比:Serializable vs Parcelable vs Protobuf
序列化这个话题,我踩过的坑真不少。早期项目用Serializable,结果用户反馈「保存数据好慢」。后来换成Parcelable,速度快了但代码量上去了。再后来接触了Protobuf,才找到平衡点。
| 方案 | 性能 | 代码量 | 跨平台 | 适用场景 |
|---|---|---|---|---|
| Serializable | 慢(反射机制) | 少(只需实现接口) | 仅Java | 简单对象、低频操作 |
| Parcelable | 快(手动编码) | 多(需写模板代码) | 仅Android | Intent传值、跨进程通信 |
| Protobuf | 极快(二进制协议) | 中等(需定义.proto文件) | 全平台 | 网络传输、持久化存储 |
4.3.1 Serializable 的坑
Serializable用起来确实方便,但性能是硬伤。每次序列化都要通过反射获取类的元数据,对象图越复杂越慢。我测过,一个包含10个字段的对象,Serializable比Parcelable慢5倍以上。
4.3.2 Parcelable 的正确写法
Parcelable是Android推荐的序列化方式。虽然代码量多,但性能确实好。我建议你使用Android Studio的插件自动生成模板代码,别手写,容易出错。
// Parcelable示例
public class UserInfo implements Parcelable {
private String name;
private int age;
protected UserInfo(Parcel in) {
name = in.readString();
age = in.readInt();
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeString(name);
dest.writeInt(age);
}
public static final Creator<UserInfo> CREATOR = new Creator<UserInfo>() {
@Override
public UserInfo createFromParcel(Parcel in) {
return new UserInfo(in);
}
@Override
public UserInfo[] newArray(int size) {
return new UserInfo[size];
}
};
}
4.3.3 Protobuf 的进阶用法
Protobuf是我现在最常用的序列化方案。它的数据体积比JSON小3~10倍,解析速度也快得多。你想想看,同样的数据,Protobuf用二进制编码,JSON用文本编码,效率差距自然就出来了。
// Protobuf定义示例(.proto文件)
message UserInfo {
required string name = 1;
required int32 age = 2;
optional string email = 3;
repeated string tags = 4;
}
// 使用示例
UserInfoOuterClass.UserInfo user = UserInfoOuterClass.UserInfo.newBuilder()
.setName("张三")
.setAge(28)
.addTags("Android")
.addTags("Kotlin")
.build();
// 序列化
byte[] data = user.toByteArray();
// 反序列化
UserInfoOuterClass.UserInfo parsed = UserInfoOuterClass.UserInfo.parseFrom(data);
4.4 本章小结
文件存储优化没有银弹。大文件读写用MappedByteBuffer,中等文件用NIO Channel,小文件用传统IO就行。缓存策略用LRU,注意控制内存上限。序列化方案,我个人推荐Protobuf,但如果你只在Android内部传递数据,Parcelable也够用。
记住一点:优化不是炫技,而是找到最适合你场景的方案。下次遇到文件读写慢的问题,先想想数据有多大、频率有多高,再选方案。嗯,这样就不会走弯路了。