【业务场景架构实战】6. 从业务痛点到通用能力:Android 优先级分页加载器设计

区分设计良好和不良的模块,最重要的因素在于,该模块在多大程度上对其他模块隐藏了它的内部数据和其他实现细节。------《代码大全2》

1. 引言:问题从何而来

这是一个真实的业务场景,发生在前后端协同工作的过程中。

1.1 场景复现

在"手机主题管理" APP 中,用户可以在"我的主题"页查看自己账号下的所有已购买主题。由于已购买主题跟随账号,且并不是所有已购主题都可以适用于当前手机。

因此,在 APP 页面展示"我的主题"页时,将已购买主题分为两类:

  • 本机已适配的主题:能够应用在当前手机的主题。
  • 本机未适配的主题:当前手机无法应用的主题,这部分主题仍然属于用户的账号已购资产。

APP 页面交互图如下:

由于后端架构设计所限,该分页请求接口无法做到分别查询"已适配"、"未适配"的主题数据,只能在同个接口里将这两种类型混合返回,并且一次分页数据请求中,两种数据量是不固定的。

1.2 痛点剖析

由上述背景信息总结出以下3个痛点:

  • 前端分页展示不均衡,首屏加载可能需要多次网络请求。
  • 业务逻辑侵入 UI 层,UI 本不应关心底部实现(是1次还是多次请求)。
  • 不同业务都在重复写类似逻辑。

1.3 目标

针对这种分类加载的场景,我们希望抽象出一个可复用的优先级分页加载能力,该能力不仅可以应用在主题页面,也可以应用在其它相似场景下,并且对于 UI 层隐藏实现,做到无痛接入。

2. 需求分析与建模

2.1 "分页"的本质是什么?

"分页"是一种前后端接口设计的通用范式,这样做的目的是降低单次请求数据量,提升接口响应速度,加快大前端的页面加载。本质上,是 对于数据的分批、有序、延迟加载

2.2 优先级分页的特殊性

由于在基础分页需求上,增加了"优先级"属性,导致单次接口请求返回的数据,不足以撑满 APP 一页的显示,请求次数存在不确定性。

2.3 对需求的抽象拆解

综合上述信息,从数据流向的角度,可以将过程抽象划分为4块:

  1. 数据来源:分页 API。
  2. 数据分类:高/低优先级(类别数也可以大于2个,处理思路是一致的)。
  3. 数据汇聚:APP 一屏所展示的数量(screenSize)。
  4. 缓冲策略:池化(pool)思想,在 APP 侧管理数据池。

2.4 用户交互图

假设前端一屏展示10条数据,且后端接口同样在一次请求中返回10条。前端优先展示"本机已适配"的主题绿色圆圈,当全部已适配主题展示完成后,才展示"本机未适配"主题,该主题以红色方块表示。

  • 第1次请求:用户进入到"我的主题"页时,进行第一次网络请求,此时返回的10条数据里,有8条是高优先级,另有2条属于低优先级。APP 判断当前首屏(需要10条数据)未填满,因此需要进行第2次分页数据请求。
  • 第2次请求:返回的10条数据里,同样是8条高优先级+2条低优先级,此时已经足以填满 APP 首屏,因此 APP 进行数据展示。
  • 用户上滑列表:由于在第2次网络请求时,后端通知"无下一页",意味着 APP 已经将该用户在云端的全部数据拉取到本地,此时展示余下6条高优先级数据,和4条低优先级数据(分别来自于2次接口请求)。

2.5 数据流程图

该流程图描述了 APP 侧处理数据的步骤和分支逻辑。

3. 架构设计思路

这一章从宏观上进行分层设计,厘清不同层级之间的边界。

3.1 层级划分

层级 职责 可测试性设计
Data Layer 后端分页接口,返回混合数据 需要本地模拟设计,以验证上层 Loader 功能正确性
Loader Layer PriorityPagingLoader 负责分拣和缓存 不需特别设计
Repo Layer 封装 Data Layer 和 Loader Lader,提供给 VM 不需特别设计
UI Layer 观察 Flow<List<T>>,它(VM)不需要关心底层实现 不需特别设计

关于可测试性,我们把 mock 实现安插在 Data Layer,即 DataSource 层,这样可以最大程度测试到上层的分拣和缓存逻辑。

3.2 职责边界

  • 后端:保证分页一致性,接口应当具备幂等性。
  • Loader:管理优先级与翻页边界,是本次设计的重点。
  • Repo:集成 Loader 与后端接口,供 VM 加载下一页时调用。
  • UI:只负责展示。

4. 实现细节

4.1 通用的优先分页加载器 PriorityPagingLoader<T>

这里采用 suspend fun 的阻塞接口形式,也可以将其改写成 Flow<T> 的实现。

kotlin 复制代码
// Loader 中维护了加载到的页数和分优先级队列
class PriorityOrderLoader<T>(
    private val pageSize: Int,
    private val uiPageSize: Int,
    private val fetcher: suspend (page: Int) -> List<T>,
    private val isHighPriority: (T) -> Boolean = { false }
) {

    private val highPriorityBuffer = mutableListOf<T>()
    private val lowPriorityBuffer = mutableListOf<T>()
    private var pageIndex = 1
    private var reachedEnd = false

    suspend fun loadFirstScreen(): List<T> {
        highPriorityBuffer.clear()
        lowPriorityBuffer.clear()
        pageIndex = 1
        reachedEnd = false
        return loadNextScreen()
    }

    // 核心函数,在当前的状态下,加载下一页数据
    suspend fun loadNextScreen(): List<T> {
        ensureHighPriorityFilled()

        val result = mutableListOf<T>()
        val highNeeded = uiPageSize - result.size
        result += takeFromBuffer(highPriorityBuffer, highNeeded)

        // 若高优先级数据已加载完,开始展示低优先级
        if (highPriorityBuffer.isEmpty() && reachedEnd) {
            val lowNeeded = uiPageSize - result.size
            result += takeFromBuffer(lowPriorityBuffer, lowNeeded)
        }

        return result
    }

    // 当前未填满高优先级数据,且后端仍有下一页
    private suspend fun ensureHighPriorityFilled() {
        while (highPriorityBuffer.size < uiPageSize && !reachedEnd) { // 未填满当前页
            val page = fetcher(pageIndex++)
            if (page.isEmpty()) { // 后端已无更多数据
                reachedEnd = true
                break
            }
            val (high, low) = page.partition(isHighPriority)
            highPriorityBuffer += high
            lowPriorityBuffer += low
        }
    }

    // 从高/低优先级池子里面取对象
    private fun takeFromBuffer(buffer: MutableList<T>, count: Int): List<T> {
        val take = buffer.take(count)
        repeat(take.size) { buffer.removeFirst() }
        return take
    }
}

4.2 使用示例(与 UI 的集成)

kotlin 复制代码
// Repo 层
val loader = PriorityOrderLoader(
    pageSize = 200,
    uiPageSize = 20
) { page ->
    api.getOrders(page) // suspend fun 返回 List<Order>
}

// 首屏
val firstScreen = loader.loadFirstScreen()
adapter.submitList(firstScreen)

// 更多
val moreScreen = loader.loadNextScreen()
adapter.submitList(moreScreen)

5. 接口可复用性与扩展性

5.1 可复用性

  • (T) -> Boolean 灵活定义优先级。
  • 泛型支持任意数据类型。
  • 可自定义 pageSizescreenSize 适应不同业务。

5.2 扩展性

  • 多级优先级(高/中/低或更多级别)。
  • 预拉取下一页(也可在 VM 中管理)。

6. 对前后端协同的再思考

6.1 前端视角下的隐患

  1. 分页语义模糊:分页页码(pageIndex)和业务排序(优先级)绑定在一起,导致前端分页失真。比如第1页取完还不够20条高优先级,要再请求第2页,会跳过一些低优先级数据。在数据稀疏的情况下,加载一页数据会有多次网络请求发生。
  2. 负担转移:前端不得不做复杂筛选和缓冲逻辑,这部分逻辑在 Android/iOS 双端都要分别实现,导致消耗双倍资源。
  3. 扩展性差:如果后面要增加"已安装"、"安装中"、"未安装"等类型,接口复杂度成倍增长。

6.2 当前方案是对低段位后端开发者的向下兼容

客观、中立地讲,在这次前后端合作过程中,暴露了后端架构设计的缺陷,在技术上有明显的短板 。这种分类请求有序接口的能力,理应是在后端进行实现的。站在全局角度看:把一个分页接口里混放两类逻辑不同的数据,是一个"可行但不优"的设计。这种做法把分页的边界模糊化了,实际上是让前端去弥补后端分页逻辑的空缺

相关推荐
程序定小飞3 分钟前
基于springboot的民宿在线预定平台开发与设计
java·开发语言·spring boot·后端·spring
渣渣盟17 分钟前
探索Word2Vec:从文本向量化到中文语料处理
前端·javascript·python·文本向量化
Pu_Nine_918 分钟前
Vue 3 + TypeScript 项目性能优化全链路实战:从 2.1MB 到 130KB 的蜕变
前端·vue.js·性能优化·typescript·1024程序员节
云枫晖35 分钟前
Webpack系列-Loader
前端·webpack
aggression38 分钟前
代码敲击乐:让你了解前端的动静结合和移动端的适配性
前端
yinuo39 分钟前
深入理解与实战 Git Submodule
前端
骑自行车的码农40 分钟前
React 事件收集函数
前端·react.js
一个处女座的程序猿O(∩_∩)O43 分钟前
Vue CLI 插件开发完全指南:从原理到实战
前端·javascript·vue.js
代码扳手1 小时前
Golang 实战:用 Watermill 构建订单事件流系统,一文掌握概念与应用
后端·go
麻木森林1 小时前
利用Apipost 的AI能力轻松破解接口测试的效率与质量困局
后端·api