news 2026/9/20 4:33:52

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
HarmonyOS离线优先架构实战:请求队列、本地缓存与增量同步设计

刚在项目里做完HarmonyOS端的离线优先改造,趁记忆还热乎,把整套方案里最关键的三个部分——请求队列、本地缓存、增量同步——从头到尾拆开聊一遍。这套架构不是只针对鸿蒙,但鸿蒙的分布式能力和弱网场景结合得最典型,所以拿它当主线来讲,干货直接落地可抄。

先说清楚适用范围。如果你的应用是强交互、强数据一致性、操作频率高,比如聊天、协同文档、审批流,那么离线优先是刚需;如果只是刷信息流、看视频,弱网下能展示缓存页就够了,不需要上全套。我这边业务主要是移动办公类应用,表单提交、审批、公告、任务流转,对“提交不丢失”“数据最终一致”要求很高,所以选择了完整的离线优先架构。

1. 离线优先架构的整体设计思路

1.1 先搞清楚“弱网”到底弱在哪

移动端弱网不是单一的信号差,它分好几种形态,每种形态对架构的挑战完全不一样:

  • 网络抖动:信号时好时坏,请求一会儿成功一会儿超时,最典型的场景是地铁、电梯、地下车库。
  • 高延迟:网络通但非常慢,比如2G/3G级别的延迟,甚至300ms以上,RTT高导致接口超时频发。
  • 带宽受限:下载/上传速率极低,大包体请求容易卡死,GitLfs上传图片视频最容易踩这种坑。
  • 带宽受限:下载/上传速率极低,大包体请求容易卡死,GitLfs上传图片视频最容易踩这种坑。
  • 完全离线:彻底没网,比如进山区、飞机模式、海外漫游断网,这时候缓存就是唯一的数据来源。

弱网架构的目标就是让应用在这四种状态下都能“不崩、不丢、可恢复”。不崩指UI不能因为请求失败而白屏或闪退;不丢指用户产生的操作和数据必须本地落盘;可恢复指网络恢复后能自动把离线期间的数据同步到服务端。

HarmonyOS在这个场景里有天然优势,它自带分布式软总线和数据管理能力,端侧数据库(RelationalStore)、首选项(Preferences)、WorkScheduler等组件配合使用,能拼出一套比较完整的离线方案。

1.2 离线优先的核心原则

离线优先不是“先联网失败再走缓存”,而是把本地作为第一数据源,网络作为同步通道。核心原则有三条:

第一,本地优先读。UI渲染时先读本地数据库/缓存,再异步去拉服务端最新数据,拉回来以后更新UI和本地。这样即使网络挂了,用户打开应用也是秒开,看到的是上次成功同步的数据。

第二,操作先行落盘。用户所有写操作(提交、编辑、删除)先写本地事务日志,并进入请求队列,不等网络结果就反馈“提交成功”。真正的网络请求在后台异步执行,失败就重试。

第三,接口幂等设计。因为请求可能被重复执行(重试导致),服务端接口必须支持幂等,客户端每次请求带上全局唯一的requestId,服务端按requestId去重。

1.3 整体模块划分

我这边拆了五个模块:

  • 数据层:RelationalStore数据库存储业务实体、缓存表、增量同步位点表。
  • 队列层:请求队列,管理待同步的写操作,支持持久化、重试、优先级。
  • 同步层:同步引擎,负责把队列中的数据推给服务端,并拉取服务端增量数据。
  • 网络层:对HarmonyOS的@ohos.net.http模块做封装,统一处理超时、重试、网络状态监听。
  • 表现层:通过状态管理(Vue/ArkUI)感知同步状态,给UI展示“离线中”“同步中”“已同步”。

整体流程一句话概括:用户操作 → 写本地库 + 入请求队列 → 同步引擎消费队列推送服务端 → 服务端返回确认 → 更新队列状态和本地缓存 → 拉取服务端增量数据更新本地。

2. 请求队列:离线操作的中转站

2.1 为什么不用系统网络库的重试机制

有些人会问,网络请求失败后系统网络库不是有重试能力吗?为什么还要自己搞队列?关键在于:

系统重试是内存态的,应用进程被杀就没了;请求队列需要持久化到磁盘,应用重启后依然能恢复。而且系统重试只解决“瞬时失败”的问题,对于长时间断网无能为力——你总不能把网络库的超时时间调成几分钟,那不现实。请求队列则是把“发送请求”这件事转化为“消息排队”,只要队列里还有未确认的记录,就重点重建连接后继续推。

此外,业务层面经常需要控制发送顺序和优先级。比如审批流里的“驳回”操作必须排在“提交”之后,如果搞乱顺序,服务端的业务状态就会错乱。请求队列天然支持FIFO和优先级插入。

2.2 队列的数据结构与持久化设计

我的请求队列表设计如下:

字段 | 类型 | 说明 request_id | TEXT | 全局唯一ID(UUID),同时也是幂等键 biz_type | TEXT | 业务类型,如“approval.submit”“task.create” payload | TEXT | 请求体JSON(因为不同业务字段不同,所以用TEXT存JSON) status | INTEGER | 0待发送、1发送中、2成功、3失败(需人工干预) priority | INTEGER | 优先级,数字越大越先执行 create_time | INTEGER | 入队时间戳 next_retry_time | INTEGER | 下次重试时间 retry_count | INTEGER | 已重试次数

这里有个关键细节:payload一定要存完整的请求JSON,不要只存业务字段,因为后续重试时我们不再去数据库里拼请求参数,而是直接从队列里取原始请求体发出去。这样可以保证“用户提交的那一刻”的数据快照,即使后续本地数据被修改,重试的还是原来的内容。

插入队列时用的请求示例:

import { BusinessError } from '@kit.BasicServicesKit'; import { relationalStore } from '@kit.ArkData'; class RequestQueueManager { private store: relationalStore.RdbStore; async enqueue(bizType: string, payload: object, priority: number = 0) { const requestId = this.generateUUID(); const values: relationalStore.ValuesBucket = { 'request_id': requestId, 'biz_type': bizType, 'payload': JSON.stringify(payload), 'status': 0, 'priority': priority, 'create_time': Date.now(), 'next_retry_time': 0, 'retry_count': 0, }; await this.store.insert('request_queue', values); // 通知同步引擎有新队列,唤醒工作线程 SyncEngine.getInstance().notifyNewTask(); return requestId; } private generateUUID(): string { // 使用@ohos.util.UUID或自己生成,保证全局唯一 return 'req-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10); } }

2.3 消费队列的逻辑与间隔退避

队列消费的核心逻辑是一个死循环线程(或WorkScheduler定时任务),不断从队列里取出status为0(待发送)的记录,发送请求,根据结果更新状态。

关键代码如下:

async function processQueue() { while (true) { const task = await getNextPendingTask(); if (!task) { // 没有待发送任务,休眠等待唤醒 await sleep(5000); continue; } // 判断是否到达重试时间 if (task.next_retry_time > Date.now()) { await sleep(1000); continue; } // 标记为“发送中”,防止重复消费 await markSending(task.requestId); try { const resp = await HttpHelper.post(task.payload); if (resp.code === 200) { await markSuccess(task.requestId); // 同步成功后,可以顺带把本地缓存里的实体状态更新 } else if (isRetryableError(resp.code)) { await scheduleRetry(task); } else { // 业务性错误,比如参数不对,放弃重试,标记为失败待人工处理 await markFailed(task.requestId, resp.message); } } catch (e) { // 网络异常,走重试 await scheduleRetry(task); } } } function isRetryableError(code: number): boolean { // 500、502、503、504服务端暂时不可用,可重试 // 400、401、403、404大概率是业务或权限问题,不重试 return [500, 502, 503, 504].includes(code); } async function scheduleRetry(task: Task) { const retryCount = task.retry_count + 1; // 指数退避:30s、1min、2min、4min...最大10min const delay = Math.min(10 * 60 * 1000, 30 * 1000 * Math.pow(2, retryCount)); const nextRetryTime = Date.now() + delay; await updateRetryInfo(task.requestId, retryCount, nextRetryTime); }

关于退避间隔,我建议采用“指数退避 + 抖动”的策略。指数退避是指重试间隔逐步翻倍,防止服务端刚恢复时被大量重试请求打垮;抖动是在间隔基础上加一个随机值,避免多个客户端同时重试造成“惊群效应”。实际代码里可以这样做:

const baseDelay = 30 * 1000 * Math.pow(2, retryCount); const jitter = Math.random() * 5 * 1000; // 随机加0-5秒 const delay = Math.min(10 * 60 * 1000, baseDelay + jitter);

2.4 队列的优先级与顺序控制

很多业务场景不只是“先进先出”这么简单。我的队列设计支持priority字段和bizType级别的前置依赖。

举个例子:用户提交流程时,先调用“创建流程实例”接口,再调用“提交审批”接口。这两个请求是有顺序依赖的,但如果队列里同时存在“驳回上一条流程”的旧任务,那么新任务的创建流程、提交审批必须排在旧任务之后。

实现方案是给同一业务线增加一个“序列号”或批次号。用户一次连续操作生成的多个请求带有同一个batchId,同步引擎消费时按batchId合并处理,保证同一批次的请求按顺序发送。不同批次之间可以并发。

但这里有个坑——如果不同批次的操作作用于同一条业务数据(比如同一文档被编辑两次),就可能出现顺序错乱。我的方案是在队列消费前增加一个预检查:把当前队列中所有作用于同一实体ID(比如approvalId)的任务提取出来,按create_time排序,然后串行发送,避免并发下的顺序问题。

2.5 队列与内存缓存的双写一致性

请求队列本身是持久化在数据库里的,但同步引擎消费时需要频繁读取队列状态、更新状态,高频的数据库读写会带来性能问题。我这边做了一个内存缓存层,队列消费时先查内存中的待发送任务列表,每处理完一个任务,同步更新内存和数据库。内存中维护的map结构是:

// key: requestId, value: task private taskCache: Map<string, Task> = new Map();

程序启动时批量加载所有待处理任务到内存;任务入队时同时写内存和数据库;任务完成时同时删内存和数据库。数据库是最终持久化保障,内存是运行时性能保障。

3. 本地缓存:弱网下的数据基石

3.1 选择RelationalStore还是Preferences

HarmonyOS提供了多种本地存储方案,我这边有两套主要的:

  • Preferences:类似Android的SharedPreferences,适合键值对、轻量数据、配置信息。
  • RelationalStore:类似Android的Room/SQLite,适合结构化、有查询需求的数据。

离线优先架构里,业务实体数据(比如审批单列表、文档元数据)用RelationalStore,轻量同步状态(比如同步游标、最后同步时间)用Preferences或RelationalStore单行表。

要注意的是,RelationalStore本身是SQLite的封装,支持标准SQL语法,所以在做SQL查询、事务方面完全没有问题。它的线程安全、事务机制都比较成熟,不用自己额外加锁。

3.2 数据缓存表的设计模式

以审批单列表举例,本地缓存表设计如下:

字段 | 类型 | 说明 approval_id | TEXT | 主键 title | TEXT | 标题 status | TEXT | 当前状态(待审、已审、驳回) update_time | INTEGER | 服务端最后更新时间(毫秒) local_update_time | INTEGER | 本地最后修改时间 dirty | INTEGER | 脏标记,1表示本地有未同步的修改 deleted | INTEGER | 逻辑删除标记

dirty和deleted很关键。dirty标记本地是否已有未同步的修改,增量同步时优先把dirty=1的记录推给服务端;deleted标记逻辑删除,用户删除数据时我们不直接物理删除,而是置为deleted=1并加入请求队列,等服务端删除成功后,下次清理时再物理删除本地记录。

3.3 缓存读取策略:Cache Aside + 异步刷新

UI获取数据的流程我总结为“缓存优先,后台更新”:

async function getApprovalList() { // 第一步,直接读本地 const localData = await db.query('SELECT * FROM approval_cache ORDER BY update_time DESC'); // 第二步,立即渲染本地数据 render(localData); // 第三步,尝试从网络拉取最新 if (NetworkManager.getInstance().isConnected()) { try { const remoteData = await api.getApprovalList({ since: lastSyncTime }); // 第四步,拿到服务端数据后替换本地并刷新UI await db.batchUpdate(remoteData); render(remoteData); } catch (e) { // 网络失败就保持本地数据,不打扰用户 } } }

这属于Cache Aside模式的变体。读请求先查缓存,缓存不中或者需要更新时再回源查服务端。跟纯Cache Aside的区别是:这里本地数据库永远是“有数据”的,只是数据新鲜度不同。

3.4 缓存淘汰策略:我用了容量 + 时间双维度

缓存不能无限膨胀,必须设置淘汰策略。我这边采用“最大条数 + 最久未使用”的组合策略:

  • 每张缓存表设置最大条数,比如审批单缓存最多保留500条。
  • 每次查询时记录last_access_time,周期任务把“超过最大条数且last_access_time最旧”的记录清理掉。

同时还有一个时间维度:超过90天未被访问的缓存记录直接清理。这样既能保证常见的近期数据有缓存,又不会让老数据无限堆积占存储。

为了评估缓存命中率,我在本地设置了统计表,每次读数据时记录是来自缓存还是网络,统计周期完成后上报。如果有兴趣,可以用这个数据优化缓存大小和预加载时机。

3.5 页面级别的缓存预加载与失效

我额外做了一个“页面缓存”概念,跟数据缓存区分开:页面缓存保存的是UI状态,数据缓存保存的是业务数据。两者结合才能让页面在断网时不仅“有数据”,而且“是上次离开时的状态”。具体做法是:页面销毁时把当前滚动位置、筛选条件、列表数据快照都存到本地;重新进入页面时先恢复快照,再根据数据缓存刷新。

这样用户就体验不到“页面白屏”或“列表从头加载”的割裂感。当然,页面缓存要注意数据量控制,不要存大的Bitmap对象,否则存储空间会爆掉。

4. 增量同步:从全量拉取到精准续传

4.1 增量同步解决的核心问题

离线期间用户操作会产生大量本地数据变更,服务端也可能会产生新的数据(比如别人审批了同一个单子)。网络恢复后,两种方向的同步需要高效完成。全量拉取显然不可行——数据量大、耗流量、耗电。所以增量和增量必须靠“同步游标”(sync cursor)来实现。

增量同步两类方向:

  • 上行同步:把本地dirty=1的记录推给服务端,服务端按requestId幂等处理。
  • 下行同步:从服务端拉取自上次同步时间点以来变更过的数据。

4.2 同步游标的管理

下行同步不能基于本地时间来判断“哪些数据是新的”,因为不同设备的时钟可能不同步,而且服务端数据可能在旧时间被修改。正确做法是:服务端为每一条数据维护一个单调递增的version(或全局的update_seq),客户端每次同步时记录拉取到的最大version,下次同步带上“since=上次最大version”的参数。

本地存储同步游标用Preferences即可:

import { preferences } from '@kit.ArkData'; class SyncCursorManager { private pref: preferences.Preferences; async getCursor(bizType: string): Promise<number> { const val = await this.pref.get(bizType + '_sync_cursor', 0); return val as number; } async setCursor(bizType: string, cursor: number) { await this.pref.put(bizType + '_sync_cursor', cursor); await this.pref.flush(); } }

这里有个容易踩的坑:如果一张表的数据来自多个业务域(比如审批列表,每个审批单有不同的业务类型),不能用单一的全局游标,否则会出现A类数据拉到最新、B类数据因为游标太新而漏掉。解决办法是按bizType维护独立的游标。

4.3 下行增量同步的分页与去重

服务端增量接口一般返回的是一个分页列表:

interface SyncPullResult { items: Array<{ id: string; version: number; operation: 'insert' | 'update' | 'delete'; data: object; }>; next_cursor: number; has_more: boolean; }

客户端循环拉取直到has_more为false。注意这里可能遇到一个问题:服务端数据在分页过程中继续变化,导致某条记录被返回两次或漏掉。我的方案是依赖version游标,服务端保证version递增且唯一,客户端按version去重,每批次处理完记录最大version,下一批请求携带该version。

合并时的规则是“后写覆盖先写”,即version大的覆盖version小的,同时保留本地的dirty标记。如果一条数据本地有未同步修改,且服务端版本比本地旧,那么以本地为准,不覆盖。这一步在下文的冲突处理里会详细展开。

4.4 上行同步的批量提交与逐条确认

上行同步时,请求队列里可能堆积了很多任务。为了减少HTTP请求次数,我把相同bizType的多个任务合并成一个批量请求:

interface BatchPushRequest { requestIds: string[]; bizType: string; items: Array<{ requestId: string; payload: object; }>; }

服务端批量处理,返回每个requestId的结果。批量提交的关键点在于:单个请求失败不能影响其他请求。服务端必须逐条处理、逐条返回结果,客户端根据results更新队列状态。

这里我踩过一个坑:批量请求过大时(比如一次提交50个审批操作),服务端处理时间可能超过HTTP超时时间,导致客户端误判为失败而重试,结果服务端已经部分成功了。我的解决方案是控制单批数量,以20条为上限,同时接口超时时间单独调大到30秒,服务端返回状态区分“全成功”“部分成功”“全失败”。

4.5 首次全量 + 后续增量的演进路径

很多团队一开始做同步时先全量拉取,数据量大了以后才改增量。我建议从一开始就设计增量机制,因为后续迁移会非常痛苦。首次启动时,客户端拉取全量数据,同时记下服务端返回的当前最大version作为初始游标;之后每次启动增量拉取。

全量拉取也要走分页,第一页拉完时,记下当前服务端游标值;如果全量拉取过程中服务端又产生了新数据,那么第二页之后的数据可以合并到增量逻辑里。

4.6 同步冲突的常见处理

离线期间,用户修改了一条服务端同时也被修改的数据,网络恢复同步时就会产生冲突。我采用三种策略分层处理:

第一,基于版本号(乐观锁)。本地数据保存baseVersion,即上一次同步时的服务端版本。同步时携带baseVersion,服务端对比当前版本,如果相同则正常更新,否则返回冲突。客户端收到冲突后,弹出冲突解决界面,让用户选择“保留本地”“保留服务端”“合并”。

第二,基于时间戳。如果数据本身不敏感,可以简单比较update_time,后修改的覆盖先修改的。这种方式简单,但不是所有场景都合适,例如审批状态这种状态机数据,不能简单按时间覆盖。

第三,基于业务规则。有些业务场景不需要用户介入,服务端可以直接根据业务规则合并。比如审批意见可以追加而不是覆盖;文档编辑可以按段落合并。目前很多团队在做离线冲突处理时都倾向于“后端合并优先,前端冲突界面兜底”。

我的建议是:优先避免冲突,而不是解决冲突。通过设计良好的数据模型,比如“追加式日志”“不可变事件流”,可以从根上规避大部分冲突。如果采用“当前值覆盖式”模型,冲突就是绕不开的问题。

5. 网络状态感知与同步触发策略

5.1 监听网络状态切换

HarmonyOS提供了网络状态监听能力,可以感知WiFi、4G/5G、无网络的切换。同步引擎要监听这个状态变化,自动触发同步机会:

import { connection } from '@kit.NetworkKit'; connection.createNetConnection().register((err, data) => { if (!err) { const hasNet = data.netAvailable; const isWifi = data.netType === connection.NetBearType.BEARER_WIFI; if (hasNet) { // 有网络时,立即触发一次同步 SyncEngine.getInstance().triggerSync(); } else { // 断网时,暂停所有网络请求,进入离线模式 SyncEngine.getInstance().pauseSync(); } } });

注意一个细节:从无网络恢复到有网络(比如从地铁隧道出来),不会自动触发网络回调,需要再利用网络探测机制确认。我通常在用网络监听的同时,增加一个轮询探测(ping服务端健康检查接口),30秒一次,探测成功就触发同步。

5.2 同步时机策略

同步不是越快越好,也不是越频繁越好,要平衡实时性和资源消耗。我的策略:

  • 应用启动时,立即触发一次同步。
  • 应用从后台切回前台时,触发一次同步,但要限制频率,5分钟内最多一次。
  • 网络从断开恢复为可用时,立即触发同步。
  • 请求队列有新任务入队、且当前网络可用时,立即触发同步。
  • 后台可以使用WorkScheduler设置定时同步任务,比如每30分钟一次,但要受系统功耗限制。

这里要特别注意HarmonyOS的后台任务限制。WorkScheduler不是随时能执行的,系统根据功耗和资源状态决定是否放行。对于需要及时性的同步,建议在前台或短时任务里处理,后台只做低频补偿。

5.3 同步状态的UI反馈

用户需要感知“现在是不是离线”“数据是否同步完成”。我在UI层暴露一个全局同步状态对象,视图通过状态管理订阅:

class SyncStateModel { syncStatus: 'online' | 'offline' | 'syncing' | 'sync_error' = 'online'; pendingCount: number = 0; lastSyncTime: number = 0; }

界面上体现为:

  • 顶部状态条显示“离线模式,数据已保存至本地”或“正在同步...剩余N条”。
  • 操作按钮的发送状态(如提交后显示“已提交,待同步”)。
  • 列表页底部显示“上次同步时间:xx:xx”。

这种设计能大幅降低用户对“数据到底发出去没有”的焦虑。

5.4 省电与流量约束

弱网场景往往伴随着移动流量,同步太激进会让用户流量跑得飞快。我的策略是:

  • 只在WiFi下自动拉取大文件(图片、视频),移动网络只同步文本数据。
  • 图片上传队列默认挂起,等WiFi再传;如果是用户手动点击发送,则走移动网络。
  • 批量同步时限制最大条数,避免单次同步耗电过高。
  • 同步过程不持有WakeLock,而是用HarmonyOS的短时任务或延迟任务机制。

6. 弱网下的网络层封装与超时控制

6.1 HTTP客户端的超时与重发机制

HarmonyOS的@ohos.net.http模块,底层是鸿蒙自研的HTTP栈,也支持各类HTTP能力。但它提供的超时控制相对底层,直接使用时往往不够灵活。我推荐在它之上封装一层,统一处理三类超时:

  • 连接超时(connectTimeout):默认10秒,弱网环境下调整为15秒。
  • 读取超时(readTimeout):默认15秒,大数据接口调整为30秒。
  • 写入超时(writeTimeout):默认15秒,上传大文件调整为60秒。

超时后立即抛错,由请求队列层捕获并安排重试。不要在UI层直接做重试,否则内存无法管理,请求会堆积导致OOM。

6.2 请求幂等与去重

网络重试最怕的是“同一个请求被发送了两次,服务端处理了两次”。我的策略是每个写请求携带requestId,服务端按requestId做唯一性约束,重复请求直接返回上次的处理结果。

这个requestId必须由客户端生成,全局唯一。我使用组合策略:

const requestId = `${deviceId}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;

其中deviceId是设备唯一标识,确保多设备碰撞概率降到最低。

6.3 弱网下的数据压缩

弱网带宽有限,请求和响应体积越大,成功率越低。我在网络层默认开启Gzip压缩,同时把一些大JSON字段改为压缩传输。HarmonyOS的HTTP模块支持设置请求头:

const httpRequest = http.createHttp(); httpRequest.request(url, { method: http.RequestMethod.POST, header: { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip', 'Accept-Encoding': 'gzip' }, extraData: gzipCompress(JSON.stringify(payload)) });

另外,列表接口如果不是实时性要求极高,建议服务端返回精简字段(只返回id、title、status、update_time等),详情页再按需拉取完整字段。这样能大量压缩弱网下的同步带宽。

6.4 请求取消与连接复用

弱网下用户可能在列表页快速滑动,触发多个列表请求。如果用户离开页面,请求取消是必要的——否则回调和UI状态会产生错乱。我封装网络层时,为每个页面创建独立的请求标签,页面销毁时通过标签取消所有未完成的请求。

同时要保持连接复用。HarmonyOS的HTTP模块默认支持keep-alive,但需要确认请求头里带上Connection: keep-alive。长连接能显著减少弱网下的握手开销,尤其是同步引擎频繁发送小请求时。

7. 完整流程串联:离线提交、恢复同步、增量刷新

7.1 一个典型场景的全流程演示

假设用户在地铁里打开审批应用,对一条审批单做了“同意”操作,随后进隧道断网,出隧道恢复网络。整个数据流是这样的:

  • 用户点击“同意” -> UI层调用业务层方法。
  • 业务层开启一个本地数据库事务:更新approval_cache表的状态字段+将脏标记置1,同时向request_queue表插入一条新任务。
  • 请求队列立即尝试发送,但网络层检测到断网,发送失败,任务保留为待发送状态。
  • UI层展示“已提交,将在网络恢复后同步”。
  • 用户退出应用,任务持久化在本地数据库。
  • 出隧道网络恢复,网络监听回调触发同步引擎。
  • 同步引擎扫描请求队列,取出待发送任务,批量发送给服务端。
  • 服务端处理成功,返回requestId确认。
  • 同步引擎把对应队列任务标记为成功,并更新本地缓存的脏标记为0。
  • 同步引擎再拉取服务端增量数据(since=上次游标),把其他人产生的变更合并到本地。
  • UI层收到同步完成通知,刷新列表,显示“已同步”。

整个过程对用户来说,只看到了“提交后短暂显示待同步,网络恢复后自动变成已同步”,中间没有任何手动操作。

7.2 数据库事务保证“操作 + 入队”的原子性

最核心的技术点是:业务数据变更和请求入队必须在一个事务里完成,否则会出现“数据改了但请求没入队”或者“请求入队了但数据没改”的不一致。

RelationalStore支持事务API:

await store.beginTransaction(); try { // 1. 更新业务表 await store.executeSql( "UPDATE approval_cache SET status = 'approved', dirty = 1 WHERE approval_id = ?", [approvalId] ); // 2. 插入请求队列 await store.insert('request_queue', queueValues); await store.commit(); } catch (e) { await store.rollback(); throw e; }

这样两步操作要么都成功,要么都失败,不会出现“脏数据”。

7.3 应用启动后的恢复流程

应用进程被杀后重启,恢复流程是:

  • 初始化数据库。
  • 从Preferences中读取同步游标。
  • 加载请求队列中所有未完成任务到内存缓存。
  • 如果当前网络可用,立即触发同步引擎;否则进入离线模式。
  • 展示本地缓存数据,同时后台开始尝试同步。

7.4 多端同步的边界情况

HarmonyOS的特色是分布式多端协同,手机、平板、手表可以同时操作同一业务。这给离线优先架构带来一个新挑战:同一用户的多个设备都可能产生离线数据,服务端要能识别同一用户的不同设备,并对同一实体的冲突做合并处理。

我的方案是服务端每个实体维护updatedBy和updatedFromDevice字段,冲突检测时优先判断是否同一用户的不同设备操作。但要注意UI层的冲突提示也需要增加“设备”维度,提示用户"该单已在另一台设备被修改"。

8. 避坑指南:我在实战中遇到的典型问题

8.1 本地时间不可信

客户端本地时间的可靠性远低于服务端时间。设备时间被用户改到未来/过去,会直接影响时间戳判断。所以同步游标、数据版本号一定要用服务端下发的version,不要用本地时间戳。

如果你在SQL里用本地时间排序,一定要意识到设备时间错误会导致排序错乱。我在本地缓存表里额外存了received_at字段,记录“数据到达客户端时”的真实时间,排序时优先用received_at,避免用户改时间导致的错位。

8.2 SQLite在弱网场景下的ANR

弱网不代表本地操作就快。网络超时重试、大量数据写入缓存,都可能导致数据库繁忙,UI线程执行SQL时出现卡顿。

我的做法是:所有数据库写操作在单线程执行器(类似HandlerThread)中排队,UI线程只读不写。同时避免在主线程执行大批量事务,比如批量同步500条数据必须放到工作线程。

8.3 请求队列与缓存的回滚问题

同步成功后更新状态,但本地缓存更新失败怎么办?比如请求队列标记为成功、服务端已更新,但本地缓存更新dirty=0时数据库异常。下一次启动同步时会认为dirty=1的数据还没同步,又推送给服务端。因为接口幂等,重复推送不会出错,但会造成多余流量。

我的方案是在请求队列表里记一个canonical_hash,即请求体的哈希值。同步前先比较本地dirty数据的哈希与最后同步成功的哈希是否一致,一致则直接跳过,避免重复推送。

8.4 图片和文件如何走离线队列

文本类请求很好入队,但图片、音频等二进制文件体积大,不能直接放进请求队列的payload字段。我选择的方案是单独建media_transfer表,记录文件路径、上传状态、重试次数,同步引擎专门处理媒体文件上传。

注意媒体文件断点续传,HarmonyOS的request.agent支持后台下载任务,但上传任务目前没有统一的断点续传API。我的做法是控制分片大小,如果上传过程中断,下次重传整个文件。用户量不大时够用,量大了建议自己做分片。

8.5 测试弱网环境的工具与方法

推荐的弱网模拟方案:

  • 真机上打开开发者选项,选择“网络”->“选择网络模式”,手动切到2G/3G。
  • 鸿蒙的DevEco Studio内置了弱网模拟插件,可以设置延迟、丢包率、带宽限制。
  • 自建HTTP代理,用工具(比如Charles或Fiddler)模拟延迟和断网。
  • 最有效的方法:在本地代码里做一个中间层开关,强制让网络层执行“延迟2秒、50%概率失败”的逻辑,进行核心链路测试。

测试时要覆盖的场景至少包括:网络抖动(丢包10%)时的同步成功率、长时间断网后恢复的同步速度、请求重试风暴(断电后100条离线记录同时重试)的服务端压力。

9. 性能优化与数据指标

9.1 同步效率优化

同步效率直接决定用户体验。我做了几层优化:

  • 请求合并:同bizType的多个请求合并成一个批量请求,减少RTT。
  • 数据压缩:对JSON body启用Gzip。
  • 增量优先:用游标避免全量拉取。
  • 列表分页:大列表只同步封面字段,详情按需加载。
  • 去重合并:如果同一实体被修改多次,离线期间在客户端做“合并最后一次修改”,可以减少网络同步时的操作次数。

9.2 关键监控指标

离线优先架构上线后,我重点关注这些指标:

指标 | 含义 同步成功率 | 已同步成功的任务数 / 全部任务数 同步平均耗时 | 网络恢复后到全量同步完成的时间 队列积压数 | 当前待处理的任务数 本地缓存命中率 | 缓存读取成功次数 / 总读取次数 重试次数分布 | 各任务重试次数的统计 冲突发生次数 | 服务端返回冲突的比例

如果同步成功率低于95%,通常是重试策略或服务端接口幂等机制有问题;如果队列积压数持续增长,可能是服务端处理能力瓶颈,需要排查吞吐量;如果缓存命中率过低,就要重新审视“缓存优先,后台更新”策略是否真的生效。

9.3 存储与功耗优化

离线优先通常会额外消耗存储空间和电量。我的建议:

  • 设置缓存上限,超限淘汰。
  • 同步时机考虑省电模式,低电量时暂停大文件同步,只同步文本。
  • 批量同步控制在合理大小,避免高频长连接。
  • 数据库开启WAL模式(write-ahead logging),可以减少事务写放大,同时提升读写并发能力。

10. 离线优先架构的扩展与演进方向

10.1 从“离线优先”到“多端协同优先”

HarmonyOS的分布式能力让“离线优先”不仅服务于单设备弱网场景,也能延伸到多端协同。比如手机离线时保存的数据,可以在平板联网后通过分布式数据管理能力同步到其他设备。这在架构上要求数据模型支持“设备维度”的版本管理,服务端也要能识别同一用户的多端写操作。

10.2 云侧同步服务的选型

自研同步服务是成本最高的路线。对中小团队来说,第一选择当然是直接使用现成的后端云同步能力,比如华为AGC的云数据库,它自带数据同步能力。它的底层是云端数据库+端侧SDK,自动处理数据同步、冲突解决、离线缓存,能省掉大量自研时间。我们团队最初评估过直接用AGC Cloud DB,但最后因为业务有复杂的审批状态机、需要自定义同步协议,选择了自研。如果业务形态相对简单,直接上云数据库会是更快的路径。

其实这套架构里最难的不是技术组件选型,而是业务模型的建模方式——如果你把业务设计成“事件追加、状态可推导”的模式,冲突和同步复杂度会大幅降低;如果你用“当前值覆盖”的模式,那就要做好面对冲突处理的准备。

10.3 端侧数据库与本地优先的进阶结合

HarmonyOS的分布式数据库(DistributedData)本身支持“本地优先”的缓存策略,数据写操作先写本地库,再通过软总线同步到其他设备。这个机制跟离线优先架构天然契合。如果团队不想完全自研同步引擎,可以把分布式数据库作为底层存储,在它之上做业务层。

不过要注意,分布式数据库的同步范围通常限定在同一个“分布式组网”内,云端同步还是需要额外对接服务端。所以架构上建议把“端到端设备同步”和“端到云同步”分开来看。

最后分享一个实用的小经验

我在做这套架构时,最大的体会是:不要在业务代码里到处写“同步”“缓存”“队列”的逻辑,一定要收敛成几个独立的Manager,对外只暴露简单的接口。好的架构不是复杂堆出来的,而是让每部分都能被独立测试、独立替换。离线优先架构看似复杂,拆成队列、缓存、同步、网络四块,每块边界清晰以后,落地难度会低很多。

如果你也是刚开始做HarmonyOS弱网改造,建议从最简单的“请求队列 + 本地缓存”入手,先把离线不丢这条主链路跑通,再逐步加增量同步、冲突处理、批量合并。一步到位很容易陷入过度设计的坑,等到实际业务把问题逼出来再迭代,反而是最稳妥的路径。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/20 4:33:24

Antigravity身份错位:refresh_token为空与设备指纹不匹配的根因解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 4:32:52

MCP客户端接入实战:一行注册GitHub工具,让AI操作Issue和PR

最近在折腾AI编程工具的时候&#xff0c;发现一个很有意思的趋势&#xff1a;MCP客户端接入成了各家AI助手的主战场。以前要给Claude、Codex这类工具接一个GitHub能力&#xff0c;要么写插件&#xff0c;要么搞自动化脚本&#xff0c;代码量不小&#xff0c;维护起来也头疼。现…

作者头像 李华
网站建设 2026/9/20 4:31:55

AI辅助公文写作全指南:从提示词技巧到本地化部署实践

写公文这件事&#xff0c;以前是典型的“笔杆子活”&#xff0c;讲究的是逻辑严密、用词准确、格式规范。现在越来越多的伙伴开始尝试用AI写公文&#xff0c;但普遍卡在一个心态上&#xff1a;看着AI几秒钟吐出一大段&#xff0c;心里既兴奋又发虚——这东西到底能不能直接用&a…

作者头像 李华
网站建设 2026/9/20 4:31:47

Colibri:面向MoE架构的纯C轻量级推理引擎

1. 项目概述&#xff1a;Colibri不是蜂鸟&#xff0c;而是一个面向MoE架构的轻量级推理引擎你搜“colibri”时&#xff0c;第一反应可能是南美洲那种翅膀能每秒扇动80次的蜂鸟——但在这个技术语境里&#xff0c;它指的是一款正在快速崛起的、专为混合专家模型&#xff08;MoE&…

作者头像 李华