前阵子在做 Flutter 鸿蒙化改造的时候,产品经理过来问了一句:“咱们这推荐列表,滑到底了能不能自动接着加载?”我当时第一反应是——这不就是个无限滚动吗?ScrollController 监听一下像素位置,触底了请求下一页,Flutter 里现成的套路。可真等我把推荐列表从 Android 端往鸿蒙端迁移、把上拉加载的逻辑重写一遍之后,才发现这件事远没有想象中那么简单:分页状态怎么设计才不闪列表?快速滑动时怎么防止重复请求?鸿蒙端的滚动容器和 Android 端有没有行为差异?失败之后是弹 Toast 还是底部直接显示重试?
这篇文章就围绕“推荐列表上拉加载”这一个点,把我在 Flutter 鸿蒙开发中的完整实现思路、状态设计、踩坑记录和优化经验都说清楚。不管是已经在做 Flutter 鸿蒙应用、还是打算把现有项目往鸿蒙端迁移的同学,这篇内容应该都能帮你少走不少弯路。文章不强求贴完整项目,重点是核心代码片段加设计思路,你完全可以直接搬到自己的业务里改改用。
1. 为什么推荐列表的上拉加载在鸿蒙端需要单独设计
很多从 Android 转过来的 Flutter 开发者会默认一件事:Flutter 是跨平台框架,滚动监听、列表组件这些逻辑跟底层系统没关系,写一遍到处跑,鸿蒙端不用额外处理。这个想法在纯 UI 层面基本没错,但放到真实业务里,尤其是推荐列表这种高频交互场景,就有点理想化了。
推荐列表和普通列表有个本质区别:数据量大、内容不可穷举、用户不知道什么时候到头。正因为没有明确的“列表终点”,上拉加载就承担了两个任务:一是给用户持续的浏览预期,让内容像流水一样不断补充;二是作为流量分发和内容曝光的核心入口,加载策略的好坏直接影响推荐内容的点击率和用户停留时长。所以推荐列表的上拉加载,本质上不是一个“有没有”的问题,而是一个“怎么才能更顺滑、更聪明”的问题。
再说鸿蒙端的特殊性。鸿蒙应用最终打包成 hap 包,运行在鸿蒙内核和方舟运行时之上,Flutter 引擎在鸿蒙端是通过鸿蒙的 NDK 接口做的适配层,渲染层用了自研的引擎接入。这意味着同样一段 Flutter 代码,在 Android 和鸿蒙上跑,底层线程调度、内存分配、GPU 合成策略可能完全不同。上拉加载触发时,通常伴随着新数据的网络请求、列表插入、图片解码缓存这一连串开销,如果动画帧率本来就不稳,那鸿蒙端很可能比 Android 更容易出现“滑到一半卡一下”的感知。
我实测下来的体会是:在鸿蒙端做推荐列表上拉加载,代码主体可以跨端复用,但你一定要针对鸿蒙端的渲染链路做收敛。什么是收敛?就是加载过程中的 UI 变化要更克制,不要搞复杂动画,不要一次性插入大量 Item,不要在主 isolate 里做耗时解析。推荐列表的加载体验,拼的不是花活,是稳定。
还有一点容易被忽略:鸿蒙应用市场的审核和分发逻辑跟 Google Play 不一样,推荐列表需要配合端上行为统计和内容合规检测。这意味着你的上拉加载逻辑里,可能要预留上报点和内容过滤的钩子。我建议在做架构设计的时候就把加载完成的回调统一收口,不要在每个页面里散着写,不然后面想加统一的埋点或者做内容安全检测的时候,就只能到处打补丁了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分页数据层的设计直接决定上拉加载的成败
上拉加载看着是 UI 交互,真正的核心其实在数据层。很多初学者喜欢这么写:一个 int 页码,一个 List 列表,请求回来之后 setState 把新数据 append 到列表末尾。逻辑简单,跑起来也能用,但一旦业务复杂起来全是坑:请求并发时数据乱序、失败后不知道当前是哪一页、下拉刷新和上拉加载互相打架。所以我想先花一章把数据层的分页协议讲清楚。
2.1 推荐列表的典型分页协议与字段语义
推荐列表服务端接口五花八门,但不管怎么变,分页参数基本逃不出下面三种风格:
| 分页风格 | 请求参数 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|
| 页码分页 | pageNo + pageSize | 信息流、资讯列表 | 实现简单,服务端好算 | 深翻页性能差,数据变化时可能重复或漏数据 |
| 偏移量分页 | offset + limit | 评论列表、搜索 | 可以精确定位 | 插入删除数据时会错位 |
| 游标分页 | cursor/lastId | 推荐流、动态流 | 数据一致性好,性能高 | 服务端实现复杂度略高 |
推荐列表我基本都建议用游标分页。原因很简单:推荐流的数据在服务端是动态排序的,今天的第一条明天可能排到第十条去,用 pageNo 翻页很容易翻出重复数据,用户刷着刷着发现刚才看过的内容又出现一遍,体验非常糟糕。游标分页的关键字段一般叫 cursor、lastId 或 recommendId,服务端返回当前批次最后一条内容的唯一标识,下次请求带上它,服务端就从这条之后继续出数据。
响应结构上,推荐列表最理想的格式是这样:
json复制{
"code": 0,
"msg": "ok",
"data": {
"items": [
{ "id": "10086", "title": "xxx", "cover": "https://..." },
{ "id": "10087", "title": "yyy", "cover": "https://..." }
],
"hasMore": true,
"cursor": "10087"
}
}
这里 hasMore 字段是我特别想强调的。它是服务端对整个数据流剩余情况的一个预判,客户端拿到它就知道还要不要继续触发加载,相当于多了一个“刹车机制”。有的服务端不返回 hasMore,那客户端就只能靠“本次返回数量小于 pageSize”这种办法推断,说实话很不靠谱,因为服务端过滤逻辑一变,这个推断就会失效。接口规范能定的,一定要在联调前定死。
2.2 Dart 侧的分页管理器封装思路
数据协议定好之后,客户端这边直接拿 Dio 或者 HttpClient 发请求当然也能跑,但我强烈建议把分页逻辑抽成一个独立的数据管理器。原因有几个:列表页的控制器不用关注页码怎么算、加载状态怎么推,只需要调 manager.loadMore(),然后响应数据或状态枚举;下拉刷新和上拉加载两个动作在 manager 内部天然互斥,不会出现同一个页面里多个入口同时发请求;后续如果做缓存、埋点或内容过滤,加在 manager 里就能全局生效。
一个精简的分页管理器核心字段和接口是这样的,代码用 Dart 写:
dart复制enum LoadStatus { initial, loading, success, empty, error, noMore }
class RecommendPageManager<T> {
String? _cursor;
bool _hasMore = true;
bool _isLoading = false;
LoadStatus _status = LoadStatus.initial;
final List<T> _items = [];
Future<List<T>?> loadFirstPage() async {
_cursor = null;
_hasMore = true;
_items.clear();
return _loadInternal(RefreshType.refresh);
}
Future<List<T>?> loadMore() async {
if (_isLoading || !_hasMore) return null;
return _loadInternal(RefreshType.loadMore);
}
Future<List<T>?> _loadInternal(RefreshType type) async {
_isLoading = true;
_status = LoadStatus.loading;
try {
final result = await repository.fetchRecommendList(cursor: _cursor, pageSize: 20);
_cursor = result.cursor;
_hasMore = result.hasMore;
if (type == RefreshType.refresh) _items.clear();
_items.addAll(result.items);
_sta
