资讯动态

HarmonyOS离线优先架构实战:请求队列、本地缓存与增量同步

发布时间:2026/9/20 11:37:38 来源:尧图企业网站定制
做 HarmonyOS 应用最怕遇到哪种用户场景不是并发大也不是机型杂而是用户在电梯里、地下车库里、地铁隧道里打开你的应用。这几秒钟的网络抖动足以决定用户会不会继续用下去。我最近从零到一搭了一个离线优先架构核心就是三件事请求队列、本地缓存、增量同步。这篇就围绕这套方案把设计思路、关键代码、参数取舍和踩过的坑完整拆开讲给正在做 HarmonyOS 应用、被弱网场景折磨的朋友一个能直接落地的参考。1. 先把架构理清楚离线优先到底在解决什么问题1.1 弱网场景下传统“先请求后渲染”为什么扛不住移动端开发做了这么多年大家早就习惯了一个模型页面加载时发网络请求拿到数据后渲染。这套模型在信号满格的办公室没问题可一旦进入弱网环境问题会成串冒出来。我司的应用是面向一线巡检人员的他们每天要进出配电房、地下管廊、信号盲区网络状况比普通用户还差。最开始我们用的就是传统模型结果用户反馈非常集中页面一直转圈、点提交没反应、退出去再进来数据没了。后来看后台日志弱网场景下请求超时率能到 20% 以上部分接口甚至超过 40%。问题出在哪里传统模型把“读取数据”和“网络可用性”绑死了。网络不可用页面就是白屏。用户不会管你是不是弱网他只会觉得应用不好用。离线优先架构的思路恰恰相反默认网络是不可靠的UI 层永远先从本地拿数据再通过网络把数据“同步”到最新。1.2 离线优先不是不做网络请求而是把网络请求放到它该在的位置很多初次接触离线优先概念的同学会误解以为离线优先就是不做网络请求或者把所有数据都缓存到本地再离线展示。真做起来你会发现它实际操作上是一个“本地为主、网络为辅”的整体架构设计。我的落地方式是把它拆成三条明确的主线请求队列负责管理所有“写操作”和需要保证送达的请求解决网络不稳定时请求丢失、重复提交、失败重试的问题。本地缓存负责所有“读操作”的数据来源保证页面在任何网络状态下都能秒开后台再用增量数据刷新视图。增量同步负责本地数据和服务端数据的最终一致用尽量少的流量、尽量少的请求次数把两边状态收敛。这三个模块不是孤立的请求队列负责“把数据送出去”本地缓存负责“把数据存下来”增量同步负责“把数据补回来”。读者拿到这套方案后先别急着写代码先把这三条主线的职责分清楚后面每个模块做起来都会顺很多。1.3 什么样的应用真正需要这套架构不是所有应用都需要离线优先。聊天工具、笔记类应用、工单系统、进销存、巡检类应用这类“用户会产生数据、又希望随时访问历史数据”的应用是离线优先架构的最佳适用对象。如果一个应用 90% 的操作都依赖服务端实时计算离线优先方案就不适合硬套。判断标准很简单如果用户在无网或弱网环境下打开你的应用他有没有“值得看的东西”有没有“必须做的事”如果答案都是否那说明你的应用大多只需做好请求超时优化即可不需要上一整套离线优先架构。反过来只要答案是“有”哪怕只是查询昨天的订单记录你也值得把本地缓存和增量同步做进去。2. 请求队列让每一次网络请求都变得可控2.1 为什么不能直接拿 HTTP 请求裸奔刚开始我也想过既然 ohos.net.http 能发请求我封装一层不就行了直到上线前压测才发现裸请求在弱网下有几个绕不开的痛点第一超时重试没有统一策略。每个页面各自设置超时时间有的设 10 秒有的设 30 秒重启应用后请求状态全丢。第二发送顺序不可控。用户连续提交多个操作因为网络抖动导致后发的请求先到服务端业务状态就乱了。第三重复提交没人管。用户点击提交没反应再点一下服务端就收到了两条一模一样的工单。请求队列要解决的正是这三个问题把请求变成可持久化的任务单元控制它的发送顺序管理它的生命周期和重试策略。本质上它就是一个带有状态管理、优先级调度、持久化能力的任务执行器。2.2 在 HarmonyOS 里用 ArkTS 落地一个最小请求队列我在工程里把请求队列拆成两层最底层是一个 Task 实体上层是一个 QueueManager。先看 Task 的字段设计export enum TaskStatus { Pending 0, Running 1, Success 2, Failed 3 } export class RequestTask { // 全局唯一ID用于去重和幂等 taskId: string; // 请求方法POST/PUT/DELETE等 method: string; // 完整URL url: string; // 请求体 body?: object; // 优先级数字越小越先执行 priority: number; // 当前状态 status: TaskStatus; // 已重试次数 retryCount: number; // 创建时间持久化时用来排序和清理 createTime: number; // 服务端返回时需要的幂等键 idempotentKey: string; constructor(options: PartialRequestTask) { this.taskId options.taskId ?? ; this.method options.method ?? POST; this.url options.url ?? ; this.body options.body; this.priority options.priority ?? 5; this.status options.status ?? TaskStatus.Pending; this.retryCount options.retryCount ?? 0; this.createTime options.createTime ?? Date.now(); this.idempotentKey options.idempotentKey ?? ; } }QueueManager 的核心职责是把 Task 按优先级排序串行或并发地执行它们失败后按策略重试同时在应用重启后从持久化存储恢复未完成任务。export class QueueManager { private tasks: ArrayRequestTask []; private isRunning false; private maxConcurrent 1; enqueue(task: RequestTask): void { this.tasks.push(task); this.tasks.sort((a, b) a.priority - b.priority); this.persistTasks(); this.processNext(); } private async processNext(): Promisevoid { if (this.isRunning) { return; } const task this.findNextPendingTask(); if (!task) { this.isRunning false; return; } this.isRunning true; task.status TaskStatus.Running; try { await this.executeTask(task); task.status TaskStatus.Success; } catch (e) { task.retryCount; if (task.retryCount this.maxRetryLimit) { task.status TaskStatus.Failed; // 进入失败队列等待用户手动触发或后台补偿 } else { task.status TaskStatus.Pending; // 指数退避后重新入队 setTimeout(() this.enqueue(task), this.calBackoffDelay(task.retryCount)); this.isRunning false; return; } } this.persistTasks(); this.isRunning false; this.processNext(); } private findNextPendingTask(): RequestTask | undefined { return this.tasks.find(t t.status TaskStatus.Pending); } private async executeTask(task: RequestTask): Promisevoid { // 这里封装 ohos.net.http 的具体请求逻辑 } private calBackoffDelay(retryCount: number): number { const base 1000; return base * Math.pow(2, retryCount) Math.random() * 200; } }这段代码里我故意把 maxConcurrent 设成 1是因为我们场景里写操作有严格的顺序性要求。如果你的业务里读操作和写操作可以并行可以在队列里拆两个通道一个串行通道管写一个并发通道管读。2.3 超时时间、重试上限和指数退避怎么定才合理参数是最容易拍脑袋的东西。我在项目里被“拍脑袋”坑过好多次所以现在所有参数都要求有依据。超时时间不要统一设一个值。弱网环境下 10 秒超时和 30 秒超时用户体验差异非常大。我的做法是分类设置查询类接口设 8 秒写操作类接口设 15 秒上传类接口单独设 30 秒以上。为什么写操作要比查询长因为写操作通常需要服务端做更复杂的处理而且一旦超时发起重试可能有重复风险所以宁可多等一会儿。重试次数建议控制在 3 次以内超过 3 次基本说明网络条件已经很不理想继续重试只是浪费用户流量和电量。指数退避公式我用的比较朴实delay baseDelay * 2^(retryCount) random(0, 200ms)baseDelay 取 1 秒第一次重试延迟 1 秒多第二次 2 秒多第三次 4 秒多。加随机项是为了避免多个设备同时重试造成服务端请求风暴这个在弱网恢复瞬间特别关键。注意千万不要在主线程里直接执行 ohos.net.http 请求。ArkTS 里网络请求是异步的但如果你用同步等待的方式处理或者频繁创建 Task仍可能引发主线程卡顿。我习惯把所有请求放到 TaskPool 或专门的 Worker 中执行UI 层只负责把 Task 丢进队列。3. 本地缓存先把数据放在用户设备上3.1 缓存层选型Preferences、RDB 还是文件HarmonyOS 提供的数据持久化方案不少各有用处。我在项目里做了明确分工用户偏好、开关状态、轻量配置用 Preferences。它本质上是 key-value 存储适合几百条以内的小数据。结构化业务数据如巡检记录、工单列表、审批流用 relationalStoreRDB它支持 SQL 查询适合量级较大且需要关联查询的数据。大文件如图片、附件、PDF直接用文件缓存目录文件路径存到 RDB 里。有些同学喜欢把所有数据都塞进 Preferences一开始数据量小还好等数据量到几万条就开始卡因为 Preferences 每次读写是整个文件加载的。还有的同学什么都往 RDB 里放杀鸡用牛刀把简单的事做复杂了。选型的核心就是按“数据结构复杂度”和“数据量级”来分。3.2 缓存策略版本号、TTL 与淘汰机制本地缓存不是把数据写进数据库就完事了必须要处理“数据多久算过期”和“数据会不会占满设备存储”这两个问题。我在每张业务表里都加了两个基础字段cache_version和cache_time。cache_version 用于和服务端下发的版本号对比一旦发现服务端版本号不同就触发增量同步。cache_time 记录数据写入本地的时间用于判断 TTL存活时间。对于消息列表这类实时性要求较高的数据我设 TTL 为 5 分钟对于配置类数据TTL 可以设到 24 小时以上。淘汰机制我用了最简单的“TTL 数量上限”。每次启动同步时扫描一遍缓存表超过 TTL 的标记为 stale等待增量同步刷新超过数量上限的按 cache_time 最早优先清理。后来数据量大了我把常用的列表页改造成了类似 LRU 的策略访问过的数据会刷新 cache_time避免频繁清理用户常看的内容。经验之谈缓存数据一定要有“版本号”概念但版本号不只是服务端下发的全局版本我建议细化到“资源维度”。比如每个工单有一个自己的 updateVersion每条审批流也有自己的 updateVersion这样增量同步时才能精确到条目避免一条数据变了就把整个表清掉重拉。3.3 缓存与服务端的数据一致性脏数据必须可见离线优先架构下必然会出现“本地数据和服务端数据不一致”的窗口期。我的经验是不要试图消除这个窗口期而是要让用户明确知道当前看到的是缓存数据。具体做法是在 UI 层加一个“数据同步状态”的标记如果当前数据来源是缓存且尚未同步到最新界面上显示“离线数据”或“同步中”的提示条。这样用户知道数据可能不是最新的但核心内容可以正常浏览。数据同步完成后再更新标记。同时要处理好“写失败时缓存怎么办”。用户新增了一条巡检记录写入请求队列后发现断网了这条记录如果直接从本地列表消失用户体验极差。我的做法是先写入本地 RDB并标记为sync_status 0待同步请求队列成功后更新为sync_status 1。这样即使用户断网也能在列表里看到自己刚创建的数据只是旁边有同步中标识。4. 增量同步如何让本地和服务端最终一致4.1 增量同步的触发时机不要只在启动时拉一次很多应用只在 App 启动时做一次同步然后整个生命周期内都依赖推送或手动刷新。这在弱网环境下有个问题用户在地铁里打开应用启动时正好没网之后即使网络恢复了应用也不会主动去同步数据。我做了四个触发通道启动后同步App 启动并初始化完缓存后先展示缓存数据再静默发起一次增量同步。网络状态恢复时同步监听网络从无网切换到有网的事件立即触发同步。页面切换到前台时同步从前台切走再回来做一次轻量同步这样数据不会长时间停留在旧状态。后台定时同步对于核心数据注册后台任务每隔一段时间做一次增量检查。第一和第二个通道是核心第三个通道是补充第四个通道视业务需求开启。4.2 同步游标设计从“全量拉取”升级到“按游标拉取”增量同步的关键是游标Cursor。游标的作用是告诉服务端“我本地数据更新到哪个位置了”服务端只返回这个位置之后的新数据。我用的是update_time auto_increment_id双游标方案。每张业务表维护一个last_sync_time和last_sync_id。请求增量数据时把这两个字段传给服务端服务端返回符合条件的数据列表和新的游标位置。伪代码如下async function syncIncremental(tableName: string, lastSyncTime: number, lastSyncId: number) { const syncUrl https://api.example.com/v1/sync; const result await httpRequest(syncUrl, { table: tableName, lastSyncTime: lastSyncTime, lastSyncId: lastSyncId, pageSize: 200 }); for (const item of result.data.list) { // 将增量数据 upsert 到本地 RDB upsertIntoLocal(tableName, item); } // 更新本地游标 updateCursor(tableName, result.data.newSyncTime, result.data.newSyncId); // 如果还有更多数据继续拉取 if (result.data.hasMore) { await syncIncremental(tableName, result.data.newSyncTime, result.data.newSyncId); } }为什么用双游标而不是只用一个时间戳因为同一时间戳下可能有多条数据如果分页过程中有新增单纯按时间戳翻页容易漏数据或重复数据。加一个自增 ID 做排序可以保证每次增量拉取都是稳定且不重不漏的。4.3 删除同步墓碑机制避不开增量同步最容易忽略的是“删除”操作。如果你在服务端删了一条数据客户端怎么知道这条数据被删了如果只是简单地拉取增量列表本地数据库里那条已删除的数据根本不会出现在增量列表里导致服务端删了客户端还留着。解决方案是墓碑Tombstone机制。服务端不真正物理删除数据而是把数据标记为deleted true同时更新 update_time。增量同步时客户端看到deleted true的数据就在本地执行软删除或物理删除。等到确认所有客户端都同步完墓碑后服务端再做真正的物理清理。冲突处理上我采用的是“服务端决胜 本地保留现场”策略。客户端和服务端同时修改同一条数据时以服务端的版本号为准。本地被覆盖前先把旧数据备份到一张conflict_backup表方便用户日后找回。实际运营中这个备份表救了不少用户的数据强烈推荐保留。5. 实操从零搭一个可运行的离线优先骨架5.1 工程初始化与权限配置我用的是 DevEco Studio 5.x HarmonyOS API 12 进行开发。创建工程时选择 Empty Ability 模板然后把网络、数据存储、任务调度的依赖和权限都配好。在module.json5里需要申请以下权限{ module: { requestPermissions: [ { name: ohos.permission.INTERNET }, { name: ohos.permission.GET_NETWORK_INFO }, { name: ohos.permission.READ_PREFERRED_NETWORK } ] } }其中ohos.permission.INTERNET是必需项GET_NETWORK_INFO用于监听网络状态变化READ_PREFERRED_NETWORK在部分 API 版本用来获取网络类型。同一权限在不同 API 版本的名字略有差异以 SDK 实际提示为准。5.2 网络监控模块装一个“信号感应器”离线优先架构里网络监控模块是触发同步的关键。我用ohos.net.connection监听网络变化import connection from ohos.net.connection; import { BusinessError } from ohos.base; export class NetworkMonitor { private static instance: NetworkMonitor; private isNetworkAvailable false; static getInstance(): NetworkMonitor { if (!NetworkMonitor.instance) { NetworkMonitor.instance new NetworkMonitor(); } return NetworkMonitor.instance; } async init(): Promisevoid { // 获取当前网络状态 const netHandle await connection.getDefaultNet(); const netCap await connection.getNetCapabilities(netHandle); this.isNetworkAvailable netCap.bearerTypes.length 0; // 注册网络变化监听 connection.on(netAvailable, () { this.isNetworkAvailable true; SyncManager.getInstance().triggerSync(); }); connection.on(netLost, () { this.isNetworkAvailable false; }); } isAvailable(): boolean { return this.isNetworkAvailable; } }网络从断开到恢复的netAvailable事件是触发增量同步最合适的时机。这个事件一触发我立刻让 SyncManager 跑起来把缓存数据和服务端对齐。5.3 完整调用时序从页面加载到最终一致把模块串起来后一个典型的用户操作流程是这样的用户打开列表页UI 层调用CacheManager.getList()直接从本地 RDB 读取数据并渲染页面几百毫秒内展示。同时SyncManager检查网络状态。如果网络可用带着本地游标去向服务端请求增量数据。服务端返回增量数据后CacheManager将数据 upsert 到本地 RDB更新游标。UI 层监听本地数据库变化我用的 relationalStore 的 RdbStore 支持 subscribe收到变化后自动刷新列表。这样用户无感知地完成了“缓存展示 - 增量刷新”的过渡。如果用户此时进行了写操作比如创建一条新工单流程是先写入本地 RDB 并标记待同步同时把请求封装成 RequestTask 塞进队列。队列串行执行请求成功后就更新本地 sync_status请求失败则按照指数退避策略重试用户不用任何操作。这套流程的关键是“先给用户看再悄悄同步”全程不阻塞 UI。5.4 弱网模拟与验证方法开发联调阶段不能等真到了地铁里才测。我用的两个工具DevEco Studio 自带的模拟器支持网络限速可以把带宽限制到 100kbps 或 20kbps。遇到需要更精细控制的场景用抓包工具配合弱网代理可以设置丢包率、延迟时间。建议至少测这几个场景完全断网打开应用页面能显示缓存内容吗写操作有提示吗弱网高延迟低带宽下打开应用页面渲染时间是否在可接受范围请求发到一半断网队列是否把任务保存住了网络恢复后是否继续执行连续提交多条写操作顺序是否保持是否有重复提交服务端返回增量数据时本地有冲突冲突数据是否进了备份表并按策略覆盖这五个场景过一遍架构的稳定性基本就有底了。6. 我踩过的坑和问题排查速查表6.1 最典型的五个坑第一个坑请求队列没有持久化应用被系统杀掉后队列里的待发送请求全丢了。解决方法是每次 enqueue、状态变更时都立刻把队列快照序列化到 Preferences 或小文件启动时先从本地恢复未完成任务。第二个坑超时时间设置得太短。一开始我把写操作超时设成 5 秒结果用户在地下室提交工单经常失败。后来改成 15 秒失败率降了一半以上。超时时间不是越短越好要结合服务端真实处理耗时来设。第三个坑增量同步没有做幂等。同一批增量数据在网络抖动时可能被请求两次如果不做去重本地会出现重复数据。解决办法是在本地表里为服务端主键建唯一索引upsert 时用INSERT OR REPLACE或先查重再插入。第四个坑在页面不可见时还在大量执行同步任务导致电量消耗严重。后来我把非核心数据的同步策略改成“仅充电时”或“仅 Wi-Fi 时”执行核心数据才允许蜂窝网络下同步。第五个坑缓存表字段类型和服务端返回类型不匹配。服务端返回的是字符串数字本地存的是 INTEGER导致排序错乱。这个多在联调阶段出现解决方案是统一在数据层做类型转换不要相信任何接口返回的原始类型。6.2 常见问题排查速查表现象可能原因排查方法与解决建议页面启动后一直白屏本地缓存表未建好或查询出错查看 Log 中 RDB 报错信息检查建表 SQL 是否执行成功数据一直显示“待同步”请求队列没被触发或网络监听失效确认 NetworkMonitor 是否初始化手动触发一次 SyncManager 观察日志同一数据本地出现两条增量同步未做幂等为服务端主键建唯一索引使用 upsert 策略网络恢复后不自动同步连接监听未注册成功检查 module.json5 权限确认connection.on是否在主线程注册删除的数据又冒出来墓碑机制未实现或服务端未返回删除标记检查同步响应里 deleted 字段本地收到后执行软删除应用被杀后请求丢失请求队列未持久化实现队列快照持久化启动时恢复待发送任务弱网下上传图片经常失败普通请求队列不擅长处理大文件将文件上传拆成单独通道支持分片和断点续传6.3 还有哪些方向可以继续做这套骨架落地后我的下一步计划是接入后台任务调度器WorkScheduler让应用在后台也能自动完成数据同步。另外一个方向是增量同步的数据压缩弱网环境下流量很宝贵可以在同步接口里加 gzip 或自定义二进制协议能省不少流量。对于正在备考 HarmonyOS 应用基础认证、或者刚开始学基础应用程序框架的朋友我的建议是先不要急着碰离线优先这么多模块。先把能力生命周期、页面路由、数据持久化这几个基础搞清楚再带着项目需求来看请求队列和同步机制理解会快很多。最后再分享一个小经验离线优先架构最怕的不是技术复杂度而是“没想清楚就动手”。先把同步冲突策略、缓存 TTL、请求队列优先级这些关键决策写在设计文档里团队达成一致后再写代码能少走很多弯路。我在第二个版本重构时才意识到这一点前期好几个模块因为策略不一致返工了。希望这篇文章能帮你一次做对。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价