GreenDao与Room对比以及Android SQLite API优化

主流库

目前Android主流的ORM相关库可以分为两类,一类是我们熟知的基于SQLite并进行一系列封装和优化的框架,比如GreenDaoRoomDBFlow等;另一类是NoSql数据库(注意这一类是数据库,是SQLite的替代品),比如Realm。

NoSql

NoSql database也可以称为no relational database,即非关系型数据库。与关系型数据库不同,NoSql不存在复杂的关系模型,在数据库设计上更自然,没有了复杂的关系型模型,维护起来也更加容易。比如一篇博文中包含正文、评论等,关系型数据库在设计时会根据三范式,将正文和评论放到不同的表中,使用时需要进行联合查询。而面向文档的NoSql数据库可以将正文和评论都保存在一个文档中。在查询时,NoSql直接读取这一个文档即可,相比关系型数据库的多次查找会更快。而且NoSql更方便扩展,在分布式集群上,普通的关系型数据库的拆分与查询举步维艰(而且关系型数据库有数据量限制),而NoSql可以轻易地将不同的文档或者键值对保存在不同的服务器上,只需要使用合适的算法,查询也不是难事。

但是NoSql的缺点也同样明显。首先是难以实现复杂的查询。在关系型数据库中,我们往往可以轻易实现多表复杂联查,但是NoSql不支持联合查询,需要上层代码进行更多的处理,比如使用多次查询。其次是占用存储空间更多。运用关系型数据库三范式能够设计出精简巧妙的数据库,很好地降低数据冗余。但是NoSql则不然。比如上面提到的博文的例子,关系型数据库的“评论”表中只需要记录评论者的ID、评论文章ID、评论内容即可,至于评论者的详细信息,则由User表提供。但是在NoSql中,为了避免使用多次查询(因为多次查询需要上层代码进行更多处理),我们可能需要把博文中需要显示出来的评论者的信息——用户名、用户头像等——都保存在同一个文档中。这在一定程度上增加了存储空间的占用。试想,如果一个活跃用户评论了绝大多数的文章,那他的用户名和头像信息冗余就会非常严重。

在移动平台上,基于SQLite的ORM框架还有一个优势,就是引入之后不会对应用安装包体积产生明显的影响。基于以上对比,可以认为基于SQLite的ORM框架更加适合移动平台。

SQLite Based ORMs的选择

一般选择以下两个:

直至目前,GreenDao都无疑是Android SQLite Based ORMs中的带头大哥,无论是Github Star数,还是各类博客中出现的次数,都稳居第一。而谷歌推出的持久化框架Room则有望与之齐平。

本文就选择这两个框架与传统SQLite API进行对比。

对比

分别从易用性和速度上进行对比。

易用性对比

GreenDao Room
开发者只需要规定Entity的属性即可 需要规定Entity的属性,需要规定Dao接口
每次更新Entity需要重新build以生成代码,大型项目build耗时会比较久 更新Entity不需要重新build,因为使用的是Dao接口规定的方法,但是需要根据情况更新Dao接口
只有进行复杂操作时才需要写SQL语句 即使是进行简单的条件查询,也要写SQL语句
有一定的学习成本,需要学习注解、查询、条件语句等API 学习成本低,基本只需要掌握Room的注解即可

很明显,如果熟悉SQL语句,那么Room会更容易上手,在开发中也相对更加便利。

速度对比

速度对比项有:

  • 插入数据 - 插入1000user数据和10000message数据
  • 查询全部 - 查询所有(10000条)message数据

使用每一个框架创建Disk Database,针对以上对比项分别测试50次并计时,结果取平均值,时间为ms。具体代码可以参考android-orm-benchmark来实现,这里就不贴代码了。在HUAWEI Mate 10 Android 8.0.0上运行,得出结果如下:

传统SQLite API GreenDao Room
插入数据 5653 2771 1441
查询全部 611 750 411

可以看出,Room在查询和写入上的速度超过了传统SQLite APIGreenDao,甚至GreenDao在查询速度上会比传统SQLite API慢。

分析

写入

为什么RoomGreenDao在写入的速度上会比传统SQLite API快这么多?首先来看一下每次传统SQLite API在写入时执行的几个步骤:

  1. 组装ContentValues
  2. 拼接SQL语句
  3. ContentValues转换为数组
  4. 创建SQLiteStatement
  5. SQLiteStatement与第3步的数组绑定
  6. 执行写入操作

Room只需要两步:

  1. 将SQLiteStatement与要插入的数据绑定
  2. 执行写入操作

这就是Room传统SQLite API快的原因。通过缓存SQLiteStatement,避免了每写一行都需要重新拼接SQL语句、创建SQLiteStatement这些重复操作。同时,由于掌握了SQLiteStatement,可以直接绑定数据,不再需要依赖ContentValues,避免了组装和反组装的耗时。

而由于GreenDao会把要写入的对象缓存起来,如果已经缓存过就更新,以此来提高查询速度,所以会有更多的时间消耗。实际上,在速度对比这一节中,我们先清除了GreenDao的对象缓存再执行查询,因为缓存无法命中,需要使用Cursor生成对象。尽管GreenDao在遍历Cursor生成对象这一步有所优化,但是其对速度的提升幅度相对查询缓存来说是很小的,所以其速度会比传统SQLite API更慢。在上述测试代码中,如果不清除缓存,GreenDao查询速度会有2~3倍的提升(随着数据量的增加,这个提升会有所下降)。由于GreenDao的缓存对象与返回的查询结果是同一对象,因此需要特别注意,在开启缓存的情况下,如果直接修改查询出来的对象,而且没有及时同步到数据库中,那么就会导致下次查询结果错误与数据库不同步。

1
2
3
4
5
6
7
8
9
DaoSession session = mDaoMaster.newSession();
Message msg = session.getMessageDao().queryBuilder()
.where(MessageDao.Properties.Id.eq(1)).unique();;
Log.e("before", msg.toString());

msg.content = "modified";
Message message1 = session.getMessageDao().queryBuilder()
.where(MessageDao.Properties.Id.eq(msg.id)).unique();
Log.e("after", message1.toString());

输出如下:

1
2
E/before: 1, JkJsHimdMNwLF9WWsJR8Vj5uea5uqPDlSzve2raGK6t0u6qwEZopLbi80KHDapqcEJiugixqHqUhhavmlOz9OGB2EnFAD3Mj9Qnt
E/after: 1, modified

考虑到部分项目可能会选择关闭这个功能(如果你的项目中确定要关闭GreenDao缓存,那么Room会是你更好的选择),因此测试代码选择每次执行数据库操作后,清除缓存。

查询

同样地,传统SQLite API执行查询也需要每次都拼接SQL语句。而且在查询完成得到Cursor之后,我们在使用传统SQLite API生成对象时,一般会在循环体中进行列下标查询,即:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// getColumnIndex every time
while (c != null && c.moveToNext()) {
Message newMessage = new Message();
newMessage.setChannelId(c.getLong(c
.getColumnIndex(Message.CHANNEL_ID)));
newMessage.setClientId(c.getLong(c
.getColumnIndex(Message.CLIENT_ID)));
newMessage.setCommandId(c.getLong(c
.getColumnIndex(Message.COMMAND_ID)));
newMessage.setContent(c.getString(c
.getColumnIndex(Message.CONTENT)));
newMessage.setCreatedAt(c.getInt(c
.getColumnIndex(Message.CREATED_AT)));
newMessage.setSenderId(c.getLong(c
.getColumnIndex(Message.SENDER_ID)));
newMessage.setSortedBy(c.getDouble(c
.getColumnIndex(Message.SORTED_BY)));

messages.add(newMessage);
}

Room只会查询一次下标:

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
// Code generated by Room
final int _cursorIndexOfId = _cursor.getColumnIndexOrThrow("_id");
final int _cursorIndexOfClientId = _cursor.getColumnIndexOrThrow("client_id");
final int _cursorIndexOfCommandId = _cursor.getColumnIndexOrThrow("command_id");
final int _cursorIndexOfSortedBy = _cursor.getColumnIndexOrThrow("sorted_by");
final int _cursorIndexOfCreatedAt = _cursor.getColumnIndexOrThrow("created_at");
final int _cursorIndexOfContent = _cursor.getColumnIndexOrThrow("content");
final int _cursorIndexOfSenderId = _cursor.getColumnIndexOrThrow("sender_id");
final int _cursorIndexOfChannelId = _cursor.getColumnIndexOrThrow("channel_id");
final RoomMessage[] _result = new RoomMessage[_cursor.getCount()];
int _index = 0;
while(_cursor.moveToNext()) {
final RoomMessage _item;
_item = new RoomMessage();
_item.id = _cursor.getLong(_cursorIndexOfId);
_item.clientId = _cursor.getLong(_cursorIndexOfClientId);
_item.commandId = _cursor.getLong(_cursorIndexOfCommandId);
_item.sortedBy = _cursor.getDouble(_cursorIndexOfSortedBy);
_item.createdAt = _cursor.getInt(_cursorIndexOfCreatedAt);
_item.content = _cursor.getString(_cursorIndexOfContent);
_item.senderId = _cursor.getLong(_cursorIndexOfSenderId);
_item.channelId = _cursor.getLong(_cursorIndexOfChannelId);
_result[_index] = _item;
_index ++;
}
return _result;

SQLite API优化

其实通过上一节的分析,大家应该已经有了优化的思路。没错,就是照抄Room:

  1. 自己生成和管理SQLiteStatement,杜绝ContentValues和拼接SQL语句

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    SQLiteStatement insertMessage = db.compileStatement(
    String.format("Insert into %s (%s, %s, %s, %s, %s, %s, %s) values (?,?,?,?,?,?,?)",
    OptimizedMessage.TABLE_NAME,
    OptimizedMessage.CONTENT,
    OptimizedMessage.SORTED_BY,
    OptimizedMessage.CLIENT_ID,
    OptimizedMessage.SENDER_ID,
    OptimizedMessage.CHANNEL_ID,
    OptimizedMessage.COMMAND_ID,
    OptimizedMessage.CREATED_AT ));
  2. 在遍历Cursor前获取列下标,而不是在循环体中

    1
    2
    3
    4
    5
    6
    7
    8
    // Do these things outside the while syntax
    int channelIdIndex = c.getColumnIndex(OptimizedMessage.CHANNEL_ID);
    int clientIdIndex = c.getColumnIndex(OptimizedMessage.CLIENT_ID);
    int commandIdIndex = c.getColumnIndex(OptimizedMessage.COMMAND_ID);
    int contentIndex = c.getColumnIndex(OptimizedMessage.CONTENT);
    int createdAtIndex = c.getColumnIndex(OptimizedMessage.CREATED_AT);
    int senderIdIndex = c.getColumnIndex(OptimizedMessage.SENDER_ID);
    int sortedByIndex = c.getColumnIndex(OptimizedMessage.SORTED_BY);

以上两点就是通过对比Room得出的优化方案,应用到测试代码中之后,写入和查询速度有了明显的提升:

优化前 优化后 GreenDao Room
写入 5653 1865 2771 1441
查询 611 536 750 411

除了上述借鉴RoomGreenDao的优化之外,还有一些基本知识,比如“在同一个事务中完成多行写入操作,而不是每写入一行都开启一个事务”等,也可以大大提升写入(查询)速度。优化无处不在,借鉴优秀的第三方框架,能够快速地学习如何写好代码。而扎实的基础是一切优化的前提。