21、Android存储系统:SharedPreferences、SQLite、Room、文件存储与分区存储
说到Android存储,我估计很多同学第一反应就是SharedPreferences。确实,它太常用了。但你真的了解它背后的坑吗?还有SQLite、Room,以及Android 10以后那个让人头疼的分区存储。今天咱们就把这些掰开揉碎了讲清楚。
21.1 SharedPreferences:轻量级存储的利与弊
SharedPreferences,说白了就是一个键值对存储。它把数据以XML文件的形式保存在/data/data/包名/shared_prefs/目录下。我刚开始做Android时,几乎所有的配置信息都用它存——用户设置、登录状态、甚至一些缓存数据。
核心特点:
- 适合存储少量、简单的配置数据
- 不支持跨进程安全访问
- 不支持复杂查询
- 加载时会将整个文件读入内存
嗯,这里要注意一个经典问题——ANR。SharedPreferences的apply()是异步写入磁盘,但commit()是同步的。如果你在主线程调用commit(),文件又很大,那界面就会卡住。我在项目中遇到过,一个同事把几百KB的JSON字符串存进SharedPreferences,结果每次启动都卡两三秒。
避坑指南:我曾经在一个日活千万级的App里排查过SharedPreferences导致的启动卡顿。最终方案是:
- 严格控制单文件大小,不超过50KB
- 永远使用
apply()而非commit() - 敏感数据不要明文存储,用EncryptedSharedPreferences
来看一个基本用法:
// 获取SharedPreferences实例
SharedPreferences sp = getSharedPreferences("config", Context.MODE_PRIVATE);
// 写入数据
sp.edit().putString("username", "张三")
.putInt("age", 28)
.putBoolean("isLogin", true)
.apply(); // 注意:用apply(),别用commit()
// 读取数据
String name = sp.getString("username", "默认值");
int age = sp.getInt("age", 0);
21.2 SQLite:关系型数据库的底层基石
当数据量变大、结构变复杂时,SharedPreferences就不够用了。这时候SQLite登场。它是Android内置的关系型数据库引擎,轻量、高效、零配置。
你想想看,SQLite本质上就是一个文件,存在/data/data/包名/databases/目录下。它支持标准的SQL语法,事务,索引,甚至触发器。我早期做的一个社交App,消息记录、联系人、聊天分组全是用SQLite存的。
个人经验:SQLite的性能瓶颈往往不在查询,而在写入。如果你需要批量插入数据,一定要用事务包裹起来。我曾经优化过一个批量同步功能,不加事务时插入1000条数据要8秒,加上事务后只要0.3秒。
// 创建数据库帮助类
public class MyDatabaseHelper extends SQLiteOpenHelper {
private static final String DB_NAME = "myapp.db";
private static final int DB_VERSION = 1;
public MyDatabaseHelper(Context context) {
super(context, DB_NAME, null, DB_VERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
String sql = "CREATE TABLE user (" +
"id INTEGER PRIMARY KEY AUTOINCREMENT," +
"name TEXT NOT NULL," +
"age INTEGER," +
"phone TEXT UNIQUE)";
db.execSQL(sql);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
// 生产环境要做数据迁移,不能直接drop
db.execSQL("DROP TABLE IF EXISTS user");
onCreate(db);
}
}
21.3 Room:Google官方推荐的ORM框架
直接写SQLite的代码,说实话挺痛苦的。你要写大量的模板代码,还要手动处理Cursor的关闭、类型转换。Room就是Google为了解决这个问题推出的ORM框架。它基于注解,在编译期生成代码,帮你省掉那些重复劳动。
我个人习惯用Room替代原生SQLite。原因很简单:
- 编译时校验SQL语法,减少运行时崩溃
- 自动处理线程切换,配合LiveData或Flow使用
- 支持数据库版本迁移,有专门的Migration机制
核心组件:
| 组件 | 作用 |
|---|---|
| @Entity | 定义数据表结构 |
| @Dao | 定义数据访问操作 |
| @Database | 定义数据库实例 |
// 1. 定义实体
@Entity(tableName = "user")
public class User {
@PrimaryKey(autoGenerate = true)
public int id;
@ColumnInfo(name = "user_name")
public String name;
public int age;
}
// 2. 定义DAO
@Dao
public interface UserDao {
@Query("SELECT * FROM user")
LiveData<List<User>> getAllUsers();
@Insert
void insert(User user);
@Delete
void delete(User user);
}
// 3. 定义数据库
@Database(entities = {User.class}, version = 1)
public abstract class AppDatabase extends RoomDatabase {
public abstract UserDao userDao();
}
// 4. 使用
AppDatabase db = Room.databaseBuilder(
getApplicationContext(),
AppDatabase.class,
"myapp.db"
).build();
UserDao dao = db.userDao();
dao.insert(new User("李四", 25));
21.4 文件存储与分区存储
文件存储是Android最基础的存储方式。你可以把任意文件写到内部存储或外部存储。内部存储是私有的,其他应用不能访问。外部存储分两种:公有目录和私有目录。
但Android 10(API 29)以后,情况变了。Google强制推行分区存储。说白了,就是你不能随便往外部存储的公有目录里写文件了。你只能访问自己应用专属目录下的文件,或者通过MediaStore访问媒体文件。
我曾经踩过的坑:项目升级targetSdkVersion到30时,发现之前保存的图片全读不出来了。原因是分区存储限制了直接通过文件路径访问。解决方案是改用MediaStore的URI来操作。
来看分区存储下的正确做法:
// 保存图片到公共目录
ContentValues values = new ContentValues();
values.put(MediaStore.Images.Media.DISPLAY_NAME, "photo.jpg");
values.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg");
values.put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES);
Uri uri = getContentResolver().insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);
try (OutputStream out = getContentResolver().openOutputStream(uri)) {
// 将Bitmap写入输出流
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out);
} catch (IOException e) {
e.printStackTrace();
}
如果你确实需要访问外部存储的非媒体文件,可以在AndroidManifest中声明MANAGE_EXTERNAL_STORAGE权限。但Google Play对上架应用审核很严,你得有充分的理由。
21.5 知识体系总览
下面这张图总结了Android存储系统的核心脉络:
我的建议:选存储方案时,先问自己三个问题:
- 数据量多大?小于100条用SharedPreferences,大于100条考虑数据库
- 需不需要查询?需要就上Room,不需要就文件存储
- 要不要跨应用共享?要的话用ContentProvider或FileProvider
最后说一句,分区存储是大趋势。从Android 11开始,即使你targetSdkVersion低于30,系统也会强制启用分区存储。所以新项目直接按分区存储来设计,别想着绕过去。我见过太多老项目在适配时改得焦头烂额了。
公众号:蓝海资料掘金营,微信deep3321