我们在生活中经常会遇到需要处理数据分页的情况。
比如:
fun getArticles (ctx: Context) = mapOf(
"data" to Site.articles,
"total" to Site.articles.size
)如果直接把 Site.articles 返回,
当数据量巨大时,会产生巨大的网络开销。
所以我们需要引入分页这一概念。
什么是分页?
简单来讲,就是将一份数据分成很多页,并且可以获取指定页。
准确来讲,将传入的数据列表按每页 n 条分割,返回第 m 页的数据。
初版
我们创建 fun/Tools.kt,
实现一个稳定的分页函数。
/**
* 通用的数据分页函数。
*
* 对传入的列表按页分割,返回指定页的数据。
* page 从 1 开始计数,超出范围的页返回空列表。
*
* @param data 要处理的原数据列表
* @param page 当前页码(从 1 开始)
* @param pageSize 每页的数据条数(必须 >= 1)
* @return 当前页的数据列表,如果无数据则返回空列表
* @throws IllegalArgumentException 当 page < 1 或 pageSize < 1 时抛出
*/
fun <T> dataPaginate(
data: List<T>,
page: Int,
pageSize: Int
): List<T>{
require(page >= 1) { "page must be at least 1, got $page" }
require(pageSize >= 1) { "pageSize must be at least 1, got $pageSize" }
val total = data.size
val startIndex = pageSize * (page - 1)
val result = mutableListOf<T>()
for ((index, item) in data.withIndex()) {
if (index in startIndex until startIndex + pageSize){
result.add(item)
}
}
return result
}原理
那么这段代码是怎么跑起来的呢?
我们观察上述代码,首先,这是一个泛型函数。
形参为:待处理数据 data: List<T>、页码 page: Int、页大小 pageSize: Int。
返回值为:List<T>,与形参 data: List<T> 的类型相同。
前两行,我们校验了 page 与 pageSize 需 >= 1,否则抛出 IllegalArgumentException。
随后,我们计算了当前页的起始值: startIndex = pageSize * (page - 1)
那么此算法从何而来?
具体原理请见:
V2:写法优化
使用 Kotlin forEach 进行写法优化。
fun <T> dataPaginate(
data: List<T>,
page: Int,
pageSize: Int
): List<T>{
require(page >= 1) { "page must be at least 1, got $page" }
require(pageSize >= 1) { "pageSize must be at least 1, got $pageSize" }
val startIndex = pageSize * (page - 1)
val result = mutableListOf<T>()
data.withIndex().forEach {
val (index, item) = it
if (index >= startIndex && index < startIndex + pageSize) {
result.add(item)
}
}
return result
}data.withIndex() withIndex() 提供带索引的遍历能力,返回 Iterable<IndexedValue<T>>。
其中的 IndexedValue 包装使其支持 componentN() 方法,进而支持解构。
val (index, item) = it解包写法,JVM 会调用 it.componentN() 得到 index 与 item。
V3:性能优化
由于全量 for/forEach 遍历到了不需要访问的元素,十分的浪费资源,且时间复杂度为 O(N)。
我们进行优化,在使用支持高效随机访问的列表时,时间复杂度可优化至 O(K)(K 为实际返回的元素数量),已达到理论极限。
(有的列表不支持高效随机访问,时间复杂度无法完全达到 O(K)。)
fun <T> List<T>.paginate(
page: Int,
pageSize: Int
): List<T>{
require(page >= 1) { "page must be at least 1, got $page" }
require(pageSize >= 1) { "pageSize must be at least 1, got $pageSize" }
val startIndex = pageSize.toLong() * (page.toLong() - 1L)
if (startIndex >= size.toLong()) {
return emptyList()
}
val endIndex = minOf(
size.toLong(),
startIndex + pageSize.toLong()
)
return subList(
startIndex.toInt(),
endIndex.toInt()
).toList()
}这就是最终的分页函数。
- 首先,我们将原本传入一个 data 数据这样蹩脚的写法,做成了拓展函数。
拓展函数为 Kotlin 语言特性,可实现为特定类添加一个新的调用方法,而不修改其源码。
我们实现List<T>.paginate,意思是:为一个内部元素为 T 的列表新增一个分页方法。
以后,任意一个 List 对象均可直接调用paginate(...)函数。 - 其次,我们将原本全 Int 换为了全 Long,避免溢出。
以及,我们分别计算前后边界索引,优化了时间复杂度。
- 首先计算
start index: 与初版不变。
值得注意的是,当 start index 超出数组,可以直接返回一个空列表。 再计算
end index:分类讨论:- 当查询右边界超出源数组,则右边界为源数组右边界。
- 当查询右边界未超出源数组,最终右边界即为传入的右边界。
即得到了start index与end index。
- 再使用
List.subList(fromIndex: Int, toIndex: Int)方法,直接得到切片数据。
值得注意的是,将得到的切片数据再调用 toList(),会拷贝一份新数据再返回,避免造成不必要的内存长期占用。
具体原因是,subList 方法并不会创建一个新数据,而是一个视图,持有原列表的强引用。一旦源数组十分巨大,且返回的 subList 没有被正确销毁,那么源数组会长期占用内存,且由于被长期占用,GC 也无法将其回收,进而可能导致内存泄漏。
- 首先计算