前阵子在做 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);
_status = _items.isEmpty ? LoadStatus.empty : LoadStatus.success;
return result.items;
} catch (e) {
_status = _items.isEmpty ? LoadStatus.error : LoadStatus.success;
return null;
} finally {
_isLoading = false;
}
}
}
注意几个细节:_isLoading 用于整体防重入,不管加载动作是从下拉刷新来还是上拉加载来,同一时间只有一个请求在飞;_hasMore 是服务端给的“还有没有下一页”,如果是 false 就不再发起新请求;_status 是最新一批请求后的状态,供列表底部的状态组件渲染。这个管理器的粒度可以按照你自己的业务继续扩展,比如加 retry 计数、加缓存字段,但骨架保持轻量,别把业务逻辑全塞进去。
2.3 空数据、失败重试与列表缓存的状态分支
数据层另外一个很关键的职责是把状态分支理清楚。推荐列表的 UI 状态至少有下面这几种:初始加载中、加载成功(且有数据)、加载成功(但为空)、加载失败(列表没有任何数据)、加载失败(但列表已经有旧数据)、没有更多了。前四种大家都比较熟悉,我就不展开了。我想重点说的是“加载失败但有旧数据”这个分支。
用户已经刷出来 30 条内容了,滑到最底下继续加载下一页,结果网络波动请求失败。这时候你如果直接把整个列表清空,显示一个错误页,用户心态肯定炸了——他还有 30 条没看完呢。正确做法是:保留已有数据,只在列表底部显示一个“加载失败,点击重试”的条目,用户点了再重新触发加载;同时把列表滚动位置保持住,不能因为状态的改变导致 scroll offset 跳变。这个分支在数据管理器里就要设计好,不能在 UI 层临时判断。
还有列表缓存,这个对鸿蒙端特别重要。鸿蒙应用冷启动的速度目前相比成熟的 Android 系统还有优化空间,如果用户每次打开 App 都要经历“白屏 -> 转圈 -> 加载出数据”这个过程,流失率会非常高。合理的做法是:列表第一页数据落本地缓存,启动时先读缓存渲染,再请求最新数据刷新。这个缓存跟分页管理器配合的方式是:loadFirstPage() 先返回缓存数据,后台请求成功后 append 新数据并更新缓存。这样上拉加载的链路在弱网环境下也不会断,用户至少能看到内容,只是新数据晚一点到。
3. 列表滚动的监听与触发策略:选择 ScrollController 还是 NotificationListener
数据管理器准备好之后,接下来要解决的是“怎么判断用户滑到底了”。Flutter 里常见的监听滚动位置的方式有两条路:ScrollController 和 NotificationListener。这两条路我都写过,也都有各自适合的场景,这里给大家做个尽量客观的对比。
3.1 两种监听方式的机制拆解与选型对比
ScrollController 是挂在 Scrollable 组件上的一个控制器,它有一个监听回调,你可以在里面读取 position.pixels(当前滚动距离)、position.maxScrollExtent(最大滚动距离),两者比较就能判断是否到达底部。NotificationListener 则是一个包裹在列表外层的组件,它监听的是滚动通知(ScrollNotification),通知里带有 ScrollMetrics,同样能拿到滚动位置信息。
| 对比维度 | ScrollController | NotificationListener |
|---|---|---|
| 使用方式 | 需要在 State 中创建并 dispose | Widget 嵌套,跟随生命周期 |
| 监听内容 | 滚动位移、滚动状态 | 滚动位移、滚动方向、用户行为 |
| 获取触发时机 | 每次滚动都会回调,需自行判断位置 | 每次滚动通知都会回调,需自行判断位置 |
| 是否侵入子组件 | 需要列表组件绑定 controller | 完全不用改子组件 |
| 对滚动方向的感知 | 不直接支持 | 直接支持(ScrollDirection) |
| 典型使用场景 | 简单的触底加载、回到顶部按钮 | 需要判断方向、多种滚动事件共存的复杂页面 |
推荐列表我最终的选型是 ScrollController。为什么?因为推荐列表的“触底加载”通常只需要关心一个方向、一个条件,ScrollController 的代码结构更清晰,后续如果要加“回到顶部”按钮,同一个 controller 可以直接复用来控制 jumpTo 动画,不用额外在 NotificationListener 里再存一个滚动控制器。
但是有一个例外场景我会推荐 NotificationListener:列表页顶部有复杂的吸顶 layout,或者一个页面内嵌了多个可滚动区,比如推荐列表上面套了一个横向分类栏,下面才是纵向列表,而且横向栏也可以滚动。这时候如果用 ScrollController,你得区分当前滚动的是哪一个 Scrollable,很麻烦。NotificationListener 因为是以 Widget 维度嵌套的,可以精确管住纵向列表那一块区域的事件。
还有个小细节:如果你用 CustomScrollView 实现推荐列表,里面有 SliverAppBar、SliverList 这些组件,ScrollController 依然可以正常工作,它跟 Scrollable 的绑定关系不受 Sliver 影响,只要保证 controller 挂在同一个 Scrollable 上就行。
3.2 触底阈值的计算公式与边界情况
很多文章在讲触底加载时会写“当 pixels 大于 maxScrollExtent 减 200 时触发”,这个 200 是拍脑袋定的吗?是,也不完全是。200 这个数值代表的意思是“差不多还有 200 逻辑像素的内容没看完,就可以开始预加载了”。这个值设置多少,其实跟你的业务形态强相关。
拿推荐列表举例,如果你确定列表里的每个 Item 卡片高度在 150 到 240 左右,那 200 的阈值大概意味着“提前 1 到 1.5 个卡片的高度触发加载”。为什么要提前触发?因为网络请求有延迟,用户手指往上滑的速度往往很快,如果等用户精确滑到底部才发请求,那用户大概率会在列表底部停顿一下,眼睁睁等着转圈。最好的体验是用户即将滑到底、但还没到底的时候,新数据已经在路上了,滑到底直接衔接上新内容。
这个阈值的下限取决于请求的耗时和用户滑动速度。加载一次推荐分页数据在正常网络下要 200 到 500 毫秒,而用户从看到“底部还剩一个卡片”到真正滑到底,通常只有 200 到 300 毫秒。所以阈值最好能覆盖至少一个卡片高度,并且稍微留点余量。我个人在资讯类 App 里常用 400,在图片比例较大的推荐流里用到 600。另外提一句,千万不要把 0 当阈值,即 pixels == maxScrollExtent 才触发,这种写法在 iOS 的弹性滚动效果下可能永远触发不了,在鸿蒙端如果滚动容器的边界 overscroll 行为不同,也会出现不触发的情况。
为了保证触发只生效一次,还要加一个标记位:
dart复制void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - triggerDistance) {
if (!_loadMoreTriggered) {
_loadMoreTriggered = true;
_loadMore();
}
} else {
_loadMoreTriggered = false;
}
}
_loadMoreTriggered 在这里相当于一个“防抖闸门”:触发过加载之后,在用户往回滚动再滑下来之前,不再重复触发。否则用户停留在底部持续滑动的话,_onScroll 会以每帧 60 次的频率触发,分页管理器再怎么防重入也扛不住这种无效调用。等加载完成、列表高度变化之后,maxScrollExtent 变大了,用户继续下滑时触发条件重新满足,_loadMoreTriggered 又会被置为 false,从而开启下一轮加载。
3.3 滑动到底部但数据未返回时的交互细节
这里要讲一个很容易被忽略的体验点:用户已经触底、请求已经发出去、但数据还没返回的这段时间,底部该显示什么。有的 App 选择什么都不显示,让用户自己感觉“卡住了”;有的 App 会在底部插入一个 loading 旋转条。推荐列表这种交互密集的场景,我建议一定要有明确的状态反馈,不然用户很容易误判为加载失败,直接退出页面。
推荐的底部状态组件至少要有四种视觉形态:
- 加载中:显示转圈加“正在加载更多”文案;
- 加载失败:显示“加载失败,点击重试”,整块可点击;
- 没有更多:显示“没有更多了”,字号要弱化,别抢内容焦点;
- 空闲且还有更多:什么都不显示,或者只保留一个极小占位。
这个底部组件建议用 AnimatedSwitcher 包一层,切换状态时加个淡入淡出动画。淡入淡出时间控制在 150 毫秒左右,不要长。因为推荐列表的刷新频率很高,底色切换、内容插入这些如果叠加复杂动画,鸿蒙端低端机的帧率会明显掉下来。
列表滚动位置的处理也要注意:加载成功之后,新的数据插入到列表末尾,如果用户当前正停在底部,那 Flutter 默认的行为是保持现有滚动像素位置,也就是说“视觉上用户还在底部区域”,新数据是在用户当前的视口下方追加的,用户需要再往上滑一点才能看到新内容。这个体验其实是自然的,不用额外调整滚动偏移。千万不要画蛇添足去尝试把滚动位置强制“顶”到新数据开头,那样用户会有一种被拉着跑的感觉。真正需要注意的反而是另一种情况:新数据加载完成后,如果列表本身的高度增长导致滚动位置出现跳动,通常是因为 Item 高度不稳定引起的——比如图片还没加载完成时高度为 0,加载完成后高度暴增。推荐列表的封面图最好显式指定宽高比,不要让它在列表中重新测量二次跳变。
4. 鸿蒙端实战中的页面组装与状态组件实现
讲完监听和触发策略,这一章给出一份可以直接参考的页面组装示例。这里我用的是 Flutter 里最通用的一套写法:ChangeNotifier + AnimatedBuilder 做状态通知,ScrollController 做滚动控制,底部用状态组件展示加载状态。这套组合在鸿蒙端跑得很稳,代码结构也清晰,对后续维护友好。
4.1 推荐列表页面的核心代码骨架
dart复制class RecommendListPage extends StatefulWidget {
const RecommendListPage({Key? key}) : super(key: key);
@override
State<RecommendListPage> createState() => _RecommendListPageState();
}
class _RecommendListPageState extends State<RecommendListPage> {
final _scrollController = ScrollController();
final _pageManager = RecommendPageManager<RecommendItem>();
bool _loadMoreTriggered = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadFirstPage();
}
@override
void dispose() {
_scrollController.dispose();
_pageManager.dispose();
super.dispose();
}
Future<void> _loadFirstPage() async {
setState(() {});
await _pageManager.loadFirstPage();
if (mounted) setState(() {});
}
Future<void> _loadMore() async {
final hasMore = await _pageManager.loadMore();
if (hasMore == false && mounted) {
setState(() {});
}
}
void _onScroll() {
if (!_scrollController.hasClients) return;
final position = _scrollController.position;
if (position.pixels >= position.maxScrollExtent - 400) {
if (!_loadMoreTriggered) {
_loadMoreTriggered = true;
_loadMore();
}
} else {
_loadMoreTriggered = false;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('推荐')),
body: AnimatedBuilder(
animation: _pageManager,
builder: (context, _) {
if (_pageManager.status == LoadStatus.error && _pageManager.items.isEmpty) {
return _buildErrorView();
}
if (_pageManager.status == LoadStatus.empty) {
return _buildEmptyView();
}
return RefreshIndicator(
onRefresh: _loadFirstPage,
child: ListView.builder(
controller: _scrollController,
itemCount: _pageManager.items.length + 1,
physics: const AlwaysScrollableScrollPhysics(),
itemBuilder: (context, index) {
if (index >= _pageManager.items.length) {
return _buildLoadMoreFooter();
}
return RecommendCard(item: _pageManager.items[index]);
},
),
);
},
),
);
}
Widget _buildLoadMoreFooter() {
if (_pageManager.status == LoadStatus.loading && _pageManager.items.isNotEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Center(child: SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))),
);
}
if (_pageManager.status == LoadStatus.error && _pageManager.items.isNotEmpty) {
return InkWell(
onTap: _loadMore,
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Center(child: Text('加载失败,点击重试', style: TextStyle(color: Colors.grey))),
),
);
}
if (!_pageManager.hasMore) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Center(child: Text('没有更多了', style: TextStyle(color: Colors.grey, fontSize: 13))),
);
}
return const SizedBox(height: 1);
}
}
这段代码里有一个细节值得展开:ListView.builder 的 itemCount 是 _pageManager.items.length + 1,多出来的那一个 index 就是底部状态组件。它的好处是让底部加载状态天然跟随列表滚动,而不是 fixed 在页面底部,这样用户在任意位置都能“感知”到当前列表是不是还有更多内容。用 ListView.builder 而不是 ListView(children: ...) 是为了保证 Item 懒加载、按需构建,推荐列表动辄几百条数据,一次性全量构建在鸿蒙端的资源开销会明显偏高。
另外,下拉刷新用 RefreshIndicator 包住列表,onRefresh 直接复用 loadFirstPage,这里 refresh 和 loadMore 的互斥已经在数据管理器里处理了,所以 UI 层不用管。还有一个容易踩的坑:如果推荐列表第一页接口返回为空,RefreshIndicator 默认无法下拉刷新,因为列表没有内容无法滚动。所以我给 ListView 加了 AlwaysScrollableScrollPhysics,保证列表内容不足一屏时也能下拉触发刷新操作。
4.2 鸿蒙端的差异化处理:模拟器、真机与日志排查
我再重点说下鸿蒙端运行时的几个差异化坑。第一个是模拟器问题。鸿蒙官方的模拟器在滚动流畅度上比真机差不少,尤其是复杂 Item 的列表页,模拟器上可能滑起来有可感知的掉帧,但这不代表你的代码在真机上也有问题。我建议上拉加载的帧率和卡顿验证一定要在鸿蒙真机上测,模拟器只能用来看布局和数据流程是否跑通。
第二个是日志。鸿蒙上调试 Flutter 应用,println 或者 debugPrint 的输出在 DevEco Studio 的 Log 面板里不一定稳定显示,需要通过命令行工具连接设备查看日志。指定查看 Flutter 相关日志时注意过滤条件,不然会被系统日志淹没。鸿蒙端的网络请求有没有发出去、响应状态码是多少,这些在网络层加日志直接打到控制台,排查上拉加载问题会快很多。
第三个是热重载的边界。Flutter 热重载在鸿蒙端的可用性不如 Android 端稳,尤其是修改了原生插件的注册逻辑或者改了数据管理器中的泛型定义,热重载有时不会生效,甚至会白屏。我的习惯是:纯 UI 层面的改动用热重载,涉及数据层和插件层的改动直接全量重启,省得浪费时间去排一个根本不是代码问题的问题。
第四个是图片资源的兼容。推荐列表里封面图如果来自网络,要注意 HTTPS 证书和图片格式兼容问题。鸿蒙端 Flutter 的网络图片加载在弱网环境下对超时时间更敏感,建议给图片加载加统一的占位图和错误图,避免因为某一张图挂了把整个列表底部加载状态都顶乱。图片加载失败在 debug 下经常表现为 Item 高度塌陷,视觉上就是“列表跳了一下”然后底部加载条上移,这种问题排查起来会很费劲。
4.3 底部状态组件的状态语义和 UI 规范
底部状态组件的每种状态对应一套明确的 UI 规则,最好在项目里定成团队规范,这样多个页面复用时不至于风格飘忽。我这边的推荐:
| 状态语义 | 触发条件 | UI 表现 | 可交互 |
|---|---|---|---|
| 初始态 | 首次进入页面,还没发请求 | 不渲染底部组件 | 否 |
| 加载中 | 正在请求第一页或更多数据 | 转圈 + “加载中” | 否 |
| 加载成功 | 请求成功且列表非空 | 不渲染额外提示 | 否 |
| 加载失败 | 请求失败 | “加载失败,点击重试” | 是 |
| 没有更多 | 服务端 hasMore 为 false | “没有更多了”,弱化显示 | 否 |
| 空数据 | 第一页请求成功但列表为空 | 页面级空状态(区别于底部) | 依赖业务 |
其中“没有更多”的展示是个有争议的话题。有的产品经理会说“没有更多不值得浪费 UI 位置”,但推荐列表如果滑到底没任何提示,用户会以为出 bug 了。反过来,如果每次都在底部显示大大的“没有更多了”,又很影响沉浸式浏览体验。我目前的做法是:文字用 12 到 13 号字,灰色,居中,上下留点 padding,不放在显眼的强调色里,让它在视觉层级里退到背景位置。用户不会觉得被打扰,但也能明确感知到内容到头了。
状态组件和列表 Item 的分隔也要处理一下。推荐列表的 Item 之间通常有间距,加载状态组件本身不算内容 Item,别让底部状态跟最后一个内容卡片挤得太近。padding 上下各给 20 左右比较合适,视觉上有呼吸感,也方便用户手指点按“点击重试”时不会误触到上方的内容卡片。
5. 竞态条件与状态机的坑:这是上拉加载最容易翻车的部分
上拉加载的代码写出来不算难,难的是把各种边界情况想全。项目里真正出问题往往不是第一次进入页面加载第一页,而是用户操作节奏比较乱的时候:刚进入页面就猛划到底部触发加载、加载还没完成又下拉刷新、刷新过程中又去点列表项、页面切后台再切回来等等。这些都属于竞态条件,处理不好会出现重复数据、列表闪烁、闪退这种让人头大的问题。
5.1 请求竞态:如何保证“总是最新一次请求说了算”
先想一个具体场景:用户进入推荐页,第一页数据还没回来,他快速下拉触发了刷新;刷新请求发出后,他又滑到底部触发了加载更多;此时第一页请求、刷新请求、加载更多请求同时在飞。三个请求的返回顺序是不确定的,如果后返回的请求反而覆盖了先返回的请求结果,列表数据就会出现错乱——比如刷新请求先发但后返回,加载更多的数据反而先返回并 append 到了旧列表后面,刷新结果一到又把整个列表 clear 掉重建,用户看到的画面就是列表内容闪了一下,甚至出现短暂的白屏。
解决办法是在数据管理器里加一个请求代号(generation/requestId)机制,每次发起一个“会重置列表”的请求时,代号加一;请求返回后,先检查自己的代号是不是当前最新的代号,如果不是就直接丢弃结果。只有“最新代号”的请求才有资格更新列表数据。这在 Flutter 里实现起来很简单:
dart复制int _requestSeq = 0;
Future<void> refresh() async {
final mySeq = ++_requestSeq;
final items = await repo.fetch(...);
if (mySeq != _requestSeq) return; // 过期响应,丢弃
_items
..clear()
..addAll(items);
notifyListeners();
}
Future<void> loadMore() async {
if (_isLoading || !_hasMore) return;
final mySeq = _requestSeq; // 加载更多沿用当前代
_isLoading = true;
final items = await repo.fetch(cursor: _cursor);
if (mySeq != _requestSeq) return; // 期间被刷新打断,丢弃
_items.addAll(items);
_isLoading = false;
notifyListeners();
}
这里 refresh 会把 _requestSeq + 1,意味着任何在刷新期间完成的 loadMore 都会因序号不匹配而被丢弃。这种“老请求让位给新请求”的策略,比单纯依赖 _isLoading 布尔值更可靠。因为 _isLoading 只能防住“自己人重复触发”,防不住“另一个操作把列表重置了”。
5.2 列表重置时机:下拉刷新时旧数据应该保留还是清空
下拉刷新是推荐列表里跟“上拉加载”必须协同处理的一个操作,但很多人对“刷新时列表显示什么”这个问题处理得很草率。有些实现是一下拉就 _items.clear(),然后界面瞬间变成空白转圈。这在网络好、响应快的场景还能忍,一旦弱网,用户可能盯着空白页好几秒,体验非常差。
我推荐的做法是“下拉后旧数据先保持,等新数据回来了再整体替换”。即刷新动作触发后,列表依旧显示旧内容,顶部出现一个细的 loading 条或者 RefreshIndicator 自带的转圈,数据返回成功后,把 _items 整体替换成新数据。这样用户始终在看内容,只是每隔一段时间内容会“滚动更新”一次。RefreshIndicator 本身自带“拉到一定距离才触发”的行为,所以视觉逻辑本身就是保留旧内容直到刷新完成。
对应到分页管理器里,refresh 方法不要在最开头 clear _items,而是到成功拿到新数据后再 clear + addAll。这样也顺便解决了一个问题:刷新失败时旧数据原封不动还在那里,用户并不感知刚才刷新失败了,只看到 RefreshIndicator 收回去,不会产生“内容怎么没了”的恐慌。如果你需要用户知道刷新失败,可以在这个位置弹一个轻量 Toast,但别用 Dialog 打断浏览节奏。
5.3 页面切后台与组件销毁后的异步回调防护
还有一个很有意思的坑:用户滑到底部触发了加载更多,然后在请求还没返回的时候,退出了页面。等网络响应回来之后,代码里如果直接 setState,Flutter 就会告诉你在 dispose 之后不能调用 setState,轻则报错,重则闪退。虽然现在 Flutter 在有 mounted 检查的情况下不会直接崩溃,但异常日志会把你埋点系统的错误率拉高,影响线上监控。
推荐的保护标准是:异步回调回来后,统一判断 mounted(State 生命周期场景)或者判断当前页面有没有 dispose(数据管理器场景)。数据管理器作为单独对象,如果页面销毁后还持有回调,很可能造成内存泄漏。所以页面销毁时有两个动作要做:一是 _pageManager.dispose() 释放监听器,二是在用到 context 的地方先判断 mounted。
另外鸿蒙端的生命周期跟 Android 有一个差异:应用退到后台之后,Flutter 引擎并不一定会立刻暂停 UI 相关的 Dart isolate,某些异步任务可能继续执行完。所以如果你在后台期间恰好有分页请求返回并触发了 UI 刷新逻辑,虽然用户看不见,但资源开销是实打实的。我遇到过的情况是:App 切后台挂了一晚上,第二天切回来列表直接卡顿了好几秒,排查发现是切后台时推荐的下一页请求一直重试累积了大量对象没有释放。后来加了页面可见性监听:不可见时不再触发分页请求,已有请求返回后也只更新数据不上报 UI 构建。推荐列表相关业务,这个点值得提前做好防护。
5.4 多入口触发同一列表的加载时如何收敛
最后一个场景,也是我在公司复盘时发现的“隐形炸弹”:同一个推荐列表页面被多个入口复用,比如首页的“为你推荐”Tab、搜索页的“相关推荐”模块、个人中心里的“猜你喜欢”,它们都指向同一个推荐列表组件。如果每个入口都独立创建自己的分页管理器,那没问题;但如果有入口复用了同一个分页管理器实例,那么多个页面同时在飞请求时,状态就会互相覆盖。
我的建议是:组件内部一定要自己管理数据生命周期,页面销毁时数据管理器跟着销毁,下次进入重新创建。永远不要让一个分页管理器长时间跨页面持有,除非你有明确的全局缓存诉求并且做了引用计数。推荐列表的核心价值是“每次进入都是新鲜的”,全局缓存会牺牲这一点,所以即使牺牲一些内存开销,也要保证每次进入页面数据都是干净、可重新加载的。
如果确实需要跨页面共享数据,比如从列表页进入详情页再返回列表页希望保持位置,那也不要把整个分页管理器做成单例,正确的做法是给列表页的 PageStorageKey 加上页面级缓存的 scroll offset,数据进入页面时重新请求。位置保持和数据处理分离,各管各的,逻辑才不会纠缠。
6. 真实项目中的一次踩坑排查:加载状态不更新
这一章单独讲一个我在鸿蒙端实际遇到的诡异问题,写出来给大家当排查思路的参考。这个 bug 是这样的:推荐列表在 Android 上跑得好好的,迁移到鸿蒙端之后,底部加载状态偶尔会卡在“加载中”不更新,转圈一直转,但数据其实已经加载完了。
最开始我怀疑是数据管理器里状态没有正确切换。仔细看代码,loadMore 成功之后我会 setState,底部组件根据 status 判断渲染。按理说 status 变成 success 之后,转圈就应该消失。但问题是卡住的时候 status 确实是 success,只是 UI 上没变化,这说明 setState 可能没触发重建,或者重建了但因为某些原因 AnimatedBuilder 的动画状态没刷新。
排查到最后发现,问题其实出在鸿蒙端 Flutter 的一个异步回调时序上:数据响应回来之后,我是在网络库的 callback 线程里直接调用了 setState,Flutter 这里会把它调度到 UI 线程,Android 上这个调度非常及时,鸿蒙端在某种 CPU 调度策略下会出现短暂延迟。如果此时用户刚好在快速滑动列表,UI 线程忙于处理滚动事件,setState 的调度可能就被推迟到好几帧之后,看起来就是“转圈停了几百毫秒才消失”。
这里真正让我意外的不是延迟本身,而是这个问题在模拟器上几乎复现不出来,只有在鸿蒙真机的低性能模式下才稳定复现。定位思路给大家整理成一个排查顺序:
- 先确认数据层 status 是否正确更新(打日志看 status 值);
- 再确认 UI 层是否收到 setState 通知(在 build 里打日志看走了哪个分支);
- 如果两者都正常但 UI 不刷新,检查是不是当前帧被其他耗时任务霸占;
- 鸿蒙端建议再确认一下是否存在原生侧线程调度造成的 setState 延迟问题,必要时把状态更新显式切到 postFrameCallback 里执行。
解决方式倒不复杂:数据请求回来之后,不直接 setState,而是调用一次 WidgetsBinding.instance.addPostFrameCallback 把状态更新放到下一帧的首部执行。这样能保证 UI 线程腾出手后再做数据刷新,避免和滚动事件抢时间。改完之后加载状态的更新明显平滑了,连续快速滑动加载十几页没有再出现卡住的情况。
这种问题本质上不是代码逻辑错了,而是跨端适配中“调度时机”的差异。Android、iOS、鸿蒙的 Flutter 引擎层实现不同,这类问题以后肯定还会遇到,关键是排查的时候不要一上来就怀疑自己的逻辑,先确认现象是不是集中在某个平台上,再逐步缩小范围。
7. 再进一步:推荐列表上拉加载的体验优化清单
上拉加载的流程全部打通之后,剩下的就是打磨体验了。根据我自己的习惯,每次做完一个列表页都会来回检查下面这份清单,看看有没有漏项:
- 首次加载:进入页面时是整页 loading 还是内容渐进式出现?推荐列表我更倾向保留一个骨架屏或者轻量的首屏占位,别一上来就是居中转圈;
- 加载中的反馈:列表底部是否有明确的加载中提示?文案是什么?是否在转圈之外还能让用户理解“这是正在补充内容”而不是卡死;
- 加载失败的处理:已有旧数据时是怎么呈现失败状态的?是否允许点击重试?失败状态是否会导致列表高度跳变?
- 没有更多的展示:hasMore 为 false 后是否停止了无效请求?底部文案是否足够弱化,不打扰浏览?
- 回到顶部的配合:用户深翻很多页之后,要不要提供一个回到顶部的快捷按钮?如果有,这个按钮的显示时机怎么定?通常滚动超过两个屏幕高度再出现比较合适;
- 列表位置保持:从详情页返回列表页时,是否保持了滚动位置?推荐列表的定位价值就在这里,用户划到第 50 条点进去看到一半,回来还想接着看,跳回顶部会非常崩溃;
- 数据一致性:返回列表页后,缓存的数据和最新数据怎么协调?我的策略是先用缓存撑住页面,在后台静默请求刷新,成功后再替换内容。
这份清单每次过一遍,虽然会花一点时间,但能挡掉大量线上问题。上拉加载这个东西,实现起来门槛不高,但把细节抠到位和粗粗写完差别很大。尤其是推荐列表这种承担核心业务指标的页面,体验上的每一点优势都会直接反映在留存和点击数据上。
最后分享一个我用了很久的小技巧:在做推荐列表上拉加载的功能开发时,先用模拟器把各种弱网状态都过一遍。把网络调成慢速,再配合 Flutter 的 debug 工具把列表滚动帧率打出来看,你会非常直观地看到加载过程中的掉帧出现在哪个环节。把掉帧的环节优化掉,鸿蒙真机上的表现自然也不会差。好的上拉加载体验不是靠一次性写出来的,是靠这样一遍一遍压测和优化磨出来的。
