【OpenHarmony/HarmonyOs 】52|弱网重试与加载态:把“请求失败”演进成可恢复的用户体验 📶
本文基于当前项目
CloudDBService.ets、ChatPage.ets、ChatDetailPage.ets与LoginPage.ets的真实实现展开。
项目已经有基础加载状态、异常捕获和 Mock 降级,但在弱网、断网、超时、重复点击与页面切换场景下,还需要一套完整的可恢复状态机。
一、为什么弱网不是一个 catch 就能解决?
在开发机和稳定 Wi-Fi 下,请求通常只有两种结果:
- 成功;
- 失败。
真实移动网络却更复杂:
- 网络已连接,但无法访问云服务;
- 请求已经发出,响应迟迟不回来;
- 用户从 Wi-Fi 切换到蜂窝网络;
- Cloud DB Zone 尚未初始化完成;
- 页面离开后,旧请求才返回;
- 写入成功,但客户端没有收到确认;
- 实时订阅建立后又临时中断;
- 用户连续点击重试,产生并发请求。
因此,弱网治理不是“多写一个重试按钮”。
它至少包含四个部分:
- 可解释的加载状态;
- 有边界的自动重试;
- 用户可控的手动恢复;
- 防止重复提交与过期响应。
二、先看项目当前已经具备的基础
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;}执行器负责:
- 启动一次任务;
- 记录尝试次数;
- 判断错误是否可重试;
- 等待退避时间;
- 达到上限后抛出明确错误。
不要让每个页面各写一套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());正确流程是:
- 用户点击发送时生成一次 ID;
- 本地消息记录这个 ID;
- 每次重试复用该 ID;
- Cloud DB Upsert 根据相同主键覆盖;
- 快照到达后按 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,而应完成三次关键升级:
布尔加载态 ↓ 可解释的请求状态机 ↓ 有边界、可取消、幂等的重试 ↓ 缓存、订阅恢复与可观测性真正成熟的弱网体验,不是永远不失败。
而是失败发生时:
- 用户知道发生了什么;
- 页面仍然尽可能可用;
- 系统能自动恢复但不会失控;
- 重试不会制造重复数据;
- 开发者能从指标中找到真实瓶颈。
这才是从“请求能跑”走向“移动端可靠体验”的完整演进。