原文 Large Database Queries on Android by Chris Craik(机翻改)
- 修复了失效的 sqlite wiki 链接,这些链接的域名前缀现改为www2。
SQLite 是在 Android 上保留数千项数据的好方法,但在 UI 中呈现这些庞大的数据集历来很困难,并且可能导致性能问题。在启动新的分页库之前,我们调查了平台中现有的分页方法,尤其是 SQLiteCursor 中的潜在缺陷。
在这篇博文中,我们将讨论它的问题,以及为什么这促使我们使用Android 架构组件中的Room Persistence和Paging库进行小型查询。
SQLiteCursor 和 CursorAdapter
SQLiteCursor是 Android SQLite 数据库查询的返回类型。它允许您以固定的初始加载成本查看大型查询结果。
第一次读取初始化一个CursorWindow,一个通常大小为 2MB 的行缓冲区,内容来自数据库。每次请求不存在的行时,SQLiteCursor 都会刷新此窗口。这样,SQLiteCursor 就实现了分页,页面大小固定。
CursorAdapter从 Android API 1 开始就已经存在,并提供了一种简单的方法来将来自 Cursor(通常是 SQLiteCursor)的数据绑定到 ListView 中的项目。虽然它可以很好地提供此功能,但它会在需要新加载时直接在 UI 线程上查询数据库。对于现代且响应迅速的应用程序而言,这本身是不可接受的。所以有人可能会问:我们不能只有一个基于 Cursor 的适配器在后台线程上加载吗?毕竟,SQLiteCursor 内置了分页功能。
SQLiteCursor 中的分页问题
在 SQLiteCursor 中分页的大部分问题来自令人惊讶的行为,因为它使用其窗口来分页内容。以下是我们在试验分页库的 SQLiteCursor 内部分页时发现的挑战列表:
SQLiteCursor 没有打开任何数据库事务
当我开始研究分页时,我对 SQLite 尤其是 Android 上的 Cursors 缺乏经验。我只是假设 SQLiteCursor 会在加载一个窗口后暂停其查询,并在需要下一个窗口时恢复。这样,访问第 10 个窗口将与第一个窗口一样有效。这是不正确的。每次读取一个新窗口时,查询都会从位置 0 重新开始并跳过不需要填充窗口的行,一次一个。这是因为SQLiteCursor 无法恢复查询。
这就像访问链表中的第 1000 到 1050 项——您必须跳过大量项才能访问要加载的下一页。加载时,每个后续窗口都必须跳过越来越多的查询,这会减慢速度。这等效于使用 SQLOFFSET
关键字跳过内容,这不是页面内容的最有效方式,但在依赖 SQLiteCursor 中的分页时无法避免。您可以在此处查看 SQLiteCursor 如何在新窗口中分页。
SQLiteCursor.getCount() 是必需的,并扫描整个查询
在读取第一行之前,SQLiteCursor 调用getCount()
边界检查。由于SQLite 必须扫描查询的整个结果集以对其进行计数(再次像链表一样),这会增加大量开销。如果您逐渐将大型查询分页到 UI,为了响应用户滚动,您可能不需要知道查询的整个大小,因此计数会增加不必要的前期工作。
SQLiteCursor.getCount() 总是加载行的第一个窗口
作为计算计数的一部分,在扫描结果集时,SQLiteCursor 主动从位置 0 填充其窗口,假设需要查询中的第一项。
它预加载这些项目,以便它可以提前知道大致有多少行适合一个窗口(更多内容见下文)。如果您从查询开始就显示数据,这种机会性加载是合理的,但从保存的实例状态恢复的位置可能会在列表的更下方启动索引,其中初始窗口不相关。如果您想显示来自第三个内容窗口的数据,您必须首先加载并丢弃 2MB 的数据。这种计数行为的代码在这里。
SQLiteCursor 可能会加载您没有要求的数据
Cursor.moveToPosition() 保证请求的行在窗口中,但SQLiteCursor 不会从请求的行开始填充。因为 SQLiteCursor 不假设应用程序正在向前阅读,所以当它距离目标位置大约 1/3 个窗口时,它开始填充它的窗口。这意味着 CursorAdapter 在窗口加载后向后滚动几行不会触发另一个窗口加载。这也意味着在第一次加载 650KB 或更多您已经看到的数据之后,每加载 2MB 的数据。您可以在此处查看此行为的代码和说明。
SQLiteCursor 加载位置可能无法预测
当 SQLiteCursor 尝试加载目标位置时,它会尝试提前启动窗口的 ⅓。这意味着它必须猜测一个窗口中有多少行。为此,它使用在其第一个窗口加载中填充的行数。不幸的是,这意味着如果您的行具有不同的大小(例如,如果您嵌入了任意长度的用户评论字符串),那么它的猜测可能是错误的。该SQLiteCursor可以冲目标位置,填充内容的窗口-然后放弃这一切,重新开始填充。例如,如果您正在扫描一个长查询并到达需要重新加载窗口的行,则加载可能只会捕获少量的新行。清除窗口和重启代码在这里。
游标需被关闭
必须使用该close()方法关闭游标,因此无论将它们存储在何处,都必须有代码在不再需要它们时将其清除。CursorAdapter 显然对此无济于事,将这一责任传递给应用程序开发人员。要存储和重用 Cursor,开发人员需要编写代码来处理活动停止等事件。
SQLiteCursor 不知道数据已更改
SQLiteCursor 不会跟踪数据库在第一个窗口读取(和第一次计数)后是否发生更改。这意味着如果添加或删除某些项目,SQLiteCursor 的缓存大小是不正确的——对于边界检查和您是否希望加载的数据看起来一致都是一个问题。在移动到不再存在的行时,这可能会导致异常,或者在某些情况下数据不一致。例如,如果您已经加载了第 N 行并且在位置 0 处插入了一个新项目,然后您尝试读取第 N+1 行,您最终将再次加载旧的第 N 行。
避免问题
上述问题告诉我们 SQLCursor 不能扩展到具有数千个结果的查询。幸运的是,这些问题都有一个简单的解决方法:小查询。适合单个 CursorWindow 的查询避免了所有问题,这就是为什么我们在 Paging 和 Room 中如此青睐它们的原因。将页面大小配置为仅 10 到 20 个项目,并且一次仅查询那么多项目是很常见的。
不过,选择页面大小有很多影响 - 较大的查询(一个窗口大小)通常会提高性能,而较小的查询会改善延迟和内存。对于 DB 不是性能瓶颈的长列表,10 项可能有意义,而如果您的列表项是小块,或者您的查询很昂贵,则 300 可能更好。
如果您依赖 SQLiteCursor 的内部分页来延迟加载更大的结果集,我们建议您切换到另一种方法。要么使用新的分页库及其与Room Persistence Library 的集成,要么使用您自己处理分页的自定义实现,并确保您的查询结果足够小以适合单个 CursorWindow。
要使用新的分页库在 Room 中使用小查询对大 SQL 查询结果进行分页,您可以将以下代码:
@Dao
interface UserDao {
// regular list query — falls over with too much data
@Query(“SELECT * FROM user ORDER BY age DESC”)
LiveData<List<User>> loadUsersByAgeDesc();
}
更改为:
@Dao
interface UserDao {
// paged query — handles arbitrarily large queries
@Query(“SELECT * FROM user ORDER BY age DESC”)
DataSource.Factory<Integer, User> loadUsersByAgeDesc();
}
然后您将其传递给一个 LivePagedListBuilder 以获得 LiveData<PagedList>,用来处理任意大小的结果集合:
LiveData<PagedList<User>> users = new LivePagedListBuilder<>(
userDao.loadUsersByAgeDesc(), /*page size*/ 20).build();
在上面的代码中,我们获得了分页查询结果的LiveData版本,当数据库发生变化时,它也会更新任何订阅的观察者。要了解有关使用架构组件从 SQLite 进行分页的更多信息,请参阅分页介绍和Github 上的分页示例。
Android 平台 — Android P 更新!
由于本文最初发布,我们在 Android P 开发者预览版中添加了新的 API 以改进上述行为。该平台现在允许应用程序禁用窗口启发式的 ⅓并配置 CursorWindow 大小。小查询仍然是避免上述所有问题的好方法,但 P 更改为应用程序提供了更多控制权,我们很快将在 Room 中使用它们以保持查询尽可能高效。
拓展阅读
http://blog.ssokolow.com/archives/2009/12/23/sql-pagination-without-offset/
https://www.techyourchance.com/data-paging-lists/ (包含一个有关原文的可运行benchmark表格程序)
androidx.Paging 使用了 kotlin 来写,这不是我想要的,好端端的java被弄成类似JavaScript的样子,但又根本不是JavaScript那样的动态语言,只得其形不得其神。
实际上可以自己实现类似的效果。官方的demo名为 PagingSample,展示的是一用个recycleview瀑布流显示数据库记录,十分流畅。
根据sqlite的文档,当存在索引时,利用索引排序进行逐量分页查询的方法,是非常快的(二分法,即使是亿级的体量,即使在低端存储设备,仍然很快)。
CREATE INDEX example1 ON tracks(singer, title);
SELECT title FROM tracks WHERE singer='Madonna' // 匹配条件 AND title>:lasttitle // 利用索引中的排序,实现逐量分页查询 ORDER BY title LIMIT 5; // 一次只查一点点,所以速度很快
此文档还说,不要用 OFFSET 命令实现这样的效果,因为 OFFSET 没有用二分法,而是一步步地跳过,类似于链表。当匹配条件的数据量很大的时候,效率会变很低。