ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Flutter通用列表组件封装:分页、状态与刷新逻辑的工程化实践

Flutter通用列表组件封装:分页、状态与刷新逻辑的工程化实践 做 Flutter 开发这几年我调试过的 App 几乎没有一个离得开列表页。朋友圈是列表订单是列表消息通知是列表设置页面本身也是列表。列表展示这个东西在 Flutter 里看起来人人都会写——一个ListView.builder套几个 item配上 loading 状态就完事了。但项目写到中后期你会发现每个页面都在重复同一套“加载 → 展示 → 分页 → 空态 → 错误重试”的逻辑。这篇文章记录的就是我如何把 Flutter 里的列表展示逻辑从业务页面里抽出来封装成一个通用列表组件的过程。它不是什么高深架构是一套我自己踩过不少坑之后沉淀下来的实操方案适合已经开始写 Flutter、但觉得列表页面越写越乱的开发者参考。1. 列表抽取这件事值不值得做1.1 重复列表代码带来的真实痛点业务页面写到第三个的时候你就会注意到一个规律每个列表页的代码结构几乎一样。初始化时请求第一页数据转圈等待渲染列表滑到底部时加载下一页请求出错显示重试按钮没数据时显示“暂无内容”。这些逻辑看起来简单但真放在项目里每个页面都是各自独立写一份代码量和维护成本就是这么堆起来的。我印象最深的是一次改版产品经理提出要把所有列表页的 loading 样式统一换成骨架屏。本来是五分钟能搞定的事结果因为列表逻辑散落在七八个页面里每个页面的状态字段命名还不一样有的叫_isLoading有的叫_loadingMore有的用_status枚举有的直接靠if (list.isEmpty)判断。改一个页面发现另一个页面没改到最后前后端联调时 QA 还能在某个角落里点出一个旧样式的转圈。那次之后我下定决心列表展示这套东西必须抽取而且要抽得彻底。除了改版困难重复代码还会引发一类很隐蔽的问题分页状态不一致。同一个数据源A 页面用 page 从 1 开始B 页面用 page 从 0 开始A 页面判断没有更多数据用list.length pageSizeB 页面用hasMore字段。一旦业务要求两个页面的列表打通这些不一致就会变成莫名其妙的 bug排查起来非常费劲。1.2 抽取前后的收益对比我用一个表格直观说明抽取前后在一个中型项目里的差别假设项目里有 8 个列表页每个页面承担简单的分页、刷新、空态、错误重试能力。对比项抽取前抽取后单列表页平均代码量400-600 行100-150 行新增一个列表页耗时1-2 天0.5 天内统一修改 loading 样式需要改 8 个文件改 1 个组件分页逻辑出 bug 的概率每个页面都可能出只可能在组件层出团队成员接手新页面的成本需要通读整页逻辑只看业务 item 和请求方法这种差距不是凭空吹出来的。抽出通用组件后新增列表页只需要做三件事写一个请求函数、写一个 item widget、把它们传给通用组件。业务迭代时你只需要关注itemBuilder里的展示逻辑状态流转、分页、重试这些脏活累活组件内部全部消化掉了。1.3 什么时候不该抽不过我也吃过“过度抽取”的亏。有一段时间我沉迷于把什么都往组件里放导致通用组件参数越加越多光构造函数的可选参数就有十几个调用它的时候自己都记不住哪个参数是干嘛的。后来我给自己立了一个规矩只有至少出现三个相似页面时才考虑抽取通用的列表组件。如果项目里只有一两个列表页或者列表形态差异极大比如一个是商品瀑布流一个是纯文本通知列表硬抽组件反而会增加理解成本。你在封装时为了兼容两边的差异会不断加判断分支和可选参数结果就是组件比原来不用抽取时的页面代码还复杂。这个边界必须想清楚抽取的目的是让业务更简单而不是让架构看着更高级。2. 抽取前先想清楚它到底要管什么2.1 拆出“变”与“不变”任何抽取工作的第一步都是把需求拆成“变”的部分和“不变”的部分。这一步想清楚了整个组件设计的骨架就出来了。我做列表抽取时会拿一张纸把列表页的完整生命周期画出来然后逐项做标记。不变的部分包括分页参数的处理方式page、pageSize、hasMore 之间的关系、loading 状态和空态的展示逻辑、错误时的重试流程、下拉刷新的触发方式、滚动到底部时加载更多的判定。这些逻辑在任何列表页里都是一样的只要后端接口遵循大致的分页约定就能沉淀成通用逻辑。变的部分包括列表项的 UI 结构、数据源的类型、点击某个 item 时执行的业务动作、列表头部尾部是否需要特殊 widget。这些内容天然和具体业务绑定不可能也不应该抽到通用组件里所以它们通过参数传入或者通过回调暴露给外部。这个思路听起来简单但很多团队的列表组件抽得失败就是因为没有先做拆分直接把整个页面代码复制过来把ListView换成别人的ListView把Text换成别人的Text。这不是抽象这是搬运。2.2 列表组件里的三层职责我在设计通用列表组件时会把它内部按职责分成三层每一层只管自己的事层与层之间通过固定接口联通。数据层负责对接网络请求内部维护分页状态。请求函数由外部传入组件本身不关心请求是走 Dio 还是 HttpClient也不关心接口返回的是订单数据还是消息数据它只负责在合适的时机调用传入的请求函数并管理page、hasMore、isLoading这几个内部变量。状态层根据数据层当前的状态决定渲染什么界面数据没回来时渲染 loading数据为空时渲染空态请求出错时渲染错误重试数据正常时渲染列表。这一层在代码里不一定是独立 class更多是 build 方法里的状态分支但设计结构时必须有意识地和数据层分开避免状态逻辑和网络请求逻辑纠缠在一起。UI 层相对简单就是ListView.builder配合外部的itemBuilder渲染列表项。UI 层还需要处理滚动监听、下拉刷新控制器和加载更多的触发逻辑。这三层各司其职通用组件才hold住多种业务场景。2.3 组件通信别把数据流搞乱组件通信是面试题里出现频率很高的词在列表抽取场景中同样绕不开。我把这里的通信拆成两个方向来理解流入和流出。流入方向是外部如何告诉组件“你要请求什么数据”。这里只用构造函数传参就够。组件接收一个request函数和一个itemBuilder函数前者负责传数据源后者负责传 UI 渲染。有些页面还需要传EmptyWidget、ErrorWidget、header这些都可以一并作为构造参数传入不额外引入复杂的通信机制。流出方向是组件如何通知外部“列表发生了什么”。分页加载完成、下拉刷新完成、列表项点击等状态变化组件通过回调函数onLoadMore、onRefresh、onItemTap抛给页面。页面在回调里做自己的业务处理。我特别想提醒一点这个场景不需要引入 EventBus、Provider 这类跨组件通信方案。列表页面和列表组件是父子关系最基本的方式恰恰是最可靠的。很多开发者一听到组件通信第一反应就是找个全局事件总线结果两个页面之间产生了隐式依赖出了问题根本查不到是谁在监听谁在发送。列表抽取的通信认准构造传参加回调就够了。2.4 状态怎么表达枚举还是多个布尔值列表组件内部最容易写乱的就是状态管理。我看到过一种写法用三个 bool 变量表示状态_isLoading、_isEmpty、_hasError。三个变量组合出来理论上最多有 8 种状态但实际合法状态只有 4 种其他组合就是不可能出现的僵尸状态一旦不小心设置了界面会同时显示 loading 和错误提示用户根本看不懂。我在通用组件里使用一个枚举来收敛状态loading、empty、error、success。每次数据变化后重新计算一次状态把结果赋值给枚举变量构建 UI 时只根据这一个枚举分支判断。这样既减少了状态组合爆炸的嫌疑也让代码可读性高很多。用枚举表达状态还有一个额外的好处是方便打日志。组件内部状态流转时加一行 debugPrint 就能在控制台看到列表从 loading 到 success 再到 empty 的完整变化过程排查问题会非常直观。3. 动手封装一个能直接用的通用列表组件3.1 基础骨架通用组件怎么搭下面给出一个可以直接抄作业的通用列表组件骨架。我用泛型T表示列表项类型这样订单列表和消息列表都能用它。组件的构造参数看着不多是因为把复杂逻辑都收进了内部。class CommonListViewT extends StatefulWidget { const CommonListView({ super.key, required this.request, required this.itemBuilder, this.pageSize 10, this.physics const AlwaysScrollableScrollPhysics(), this.onListChanged, }); /// 请求分页数据的函数page 从 1 开始返回当前页数据 final FuturePageResultT Function(int page) request; /// 构建列表项 final Widget Function(BuildContext context, T item) itemBuilder; /// 每页条数 final int pageSize; /// 列表滚动物理效果默认允许回弹 final ScrollPhysics physics; /// 列表内部状态如数量、加载状态变化时的回调 final ValueChangedListStatus? onListChanged; override StateCommonListViewT createState() _CommonListViewStateT(); } class _CommonListViewStateT extends StateCommonListViewT { final ListT _items []; int _page 1; bool _isLoading false; bool _isLoadingMore false; bool _hasMore true; ListStatus _status ListStatus.loading; final ScrollController _scrollController ScrollController(); override void initState() { super.initState(); _loadFirstPage(); _scrollController.addListener(_onScroll); } Futurevoid _loadFirstPage() async { setState(() { _status ListStatus.loading; _hasMore true; }); try { final result await widget.request(1); if (!mounted) return; setState(() { _items ..clear() ..addAll(result.items); _page 1; _hasMore result.hasMore; _status _items.isEmpty ? ListStatus.empty : ListStatus.success; }); } catch (_) { if (!mounted) return; setState(() _status _items.isEmpty ? ListStatus.error : ListStatus.success); } widget.onListChanged?.call(_status); } Futurevoid _loadMorePage() async { if (_isLoadingMore || !_hasMore || _status ! ListStatus.success) return; _isLoadingMore true; final nextPage _page 1; try { final result await widget.request(nextPage); if (!mounted) return; setState(() { _items.addAll(result.items); _page nextPage; _hasMore result.hasMore; }); } catch (_) { // 加载更多失败时也允许用户再滑一次重试 } finally { if (mounted) { setState(() _isLoadingMore false); } } } void _onScroll() { if (_scrollController.position.extentAfter 200) { _loadMorePage(); } } override Widget build(BuildContext context) { return switch (_status) { ListStatus.loading const Center(child: CircularProgressIndicator()), ListStatus.error _buildError(), ListStatus.empty const Center(child: Text(暂无数据)), ListStatus.success RefreshIndicator( onRefresh: _loadFirstPage, child: ListView.builder( controller: _scrollController, physics: widget.physics, itemCount: _items.length, itemBuilder: (context, index) widget.itemBuilder(context, _items[index]), ), ), }; } }这个骨架的核心在于列表页开发者只需要关心request和itemBuilder其他事情组件全管。构造函数里特意把ScrollPhysics设为可配置是因为在某些页面里你希望列表禁掉回弹或者永远可滚动这个参数在嵌套滚动场景里尤其有用。3.2 分页逻辑抽出来之后细节在哪里分页逻辑看着只有几行代码实际上坑点不少。第一个坑是 page 的起始值。很多后端接口的 page 是从 0 开始的也有从 1 开始的还有传 offset 的。我在组件内部统一处理对外暴露的request函数返回一个PageResultT组件用page从 1 递增具体 page 和 offset 的换算放到业务请求函数内部做。第二个坑是“没有更多数据”的判定。我见过两种主流方式一种是根据返回数量判断return当前页数据.length pageSize就认为没有更多另一种是后端显式返回hasMore字段。我的建议是优先用后端的显式字段因为这能处理最后一条恰好吃满pageSize的情况。如果后端不提供再用长度判断兜底。组件里有一个_hasMore布尔值从外部结果直接赋值业务方在选择判断方式时有充足的自由度。第三个坑是防止重复请求。列表组件里最容易出现的问题是用户快速滑动到列表底部滚动监听在短时间内触发多次_loadMorePage。我在方法开头加了一道闸_isLoadingMore为 true 时直接 return。这个判断虽然简单但少了它在真实网络环境下几乎必现数据重复或界面抖动。分页和状态层还要想清楚一个问题加载更多失败时页面应该呈现什么。我的策略是静默失败允许用户再次上滑重试而不是弹一个明显错误打断用户。只有第一页加载失败时才显示整页错误因为第一页失败时列表里根本没有内容必须给用户一个明确的重试入口。3.3 下拉刷新和加载更多怎么配合下拉刷新我用了 Flutter 官方的RefreshIndicator组件它的onRefresh回调返回一个 Future刷新动画会在 Future 完成后自动收起。这里要注意一点RefreshIndicator默认只在列表可滚动时才生效。如果你的列表为空即空态页面此时下拉手势无法触发刷新用户会以为页面死了。解决办法有两种一种是在空态和错误态外面也包一层RefreshIndicator另一种是给ListView.builder设置physics: AlwaysScrollableScrollPhysics()。我在基础骨架里默认设置了physics: AlwaysScrollableScrollPhysics()目的就是保证内容不满一屏时用户依然可以下拉触发刷新。实际业务里如果某个列表页需要禁用下拉刷新再通过构造参数把 physics 传成NeverScrollableScrollPhysics即可。加载更多的触发方式我用的是ScrollController配合extentAfter判断。extentAfter表示当前滚动位置距离列表最底部的距离单位是逻辑像素。当这个值小于 200 时说明用户即将滑到底部此时触发加载更多。200 这个阈值是我实测下来比较舒服的太大会导致用户还在浏览中部内容时就开始加载下一页太小会导致请求来不及衔接列表出现短暂白屏。你也可以根据内容高度调整。RefreshIndicator和isLoadingMore之间有一个隐蔽的冲突刷新动作本身会请求第一页此时如果原来的加载更多请求还没结束两边都在改_items会出现同一批数据被追加两次。我的解决办法是刷新前强制标记_isLoadingMore false并且刷新完成后重建整个列表数据从根上避免脏合并。3.4 用泛型把组件做得更通用一个组件吃遍所有列表组件里的泛型T是这段实现的精髓。有了它同一个CommonListViewOrder能渲染订单列表同一个CommonListViewMessage能渲染消息列表互不干扰。泛型把“数据的类型”和“组件的行为”解耦组件不需要知道 item 内部有哪些字段它只需要在你需要它时把item原封不动交给itemBuilder。不过泛型也不是越通用越好。如果你的页面目标类型特别多样比如同一个页面既要展示订单又要展示用户卡片我建议还是把这个类型定义成页面级的数据模型而不是直接用Map或dynamic。用dynamic会让itemBuilder里全是强转和判空等于把类型安全抛掉了得不偿失。如果你要支持多种列表形态比如横滑列表、瀑布流可以在泛型组件基础上再做一层包装或者把内部的核心逻辑抽成一个Controller类让外部自由选择ListView、GridView或CustomScrollView。这个做法适合更复杂的场景常规项目里一个泛型ListView组件基本够用。配套代码里还需要一个PageResultT数据类class PageResultT { const PageResult({ required this.items, required this.hasMore, this.total 0, }); final ListT items; final bool hasMore; /// 后端返回的总数没有可以不传用于页面展示“共 xx 条” final int total; }有了这个数据类业务方只需要把接口返回的列表数据装进PageResult交还组件剩下的分页和刷新逻辑全部交给组件。4. 封装过程中的常见问题和排查实录4.1 itemBuilder 里写业务代码列表卡到掉帧列表组件封装完成后最常见的问题不是组件本身而是使用方把itemBuilder当成任意写代码的地方。有人在itemBuilder里直接做 JSON 解析有人在里面 decode 大图片还有人直接打印整个对象集合。这些操作都在 UI 线程执行列表一旦快速滑动掉帧就是必然的。理解这个问题的关键在于掌握ListView.builder的懒加载原理。它只会构建当前滚入可视区域的 item如果你一次性渲染 1000 条数据Flutter 也只会创建屏幕上可见的十几个 item这是它能支撑长列表的最重要原因。但如果你在itemBuilder内部做了重活每次创建 item 时都要卡上一会用户感知到的不再是首屏慢了而是滚动时的持续卡顿。我的排查办法是先看 Flutter DevTools 里的 Timeline定位到具体是哪一帧耗时过长再看paddingWidgets和debugPrintMarkers里有没有可疑的构建。一般抓到的都是Image解码或者Text文本布局问题。处理图片时优先用cached_network_image并设置合理的内存缓存处理复杂文本时考虑是否把富文本映射成了多个 widget 叠加。还有一点很多人忽略给 item widget 尽量加上const构造虽然itemBuilder内部无法完全消除重建但常量构造能省掉一部分 widget 重建的树 diff 开销。4.2 页面切换回来列表位置丢了通用列表组件封装之后最常见的一个体验 bug 是用户从列表页点进详情页返回后列表滚动位置直接回到顶部。这种问题在业务代码里不常见但被抽取后反而容易触发因为你把ScrollController封装在了组件内部页面无法轻易访问和干预。Flutter 官方对这类需求提供了标准的解决方案PageStorageKey。它会把Scrollable的偏移量自动存入PageStorage页面状态重建时恢复现场。给ListView.builder加上一个 key 就能生效。我推荐直接在通用组件内部给ScrollController配置一个带PageStorageKey的ScrollController或者要求使用方在传入列表组件时设置一个固定的pageStorageKey参数。ListView.builder( key: PageStorageKeyString(widget.pageStorageKey), controller: _scrollController, itemCount: _items.length, itemBuilder: widget.itemBuilder, )注意PageStorageKey必须搭配ScrollController才能完整保存位置。只用 key 不做其他处理时某些情况下列表数据刷新后位置恢复会不准确我加了PageStorage之后再配合_items数量变化时手动跳回原位置的补偿逻辑才彻底解决。这个坑一般是在多标签页切换或 Tab 嵌套滚动时才暴露出来单列表页不会遇到。4.3 异步 setState 报错和 Future 微任务队列Flutter 开发者在运行时经常遇到一类报错setState() called after dispose()。这类问题在列表抽取封装后更容易出现因为网络请求和页面销毁的时序不受组件本身控制。用户进入列表页后立即返回此时列表请求还没返回请求成功回调里执行 setState但页面已经被销毁了于是报错。我在基础组件里做了mounted检查也就是在异步回调里先判断if (!mounted) return;再执行 setState这能避免大部分崩溃。但有些场景下mounted为 truesetState 依然会出问题比如你在 build 方法里写了一行_controller.addListener却在 dispose 里忘了移除。组件反复创建销毁后监听器堆积导致回调被触发多次。这里想补充一个和 Dart 微任务队列相关的知识点。Future的then回调默认进入微任务队列微任务会在当前同步代码执行完毕后立刻排队执行不等下一个事件循环。这意味着如果你在initState里发起一个网络请求请求的then回调极有可能在initState还没完全结束前执行。此时如果调用了setState或者访问了尚未初始化的变量就可能出现诡异的空指针或状态不一致。我在封装列表组件时会刻意让请求方法的调用走异步 gap比如加一个Future.microtask或Future.delayed(Duration.zero)给组件一个完整的初始化周期。这种小细节平时不起眼但面试和实际项目遇到偶发崩溃时都会追到这一层。4.4 下拉刷新和加载更多互相打架列表组件在双刷场景下最容易出的问题是状态互踩。用户先触发上拉加载更多请求还没回来紧接着又下拉刷新此时两个请求并存可能导致加载更多的结果被刷新打断刷新完成后数据反而少了或者加载更多的结果把刷新后的新数据又追加回来了。我采用的策略是把刷新和加载更多做成互斥的。刷新时如果发现_isLoadingMore为 true先取消加载更多的后续处理或者直接等待加载更多请求结束再执行刷新。加载更多时如果发现正在刷新则直接返回不处理。实现上不复杂但需要把状态判断放在方法的入口处而且要保证setState的状态流转顺序正确。我踩过的一个坑是加载更多在finally里无条件把_isLoadingMore置为 false导致刷新过程中加载更多还在继续追加数据。另一个常见冲突是用RefreshIndicator包裹了GridView时横向滚动和纵向下拉手势识别出错。正确的做法是确保GridView的physics和RefreshIndicator的触发区域设置一致必要时用NotificationListener定制手势优先级。我一般只在竖滑列表里用RefreshIndicator横滑列表基本都禁掉下拉刷新免得手势冲突影响体验。4.5 运行环境相关的几个报错速查列表组件封装和使用过程中有时会遇到和业务逻辑无关的环境报错。我整理几个排查过的高频问题方便遇到时快速对照解决。报错现象常见原因处理方向E/flutter ... dart_vm_initializer.cc(41) unhandled exception未捕获的 Dart 异常常见于异步回调抛错给异常统一打点查看具体堆栈定位到哪段请求回调Gradle 报错You are applying Flutters main Gradle plugin imperatively using the apply script项目使用了旧式 Gradle 插件声明方式切换到新版plugins {}DSL同步 Gradle 和 Flutter 版本列表渲染出现异常模糊或像素错乱Flutter Impeller 渲染引擎在某些设备上的兼容问题临时切换回 Skia 渲染引擎排查确认是引擎 bug 还是组件问题新建项目后直接跑不起来Gradle 下载失败或 SDK 版本不匹配优先检查 Gradle 网络缓存、AGP 版本和 compileSdk 配置这些环境问题虽然不是列表抽取的核心内容但组件封装越通用跑的环境差异越大踩到的环境坑也就越多。遇到后不要急着改业务代码先根据报错堆栈判断是引擎、构建工具还是业务逻辑的问题能省下很多无效排查时间。4.6 组件里滚动位置丢失和列表数据粘连还有一个我在实际项目里踩过的坑是列表数据粘连即刷新后旧数据没有清空新数据直接追加在旧数据后面。这个问题的根源在于刷新方法的实现逻辑如果你用了addAll而不是clear addAll数据自然会粘连。基础骨架里的_loadFirstPage是先用_items..clear()再追加新数据的顺序不能颠倒。类似的坑还有刷新完成回调的时机。有人在下拉刷新时直接把请求结果填充给_items然后立刻结束onRefresh的 Future但这会导致RefreshIndicator的收起动画提前结束用户看到的是一个快速闪过的刷新动画体验很生硬。我的做法是在_loadFirstPage的setState之后再通过await一点间隔或者等待动画帧结束再返回让刷新动画完整走完。这个细节在两个连续刷新时尤其明显。5. 写在最后的工程化建议这一路封装下来我最大的感受是列表抽取的难点不在代码量而在边界感。哪些逻辑该收进组件哪些逻辑必须留在业务层这个度把握不好组件就会变成另一个“大杂烩页面”。我目前使用的一个小技巧是给通用列表组件和具体列表页命名定一套规范。组件层叫CommonListView具体页面文件叫xxx_list_page.dart里面只出现CommonListView、itemBuilder、request三个关键要素。页面读起来像一份“配置清单”而不是几百行状态机代码。团队接手页面时先看这个清单再看itemBuilder里的 UI基本十分钟就能上手改需求。如果你刚开始做列表抽取我的建议是不要一上来就照搬完整版组件。先写一个只支持分页加载的最简版本用到一个页面上确认逻辑可靠后再逐步加上下拉刷新、空态、错误重试。每加一个能力就保证它的状态流转是完整的而不是把所有功能堆在同一个组件里。这个迭代过程本身也是你对列表页背后通用逻辑理解不断加深的过程。最后再补一个个人习惯我会把列表组件里所有状态流转的日志开关单独抽出来线上环境关闭开发环境打开。这样项目跑着跑着突然出现列表异常时直接看控制台那几条状态变化日志能省下至少半天打断点的时间。列表展示的抽取解决的是重复问题而这些工程细节解决的是后续维护时的体验问题两者同样重要。
返回列表