文章目录
-
- 文件存储
- SharedPreferences
- [SQLite 数据库](#SQLite 数据库)
-
- SQLiteOpenHelper
- 获取数据库实例
- [insert 插入](#insert 插入)
- [delete 删除](#delete 删除)
- [update 更新](#update 更新)
- [query 查询](#query 查询)
- [SQLite 的数据类型](#SQLite 的数据类型)
- 数据库版本升级
- 事务
- [SQL 注入](#SQL 注入)
- LitePal
- 总结
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_READABLE和MODE_WORLD_WRITEABLE:已废弃,不再使用
FileOutputStream 和 BufferedWriter 都实现了 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.xml。MODE_PRIVATE 只有一个含义------只有当前应用能访问。以前的 MODE_WORLD_READABLE 和 MODE_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);
几个问题
-
首次加载是同步的 。第一次调用
getSharedPreferences()的时候,系统会同步读磁盘把整个 XML 文件加载进内存。文件大了就卡主线程。所以不要在一个 SP 文件里塞太多数据,按功能拆分成多个 SP 文件更好。 -
全量写入 。哪怕你只改了
age这一个值,Editor 也会把整个文件重新写一遍。这就是 SP 不适合存大量数据的原因。 -
不支持多进程。在多进程环境下,各进程各自维护一份内存缓存,互相同步不到。
-
类型不安全 。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 查询
查询返回一个 Cursor,Cursor 就是结果集的指针:
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 | 二进制数据,原样存储 |
定义表的时候写的 INT、BIGINT、VARCHAR 啥的,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的版本号一样,需要升级时加 1list:列出所有映射类。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 字段会被自动当做主键,自增 - 成员变量的命名会按驼峰转下划线规则映射成列名------
categoryName→category_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