【OpenHarmony/HarmonyOs 】52|弱网重试与加载态:把“请求失败”演进成可恢复的用户体验
2026/7/20 14:07:10 网站建设 项目流程

【OpenHarmony/HarmonyOs 】52|弱网重试与加载态:把“请求失败”演进成可恢复的用户体验 📶

本文基于当前项目CloudDBService.etsChatPage.etsChatDetailPage.etsLoginPage.ets的真实实现展开。
项目已经有基础加载状态、异常捕获和 Mock 降级,但在弱网、断网、超时、重复点击与页面切换场景下,还需要一套完整的可恢复状态机。

一、为什么弱网不是一个 catch 就能解决?

在开发机和稳定 Wi-Fi 下,请求通常只有两种结果:

  • 成功;
  • 失败。

真实移动网络却更复杂:

  • 网络已连接,但无法访问云服务;
  • 请求已经发出,响应迟迟不回来;
  • 用户从 Wi-Fi 切换到蜂窝网络;
  • Cloud DB Zone 尚未初始化完成;
  • 页面离开后,旧请求才返回;
  • 写入成功,但客户端没有收到确认;
  • 实时订阅建立后又临时中断;
  • 用户连续点击重试,产生并发请求。

因此,弱网治理不是“多写一个重试按钮”。

它至少包含四个部分:

  1. 可解释的加载状态;
  2. 有边界的自动重试;
  3. 用户可控的手动恢复;
  4. 防止重复提交与过期响应。

二、先看项目当前已经具备的基础

ChatPage已经声明:

@StateisLoading:boolean=true;

加载用户时使用了完整的try/catch/finally

async loadUsers() {this.isLoading =true;try{constzone = CloudDBService.getInstance().getZone();if(!zone) {this.chatList =this.getFallbackChatList();return; }constquery = CloudDBZoneQuery.where(AGCUser);constresult = await zone.executeQuery(query);this.applyChatList(result.getSnapshotObjects()); }catch(e) {this.chatList =this.getFallbackChatList(); }finally{this.isLoading =false; } }

这段实现已经解决了三个 MVP 问题:

  • 页面不会永久停在加载中;
  • 云端不可用时仍有内容可展示;
  • 异常不会直接击穿页面。

但它仍然无法回答:

  • 当前展示的是云端数据还是降级数据?
  • 用户应该等待、重试还是检查网络?
  • 自动重试是否正在进行?
  • 上一次成功数据是否应该保留?
  • 多次调用loadUsers()谁的结果生效?

三、布尔值无法描述完整请求生命周期

isLoading只能表达:

true=正在加载false=没在加载

它无法区分:

  • 首次加载;
  • 静默刷新;
  • 空数据;
  • 失败但有缓存;
  • 失败且无数据;
  • 等待自动重试;
  • 用户主动取消。

生产演进建议先定义明确状态:

enumLoadPhase {IDLE='idle', LOADING ='loading', SUCCESS ='success', EMPTY ='empty', RETRY_WAITING ='retry_waiting', ERROR ='error'}

再定义页面需要的字段:

@StateloadPhase: LoadPhase = LoadPhase.IDLE;@StateloadErrorText: string ='';@StateretryCount: number =0;@StatenextRetrySeconds: number =0;@StateisRefreshing: boolean = false;

核心原则是:

状态必须能直接驱动 UI,而不是让 UI 猜测请求发生了什么。


四、首屏加载与刷新加载必须分开

首次进入页面时没有任何数据。

这时适合显示:

  • 骨架屏;
  • 居中进度条;
  • “正在连接云端”提示。

如果已有聊天列表,只是后台刷新,则不应该清空页面。

推荐状态规则:

场景列表加载提示失败处理
首次加载暂无全屏加载态全屏错误态
下拉刷新保留顶部刷新态Toast 或轻提示
自动重试保留弱提示到上限后显示重试入口
分页加载保留底部加载态底部重试项

如果每次请求都把列表清空,弱网下页面会不断闪烁。

正确思路是:

旧数据继续可读 ↓ 后台尝试更新 ↓ 成功后原子替换 ↓ 失败时保留旧数据

五、错误态也要按“是否有数据”分级

同样是请求失败,用户体验完全不同。

场景 A:首屏无数据

适合显示完整错误页:

  • 请求失败原因;
  • 检查网络提示;
  • 重新加载按钮;
  • 必要时提供游客或离线入口。

场景 B:已经有历史数据

不应遮挡整个页面。

可以显示:

  • 顶部“当前为离线内容”;
  • 非阻塞 Toast;
  • 列表继续可操作;
  • 恢复网络后静默刷新。

场景 C:发送消息失败

不能只写日志。

消息气泡需要呈现:

  • 发送中;
  • 已发送;
  • 发送失败;
  • 点击重试。

这三类错误态不能复用同一块全屏 UI。


六、不要对所有错误无脑重试

适合重试的错误通常是暂时性故障:

  • 网络瞬时断开;
  • 请求超时;
  • 服务端临时不可用;
  • Cloud DB 连接正在恢复。

不适合自动重试的错误包括:

  • 用户未登录;
  • 权限规则拒绝;
  • 查询字段错误;
  • 对象类型未配置;
  • 参数非法;
  • 账号状态异常。

如果权限错误也自动重试三次,只会:

  • 延迟真正错误的展示;
  • 增加无效请求;
  • 让日志更难阅读;
  • 让用户误以为应用卡住。

因此要先做错误分类,再决定恢复策略。


七、建议建立统一错误分类

可以定义业务层错误类型:

enumRequestErrorKind {NETWORK='network', TIMEOUT ='timeout', AUTH ='auth', PERMISSION ='permission', CONFIG ='config', UNKNOWN ='unknown'}

分类结果不应依赖页面到处解析字符串。

推荐链路:

SDK 原始异常 ↓CloudDBService统一归类 ↓ 业务方法决定是否重试 ↓ 页面映射成用户可理解文案

页面不需要知道 SDK 的全部错误结构。

页面只需要知道:

  • 是否可重试;
  • 是否需要重新登录;
  • 是否可以继续展示旧数据;
  • 应显示什么提示。

八、指数退避比固定间隔更适合移动端

固定每秒重试一次的问题是:

  • 网络持续不可用时浪费电量;
  • 多个客户端会同时冲击服务;
  • 用户切网过程中产生大量无效请求。

推荐指数退避:

1次失败:1 秒后重试 第2次失败:2 秒后重试 第3次失败:4 秒后重试 第4次失败:8 秒后重试

再加一个最大等待时间:

实际等待 =min(基础间隔 ×2^重试次数, 最大间隔)

还可以加入少量随机抖动:

最终等待=实际等待 + 随机抖动

随机抖动能避免大量设备同时恢复网络后一起请求。


九、一个适合当前项目的重试参数

聊天应用强调及时性,但也不能无限请求。

建议初始配置:

参数建议值
首次延迟800ms
最大自动重试3 次
最大间隔8s
单次超时10s
随机抖动0~300ms
后台恢复静默重试一次

这些值不是固定答案。

后续应根据:

  • 平均请求耗时;
  • 超时占比;
  • 用户主动重试频率;
  • Cloud DB 错误码分布;
  • 不同网络类型成功率;

持续调整。


十、重试执行器要避免 ArkTS 不兼容写法

建议使用明确接口:

interfaceRetryOptions{maxAttempts:number;baseDelayMs:number;maxDelayMs:number;timeoutMs:number;}

执行器负责:

  1. 启动一次任务;
  2. 记录尝试次数;
  3. 判断错误是否可重试;
  4. 等待退避时间;
  5. 达到上限后抛出明确错误。

不要让每个页面各写一套setTimeout()

否则登录、聊天列表、消息发送会出现不同规则。


十一、超时必须由业务层主动定义

很多 SDK Promise 不会按页面预期快速失败。

弱网下可能长时间 pending。

业务上需要定义:

超过10秒没有结果 不等于云端一定失败 但等于本次页面交互已经超时

超时后可以:

  • 页面退出阻塞加载;
  • 标记本次任务过期;
  • 提供手动重试;
  • 保留后续结果但不覆盖新状态。

注意:

Promise 超时不代表底层请求一定被取消。

所以还需要请求版本号防止旧结果回写。


十二、用请求版本号解决“后发先至”

假设用户连续触发两次加载:

请求A:弱网,5秒返回 请求B:网络恢复,1秒返回

B 先返回并更新了新数据。

A 随后返回旧数据,又把页面覆盖回去。

可以维护请求序号:

privateloadRequestId:number=0;

每次请求开始:

this.loadRequestId +=1;constcurrentRequestId =this.loadRequestId;

结果返回时判断:

if(currentRequestId !==this.loadRequestId) {return; }

这样只有最后一次请求有权更新页面。


十三、页面销毁后不能继续更新状态

页面离开时,网络任务可能仍在执行。

建议维护页面活跃标记:

privateisPageActive:boolean =false;

生命周期中更新:

aboutToAppear(): void {this.isPageActive =true; } aboutToDisappear(): void {this.isPageActive =false;this.loadRequestId +=1; }

异步结果写入前检查:

if(!this.isPageActive) {return; }

这能避免:

  • 离开页面后弹错误提示;
  • 已销毁页面更新@State
  • 旧会话结果污染新会话。

十四、CloudDBService 初始化也需要状态机

当前服务只有:

privateisInitialized:boolean =false;

并发页面同时调用init()时,可能重复初始化。

建议演进为:

IDLE↓ INITIALIZING ↓ READY ↓ FAILED

并缓存初始化 Promise:

privateinitPromise:Promise<void> |null=null;

这样多个调用方可以等待同一个任务。

初始化失败后也要允许下一次重新发起,而不是永久卡在错误状态。


十五、zone === null不应该只有一种含义

当前多个页面把空 Zone 直接视为 Mock 降级。

但空 Zone 可能表示:

  • 用户处于游客模式;
  • 服务尚未初始化;
  • 初始化正在进行;
  • 配置错误;
  • 网络暂时不可用。

这些情况的 UI 不应完全相同。

建议服务层提供明确状态:

enumCloudZoneState {IDLE='idle', OPENING ='opening', READY ='ready', OFFLINE ='offline', FAILED ='failed'}

页面由状态决定:

  • 等待;
  • 降级;
  • 重试;
  • 提示配置错误。

十六、聊天列表的演进方案

当前loadUsers()可以按以下阶段演进。

阶段 1:保留现有 finally

确保任何路径都结束加载态。

阶段 2:区分首屏与刷新

已有列表时不显示全屏加载。

阶段 3:增加自动重试

只对网络与超时错误重试。

阶段 4:增加请求版本控制

避免过期结果覆盖。

阶段 5:展示数据来源

Mock、缓存、云端数据要可区分。

阶段 6:接入指标

统计成功率、耗时与重试效果。

这条路线不要求一次推翻现有页面。


十七、消息发送不能在点击后立刻“假装成功”

当前发送逻辑是:

await zone.executeUpsert(msg);

UI 更新依赖快照监听。

弱网下会出现空窗期:

  • 用户点击发送;
  • 输入框已清空;
  • 消息还没出现在列表;
  • 用户不知道是否点成功。

推荐本地先插入临时消息:

LOCAL_SENDING↓ 云端写入成功 ↓ SENT

失败则变成:

LOCAL_SENDING↓ 达到重试上限 ↓ FAILED

用户点击失败图标后,再转回LOCAL_SENDING


十八、重试发送必须保证幂等

如果第一次写入其实成功,只是响应丢失,第二次重试可能创建重复消息。

解决关键是:

同一条逻辑消息的每次重试必须复用同一个消息 ID。

不要在每次尝试里重新执行:

msg.setId(Date.now().toString());

正确流程是:

  1. 用户点击发送时生成一次 ID;
  2. 本地消息记录这个 ID;
  3. 每次重试复用该 ID;
  4. Cloud DB Upsert 根据相同主键覆盖;
  5. 快照到达后按 ID 合并去重。

幂等比“多重试几次”更重要。


十九、发送按钮需要防重复点击

可以维护:

@StateisSending:boolean=false;

但全局isSending会阻塞连续发送不同消息。

更合理的是每条消息有自己的发送状态:

enumMessageSendState {SENDING='sending', SENT ='sent', FAILED ='failed'}

这样可以:

  • 连续发送多条消息;
  • 单独重试失败消息;
  • 不让同一条失败消息重复并发重试。

二十、实时订阅断开后的恢复策略

subscribeSnapshot()不是建立一次就永久可靠。

页面应考虑:

  • 首次订阅失败;
  • 监听中途报错;
  • 应用切后台;
  • 网络切换;
  • 页面重新进入。

推荐恢复流程:

关闭旧 ListenerHandler ↓ 等待退避时间 ↓ 执行一次增量或最近消息查询 ↓ 重新建立订阅 ↓ 按消息 ID 合并去重

不能每次错误都直接新增监听。

否则会形成多个监听器,导致:

  • 同一快照重复处理;
  • 列表重复刷新;
  • 页面退出后仍占用资源。

二十一、加载 UI 应该直接引用状态

HarmonyOS 声明式 UI 中,状态必须直接驱动组件。

推荐结构:

@BuilderbuildLoadContent() { if (this.loadPhase === LoadPhase.LOADING) { Progress() Text('正在加载会话') } else if (this.loadPhase === LoadPhase.ERROR) { Text(this.loadErrorText)Button('重新加载') } }

不要提前把状态计算成固定字符串再传递到不响应的路径。

尤其是倒计时:

Text('将在 '+ this.nextRetrySeconds +' 秒后重试')

要在 UI 中直接读取@State


二十二、弱网下骨架屏不是越久越好

骨架屏适合短暂等待。

如果超过一定时间仍无结果,应切换为可操作状态。

推荐时间感知:

0~300ms:不急着展示加载动画 300ms~3s:展示轻量加载态 3s~10s:展示“网络较慢”提示 超过 10s:结束阻塞,提供重试

这样可以减少快速请求时的闪烁,也避免用户无限等待。


二十三、错误文案要告诉用户下一步

不推荐:

加载失败

更有效的是:

网络连接不稳定,已为你保留上次内容

或者:

暂时无法连接云端,请检查网络后重试

权限错误则应该明确:

登录状态已失效,请重新登录

用户文案不要直接显示完整 SDK 异常 JSON。

详细异常应进入开发日志和可观测数据。


二十四、Mock 降级必须对用户和测试可辨识

当前项目在 Zone 不可用时使用 Mock 数据,适合演示。

但生产环境如果静默混入 Mock,会带来风险:

  • 用户误以为虚拟联系人是真实联系人;
  • 测试人员无法判断云端是否成功;
  • 错误被漂亮页面掩盖;
  • 真实数据与示例数据可能混合。

建议环境分级:

环境降级策略
开发环境可展示 Mock,并显示调试标识
演示环境明确标注“演示数据”
生产环境优先缓存,禁止伪造真实业务数据

生产降级应使用“上次成功数据”,而不是固定 Mock。


二十五、缓存是弱网体验的重要组成

只有重试,没有缓存,用户仍然只能看加载动画。

聊天列表适合缓存:

  • 好友昵称;
  • 头像地址;
  • 最后一条消息摘要;
  • 最后活跃时间;
  • 未读数快照。

缓存读取流程:

先读本地缓存 ↓ 立即展示 ↓ 后台请求云端 ↓ 成功后更新 UI 和缓存

缓存不能替代云端权限校验。

退出账号后要按用户隔离或清理缓存。


二十六、网络恢复后不要刷新所有页面

全局网络恢复事件可能同时唤醒多个模块。

如果登录、聊天、动态、好友申请一起请求,会形成瞬时峰值。

更稳妥的策略:

  • 当前可见页面立即刷新;
  • 后台页面等到重新显示再刷新;
  • 发送队列按顺序恢复;
  • 实时订阅先关闭旧句柄再重建;
  • 使用轻微抖动错开请求。

恢复并不等于全量重启应用数据层。


二十七、建议记录哪些弱网指标?

至少记录:

  • 请求类型;
  • 首次成功耗时;
  • 最终成功耗时;
  • 自动重试次数;
  • 手动重试次数;
  • 超时次数;
  • 最终错误分类;
  • 是否使用缓存;
  • 是否发生过期响应丢弃;
  • 订阅重连次数。

不要记录:

  • 聊天正文;
  • 用户令牌;
  • 完整账号隐私信息;
  • AGC 密钥或配置秘密。

可观测性必须遵守最小化原则。


二十八、测试矩阵不能只测“关闭 Wi-Fi”

建议覆盖:

首次加载

  • 正常网络快速成功;
  • 慢速网络最终成功;
  • 超时后自动重试成功;
  • 三次重试全部失败;
  • 无缓存失败;
  • 有缓存失败。

消息发送

  • 一次成功;
  • 写入成功但响应延迟;
  • 第一次失败、第二次成功;
  • 重试仍失败;
  • 连续点击同一重试按钮;
  • 多条消息并行发送。

页面生命周期

  • 请求中返回上一页;
  • 快速进入两个不同会话;
  • 前后台切换;
  • 网络切换后重建订阅;
  • 页面销毁后旧请求返回。

数据一致性

  • 重试不产生重复消息;
  • 旧响应不覆盖新响应;
  • Mock 不混入生产数据;
  • 缓存按账号隔离。

二十九、推荐的分阶段落地计划

第一阶段:让失败可见

  • isLoading升级为LoadPhase
  • 增加无数据错误页;
  • 有数据时保留旧列表;
  • 增加手动重试入口。

第二阶段:让请求可恢复

  • 增加错误分类;
  • 增加超时;
  • 增加最多三次指数退避;
  • 增加请求版本号。

第三阶段:让写入可重试

  • 本地生成稳定消息 ID;
  • 消息增加发送状态;
  • 失败气泡支持单条重试;
  • 按消息 ID 去重。

第四阶段:让系统可观测

  • 统计耗时与重试次数;
  • 监控订阅重连;
  • 分析错误分类;
  • 用数据调整参数。

三十、常见反模式速查

反模式后果改进
所有错误都重试权限错误也反复请求先分类再重试
无限自动重试耗电、耗流量次数与间隔设上限
重试时生成新 ID重复消息复用逻辑消息 ID
刷新前清空列表页面闪烁保留旧数据
只用isLoading状态表达不足使用加载状态机
超时后仍接收旧结果新数据被覆盖请求版本控制
页面销毁后更新状态异常 UI 行为活跃标记与失效序号
Zone 为空就静默 Mock掩盖真实故障区分环境与原因
订阅失败直接再订阅多监听泄漏先关闭再重建
展示完整异常 JSON用户无法理解映射业务文案

三十一、上线前检查清单

  • 首屏加载、刷新加载、分页加载是否分离;
  • 错误态是否区分有数据和无数据;
  • 自动重试是否只覆盖暂时性错误;
  • 是否设置最大重试次数;
  • 是否使用指数退避;
  • 是否定义业务超时;
  • 是否防止旧请求覆盖新请求;
  • 页面离开后是否停止状态写入;
  • 消息重试是否复用同一 ID;
  • 发送失败是否有可见状态;
  • ListenerHandler 是否正确关闭;
  • 网络恢复时是否避免请求风暴;
  • Mock 与生产数据是否严格隔离;
  • 缓存是否按用户隔离;
  • 日志是否避免记录聊天正文与凭证;
  • 是否完成断网、弱网、切网和超时测试。

三十二、总结

当前项目已经具备弱网治理的起点:

  • try/catch/finally保证加载收尾;
  • isLoading能驱动基础加载 UI;
  • Zone 不可用时有演示降级;
  • Cloud DB 写入与实时订阅链路已经打通。

下一步不应把逻辑堆进更多catch,而应完成三次关键升级:

布尔加载态 ↓ 可解释的请求状态机 ↓ 有边界、可取消、幂等的重试 ↓ 缓存、订阅恢复与可观测性

真正成熟的弱网体验,不是永远不失败。

而是失败发生时:

  • 用户知道发生了什么;
  • 页面仍然尽可能可用;
  • 系统能自动恢复但不会失控;
  • 重试不会制造重复数据;
  • 开发者能从指标中找到真实瓶颈。

这才是从“请求能跑”走向“移动端可靠体验”的完整演进。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询