去年年底把手上项目升到 HarmonyOS 5.0.0 之后,我接到的第一个“看起来贼简单”的需求就是列表页的下拉刷新。产品原话是“就要那种,拉下来有个圈圈在转,松手重新加载,动画顺一点”。听完我心想这不就是套个组件的事,结果真上手才发现,HarmonyOS 5.0.0 的 Refresh 组件虽然把下拉手势、回弹、状态回调都给你了,但“动画顺”这三个字背后藏着状态机、手势冲突、竞态请求、无效重渲染一堆细活。这篇文章就把我这套下来踩的坑、试过的方案、最终落地的封装完整摊开讲一遍:怎么在 HarmonyOS 5.0.0 及以上版本实现一个跟手的下拉刷新动画,并且可靠地重新加载内容。适合已经能写 ArkTS 页面、想把交互细节打磨到位的中级开发者,也适合刚接手列表页、不想在同一个坑里摔第二次的新手。
1. 先把需求拆穿:这个标题里藏着四个独立问题
1.1 从“能下拉”到“下拉好看”的距离
很多人看到“下拉刷新动画”第一反应是找组件、查参数、贴上跑通,完事。但真跑到测试机上,你会发现这四个问题其实是分开的:第一个是手势能不能被正确捕获,这在列表嵌套、空数据、横向 Tab 场景下都会翻车;第二个是刷新头长什么样、怎么跟手,官方默认那个小圈圈能不能满足设计要求;第三个是“重新加载内容”到底怎么定义,是全量重拉、增量补齐,还是拿缓存先渲染再静默对齐;第四个是动画节奏,什么时候开始转、什么时候停、停得太快会不会闪一下。
这四件事里,只有第一件是组件帮你做完的。第二件取决于你用默认 promptText 还是自定义 builder,第三件完全在数据层,第四件是体验层,也是最容易被忽略、最容易被用户感知的部分。我见过太多页面,功能全对,但下拉一下松手,刷新头“啪”地弹回去再“啪”地弹下来,或者数据回来了刷新头还挂在那儿转半秒,用户会本能地觉得这 App 卡。
所以我在动手前会先把这四件事写成一张清单,逐条确认边界:列表是单页还是分页?刷新失败要不要保留旧数据?空列表能不能下拉?页面切走再切回来,正在转的动画要不要停?这些问题想清楚了,代码结构基本就定了,写起来一点都不纠结。
1.2 平台给了什么,我们还得自己补什么
HarmonyOS 5.0.0 上的 Refresh 容器组件,本质是一个“带下拉手势的容器”:你把内容塞进去,它在顶部区域接管下拉手势,根据下拉距离维护内部状态,并在松手超过阈值时回调你一个 onRefreshing。与刷新过程相关的东西,它给了三样:refreshing 这个布尔开关(控制“转”还是“不转”)、一组可调的下拉参数(偏移量、摩擦、触发距离)、以及 onStateChange 状态回调让你知道用户现在拉到哪个阶段了。
它没给的,恰恰是最费工夫的:刷新头的内容完全空白,默认只有一个很小的提示文本;刷新过程中的旋转动画、成功后的对勾、失败后的重试按钮,全要你自己塞进 builder 里;刷新和数据的生命周期是解耦的,组件不知道你的请求发没发出去、成没成功,它只认 refreshing 这个布尔值。这意味着一个最常见的错误写法就是:onRefreshing 里发请求,成功了置 false,失败了忘了置 false,页面就永远卡在“正在刷新”。
还有一个隐含的能力缺口:Refresh 只允许放一个子组件。这不是限制,是设计——它需要独占那条手势链。所以当你要在同一下拉区域里同时显示列表和空态、错误态、骨架屏时,得自己用 Stack 或者 Column 把它们包起来,并且注意别让盖在上面的视图把手势吃掉。
2. 方案选型:三条路,我为什么只留一条
2.1 自绘手势、官方组件与三方封装的取舍
真要实现下拉刷新,市面上能走的路就那么三条,我当初认真对比过,最后结论很明确。
| 方案 | 实现成本 | 跟手程度 | 维护成本 | 我的评价 |
|---|---|---|---|---|
| 自己监听 PanGesture 做位移 | 高 | 完全可控 | 极高 | 只在需要“非标准”交互时才考虑 |
| 官方 Refresh 容器 + 自定义 builder | 中 | 好 | 低 | 绝大多数场景的正解 |
| 引入三方刷新组件库 | 低 | 取决于实现 | 中 | 依赖版本跟进,升级易翻车 |
自绘手势那条路我试过半天就放弃了。原因不是画不出来,而是边界情况太多:下拉过程中手指反向、多指、下拉到一半列表开始滚动、下拉时页面同时被外层 Scroll 接管,这些都要自己判。而且回弹曲线、阻尼手感这些细节,官方组件已经调得比大多数人手写的好。三方库的问题在于,HarmonyOS 5.0.0 之后不少老库的适配节奏跟不上,一旦 SDK 升级出了 API 变更,你只能等或者自己改。
官方 Refresh 唯一的“不足”是刷新头太素,但这恰恰是最容易补的——给个 builder 就完事。所以我最终的结论是:除非交互设计明确要求“下拉时整页跟着位移、头部渐变展开”这类非常规效果,否则一律用 Refresh,把精力花在自定义刷新头和数据层上。
2.2 Refresh 那几个参数,默认值里全是坑
参数不多,但每一个都值得单独写一行注释,因为它们直接决定手感。
refreshing是必填的布尔值,建议用双向绑定$$this.isRefreshing的方式传进去。为什么?因为你手动维护两个状态(组件内部的和我自己的)时,一旦某一刻两边不一致,就会出现“数据已经回来了但圈还在转”或者“圈停了但数据没回来”的诡异现象。双向绑定之后,组件回弹时会自己把值改回来,省掉一次手工同步。
refreshOffset是触发刷新所需的下拉距离。这个值我给的建议是:别用默认值,显式写。因为它的默认值与刷新头高度是绑定的,你把自定义 builder 做成 80vp 高,视觉上“拉满了”但实际没到触发线,用户会觉得“我明明拉到底了怎么不刷新”。我的习惯是让 refreshOffset 略大于刷新头高度,比如头 64vp、阈值 72vp,留一点点“拉满还要再拉一点”的确认感。
friction / pullDownRatio这两个控制阻尼。前者影响整体下拉的“重量”,后者影响超过阈值之后额外距离的折算比例。我的经验值是让超过阈值后的位移明显变“粘”——拉过线之后手指再往下走,刷新头只走一点点,这个反馈会让用户明确感知到“已经够了,松手吧”。参数调优没有标准答案,我一般会在真机上连着试三组,选那个“松手时不会突然弹跳”的。
pullToRefresh这个开关容易被忽略。设成 false 后组件不再响应下拉手势,但 refreshing 依然可以程序控制。什么时候用?做“点击右上角刷新按钮”的时候,你可以复用同一个刷新头动画,但不需要下拉手势,这时候关掉它,视觉上还能保持一致。
注意:Refresh 内部只有一个子组件槽位。放 Column、Stack 都行,但别再想往里塞第二个兄弟节点,编译能过,运行时的布局和手势行为会让你怀疑人生。
3. 把下拉拆成状态机:动画才不会乱
3.1 五个阶段与文案的对应关系
我发现一个规律:凡是下拉刷新做得别扭的页面,基本都是因为把“下拉”当成了一个连续的数值在处理,而没有当成状态机。实际上组件内部是有明确状态的,通过 onStateChange 回调给你,大致会经历这么几个阶段:静止、下拉中(还没到阈值)、下拉超过阈值、松手触发刷新、刷新中、刷新完成回弹。
这几个阶段对应的视觉反馈应当是截然不同的。静止时刷新头应该是收起的、不可见的;下拉中应该跟手,拉多少露多少,文案是“下拉刷新”这种引导语;超过阈值时文案要立刻变成“松手刷新”,同时给一个轻微的形变(比如箭头翻转、圆圈变色),这一下是给用户的关键确认;触发刷新后进入循环动画,文案变成“正在刷新”;结束回弹。
我见过最粗糙的实现是全程只显示一句“下拉刷新”,用户拉到底也不知道松手会不会生效。这个体验差距不是技术难度带来的,纯粹是状态没用起来。
实现上我建议这样:用一个@State statusText: string存文案,在 onStateChange 里根据状态赋值,同时用一个@State pullProgress: number(0 到 1)记录下拉进度,供自绘刷新头使用。注意别在 onOffsetChange 里做重活——这个回调在下拉过程中触发频率非常高,你在里面 setState 一个对象或者触发列表重建,掉帧是必然的。
3.2 自绘刷新头:跟着手指走的进度环
自定义刷新头有两种做法,我两种都用过,看场景选。
第一种是省事派:直接用系统自带的 LoadingProgress,配一行文案,用 Row 竖排居中。优点是零维护、深浅色自适应、性能稳。缺点是“没有辨识度”,产品经理一般会说“能不能有点设计感”。
第二种是自绘派:用 Canvas 画一个圆弧,圆弧长度随 pullProgress 变化,拉得越多弧越满,松手后变成旋转的完整圆环。这种感觉跟手性极强,用户会明显觉得“这个 App 是有反馈的”。代码大致是这样:
@Entry @Component struct RefreshDemoPage { @State isRefreshing: boolean = false; @State pullProgress: number = 0; @State statusText: string = '下拉刷新'; @State rotateAngle: number = 0; private settings: RenderingContextSettings = new RenderingContextSettings(true); private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings); private refreshOffset: number = 72; // 下拉进度变化时重绘,注意只在进度有意义的区间内触发 @Watch('onProgressChanged') @State progressForDraw: number = 0; onProgressChanged() { this.drawRing(this.progressForDraw); } drawRing(progress: number) { const w = this.ctx.width; const h = this.ctx.height; const cx = w / 2; const cy = h / 2; const r = Math.min(w, h) / 2 - 4; this.ctx.clearRect(0, 0, w, h); // 底环 this.ctx.beginPath(); this.ctx.strokeStyle = '#E6E9F0'; this.ctx.lineWidth = 3; this.ctx.arc(cx, cy, r, 0, Math.PI * 2); this.ctx.stroke(); // 进度弧,从 12 点方向顺时针 const sweep = Math.PI * 2 * Math.max(0, Math.min(1, progress)); this.ctx.beginPath(); this.ctx.strokeStyle = '#4E8CFF'; this.ctx.lineWidth = 3; this.ctx.lineCap = 'round'; this.ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + sweep); this.ctx.stroke(); } @Builder customHeader() { Column({ space: 4 }) { Canvas(this.ctx) .width(28) .height(28) .onReady(() => this.drawRing(this.progressForDraw)) .rotate({ angle: this.isRefreshing ? this.rotateAngle : 0 }) Text(this.statusText) .fontSize(12) .fontColor('#8A8F99') } .width('100%') .height(64) .justifyContent(FlexAlign.Center) } build() { Refresh({ refreshing: $$this.isRefreshing, offset: 64, friction: 0.8, builder: this.customHeader.bind(this) }) { List() { // 列表内容省略 } .width('100%') .height('100%') .edgeEffect(EdgeEffect.None) } .onOffsetChange((offset: number) => { // 只在 0~1 之间映射,超过阈值后进度封顶 const p = Math.min(offset / this.refreshOffset, 1); // 关键优化:按百分位取整,避免每一像素都触发重绘 const rounded = Math.round(p * 100) / 100; if (rounded !== this.progressForDraw) { this.progressForDraw = rounded; } }) .onStateChange((state: RefreshStatus) => { if (state === RefreshStatus.Drag) { this.statusText = '下拉刷新'; } else if (state === RefreshStatus.OverDrag) { this.statusText = '松手刷新'; } else if (state === RefreshStatus.Refreshing) { this.statusText = '正在刷新'; this.startSpin(); } else if (state === RefreshStatus.Done) { this.statusText = '刷新完成'; } else { this.statusText = '下拉刷新'; } }) .onRefreshing(() => { this.loadData(); }) } startSpin() { this.rotateAngle = 0; animateTo({ duration: 900, curve: Curve.Linear, iterations: -1 }, () => { this.rotateAngle = 360; }); } async loadData() { // 具体实现见第 4 节 } }这段代码里有两个细节值得单拎出来说。第一是builder: this.customHeader.bind(this),很多人直接写this.customHeader,编译不报错,但 builder 内部访问this.statusText时可能拿不到更新后的值,表现就是文案永远是初始值。绑定一次上下文,省掉半小时排查。
第二是 onOffsetChange 里的取整。下拉过程中这个回调的触发频率非常高,如果每次都用原始浮点数去赋值,@State 会因为“值变了”而触发重绘,一秒钟几十次,刷新头本身还好,但它所在的父容器如果一起重渲染,列表就开始抖。按百分位取整之后,触发次数直接降两个数量级,肉眼完全看不出差别。
还有那个无限旋转的 animateTo。iterations: -1会让动画一直转下去,效果很好,但刷新结束后如果不处理,这个动画会一直挂着,白白耗电。我的做法是在刷新结束、isRefreshing 置 false 之后,再调一次animateTo({ duration: 0 }, () => { this.rotateAngle = 0 })把它打断,或者干脆用条件渲染把 Canvas 换成一个静态图标。这一点在长时间停留页面上特别明显,测试同学会拿着电量曲线来问你的。
4. 重新加载内容的工程化写法
4.1 全量、增量与静默对齐该选哪个
“支持重新加载内容”这句话,产品说的是“重新加载”,但工程上有三种完全不同的含义,选错了轻则浪费流量,重则数据错乱。
第一种是全量重载:页码重置为 1,清空旧数据,重新拉第一页。这是最符合直觉的做法,绝大多数列表页都用它。它的问题是用户视觉上会经历一次“内容清空再填充”的闪烁,尤其在网络慢的时候,屏幕会白一下。解决办法有两个:要么不清空旧数据,等新数据回来了整体替换(替换是瞬间的,没有白屏);要么保留旧列表,只在顶部加一个细进度条。
第二种是增量补齐:只拉“上次之后新增的部分”,然后插到列表头部。适合消息流、订单列表这种数据有明确时间序的场景。好处是流量小、无闪烁,坏处是接口必须支持“since”这类参数,而且插入后要处理重复项。
第三种是静默对齐:界面完全不动,后台偷偷拉一次,比对差异后最小化更新。这种做法用户完全无感,适合“进入页面自动刷新”而不是“用户主动下拉”。用户主动下拉却什么都没发生时,他会怀疑是不是坏了,所以主动刷新必须给足反馈。
我的实践是:用户下拉时走全量重载,但不清空旧数据,新数据回来整体替换;同时给一个最短刷新时长(比如 400 毫秒),哪怕走缓存瞬间就拿到了,也让刷新头转够这个时间。原因很直白,如果 80 毫秒就转完,用户会觉得“它到底刷没刷”,反而会再拉一次。400 毫秒是个比较舒服的心理预期,低于这个数显得敷衍,高于 1.5 秒又显得卡。
4.2 竞态、超时与刷新锁
数据层这块,我总结了三个必须处理的点,缺一个都会在测试阶段被提 bug。
第一是竞态。用户手快,下拉一次没反应,再拉一次,两个请求同时在飞。如果第一个请求后返回,它会把第二个请求的新数据覆盖成旧数据,表现就是“刷新了但内容没变”。解决办法是给每次请求打一个自增序号,回来时比对:
private requestSeq: number = 0; async loadData(mode: 'refresh' | 'more' = 'refresh') { // 刷新锁:正在刷新时不重复发起 if (this.isRefreshing && mode === 'refresh') { return; } const seq = ++this.requestSeq; if (mode === 'refresh') { this.isRefreshing = true; } try { const res = await this.fetchPage(1); // 刷新永远是第一页 if (seq !== this.requestSeq) { return; // 已被更新的请求覆盖,直接丢弃 } this.listData = res.items; this.page = 1; this.hasMore = res.hasMore; this.errorMsg = ''; } catch (e) { if (seq !== this.requestSeq) { return; } this.errorMsg = '刷新失败,请稍后重试'; // 关键:失败时不动旧数据,用户至少还能看到内容 } finally { if (seq === this.requestSeq) { this.isRefreshing = false; // 无论成功失败,必须关 } } }finally里那一行是整个函数最重要的地方。我见过不止一个项目因为异常分支漏了这行,导致刷新头永远转下去,用户只能杀进程。
第二是超时。网络请求不能无限等,尤其是下拉刷新这种用户主动发起、会盯着看的操作。给 8 到 10 秒的硬超时,超时就当失败处理,把 refreshing 关掉,让用户可以重试。比起到时候转一分钟,宁可早失败。
第三是刷新期间的交互锁。刷新中如果再触发一次下拉,应当被忽略,而不是排队。这也是我在上面加if (this.isRefreshing) return的原因。需要注意的是,这个锁只管“刷新”,不影响“加载更多”,两个动作的锁要分开管理,否则用户下拉刷新的时候底部上一页的加载会被误杀。
5. 完整实操:封装一个可复用的刷新容器
5.1 组件签名与目录结构
列表页一多,如果每个页面都复制一遍 Refresh、状态机、文案、旋转动画,改一次样式就是十处改动。所以我把它抽成了一个组件,对外只暴露四样东西:当前是否在刷新、刷新回调、内容构建器、以及刷新头的样式变体。
目录我习惯这样放:
entry/src/main/ets/ components/ refresh/ RefreshContainer.ets // 容器本体 RefreshHeader.ets // 刷新头(可替换) RefreshStatusText.ets // 文案策略 list/ ListContainer.ets // 列表封装 pages/ FeedPage.ets组件的对外签名大致是:接收一个@Prop refreshing(由父页面持有真实状态)、一个onRefresh: () => void回调、以及一个@BuilderParam content用于塞入列表内容。这里之所以让父页面持有 refreshing 而不是组件内部自己管,是为了让父页面在“从别处返回需要刷新”“下拉刷新的结果要同步给其他组件”这类场景下还能控制它。
有一点必须提醒:使用 @BuilderParam 的组件,尾随闭包只能赋值给唯一那个 BuilderParam,而且如果你在组件内还想再塞一层固定布局(比如统一的空态遮罩),得用 Stack 包起来,别指望在组件内部再放第二个尾随块。
5.2 核心实现逐段讲
容器组件的主体逻辑其实不复杂,难的是把“状态同步”这件事收干净。
@Component export struct RefreshContainer { @Prop refreshing: boolean; @Prop tipText: string = '下拉刷新'; onRefresh: () => void = () => {}; @BuilderParam content: () => void = this.emptyContent; @State innerStatus: string = '下拉刷新'; @State progress: number = 0; private threshold: number = 72; private spinAngle: number = 0; @Builder emptyContent() {} @Builder header() { Column({ space: 6 }) { Stack() { // 默认态:箭头,随进度旋转 Text('↓') .fontSize(16) .fontColor('#8A8F99') .rotate({ angle: this.progress >= 1 ? 180 : 0 }) .animation({ duration: 180, curve: Curve.EaseOut }) .opacity(this.refreshing ? 0 : 1) // 刷新态:系统加载圈 LoadingProgress() .width(26) .height(26) .color('#4E8CFF') .opacity(this.refreshing ? 1 : 0) } .width(30) .height(30) Text(this.refreshing ? '正在刷新' : this.innerStatus) .fontSize(12) .fontColor('#8A8F99') } .width('100%') .height(64) .justifyContent(FlexAlign.Center) } build() { Refresh({ refreshing: $$this.refreshing, offset: 60, builder: this.header.bind(this) }) { Column() { this.content() } .width('100%') .height('100%') } .onOffsetChange((offset: number) => { const p = Math.min(1, offset / this.threshold); const rounded = Math.round(p * 50) / 50; // 分成 50 档,够用了 if (rounded !== this.progress) { this.progress = rounded; } }) .onStateChange((state: RefreshStatus) => { if (state === RefreshStatus.Drag) { this.innerStatus = this.tipText; } else if (state === RefreshStatus.OverDrag) { this.innerStatus = '松手立即刷新'; } else if (state === RefreshStatus.Refreshing) { this.innerStatus = '正在刷新'; } else { this.innerStatus = this.tipText; } }) .onRefreshing(() => { this.onRefresh(); }) } }这里用@Prop refreshing而不是双向绑定,是因为双向绑定要求父子共享同一个可写状态,@Prop 是单向拷贝,父页面改了组件会同步,但组件自己回弹时改的是自己的副本。所以在组件里用$$this.refreshing会有一个隐患:组件把 refreshing 置回 false 之后,父页面的状态可能还是 true,下次刷新就会卡住。所以更稳的做法是:父页面负责置 true,也负责置 false,组件只负责上报事件。上面这段代码里,组件内部的$$this.refreshing回弹时会写自己那份,父页面在onRefresh回调里同步置 true,数据回来后再置 false,这条链是闭合的。
使用姿势大概是这样:
@Entry @Component struct FeedPage { @State isRefreshing: boolean = false; @State listData: FeedItem[] = []; @State errorMsg: string = ''; async onPullRefresh() { this.isRefreshing = true; const startAt = Date.now(); try { const res = await this.fetchPage(1); this.listData = res.items; this.errorMsg = ''; } catch (e) { this.errorMsg = '刷新失败,下拉重试'; } finally { // 最短刷新时长,避免一闪而过 const cost = Date.now() - startAt; if (cost < 400) { await this.sleep(400 - cost); } this.isRefreshing = false; } } build() { RefreshContainer({ refreshing: this.isRefreshing, onRefresh: () => { this.onPullRefresh(); } }) { List() { // 内容 } .edgeEffect(EdgeEffect.None) } } }5.3 空态、错误态和多列表场景的接入
真跑起来你会发现,列表为空的时候最容易被忽略。列表没数据,用户在空白区域下拉,如果空态视图是一个盖在列表上的 Column 并且不带手势穿透,那下拉手势就被它吃掉了,表现就是“没数据的时候刷不了”。
我的处理方式有两种,都很干脆。第一种是把空态塞进列表本身,作为 List 的一个 Item 渲染,这样下拉手势天然属于列表;第二种是空态用 Stack 覆盖,但给它加上.hitTestBehavior(HitTestMode.Transparent),手势继续往下传。前者更稳,后者更灵活,我一般用前者,因为空态通常也就一个插画加一句话,放在列表里完全没问题。
错误态则是另一种情况:刷新失败后刷新头收起了,用户需要知道发生了什么。这时候不要用全屏弹窗,太重,只需要在列表顶部插一条细提示条,写“刷新失败,下拉重试”,下次下拉成功自动消失。这个提示条的高度变化要带动画,不然突然出现会把列表顶一下,看起来像 bug。
多列表场景,比如顶部有横向 Tab,每个 Tab 下各自一个列表各自刷新,我的做法是给每个 Tab 维护独立的 refreshing 和 page 状态,用一个对象数组管理,而不是共用一份。共用一份的后果是:在 A 标签下拉到一半切到 B 标签,那个半拉的状态会错乱,甚至两个列表同时显示“正在刷新”。
6. 踩坑实录:文档里不会写的那些问题
6.1 手势冲突与嵌套滚动
下拉刷新最烦的问题永远是手势打架。我遇到过的形态有这几种。
一是 Refresh 里包 Scroll,Scroll 里又包 List,三层滚动叠在一起。手指往上推列表正常,往下拉到顶之后继续拉,外层 Refresh 能不能接住,取决于中间那层 Scroll 的嵌套滚动策略有没有配好。这种情况我一般直接劝退:把中间那层 Scroll 去掉,用 List 的 header 能力做分组头,层数少一层,冲突少一半。
二是列表顶部自带弹性回弹,下拉的时候列表先回弹一段,然后 Refresh 才开始响应,视觉上像卡了一下。解决办法很直接:给 Refresh 内部的列表设.edgeEffect(EdgeEffect.None),把顶部的弹性关掉,底部的加载更多如果需要弹性可以单独处理。关掉之后下拉动作会直接交给 Refresh,跟手感立刻回来。
三是刷新头高度变化引发的抖动。有些刷新头在“松手刷新”时会从 64vp 变成 80vp 显示更多信息,这一下高度变化会推动下面的内容,用户会觉得列表跳了一格。我的做法是刷新头固定高度,变化只做内部元素的透明度、位移和缩放,绝不改外层高度。
提示:如果页面本身不可滚动(比如内容很少),下拉手势确实可能被更外层的滚动容器截走。这种情况优先考虑让可滚动容器尽量内移,而不是去调 nestedScroll 的各种组合,后者调试成本高得多。
6.2 动画闪烁、刷新头跳变与“转不停”
这一类问题最影响观感,也最难在代码 review 里看出来。
闪一下就没这个前面提过,原因是数据太快回来,刷新头刚展开就被收起。加最短刷新时长能解决九成情况。剩下那一成是 refreshing 被连续置了 true、false、true,组件来不及响应,表现就是抽一下。
刷新头回弹时跳变,多见于自定义 builder 里用了if (this.isRefreshing)做条件渲染。因为刷新结束那一瞬间,条件从 true 变 false,整个子树被销毁重建,高度和位置都会重算,视觉上就是“啪”地换了一个东西。解决方式是用 opacity 切换而不是条件渲染,两个状态同时存在,只改透明度。
转不停,几乎百分之百是异常分支没关 refreshing。我在项目里给自己定了一条死规矩:凡是设置isRefreshing = true的地方,必须在同一个函数的 finally 里出现isRefreshing = false,不许分散在回调里。这条规矩救过我好几次。
还有一个隐蔽的:页面被切走(比如跳到详情页再返回)时,如果刷新还在进行,回来之后刷新头会保持展开状态,但其实请求早就在后台结束了。解决办法是把刷新状态和页面生命周期绑一下,页面重新可见时检查一次,如果请求已经结束但状态还是 true,主动置 false。
6.3 常见问题速查表
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| 下拉完全没反应 | 内容区没撑满且被外层容器吃掉手势 | 内容最小高度撑满,关闭内层顶部回弹 |
| 拉到底松手不刷新 | refreshOffset 大于实际可达下拉距离 | 显式设置阈值,并确认刷新头高度小于它 |
| 刷新头一直转 | 异常分支未关闭 refreshing | 统一在 finally 中置 false |
| 刷新后数据没变 | 并发请求,旧响应覆盖新响应 | 请求序号比对,丢弃过期响应 |
| 下拉时列表抖动 | onOffsetChange 中高频 setState | 进度取整后再赋值 |
| 刷新头一闪而过 | 数据返回太快 | 加 300 到 500 毫秒最短时长 |
| 空列表无法下拉 | 空态视图拦截了手势 | 空态塞进列表或设置手势穿透 |
| 切页面回来还在转 | 状态未与页面生命周期对齐 | 页面可见时校验并复位 |
| 刷新头文案不更新 | builder 未绑定上下文 | 使用 bind(this) 传 builder |
7. 性能与体验:那些“看不出问题但测得出问题”的细节
7.1 减少无效重渲染
下拉刷新是个高频交互,性能问题往往不是卡死,而是“微微不顺”,用户说不清哪里不对但就是觉得不够高级。
除了前面说的进度取整,还有两个点值得做。第一,列表数据替换时尽量整体赋值一次,而不是循环 push。this.listData = newArray只触发一次刷新,而 for 循环 push 十次会触发十次比对和十次布局计算,数据量大的时候差异很明显。第二,列表项要给它稳定的 key。用 ForEach 时要提供第三个参数 keyGenerator,返回一个唯一且稳定的字符串,比如数据 id;如果不给,框架会按索引来比对,刷新后数据顺序一变,所有 item 都重建,滚动位置还可能跳动。
对于超过几十上百条的长列表,直接换 LazyForEach 更省心。它的数据源需要实现一个 IDataSource 接口,说白了就是维护一个数组加一组监听器,对外提供总数、按索引取数据、注册和注销监听。封装一次之后所有列表都能复用,我现在的项目里基本看不到 ForEach 了。
7.2 动画的收尾与降级
无限旋转的动画一定要有明确的结束点。我在项目里加了一个小函数专门负责“停转并复位”,刷新结束、页面隐藏、组件销毁三个时机都调它。别小看这一下,长时间挂在后台的页面如果还在跑无限动画,是会被测试同学记上一笔的。
另外是低端机的降级策略。自绘 Canvas 进度环在中低端设备上、配合长列表滚动时,可能会掉帧。我的做法是准备两套刷新头:一套是 Canvas 跟手环,一套是纯系统的 LoadingProgress 加箭头,用设备性能等级或者简单的“列表长度超过阈值就降级”来切换。这个降级不需要做得很复杂,一个布尔值控制就行,但真到了低端机上,体验差别非常明显。
最后一个体验细节:刷新成功的反馈。如果刷新回来数据跟之前一模一样,用户可以合理地怀疑“是不是坏了”。我的处理是刷新完成后让刷新头短暂显示一个对勾,持续 300 毫秒左右再收起,同时在列表顶部如果数据没变,就保持原有滚动位置不动。这个小对勾成本极低,但用户对“刷新成功了”的确信度会高很多。
我个人在实际项目里的体会是,下拉刷新这个功能,代码量不大,但它把所有“交互与数据配合”的问题都压缩在了一次手势里。真正决定体验的不是你用了多花哨的动画,而是三件事:状态有没有对齐、异常有没有兜住、收尾有没有做干净。把这三件事捋顺了,剩下的就是调参数的手感问题,那部分没有任何标准答案,只能自己在真机上一遍遍拉,拉到你自己觉得“就这个感觉”为止。我通常会连着拉二十次,如果第二十次和第一次的感受一致,说明它稳了。