Android 数据持久化解析

文章目录

Android 中实现数据持久化的手段主要有四种:文件存储、SharedPreferences、SQLite 数据库、以及 ORM 框架(如 LitePal)。下面逐一说明。

文件存储

文件存储是 Android 最基础的持久化方式,没有之一。内部存储和外部存储是两套东西,先说内部存储。

内部存储

内部存储指的是 /data/data/<包名>/ 下面这块区域,应用私有,不需要任何权限,卸载时会被系统自动清掉。往里写文件主要用 Context 提供的两个方法:openFileOutput()openFileInput(),文件默认放在 /data/data/<包名>/files/

java 复制代码
private void save(String inputText) {
    try (FileOutputStream out = openFileOutput("data.txt", MODE_PRIVATE);
         BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out))) {
        writer.write(inputText);
    } catch (IOException e) {
        e.printStackTrace();
    }
}

openFileOutput() 的第二个参数是文件模式:

  • MODE_PRIVATE:默认,覆盖写入,只有本应用能读写
  • MODE_APPEND:追加写入,如果文件已存在,内容往后追加
  • MODE_WORLD_READABLEMODE_WORLD_WRITEABLE:已废弃,不再使用

FileOutputStreamBufferedWriter 都实现了 Closeable,所以可以直接用 try-with-resources,不用手动关流。

读取反过来,用 openFileInput()

java 复制代码
private String load() {
    try (FileInputStream in = openFileInput("data.txt");
         BufferedReader reader = new BufferedReader(new InputStreamReader(in))) {
        StringBuilder sb = new StringBuilder();
        String line;
        while ((line = reader.readLine()) != null) {
            sb.append(line);
        }
        return sb.toString();
    } catch (IOException e) {
        e.printStackTrace();
        return "";
    }
}

配合 Activity 生命周期,在 onDestroy() 时保存,onCreate() 时恢复:

java 复制代码
@Override
protected void onDestroy() {
    super.onDestroy();
    save(mEditText.getText().toString());
}

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    mEditText = findViewById(R.id.editText);
    String inputText = load();
    if (!TextUtils.isEmpty(inputText)) {
        mEditText.setText(inputText);
        mEditText.setSelection(inputText.length());
    }
}

除了 openFileOutput()openFileInput(),也可以通过 getFilesDir() 获取 File 对象,然后用标准 Java IO 操作。如果想写到内部存储的缓存目录,用 getCacheDir(),系统在空间紧张时可能会清掉这里的内容。

java 复制代码
// 写到 files 目录
File file = new File(getFilesDir(), "data.txt");
FileOutputStream fos = new FileOutputStream(file);

// 写到 cache 目录
File cacheFile = new File(getCacheDir(), "cache.txt");

外部存储

外部存储通常指 SD 卡或者设备的公共存储区域。和内部存储不一样,Android 10 以后引入了分区存储(Scoped Storage),对外部存储的访问限制变多了。

如果是要存应用私有的文件,可以直接用 getExternalFilesDir(),这些文件会随着应用卸载被清理,且不需要存储权限:

java 复制代码
File externalFile = new File(getExternalFilesDir(null), "data.txt");

如果是存公共文件(比如照片、下载内容),得用 MediaStore API,或者通过 SAF(Storage Access Framework)让用户自己选目录。

不过大部分场景下,内部存储已经完全够用了。外部存储更多是为了那些需要被其他应用访问、或者长期保留的文件。

文件存储适合干什么

存文本内容、二进制文件、序列化对象都行。但文件存储没有数据结构,没有查询能力。如果要对数据做增删改查、按条件筛选、排序,文件存储就不合适了,数据库才是正解。


SharedPreferences

SharedPreferences 是 Android 提供的轻量级键值对存储。底层其实也是一个 XML 文件,存在 /data/data/<包名>/shared_prefs/ 下面。打开看一下长这样:

xml 复制代码
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <string name="name">Tom</string>
    <int name="age" value="28" />
    <boolean name="married" value="false" />
</map>

每一次写操作都是整个 XML 文件的一次完整序列化,所以不适合存大量数据。少量配置没问题。

写入

java 复制代码
SharedPreferences.Editor editor = getSharedPreferences("data", MODE_PRIVATE).edit();
editor.putString("name", "Tom");
editor.putInt("age", 28);
editor.putBoolean("married", false);
editor.apply();

getSharedPreferences(name, mode) 的第一个参数决定了生成的 XML 文件名,这里传 "data" 就会生成 data.xmlMODE_PRIVATE 只有一个含义------只有当前应用能访问。以前的 MODE_WORLD_READABLEMODE_MULTI_PROCESS 都废弃了。

edit() 返回一个 Editor 对象,所有修改都在这个 Editor 上操作。

apply()commit() 的区别:

  • apply() 先把修改写到内存里,然后异步刷盘。不阻塞调用线程,没有返回值
  • commit() 同步写入磁盘,返回 boolean 表示是否成功。在主线程里调用会阻塞 UI

大部分场景用 apply() 就行。只有一种情况需要用 commit():你的后续逻辑必须确认写入成功了才能继续,并且你不在主线程。

实际开发中,通常会封装一个工具类来避免到处写文件名和 key:

java 复制代码
public class SPUtils {
    private static final String SP_NAME = "app_config";
    private static SharedPreferences sp;

    public static void init(Context context) {
        sp = context.getSharedPreferences(SP_NAME, Context.MODE_PRIVATE);
    }

    public static void putString(String key, String value) {
        sp.edit().putString(key, value).apply();
    }

    public static String getString(String key, String defaultValue) {
        return sp.getString(key, defaultValue);
    }

    public static void putInt(String key, int value) {
        sp.edit().putInt(key, value).apply();
    }

    public static int getInt(String key, int defaultValue) {
        return sp.getInt(key, defaultValue);
    }
}

初始化一次之后全局就可以直接用 SPUtils.putString("token", token),不用每次都写 getSharedPreferences()

读取

java 复制代码
SharedPreferences pref = getSharedPreferences("data", MODE_PRIVATE);
String name = pref.getString("name", "");
int age = pref.getInt("age", 0);
boolean married = pref.getBoolean("married", false);

第二个参数是默认值,key 不存在的时候返回这个值。

支持的数据类型

SharedPreferences 只支持这几种类型:

  • String
  • int
  • boolean
  • float
  • long
  • Set<String>

没有 byte、double、Date,更放不了对象。想存对象的话,先序列化成 JSON 字符串,再 putString()。比如用 Gson:

java 复制代码
User user = new User("Tom", 28);
String json = new Gson().toJson(user);
sp.edit().putString("user", json).apply();

// 取的时候反序列化回来
String json = sp.getString("user", "");
User user = new Gson().fromJson(json, User.class);

几个问题

  1. 首次加载是同步的 。第一次调用 getSharedPreferences() 的时候,系统会同步读磁盘把整个 XML 文件加载进内存。文件大了就卡主线程。所以不要在一个 SP 文件里塞太多数据,按功能拆分成多个 SP 文件更好。

  2. 全量写入 。哪怕你只改了 age 这一个值,Editor 也会把整个文件重新写一遍。这就是 SP 不适合存大量数据的原因。

  3. 不支持多进程。在多进程环境下,各进程各自维护一份内存缓存,互相同步不到。

  4. 类型不安全 。key 是字符串,value 类型靠你调的方法决定,编译期检查不到类型错误。如果存的时候 putInt("age"),取的时候 getString("age"),编译能过但运行时会拿不到值。

什么场景用

登录状态、用户设置、功能开关、Token......这类数据量小、结构简单的东西。一条原则:一个 SP 文件控制在几十个 key 以内,不要把它当数据库用。


SQLite 数据库

数据量一上来,文件存储和 SharedPreferences 就不够用了。这个时候 SQLite 上场。Android 内置了 SQLite,不需要额外安装,数据库文件存在 /data/data/<包名>/databases/ 下。

SQLite 是关系型数据库,支持标准 SQL 语法、多表关联、事务、索引。所有东西自己控制,灵活但也意味着代码量不小。

SQLiteOpenHelper

Android 用 SQLiteOpenHelper 管理数据库的创建和版本升级。使用时继承它,重写 onCreate()onUpgrade() 两个方法:

java 复制代码
public class MyDatabaseHelper extends SQLiteOpenHelper {

    public static final String CREATE_BOOK = "create table Book ("
            + "id integer primary key autoincrement, "
            + "author text, "
            + "price real, "
            + "pages integer, "
            + "name text)";

    public static final String CREATE_CATEGORY = "create table Category ("
            + "id integer primary key autoincrement, "
            + "category_name text, "
            + "category_code integer)";

    private Context mContext;

    public MyDatabaseHelper(Context context, String name,
                            SQLiteDatabase.CursorFactory factory, int version) {
        super(context, name, factory, version);
        mContext = context;
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
        db.execSQL(CREATE_BOOK);
        db.execSQL(CREATE_CATEGORY);
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
    }
}

构造方法的四个参数:Context、数据库文件名(如 "BookStore.db")、CursorFactory(一般传 null,使用默认的)、版本号(从 1 开始)。

onCreate() 在数据库文件第一次创建时调用,这里执行建表语句。多次打开不会重复调用。

onUpgrade() 在数据库版本号变大时调用。如果你传了 version = 1,数据库已经存在且版本号也是 1,就不触发。改成 version = 2,才会触发 onUpgrade()

获取数据库实例

java 复制代码
MyDatabaseHelper dbHelper = new MyDatabaseHelper(this, "BookStore.db", null, 1);
SQLiteDatabase db = dbHelper.getWritableDatabase();

getWritableDatabase()getReadableDatabase() 都返回 SQLiteDatabase 对象,区别在于:

  • getWritableDatabase():获取可读写数据库。磁盘满了会抛异常
  • getReadableDatabase():优先获取可读写,磁盘满了返回只读。读操作不会出错

大部分情况直接用 getWritableDatabase()。数据库操作完毕后记得 db.close()

注意:getWritableDatabase()getReadableDatabase() 第一次调用时才会真正创建数据库文件或打开已存在的文件。在此之前,构造 MyDatabaseHelper 对象只是准备参数,不涉及任何 I/O。

另外如果有多个线程同时操作数据库,每次调 getWritableDatabase() 拿到的都是同一个 SQLiteDatabase 实例,不用担心并发写的问题,Android 已经自己处理了。

insert 插入

java 复制代码
SQLiteDatabase db = dbHelper.getWritableDatabase();
ContentValues values = new ContentValues();
values.put("name", "The Da Vinci Code");
values.put("author", "Dan Brown");
values.put("pages", 454);
values.put("price", 16.96);
long rowId = db.insert("Book", null, values);

ContentValues 是一个键值对容器,key 是列名,value 是值。只能存 int、long、float、double、String、byte\[\]、boolean 这几种基础类型。

insert() 三参数:表名、nullColumnHack、ContentValues。返回新行的 rowId,失败返回 -1。

nullColumnHack 这个参数有点绕。SQL 不允许插入一行全是 null 的数据,如果 ContentValues 是空的,insert() 会执行 INSERT INTO Book() VALUES(),这在 SQL 里是非法的。如果传了 nullColumnHack(比如 "name"),就会变成 INSERT INTO Book(name) VALUES(NULL),能过。大部分时候你传的数据不会空,直接传 null 就行。

一种常见的写法是一次插入多条数据:

java 复制代码
SQLiteDatabase db = dbHelper.getWritableDatabase();
ContentValues values = new ContentValues();

values.put("name", "The Da Vinci Code");
values.put("author", "Dan Brown");
values.put("pages", 454);
values.put("price", 16.96);
db.insert("Book", null, values);

values.clear();  // 清空 ContentValues 复用

values.put("name", "The Lost Symbol");
values.put("author", "Dan Brown");
values.put("pages", 510);
values.put("price", 19.95);
db.insert("Book", null, values);

db.close();

values.clear() 清空后可以复用同一个 ContentValues 对象,避免重复 new。

delete 删除

java 复制代码
SQLiteDatabase db = dbHelper.getWritableDatabase();
int deletedRows = db.delete("Book", "pages > ?", new String[]{"500"});
db.close();

三个参数:表名、WHERE 条件、占位符参数。返回被删除的行数。

WHERE 条件中的 ? 是占位符,第三个参数里的值按顺序替换。如果需要多个条件:

java 复制代码
db.delete("Book", "pages > ? and price < ?", new String[]{"500", "20"});

update 更新

java 复制代码
SQLiteDatabase db = dbHelper.getWritableDatabase();
ContentValues values = new ContentValues();
values.put("price", 10.99);
int updatedRows = db.update("Book", values, "name = ?", new String[]{"The Da Vinci Code"});
db.close();

四个参数:表名、ContentValues、WHERE 条件、占位符参数。返回被更新的行数。

如果第三个参数传 null,会更新全表所有行。

query 查询

查询返回一个 CursorCursor 就是结果集的指针:

java 复制代码
SQLiteDatabase db = dbHelper.getWritableDatabase();
Cursor cursor = db.query("Book", null, null, null, null, null, null);
if (cursor.moveToFirst()) {
    do {
        String name = cursor.getString(cursor.getColumnIndex("name"));
        String author = cursor.getString(cursor.getColumnIndex("author"));
        int pages = cursor.getInt(cursor.getColumnIndex("pages"));
        double price = cursor.getDouble(cursor.getColumnIndex("price"));
    } while (cursor.moveToNext());
}
cursor.close();
db.close();

query() 的 7 个参数:

参数 含义 传 null 的行为
table 表名 -
columns 要查的列,String\[\] 查所有列
selection WHERE 条件 查所有行
selectionArgs 占位符参数 -
groupBy GROUP BY 不分组
having HAVING 不过滤
orderBy ORDER BY 不排序

比如查价格低于 20 的书,按价格排序:

java 复制代码
Cursor cursor = db.query("Book", null, "price < ?", new String[]{"20"}, null, null, "price DESC");

Cursor 的几个注意点:

  • moveToFirst() 返回 false 说明表是空的,要判一下
  • getColumnIndex() 拿列索引,直接用索引比每次拿列名快,但代码可读性更差
  • 用完后 必须 cursor.close()Cursor 持有数据库连接资源,不关会内存泄漏和文件句柄泄漏

除了 query(),也可以用 rawQuery() 写原生 SQL:

java 复制代码
Cursor cursor = db.rawQuery("SELECT * FROM Book WHERE price < ?", new String[]{"20"});

复杂查询用 rawQuery() 更直观,简单查询用 query() 就行。

SQLite 的数据类型

SQLite 的类型系统和其他数据库不太一样,它不是严格类型的。大部分数据库是你先声明列的类型,然后只能插这个类型的数据。SQLite 的类型是建议性 的,你声明了 INTEGER 的列,插个字符串它也不报错。

SQLite 支持 5 种存储类型:

类型 说明
NULL 空值
INTEGER 有符号整数,1/2/3/4/6/8 字节
REAL 浮点数,8 字节 IEEE
TEXT 字符串
BLOB 二进制数据,原样存储

定义表的时候写的 INTBIGINTVARCHAR 啥的,SQLite 会根据规则映射到这 5 种之一。比如 VARCHAR → TEXT,BOOLEAN → INTEGER(0 或 1)。

虽然 SQLite 不强制类型,但写代码还是按常规习惯来。建表时声明好类型,插入时别乱插,不然查出来类型不一致后面一堆 bug。

数据库版本升级

开发阶段图省事,很多人直接在 onUpgrade() 里掉表重建:

java 复制代码
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
    db.execSQL("drop table if exists Book");
    db.execSQL("drop table if exists Category");
    onCreate(db);
}

本地开发没问题,反正数据丢了也无所谓。但如果应用已经上线,用户有数据了,你这么删表重建就等于把用户数据全删了。

线上的正确做法是写迁移脚本,每次升级只改需要改的地方。旧数据不动:

java 复制代码
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
    if (oldVersion < 2) {
        // v1 → v2:新增 Category 表
        db.execSQL(CREATE_CATEGORY);
    }
    if (oldVersion < 3) {
        // v2 → v3:Book 表加 publisher 列
        db.execSQL("ALTER TABLE Book ADD COLUMN publisher TEXT");
    }
    if (oldVersion < 4) {
        // v3 → v4:给 name 列加索引
        db.execSQL("CREATE INDEX idx_book_name ON Book(name)");
    }
}

if (oldVersion < N) 而不用 switch-case,因为用户可能跨越多个版本升级。比如从 v1 直接升到 v3,oldVersion 是 1,switch(v2) 不命中,V2 的改动就漏掉了。if (oldVersion < 2)if (oldVersion < 3) 两个条件都成立,不会漏。

事务

往 SQLite 里插数据默认是自动提交模式,每执行一条语句就是一个事务------BEGIN、执行、COMMIT------一条一条来。

如果你要插 1000 条数据,默认行为就是 1000 次磁盘写入。每条 10ms 加起来就是 10 秒。开了事务之后,1000 条操作合并成一次提交,性能差几个数量级:

java 复制代码
SQLiteDatabase db = dbHelper.getWritableDatabase();
db.beginTransaction();
try {
    for (int i = 0; i < 1000; i++) {
        ContentValues values = new ContentValues();
        values.put("name", "Book " + i);
        values.put("pages", 300 + i);
        db.insert("Book", null, values);
    }
    db.setTransactionSuccessful();
} finally {
    db.endTransaction();
}
db.close();

流程:beginTransaction() → 执行操作 → setTransactionSuccessful() 标记成功 → endTransaction() 提交。如果 setTransactionSuccessful() 没调,endTransaction() 会回滚。

finally 里调 endTransaction() 是必须的,不然事务不结束,数据库可能锁死。

SQL 注入

凡是用户输入的东西,当成数据而不是 SQL 的一部分,用参数化查询:

java 复制代码
// 绝对不要这样
String name = editText.getText().toString();
db.execSQL("SELECT * FROM Book WHERE name = '" + name + "'");

// 应该这样
db.rawQuery("SELECT * FROM Book WHERE name = ?", new String[]{name});

SQLite 的 query()update()delete() 都支持 selectionArgs,就是为了让你用占位符传参。如果用户输入 ' OR '1'='1,拼字符串的写法就会得出 WHERE name = '' OR '1'='1',永远为真,全表数据就全出去了。


LitePal

SQLite 功能全但写起来是啰嗦。每次插入都要 new 一个 ContentValues,挨个 put(),查完要手动关 Cursor。代码里大量的 db.open()db.close()try-catch,真正的业务逻辑反而被淹掉了。

LitePal 是郭霖写的 ORM 库,把数据库表映射成 Java 类,增删改查直接面向对象操作。不用碰 ContentValues,不用管 Cursor

配置

加依赖:

groovy 复制代码
dependencies {
    implementation 'com.github.guolindev:LitePal:master-SNAPSHOT'
}

main/assets/ 下创建 litepal.xml

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<litepal>
    <dbname value="BookStore" />
    <version value="1" />
    <list>
        <mapping class="com.example.litepaltest.Book"/>
        <mapping class="com.example.litepaltest.Category"/>
    </list>
</litepal>
  • dbname:数据库文件名,不用带 .db 后缀,LitePal 会自动加
  • version:版本号,跟 SQLiteOpenHelper 的版本号一样,需要升级时加 1
  • list:列出所有映射类。LitePal 会自动扫描这些类的字段,建对应的表和列

还需要在 AndroidManifest.xml<application> 标签里加上:

xml 复制代码
android:name="org.litepal.LitePalApplication"

如果已经有自定义的 Application 类,让自定义类继承 LitePalApplication 就行。

定义实体类

实体类继承 LitePalSupport,字段自动映射:

java 复制代码
public class Book extends LitePalSupport {
    private int id;
    private String author;
    private double price;
    private int pages;
    private String name;
    private String press;

    public int getId() { return id; }
    public void setId(int id) { this.id = id; }
    public String getAuthor() { return author; }
    public void setAuthor(String author) { this.author = author; }
    public double getPrice() { return price; }
    public void setPrice(double price) { this.price = price; }
    public int getPages() { return pages; }
    public void setPages(int pages) { this.pages = pages; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getPress() { return press; }
    public void setPress(String press) { this.press = press; }
}
  • 名为 id_id 的 int/long 字段会被自动当做主键,自增
  • 成员变量的命名会按驼峰转下划线规则映射成列名------categoryNamecategory_name
  • 如果某个字段不想存到数据库,用 private 修饰就行。只有带 getter 和 setter 的 private 字段才会被识别为数据库列

有关联关系的场景,比如一个分类(Category)下有很多本书(Book):

java 复制代码
public class Category extends LitePalSupport {
    private int id;
    private String categoryName;
    private int categoryCode;
    // getter setter 省略
}

两个类都在 litepal.xml 里注册后,LitePal 会自动建两张表。如果需要关联关系,可以在 Book 里声明外键,LitePal 也支持一对多、多对多的关联查询,用 getXXXList() 之类的便捷方法。

建库

只要调一次,数据库和表自动创建:

java 复制代码
createDatabaseBtn.setOnClickListener(v -> {
    LitePal.getDatabase();
});

LitePal.getDatabase() 第一次调用时创建数据库、扫描注册的类、生成表。表已经存在就跳过。

CRUD

插入,直接 new 对象,设值,调 save()

java 复制代码
Book book = new Book();
book.setName("The Da Vinci Code");
book.setAuthor("Dan Brown");
book.setPages(454);
book.setPrice(16.96);
book.setPress("Doubleday");
book.save();  // 插入数据库

save() 返回 boolean,成功 true。如果 id 已经有值且行存在,save() 会变成更新而不是插入。

查询:

java 复制代码
// 查所有
List<Book> allBooks = LitePal.findAll(Book.class);

// 条件查询
List<Book> books = LitePal.where("pages > ?", "400")
        .order("price desc")
        .find(Book.class);

// 查单条
Book book = LitePal.findFirst(Book.class);

// 查最后一条
Book lastBook = LitePal.findLast(Book.class);

// 按 ID 查
Book book = LitePal.find(Book.class, 1);

// 统计
int count = LitePal.count(Book.class);

// 聚合查询
double avgPrice = LitePal.average(Book.class, "price");
int maxPages = LitePal.max(Book.class, "pages", Integer.class);

链式调用,跟写英文句子差不多。

更新:

java 复制代码
// 方式一:先查到再更新
Book book = LitePal.findFirst(Book.class);
book.setPrice(9.99);
book.save();

// 方式二:直接用 updateAll
Book book = new Book();
book.setPrice(9.99);
book.updateAll("name = ?", "The Da Vinci Code");

updateAll() 第一个参数是 WHERE 条件,第二个是占位符值。条件传 null 会更新全表。

删除:

java 复制代码
// 按条件删
LitePal.deleteAll(Book.class, "pages < ?", "200");

// 删全表
LitePal.deleteAll(Book.class);

版本升级

litepal.xml 里把 version 加 1 就行。LitePal 会自动检测版本变化,执行升级逻辑(默认是删除所有表再重建)。如果需要自定义升级逻辑(保留旧数据),可以用 LitePalDB 类手动构建数据库配置。

不过这块是 LitePal 的一个弱项。如果项目对数据库升级有严格要求,得特别注意。

和原生 SQLite 的对比

操作 原生 SQLite LitePal
建表 手写 CREATE TABLE SQL 继承 LitePalSupport + litepal.xml 注册
插入 ContentValues + db.insert() book.save()
更新 ContentValues + db.update() book.updateAll() 或查出来改再 save()
删除 db.delete() LitePal.deleteAll()
查询 Cursor 逐行遍历 LitePal.findAll() / 链式查询
事务 beginTransaction / endTransaction 同样支持,且有封装的方法
版本升级 onUpgrade() 手动写迁移 litepal.xml 改 version,默认重建表

现状

LitePal 已经很久没更新了,最后一个正式版停留在了 3.x。新项目可能不太会选它,但它的思路很简单------把数据库表映射成 Java 对象,增删改查自动生成 SQL------理解了这套 ORM 是怎么运作的,再上手其他 ORM 库会快很多。


总结

四种方案各管一部分:

  • 文件存储:存储完整的文件数据,没有结构,没有查询。适合文本缓存、图片、二进制文件
  • SharedPreferences:键值对,适合少量配置项和用户偏好。数据量大了会卡,复杂对象要手动序列化
  • SQLite:结构化数据的完整方案,功能最强但也最啰嗦。适合业务数据、需要查询和多表关联的场景
  • LitePal:ORM 层,用面向对象的方式操作数据库,不用手写 SQL
相关推荐
NWU_LK21 小时前
【WebFlux】第三篇 —— 常用操作符与数据流转换
java
Mr__Miss1 天前
Java泛型完全指南:从入门到精通
java·开发语言·python
jinyishu_1 天前
模拟实现 C++ 栈和队列——从适配器模式看懂 STL 容器之美
java·c++·适配器模式
前端工作日常1 天前
我学习到的Java类和对象区别
java·后端
前端工作日常1 天前
我学习到的Java类完整结构
java·后端
什巳1 天前
JAVA练习309- 二叉树的层序遍历
java·数据结构·算法·leetcode
宠友信息1 天前
消息序号如何保证即时通讯源码聊天记录稳定加载
java·spring boot·redis·python·mysql·uni-app
圣光SG1 天前
Java Web入门基础知识笔记
java·前端·笔记
行思理1 天前
微信支付“商家转账用户确认模式”,新手教程
java·开发语言·微信