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存储系统的核心脉络:

Android存储系统架构 SharedPreferences SQLite / Room 文件存储 键值对存储 适合配置、小数据 不支持复杂查询 关系型数据库 支持SQL、事务、索引 Room提供ORM封装 文件系统 内部/外部存储 分区存储限制 选择依据 数据量 | 结构复杂度 | 查询需求 | 隐私合规

我的建议:选存储方案时,先问自己三个问题:

  1. 数据量多大?小于100条用SharedPreferences,大于100条考虑数据库
  2. 需不需要查询?需要就上Room,不需要就文件存储
  3. 要不要跨应用共享?要的话用ContentProvider或FileProvider

最后说一句,分区存储是大趋势。从Android 11开始,即使你targetSdkVersion低于30,系统也会强制启用分区存储。所以新项目直接按分区存储来设计,别想着绕过去。我见过太多老项目在适配时改得焦头烂额了。


公众号:蓝海资料掘金营,微信deep3321