我们在生活中经常会遇到需要处理数据分页的情况。

比如:

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)
那么此算法从何而来?
具体原理请见:
image.webp

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 也无法将其回收,进而可能导致内存泄漏。
最后修改:2026 年 10 月 13 日
如果觉得我的文章对你有用,请随意赞赏!