Android数据库更新设计

Android数据库更新方案设计

数据库更新场景

简单的场景

表增减、表字段增减、跨单版本升级。

复杂的场景

表名更改、字段名更改、跨多版本升级。

常见解决方案概述

升级方案的需求
  • 易用,易维护,尽量自动化。这个需求要求设计的数据库升级方案在第一次编码完成后,后续每次升级应该无需改动升级的逻辑,做到升级的透明化与自动化。
  • 适用范围广,尽量覆盖多的数据库更新场景。这个需求要求设计的数据库升级方案能应对各种数据库变化导致的升级。

可以发现这两个需求是很难同时满足的,毕竟没有银弹,因此第三部分的方案讨论也都是在这两者中做取舍与平衡。

常见方案
  • 方案X:不做数据迁移,直接重建所有表

    该方案实现最简单,但会导致每次升级所有数据都丢失。

  • 方案Y:编写Sql,执行本地Sql脚本进行数据迁移

    该方案需要在每次版本迭代后手写Sql,以完成表的更新和数据迁移。

    该方案灵活性高,但是维护成本较高。

  • 方案Z:通过ORM中的DAO配合特定的更新逻辑实现自动更新

    该方案相比方案2,并不需要开发者关心具体的更新逻辑,自动更新工具会自动根据数据库版本差异实现自动更新。该方案在跨版本升级的时候因为旧表信息的缺失,会导致一些实现上的困难。

    该方案灵活性相对较低,但是可以做到透明更新,无维护成本。

第三方库现状

通过查看几个第三方库数据库升级部分的源码,总结如下:

ActiveAndroid:执行本地文件中的sql脚本(方案Y)

Litepal:字段比对、数据迁移、赋默认值(方案Z)

GreenDao:由使用者自定义,本身不提供解决方案

DBFlow:执行本地文件中的sql脚本(方案Y)

Room:由使用者自定义,但是提供了一定程度的封装,与跨版本升级的管理逻辑

通过第三方库的实现方案可以知道方案X是不被推荐的一种实现方式,而方案Y、Z则较为通用。

各方案逻辑设计与实现细节

这部分会从两个主要需求出发,基于第二部分介绍的方案进行展开与拓展,介绍各个方案的设计逻辑与部分实现细节。

基于Sql脚本执行的方案(方案Y)
  • 核心思路:每次单版本升级都会有一个由一系列的Sql语句构成的Sql脚本文件,该文件与SqlStatement一一对应。一次跨版本升级由多次单版本升级构成。

  • 具体执行逻辑(以从版本1升级到版本3为例):首先执行1-2的版本升级脚本,执行完以后再执行2-3的升级脚本。

  • 优点:这样的实现不用在onUpgrade()中编码判断各个版本的情况,直接做了统一处理,使得开发者在编写升级脚本的时候,只要考虑前一个版本的数据库状况即可,易维护。同时由于每次单版本升级均有写脚本,所以基本可以应对所有情况的升级,完美满足了需求2。

  • 缺点:这个方案要求每次升级都要自己写升级脚本,虽然做到很大的灵活性(需求2),但是开发者需要自己去控制具体的升级迁移操作,在易用性上做了妥协(需求1)。

可能的实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
// 伪代码
List<SqlStatement> sqls;

@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
if (oldVersion < newVersion) {
for (SqlStatement sql : sqls) {
// 执行一系列的sql脚本
executeSqlScript(db, sql);
}
}
}
通过ORM中的DAO配合特定的更新逻辑实现自动更新(方案Z)
  • 核心思路:利用DAO获取当前版本的表信息,利用sql_master表与PRAGMA TABLE_INFO获取旧表信息,在升级逻辑中对表差异做分析,依据分析结果辅助完成自动化的数据迁移与数据库升级。

  • 具体执行逻辑(以从版本1升级到版本3为例):不论从版本几到几,该升级方案均只做一次升级,即用户当前手机上的版本与即将安装的版本。具体逻辑参考下面的代码。

  • 优点:这样的升级对使用者来说是完全透明的,他不需要关心升级过程具体需要做哪些事情(满足需求2)。

  • 缺点:这个方案的缺点也非常明显,因为丢失了中间版本的信息,因此难以应对复杂的升级情况,对于无法处理的情况,该方案的实现只能选择将其忽略,因此可能会导致部分数据丢失。

升级逻辑伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
 // onUpgrade中的逻辑
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
// 先取出所有旧表的表名
List<String> originTableList = originTableList(db);
// 如果存在则执行升级,不存在则执行创建
for (Map.Entry<Class, Dao> entry : map.entrySet()) {
Dao value = entry.getValue();
boolean remove = originTableList.remove(value.getTableName());
if (remove) {
value.onDataBaseUpgrade(cdb, oldVersion, newVersion);
} else {
value.onDataBaseCreate(cdb);
}
}
// 剩下的表删除
for (String leftTableName : originTableList) {
cdb.execSQL("DROP TABLE " + leftTableName);
}
}

// DAO中执行表升级的具体逻辑
public void onDataBaseUpgrade() {
// 获取旧表所有字段集合
List<DbProperty> oldDbProperties = getOldProperties();
List<DbProperty> newDbProperties = getNewProperties();
createNewTable(newDbProperties);
// 求新旧字段的交集
List<DbProperty> upgradeProperties = intersect(oldDbProperties, newDbProperties);
// 迁移数据并为新增的字段添加默认值
migrateData(newDbProperties, upgradeProperties);
// 删除旧表
db.execSQL(SqlUtils.getDropTableSql(getOldTableNama()));
}

其中originTableList()与getOldProperties()用到了sql_master表与PRAGMA TABLE_INFO,具体实现这里不再给出。

综合Y、Z方案的解决方案(支持绝大多数情况的半自动化升级)

在具体的实践中,我们发现在业务迭代中,遇到的需要数据库更新的场景,基本上都是简单场景,而复杂场景仅在极少数情况下才出现。因此,如果有一个方案能在多数情况下自动更新,只有在复杂场景才需要手动编码,那也可以满足绝大多数的需求了。

因为并不是每次升级都是复杂情况的升级,多数情况下,都是简单的升级。因此默认的升级逻辑应该使用方案Z,当遇到复杂的升级情况的时候,再使用Y方案进行特殊化处理,不过在我们的具体实践中,并没有使用手写sql去实现这一方案,而是用Java封装了一些操作,便于维护。

看代码觉得难以理解的,可以结合代码后面的流程图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
// 自定义迁移实现类
private List<Migration> migrations;
// 需要自定义数据迁移的数据集合
private Map<String, DataSet> dataSetMap = new HashMap<>();

@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
// 收集手动升级所需要的信息,用以在doCustomUpgrade()执行最终的数据迁移
colectCustomUpgrade(db, oldVersion, newVersion);
// 后执行自动升级
autoUpgrade(db, oldVersion, newVersion);
// 最后执行手动升级
doCustomUpgrade(SQLiteDatabase db, int oldVersion, int newVersion);
}

public void colectCustomUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
// Migration的实现类承载了收集自定义表迁移的完整逻辑,
// 这里的实现逻辑去除了一些容错代码,仅保留核心逻辑

// 保留需要迁移的migration,过滤掉版本过低的迁移
Iterator<Migration> iterator = migrations.iterator();
while (iterator.hasNext()) {
Migration next = iterator.next();
int startVersion = next.getStartVersion();
if (startVersion <= oldVersion || startVersion > newVersion) {
iterator.remove();
}
}
// 执行自定义数据迁移的核心逻辑
for (Migration migration : migrations) {
// 获取当前migration会变更的表
String[] migrationTables = migration.dataMigrationTables();
for (String table : migrationTables) {
// 取出变更表的数据
List<ContentValues> contentValuesList = DBUtils.queryAll(db, table);
DataSet dataSet = new DataSet(table, contentValuesList);
migration.onDataMigrate(dataSet);
}
}
}

public void autoUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
// 先取出所有旧表的表名
List<String> originTableList = originTableList(db);
// 如果存在则执行升级,不存在则执行创建
for (Map.Entry<Class, Dao> entry : map.entrySet()) {
Dao value = entry.getValue();
boolean remove = originTableList.remove(value.getTableName());
if (remove) {
value.onDataBaseUpgrade(cdb, oldVersion, newVersion);
} else {
value.onDataBaseCreate(cdb);
}
}
// 剩下的表删除
for (String leftTableName : originTableList) {
cdb.execSQL("DROP TABLE " + leftTableName);
}
}

public void doCustomUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
// 获取到当前所有的table表名
List<String> tableList = DBUtils.tableList(db);
Set<Map.Entry<String, DataSet>> entries = dataSetMap.entrySet();
for (Map.Entry<String, DataSet> entry : entries) {
// 此处要判断是否是所有的字段都需要插入
DataSet value = entry.getValue();
if (value != null && !value.isEmpty()) {
String tableName = value.getTableName();
if (tableList.contains(tableName)) {
Set<String> columnNames = getColumnNames(db, tableName);
db.beginTransaction();
for (ContentValues contentValues : value) {
// 过滤一次ContentValues
db.replace(tableName, null, filterContentValues(contentValues, columnNames));
}
db.setTransactionSuccessful();
db.endTransaction();
}
}
}
}

光看代码可能不好理解整个升级过程,下面给出一个升级例子的图例描述:

假设我们目前的数据情况如如图,并且当前进行的升级时从1升级到3,b字段的字段名改为了d,即b的数据需要迁移到d。

image-20190520213305424

整体的升级过程见下图

image-20190520214154063

该方案在多数情况下无需使用者改动升级逻辑与编写脚本,但是在遇到复杂升级的情况则需要改动升级逻辑,因此为半自动的升级方案。

注意到核心的迁移逻辑其实是在Migration中,以这个例子为例的话,该类的实现大致为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Migration {
public String[] dataMigrationTables() {
return new String[]{"A"};
}

public void onDataMigrate(DataSet dataSet) {
// 这里依然去掉容错与边界情况的处理,仅展示核心逻辑
String tableName = dataSet.getTableName();
if ("A".equals(tableName)) {
Iterator<ContentValues> iterator = dataSet.iterator();
while (iterator.hasNext()) {
// 每个cv都是数据库中一行
ContentValues cv = iterator.next();
String b = cv.getAsString("b");
cv.put("d", b);
}
}
}

public int getStartVersion() {
return 3;
}
}
总结

较为成熟的基础方案为上面的方案X、Y,也就是现在主流框架所采用的方案。基于X、Y这两个基本方案可以衍生出其他更多的解决方案,但不可避免的是需要在这易用和适用性中做取舍。

之所以需要做取舍,仔细考量之下可以发现,如果想要有高度的适用性,则无法避免使用者对某次数据库升级细节的了解,并将该细节通过某种方式(如撰写Sql脚本)侵入到升级过程中,而这与易用性是直接矛盾的。

同时,方案的选择也需要考虑到实际使用场景。在多数场景下,数据库升级的触发次数不多,而数据库复杂升级的触发次数应该更少。如果不满足这个规律,那么说明使用者在设计数据库的时候缺乏足够的考量。因此,综合Y、Z方案的解决方案或者其变体可能是更合适的方案。当然如果业务上对本地数据完整性需求不强,那么推荐使用方案X或Y,它让开发者完全不需要关心数据库升级的问题。

表设计的建议

从有利于数据库升级的角度为数据库表设计提一些建议:

  • 规范表名与字段名(大小写,关系表表名)
  • 规范字段是否允许默认值
  • 规范字段Java数据类型与数据库数据类型的映射

这些规范的最终目的都是减少数据库迭代中不必要的数据库更新。