系列第 10 篇。本文复盘听书页上下两个进度条不一致、拖动后列表抽动、选中卡片每秒闪动的问题,并给出 ArkUI 响应式修复方案。

听书进度 UI

一、问题现象

听书页有两个进度展示:

  • 顶部播放器 Slider
  • 下方当前音频小卡片 Progress

一开始出现过:

  • 顶部显示 1:04,下方显示已听 3 分钟
  • 拖动顶部进度条,下方所有卡片抽动一下
  • 当前播放卡片每秒“一抽一抽”

这个问题在 HarmonyOS 设备上比桌面预览更明显。因为真机播放时,TTS 进度、setInterval() 计时器、列表 ForEach 渲染和 Slider 拖动事件会同时发生。一旦把“高频秒级状态”和“列表结构状态”混在一起,用户就会看到三个表面现象:

  • 文本时间在变化,但底部 Progress 条偶尔倒退一帧
  • 选中音频卡片边框和蒙层有轻微闪烁
  • 拖动结束后,列表像被整体重新 mount 一次

如果只盯着某个组件样式,很容易把问题误判成动画或过渡效果;但这个案例的根因其实是状态边界设计错误

听书页原始截图

为了把问题说透,下面不只给“改完后的代码”,还会把当时错误推理、调试过程、状态拆分方法和最终验收方式都完整写出来。这样你以后再遇到 ArkUI 的“局部组件频繁抖动”,可以直接套这套排查路径。

二、错误修复:把秒级进度塞进 ForEach key

曾经尝试这样做:

ForEach(this.audios(), (item: AudioRecord) => {
  this.audioRow(item);
}, (item: AudioRecord) => item.id + '_' + item.listenedSeconds.toString())

这样确实能刷新,但代价是:每秒重建整行 UI。视觉上就是当前卡片不停抖动。

拖动结束时如果再加:

this.audioListProgressRefreshToken++;

所有 key 都变化,整个列表都会抽动。

这类写法的危险点在于,它把 ForEach key 当成了“强制刷新按钮”。在 React、Vue、ArkUI 这类声明式 UI 里,key 的职责都不是通知框架“重新算一次”,而是描述“这是不是同一个节点”。一旦你把每秒变化的字段拼进 key,框架得到的信号就是:

  1. 旧节点不再存在
  2. 当前节点是一个全新的节点
  3. 之前的内部状态、布局缓存、过渡上下文都可以丢掉

所以表面上只是想刷新一行进度,实际发生的是整行卡片被销毁再创建。对于带蒙层、封面、按钮、边框和点击事件的复杂 row,这种重建每秒做一次,抖动几乎不可避免。

当时我还走过一个典型弯路:以为“只是当前播放项在抖”,那就只给当前项拼时间戳,其他项不用变。结果问题依旧,因为当前项恰恰是用户最关注的一行,哪怕只有一行被重建,也会被放大成明显的视觉闪烁。

三、正确思路:动态字段读 @State

列表 key 应保持稳定:

ForEach(this.audios(), (item: AudioRecord) => {
  this.audioRow(item);
}, (item: AudioRecord) => item.id + '_' + this.audioListProgressRefreshToken.toString())

而选中行的实时进度,不从 item.listenedSeconds 读,而从页面状态读:

private displayedAudioSecondsFor(item: AudioRecord): number {
  if (item.id === this.selectedAudioId) {
    return this.audioSeeking ? this.audioSeekSeconds : this.audioProgressSeconds;
  }
  return item.listenedSeconds;
}

这样 ArkUI 会根据 @State audioProgressSeconds 局部刷新文本和进度条,而不是重建整行。

这里的核心不是“换一个函数名”,而是把状态分层:

  • audioRecords 负责列表结构和静态信息
  • selectedAudioId 负责当前选中行身份
  • audioProgressSeconds 负责真实播放进度
  • audioSeekSeconds 负责拖动期间的临时进度
  • audioSeeking 负责当前 UI 是展示实时进度还是展示拖动预览

只要这五类状态职责清晰,ArkUI 的刷新就会自然变成“谁依赖谁,谁局部刷新”。这比手动 bump token 更稳定,也更容易维护。

在当前工程里,对应实现已经落在 D:\HuaweiDevelopFormalStudy\a_myHarmonyOSApplications\recordOfThreeKingdoms\library2\src\main\ets\pages\MainFrame.ets

private displayedAudioSeconds(): number {
  return this.audioSeeking ? this.audioSeekSeconds : this.audioProgressSeconds;
}

private displayedAudioSecondsFor(item: AudioRecord): number {
  if (item.id === this.selectedAudioId) {
    return this.audioSeeking ? this.audioSeekSeconds : this.audioProgressSeconds;
  }
  return item.listenedSeconds;
}

这段逻辑有两个实际收益:

  • 顶部主播放器和列表当前行读取的是同一套“显示值”,因此不会再出现上面 1:04、下面 3:00 的错位
  • 非当前行继续使用各自的持久化 listenedSeconds,避免整列因为一个播放器状态变化而被牵连

也就是说,不要问“怎样让列表全部重刷”,而要问“当前这个 UI 片段到底依赖哪一个最小状态”。这是后面所有优化成立的前提。

四、顶部 Slider 实现

Slider({
  value: this.displayedAudioSeconds(),
  min: 0,
  max: Math.max(1, this.selectedAudioDurationSeconds()),
  step: 1,
  style: SliderStyle.OutSet
})
.onChange((value: number, mode: SliderChangeMode) => {
  this.audioSeeking = true;
  this.audioSeekSeconds = value;
  if (mode === SliderChangeMode.End) {
    this.seekSelectedAudio(value, true);
  }
})

拖动过程中只更新临时 seek 状态;结束后再写入真实播放进度。

这里必须强调一点:Slider 是交互控件,不是最终状态源。用户手指还没离开之前,页面只能把当前位置视作“预览态”,不能立刻把它当成最终播放态写回模型,否则又会引出两类新问题:

  • TTS/后台播放逻辑可能基于这个中间值提前 seek,导致声音跳动
  • 列表里的“已听进度”被连续持久化,形成大量无意义写入

当前页面代码里,Slider 使用的就是这个思路:

Slider({
  value: this.displayedAudioSeconds(),
  min: 0,
  max: Math.max(1, this.selectedAudioDurationSeconds()),
  step: 1,
  style: SliderStyle.OutSet
})
  .onChange((value: number, mode: SliderChangeMode) => {
    this.audioSeeking = true;
    this.audioSeekSeconds = value;
    if (mode === SliderChangeMode.End) {
      this.seekSelectedAudio(value, true);
    }
  })

这个实现里有一个很关键的细节:顶部时间文本和 Slider value 都读 displayedAudioSeconds(),所以拖动过程中,用户看到的是完整一致的“预览结果”;只有在 SliderChangeMode.End 时,才真正落地到 seekSelectedAudio()

如果这里直接双向绑定真实播放状态,视觉上看似更“实时”,实际上会把“拖动中”和“已提交”的语义搅乱。复杂交互里,中间态单独建模几乎总比“复用一个状态顶到底”更可靠。

五、取消无差别 token bump

拖动结束不再执行:

this.audioListProgressRefreshToken++;

因为当前选中行已经通过 @State 自动刷新,不需要强制所有 row 重建。

audioListProgressRefreshToken 这个字段并不是完全不能存在。它仍然有用,但应该只服务于低频、结构性的刷新需求,例如:

  • 重置整个听书列表
  • 替换整套 mock 记录
  • 某次批量迁移后需要让列表重新对齐

D:\HuaweiDevelopFormalStudy\a_myHarmonyOSApplications\recordOfThreeKingdoms\library2\src\main\ets\pages\MainFrame.ets 里,它现在仍保留在 resetAudioPlaybackToDefault() 这样的低频场景里,这个边界是合理的。真正该删掉的是“每次拖动结束都 bump 一次”的路径。

判断标准可以非常直接:如果一个状态变化的频率是秒级、帧级、手势连续事件级,就不该再通过列表 key 去表达它。

六、进度百分比计算

private audioProgressPercent(item: AudioRecord): number {
  const duration: number = this.audioDurationSeconds(item);
  if (duration <= 0) {
    return 0;
  }
  return Math.min(100,
    Math.floor(this.displayedAudioSecondsFor(item) * 100 / duration));
}

选中行和顶部播放器读取同一套状态,因此可以保持一致。

这一步看起来只是小函数,实际上它是“数值是否统一”的最后一道口子。以前上下进度不同步,除了刷新抖动,还有一个原因是参与计算的输入并不一致:

  • 顶部播放器读的是当前播放态
  • 列表卡片读的是 item.listenedSeconds
  • 某些拖动瞬间还夹杂了临时 seek 值

只要这些来源没有统一,哪怕 UI 不抖,也会出现百分比和时间文案不一致的问题。现在改成 displayedAudioSecondsFor(item) 后,当前选中项的百分比、时间、Slider、已听分钟数都来自同一套状态,逻辑上就闭环了。

如果你在项目里遇到“两个 UI 都展示同一业务值,但偶尔对不上”,最该先检查的不是格式化函数,而是它们是不是从不同的状态源取值

七、经验总结

  • ForEach key 适合表达“身份”,不适合表达“每秒变化的状态”。
  • 秒级刷新应走 @State,不要靠 key 重建。
  • 普通 class 对象字段变化不一定能驱动 row 内刷新。
  • 拖动进度时使用临时状态,松手再提交。
  • UI 抖动往往不是动画问题,而是节点被频繁销毁重建。

八、调试定位过程

真正把这个问题定位清楚,靠的不是“肉眼多看几次”,而是把播放链路拆开观察。下面这些命令和日志片段,是我这次判断问题边界时最有用的部分。

先本地构建,保证改动没有引入新的 ArkTS 编译错误:

.\hvigorw.bat --mode module -p module=library2@default -p product=default assembleHap

再连接设备查看页面日志,重点盯住进度同步、TTS 完成回调和 seek 后状态:

hdc shell hilog | findstr MainFrame

如果只想看音频链路,建议加上更聚焦的关键词:

hdc shell hilog | findstr /i "audio trace tts deferred complete progress seek"

当前页面里已经有一组很实用的调试日志:

hilog.warn(DOMAIN, TAG,
  'audio trace %{public}s req=%{public}s life=%{public}s playing=%{public}s pause=%{public}s progress=%{public}d seg=%{public}s startedAgo=%{public}d',
  action, requestId, this.appLifecycleState, this.isPlaying ? '1' : '0', this.pauseRequested ? '1' : '0',
  progress, segmentInfo, startedAgo);

用这组日志可以验证三件关键事实:

  1. 进度值本身在正常递增,并不是播放器没更新
  2. 抖动发生时,问题主要出现在 UI 行重建,不在 TTS 进度计算
  3. 拖动结束后的状态切换点,正好是之前 token bump 触发整列刷新的时刻

我通常会按下面顺序排查:

  1. 先看数值有没有错,确认是不是业务状态问题
  2. 再看日志节奏和 UI 抖动是否同步,确认是不是渲染问题
  3. 最后检查 ForEach key@State 依赖和临时状态有没有串线

这个顺序很重要。因为如果你一开始就进到样式层或动画层,很容易在错误层面上修半天。

九、工程实现与验收清单

进度条同步问题看似是 UI 小问题,实际反映的是状态粒度设计。播放进度是高频状态,列表结构是低频状态,两者不能混在一起。

interface AudioUiState {
  currentAudioId: string;
  progressSeconds: number;
  durationSeconds: number;
  playing: boolean;
}

interface AudioListState {
  records: AudioRecord[];
  version: number;
}

progressSeconds 每秒都可能变化,而 records 只有新增、删除、排序时才变化。把它们拆开后,UI 刷新范围会小很多。

Slider({
  value: this.progressSeconds,
  min: 0,
  max: Math.max(this.durationSeconds, 1)
})
.onChange((value: number, mode: SliderChangeMode) => {
  if (mode === SliderChangeMode.End) {
    this.seekTo(value);
  }
})
场景 期望结果
正常播放 顶部进度每秒更新
列表滚动 进度更新不导致列表跳动
切换条目 当前项高亮正确
拖动 Slider 结束拖动后再 seek
删除记录 列表结构变化才刷新
后台恢复 进度与当前条目一致

除了表格里的功能验收,我还会额外做三轮手工验证:

第一轮,正常播放 30 秒以上,确认顶部时间、底部 Progress、当前卡片“已听”文案始终一致。

第二轮,连续多次拖动:

  • 向前拖到中间位置
  • 立刻向后拖回开头
  • 在播放中和暂停中各做一轮

这一步主要看 audioSeeking 临时态是否正确退出,拖动结束后有没有残留假进度。

第三轮,切后台再恢复:

  • 播放 10 秒后切后台
  • 等 5 到 10 秒再回前台
  • 观察是否出现恢复后顶部时间跳变、列表卡片未同步、当前项错位

如果这三轮都稳定,基本就说明“高频进度态”和“低频列表态”已经被拆干净了。

十、问题复盘

这次问题之所以值得单独写一篇,不是因为它难,而是因为它非常典型:很多移动端 UI bug 看起来是“某个组件表现异常”,本质却是状态模型没有收束。

这次踩坑可以归纳成三条:

  • key 当成刷新器,而不是身份标识
  • 把拖动预览态和真实播放态混为一谈
  • 把列表结构刷新和进度数字刷新放进同一条更新路径

如果只修表面症状,比如给组件加动画、去掉动画、加节流、减小刷新频率,通常只能把抖动“变轻”,不能把问题根除。真正有效的做法是反过来问:

  • 这个值是谁的真实来源?
  • 这个值变化时,最小应该刷新哪一块 UI?
  • 这个状态是持久态、临时态,还是结构态?

一旦这三个问题答清楚,很多看似复杂的交互问题会突然变简单。

十一、小结

第 10 篇真正想说明的不是某个 Slider 写法,而是 ArkUI 下处理高频交互的一个基本原则:稳定身份、拆开状态、只让依赖它的局部 UI 刷新

对应到这次听书页问题,就是:

  • ForEach key 只表达列表项身份
  • audioProgressSeconds 只表达真实播放进度
  • audioSeekSeconds 只表达拖动中的临时预览
  • 当前选中行和顶部播放器共用同一套显示状态
  • 只有低频结构变更才允许触发整列刷新

这样做之后,进度同步、拖动体验和列表稳定性会一起提升,而不是修好一个又带出另一个。

通过 10 篇文章,我们从项目架构、主题系统、首页、详情页、收藏笔记、地图、AI 听书、后台播放、TTS 并发到 ArkUI 刷新机制,完整复盘了一个 HarmonyOS 单机历史知识 App 的开发过程。

这个项目最大的收获是:移动端体验问题往往来自状态边界不清晰。数据、UI、系统能力、异步回调都需要有明确职责。

十二、下一篇与系列收尾

这个系列到这里先完成第一轮主线复盘。前 10 篇已经把这个三国志知识 App 从结构设计、内容页实现、收藏笔记、地图能力、AI 朗读、后台播放到高频 UI 刷新问题都串起来了。

如果后面继续写扩展篇,我更倾向于补两类内容:

  • 一类是“工程收尾”,例如数据导入、资源裁剪、构建发布和多设备适配清单
  • 一类是“体验深挖”,例如长文 TTS 的分段策略、后台恢复一致性、列表大数据量性能优化

如果你正在做 HarmonyOS 内容型 App,希望这组文章对你有一个清晰结论:先把状态模型设计清楚,再谈 UI 技巧,后续维护成本会低很多。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐