主流库
目前Android主流的ORM相关库可以分为两类,一类是我们熟知的基于SQLite并进行一系列封装和优化的框架,比如GreenDao、Room、DBFlow等;另一类是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会更容易上手,在开发中也相对更加便利。
速度对比
速度对比项有:
- 插入数据 - 插入
1000
条user
数据和10000
条message
数据 - 查询全部 - 查询所有(
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 API
和GreenDao
,甚至GreenDao
在查询速度上会比传统SQLite API
慢。
分析
写入
为什么Room
和GreenDao
在写入的速度上会比传统SQLite API
快这么多?首先来看一下每次传统SQLite API
在写入时执行的几个步骤:
- 组装
ContentValues
- 拼接SQL语句
- 将
ContentValues
转换为数组 - 创建
SQLiteStatement
- 将
SQLiteStatement
与第3步的数组绑定 - 执行写入操作
而Room
只需要两步:
- 将SQLiteStatement与要插入的数据绑定
- 执行写入操作
这就是Room
比传统SQLite API
快的原因。通过缓存SQLiteStatement
,避免了每写一行都需要重新拼接SQL语句、创建SQLiteStatement
这些重复操作。同时,由于掌握了SQLiteStatement
,可以直接绑定数据,不再需要依赖ContentValues
,避免了组装和反组装的耗时。
而由于GreenDao
会把要写入的对象缓存起来,如果已经缓存过就更新,以此来提高查询速度,所以会有更多的时间消耗。实际上,在速度对比
这一节中,我们先清除了GreenDao
的对象缓存再执行查询,因为缓存无法命中,需要使用Cursor生成对象。尽管GreenDao
在遍历Cursor生成对象这一步有所优化,但是其对速度的提升幅度相对查询缓存来说是很小的,所以其速度会比传统SQLite API
更慢。在上述测试代码中,如果不清除缓存,GreenDao
查询速度会有2~3倍的提升(随着数据量的增加,这个提升会有所下降)。由于GreenDao
的缓存对象与返回的查询结果是同一对象,因此需要特别注意,在开启缓存的情况下,如果直接修改查询出来的对象,而且没有及时同步到数据库中,那么就会导致下次查询结果错误与数据库不同步。
1 | DaoSession session = mDaoMaster.newSession(); |
输出如下:1
2E/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:
自己生成和管理
SQLiteStatement
,杜绝ContentValues和拼接SQL语句1
2
3
4
5
6
7
8
9
10SQLiteStatement 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 ));在遍历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 |
除了上述借鉴Room
和GreenDao
的优化之外,还有一些基本知识,比如“在同一个事务中完成多行写入操作,而不是每写入一行都开启一个事务”等,也可以大大提升写入(查询)速度。优化无处不在,借鉴优秀的第三方框架,能够快速地学习如何写好代码。而扎实的基础是一切优化的前提。