鸿蒙 ArkTS 数据同步实战:带可跑 Demo!搞定增量同步、冲突解决、离线 / 后台同步

#新星杯·14天创作挑战营·第14期#

在这里插入图片描述

摘要

做多端/多设备应用时,数据同步是逃不掉的一个核心问题:怎么保证多设备数据一致、冲突不至于把用户数据冲坏、网络不稳也能用、隐私安全不掉链子、同步还得够快。实际开发里,我们通常落一个“微型同步引擎”:本地有“离线存储 + 待发队列 + 版本水位”,云端有“增量拉取 + 冲突合并 + 校验签名”。这篇文章给出一套在 HarmonyOS/ArkTS 上能落地的做法,配一份能跑的 Demo,帮助你把概念变成工程代码。

引言

HarmonyOS 的应用生态里,跨设备和多端协同越来越常见:记事/待办、健身/家居、教育/表单收集、企业移动办公……这些都需要“离线可写、恢复自动同步、冲突可控、同步尽可能增量”。本文不依赖特定云厂商,走“本地存储 + HTTP 传输(可替换为分布式或直连)”的通用方案;安全性方面,Demo 用简化加密保证可运行,另提供一份生产级的 HUKS/cryptoFramework落地建议。

总体设计

同步模型与组件

  • 数据模型:每条记录都有 idpayloadversion(单调递增)、updatedAt(毫秒时间戳)、deviceIdtombstone(软删除)。

  • 本地层Preferences 做轻量 KV 存储;维护:

    • localStore:记录集合
    • dirtyQueue:待上传增量
    • watermark:上次成功同步的版本号(或时间戳)
  • 传输层Transport 负责签名/加密、发送/接收;Demo 用内存模拟远端服务(方便直接跑)。

  • 合并策略:默认 LWW(Last-Write-Wins)+ 设备优先级

    1. 先比 version
    2. 再比 updatedAt
    3. 若还相同,用 deviceId 字典序打破平局。
  • 增量同步:客户端带 since(上次水位)拉取;上传 dirty(本地改动);服务端返回 delta

  • 安全:Demo 用 HMAC 模拟签名 + 简化“加密”;生产用 HUKS 管密钥 + cryptoFramework 做 AES-GCM/HMAC。

  • 后台与离线WorkScheduler 定时补偿;无网则先落 dirtyQueue,有网再自动发送。

最小可运行 Demo

目录建议(照着建文件即可运行)

/features/sync/
  ├─ types.ts
  ├─ local_store.ets
  ├─ conflict.ets
  ├─ transport.ets
  ├─ engine.ets
  ├─ worker.ets
/pages/SyncDemo.ets

代码示例

types.ts
// /features/sync/types.ts
export type DeviceId = string

export interface RecordItem {
  id: string
  payload: any
  version: number         // 单调递增
  updatedAt: number       // Date.now()
  deviceId: DeviceId
  tombstone?: boolean     // 软删除
}

export interface Delta {
  since: number           // 客户端请求的水位
  changes: RecordItem[]   // 变更记录
  newWatermark: number    // 服务端最新水位
}

export interface SyncRequest {
  deviceId: DeviceId
  since: number
  dirty: RecordItem[]
  nonce: string
  signature: string
}

export interface SyncResponse {
  ok: boolean
  delta: Delta
}

export const now = () => Date.now()
export const genId = () => Math.random().toString(36).slice(2)
local_store.ets
// /features/sync/local_store.ets
import preferences from '@ohos.data.preferences'
import { RecordItem, now } from './types'

const PREF_NAME = 'sync_demo_pref'
const KEY_RECORDS = 'records'
const KEY_DIRTY = 'dirty'
const KEY_WATERMARK = 'watermark'

let pref: preferences.Preferences | null = null

async function ensurePref() {
  if (!pref) {
    pref = await preferences.getPreferences(globalThis.getContext(), PREF_NAME)
  }
  return pref!
}

export async function loadRecords(): Promise<RecordItem[]> {
  const p = await ensurePref()
  return (await p.get(KEY_RECORDS, [])) as RecordItem[]
}
export async function saveRecords(list: RecordItem[]) {
  const p = await ensurePref()
  await p.put(KEY_RECORDS, list)
  await p.flush()
}
export async function loadDirty(): Promise<RecordItem[]> {
  const p = await ensurePref()
  return (await p.get(KEY_DIRTY, [])) as RecordItem[]
}
export async function saveDirty(list: RecordItem[]) {
  const p = await ensurePref()
  await p.put(KEY_DIRTY, list)
  await p.flush()
}
export async function getWatermark(): Promise<number> {
  const p = await ensurePref()
  return (await p.get(KEY_WATERMARK, 0)) as number
}
export async function setWatermark(v: number) {
  const p = await ensurePref()
  await p.put(KEY_WATERMARK, v)
  await p.flush()
}

// 便捷操作:新增/更新/删除 -> 进入 records 与 dirty 队列
export async function upsertLocal(item: Omit<RecordItem, 'version' | 'updatedAt'> & { version?: number }) {
  const list = await loadRecords()
  const index = list.findIndex(i => i.id === item.id)
  const version = (item.version ?? 0) + 1
  const updated: RecordItem = { ...item, version, updatedAt: now() }
  if (index >= 0) list[index] = { ...list[index], ...updated }
  else list.push(updated)
  await saveRecords(list)

  const dirty = await loadDirty()
  // 合并同 id 的最新改动
  const dIdx = dirty.findIndex(d => d.id === updated.id)
  if (dIdx >= 0) dirty[dIdx] = updated
  else dirty.push(updated)
  await saveDirty(dirty)
}

export async function softDeleteLocal(id: string, deviceId: string) {
  const list = await loadRecords()
  const idx = list.findIndex(i => i.id === id)
  if (idx >= 0) {
    const del: RecordItem = { ...list[idx], tombstone: true, version: list[idx].version + 1, updatedAt: now() }
    list[idx] = del
    await saveRecords(list)
    const dirty = await loadDirty()
    const dIdx = dirty.findIndex(d => d.id === id)
    if (dIdx >= 0) dirty[dIdx] = del
    else dirty.push(del)
    await saveDirty(dirty)
  }
}
conflict.ets
// /features/sync/conflict.ets
import { RecordItem } from './types'

// 默认策略:先比 version,再比 updatedAt,仍持平用 deviceId 字典序
export function resolveConflict(a: RecordItem, b: RecordItem): RecordItem {
  if (a.version !== b.version) return a.version > b.version ? a : b
  if (a.updatedAt !== b.updatedAt) return a.updatedAt > b.updatedAt ? a : b
  return a.deviceId < b.deviceId ? a : b
}

// 批量合并到本地列表
export function mergeIntoLocal(base: RecordItem[], incoming: RecordItem[]): RecordItem[] {
  const map = new Map<string, RecordItem>()
  base.forEach(x => map.set(x.id, x))
  for (const r of incoming) {
    const cur = map.get(r.id)
    if (!cur) {
      map.set(r.id, r)
    } else {
      map.set(r.id, resolveConflict(cur, r))
    }
  }
  return Array.from(map.values())
}
transport.ets
// /features/sync/transport.ets
import { SyncRequest, SyncResponse, Delta, RecordItem, now } from './types'

// ===== Demo 用的“远端内存数据库”(可直接跑) =====
const remoteDB: { items: Map<string, RecordItem>, watermark: number } = {
  items: new Map(),
  watermark: 0,
}

// 简易 HMAC 签名(Demo:不是生产强度)
function hmacDemo(secret: string, payload: string): string {
  // 非加密场景,仅做演示
  let acc = 0
  for (let i = 0; i < payload.length; i++) acc = (acc + payload.charCodeAt(i)) % 1000000007
  for (let i = 0; i < secret.length; i++) acc = (acc * 131 + secret.charCodeAt(i)) % 1000000007
  return String(acc)
}
const SHARED_SECRET = 'demo-secret-key'

// 远端的冲突解决与水位推进
function remoteApply(dirty: RecordItem[]): void {
  for (const r of dirty) {
    const cur = remoteDB.items.get(r.id)
    if (!cur) {
      remoteDB.items.set(r.id, r)
    } else {
      // 与客户端一致的策略
      const win = (r.version !== cur.version)
        ? (r.version > cur.version ? r : cur)
        : (r.updatedAt !== cur.updatedAt ? (r.updatedAt > cur.updatedAt ? r : cur)
          : (r.deviceId < cur.deviceId ? r : cur))
      remoteDB.items.set(r.id, win)
    }
    remoteDB.watermark = Math.max(remoteDB.watermark, r.version)
  }
}

function remoteDelta(since: number): Delta {
  const changes = Array.from(remoteDB.items.values()).filter(r => r.version > since)
  const newWatermark = remoteDB.watermark
  return { since, changes, newWatermark }
}

export async function sendSync(req: SyncRequest): Promise<SyncResponse> {
  // 校验签名
  const body = JSON.stringify({ deviceId: req.deviceId, since: req.since, dirty: req.dirty, nonce: req.nonce })
  const expect = hmacDemo(SHARED_SECRET, body)
  if (expect !== req.signature) {
    return { ok: false, delta: { since: req.since, changes: [], newWatermark: req.since } }
  }

  // 模拟网络时延
  await new Promise(r => setTimeout(r, 120))

  // 应用客户端增量 -> 计算服务端增量
  remoteApply(req.dirty)
  const delta = remoteDelta(req.since)
  return { ok: true, delta }
}

// 生产建议(非 Demo):用 HUKS 管理对称密钥 + cryptoFramework 做 AES-GCM/HMAC;
// 传输走 HTTPS;请求头带时间戳与随机 nonce,服务端校验时间窗与重放。
engine.ets
// /features/sync/engine.ets
import { loadRecords, saveRecords, loadDirty, saveDirty, getWatermark, setWatermark } from './local_store'
import { sendSync } from './transport'
import { mergeIntoLocal } from './conflict'
import { DeviceId, RecordItem } from './types'

function randomNonce() { return Math.random().toString(36).slice(2) }

function signDemo(secret: string, payload: string): string {
  // 与 transport.hmacDemo 同步算法;真实场景请替换为 HMAC
  let acc = 0
  for (let i = 0; i < payload.length; i++) acc = (acc + payload.charCodeAt(i)) % 1000000007
  for (let i = 0; i < secret.length; i++) acc = (acc * 131 + secret.charCodeAt(i)) % 1000000007
  return String(acc)
}
const SHARED_SECRET = 'demo-secret-key'

export async function syncOnce(deviceId: DeviceId): Promise<{ applied: number, pulled: number }> {
  const since = await getWatermark()
  const dirty = await loadDirty()
  const nonce = randomNonce()
  const body = JSON.stringify({ deviceId, since, dirty, nonce })
  const signature = signDemo(SHARED_SECRET, body)

  const resp = await sendSync({ deviceId, since, dirty, nonce, signature })
  if (!resp.ok) throw new Error('sync failed')

  // 合并服务端增量
  const local = await loadRecords()
  const merged = mergeIntoLocal(local, resp.delta.changes)
  await saveRecords(merged)

  // 成功后清空 dirty,并推进水位
  await saveDirty([])
  await setWatermark(resp.delta.newWatermark)

  return { applied: dirty.length, pulled: resp.delta.changes.length }
}
worker.ets
// /features/sync/worker.ets
import workScheduler from '@ohos.resourceschedule.workScheduler'
import { syncOnce } from './engine'

const WORK_ID = 2025082501

export function registerPeriodicSync(deviceId: string) {
  const workInfo: workScheduler.WorkInfo = {
    workId: WORK_ID,
    // 15 分钟周期(最小周期可能受系统限制),这里用约数做演示
    repeatCycleTime: 15 * 60 * 1000,
    isPersisted: true, // 设备重启后保留
    // 可按需加网络、充电状态等约束
  }
  try {
    workScheduler.startWork(workInfo, async () => {
      try { await syncOnce(deviceId) } catch (e) { /* 忽略错误,下一轮再补偿 */ }
    })
  } catch (e) {
    // 某些版本需配合后台任务权限/声明;失败不影响手动同步
  }
}

export function cancelPeriodicSync() {
  try { workScheduler.stopWork(WORK_ID) } catch (_) {}
}
页面演示:SyncDemo.ets
// /pages/SyncDemo.ets
import { genId } from '../features/sync/types'
import { upsertLocal, softDeleteLocal, loadRecords, loadDirty } from '../features/sync/local_store'
import { syncOnce } from '../features/sync/engine'
import { registerPeriodicSync, cancelPeriodicSync } from '../features/sync/worker'

@Entry
@Component
struct SyncDemo {
  @State deviceId: string = 'device-A'  // 你可以改成 device-B 来模拟多设备
  @State msg: string = ''
  @State list: any[] = []
  @State offline: boolean = false

  aboutToAppear() {
    this.refresh()
    registerPeriodicSync(this.deviceId)
  }
  aboutToDisappear() {
    cancelPeriodicSync()
  }

  async refresh() {
    this.list = await loadRecords()
  }

  build() {
    Column() {
      Text(`当前设备: ${this.deviceId}`).fontSize(18).margin({ bottom: 8 })
      Row() {
        Button('新增一条记录').onClick(async () => {
          const id = genId()
          await upsertLocal({ id, payload: { text: `Hello ${id.slice(0,4)}` }, deviceId: this.deviceId })
          await this.refresh()
          this.msg = `本地新增完成:${id}`
        })
        .margin({ right: 8 })

        Button('随机更新').onClick(async () => {
          if (this.list.length === 0) return
          const r = this.list[Math.floor(Math.random() * this.list.length)]
          await upsertLocal({ id: r.id, payload: { text: `Updated@${Date.now()}` }, deviceId: this.deviceId, version: r.version })
          await this.refresh()
          this.msg = `本地更新完成:${r.id}`
        })
      }.margin({ bottom: 8 })

      Row() {
        Button(this.offline ? '当前离线(点我改为在线)' : '当前在线(点我改为离线)')
          .onClick(() => { this.offline = !this.offline; this.msg = this.offline ? '切到离线' : '切到在线' })
          .margin({ right: 8 })

        Button('手动同步').onClick(async () => {
          if (this.offline) { this.msg = '离线模式:已缓存到 dirty 队列'; return }
          try {
            const { applied, pulled } = await syncOnce(this.deviceId)
            await this.refresh()
            this.msg = `同步成功:上传 ${applied},拉取 ${pulled}`
          } catch (e) {
            this.msg = `同步失败:${(e as Error).message}`
          }
        })
      }.margin({ bottom: 8 })

      List() {
        ForEach(this.list, (item: any) => {
          ListItem() {
            Row() {
              Text(`${item.id.slice(0,6)} v${item.version}`).fontSize(14).margin({ right: 6 })
              Text(item.tombstone ? '[DEL]' : item?.payload?.text ?? '').fontSize(16)
              Blank()
              Button('删除').onClick(async () => {
                await softDeleteLocal(item.id, this.deviceId)
                await this.refresh()
                this.msg = `已标记删除:${item.id}`
              })
            }.width('100%')
          }
        })
      }.height(300).margin({ bottom: 8 })

      Button('查看待发队列').onClick(async () => {
        const dirty = await loadDirty()
        this.msg = `待发条数:${dirty.length}`
      })
      Text(this.msg).margin({ top: 10 }).fontSize(14).fontColor('#666')
    }.padding(16)
  }
}

关键点拆解

增量同步

  • 为什么要增量:节流带宽,避免“大全量覆盖”;移动端尤甚。
  • 怎么定义增量:用 version 作为单调水位(或“逻辑时钟”),只传 version > since 的变更。
  • 客户端职责:维护 dirtyQueue,失败不丢;成功清空并推进 watermark

冲突解决

  • 并发写不可避免;比如 A、B 设备同时改同一条记录。
  • 策略:LWW + 设备优先级(可换成向量时钟、CRDT等更强方案)。
  • 实现点:在客户端与服务端两边都实现同一套规则,保证幂等和一致。

安全与认证

  • Demo 用简化 HMAC 与“伪加密”保证可运行。

  • 生产建议

    HTTPS 强制;
    HUKS 持久化对称密钥(App 级)/ 非对称握手;
    cryptoFramework 做 AES-GCM(保密 + 完整性)与 HMAC-SHA256(消息鉴别);
    ** Header** 带 X-TimestampX-Nonce,服务端校验时间窗与重放;
    最小权限:密钥只在需要的模块可见;异常路径也要清理明文。

参考落点(伪代码,非 Demo):

// huks: 生成/读取对称密钥(alias: 'sync-key')
// cryptoFramework: cipher = AES/GCM/NoPadding; mac = HmacSha256
// 加密: cipher.init(ENCRYPT_MODE, key, iv); cipher.doFinal(plain)
// HMAC: mac.init(key); mac.update(body); mac.doFinal()

后台与离线

  • 离线优先:本地可写、可查;待发队列保序重试。
  • 后台补偿WorkScheduler 定时;也可以在 App 启动、前后台切换、网络恢复时触发一次 syncOnce()
  • 幂等:服务端根据 id + version 做去重;客户端“重试不产生重复”。

场景化示例

场景一:跨设备的记事应用

需求:手机上写笔记,平板上能秒同步;离线可写,在线自动补。

关键点

  • 记录结构:{ id, title, content, tags, ... }
  • 更新频繁,冲突概率高 → 用 LWW;给用户展“冲突版本”也可(保留历史)。

示例代码(在实际 App 的 saveNote() 里):

import { upsertLocal } from '../features/sync/local_store'
import { syncOnce } from '../features/sync/engine'

async function saveNote(deviceId: string, noteId: string, payload: any) {
  await upsertLocal({ id: noteId, payload, deviceId })
  // 若在线则顺手拉一次
  try { await syncOnce(deviceId) } catch (_) {}
}

讲解:写入直接落本地与 dirtyQueue;若在线,顺带“上传+拉取”。冲突发生时,由合并策略决定胜负;你也可以把“冲突双方”写入另一个“历史表”,方便用户手动比对。

场景二:IoT 设备参数下发与状态回传

需求:手机改了温控策略,网关/面板要尽快拿到;设备离线时先缓存,在线再补发;回传状态增量采集。

做法

  • 参数作为配置记录(payload.config),设备回传状态(payload.state)单独记录。
  • 设备侧也跑同一“同步引擎”,只不过 Transport 实现换成本地直连/网关转发。

示例代码(参数变更):

await upsertLocal({
  id: 'thermo-config-001',
  payload: { target: 24, mode: 'auto' },
  deviceId: 'phone-1'
})
// 后台周期任务会自动带走;或者手动 syncOnce('phone-1')

讲解:用同一记录 ID代表同一设备的那份配置;多端修改产生冲突时,LWW 保证有且只有一个“最终生效”的版本(必要时让设备端也做“版本签收”)。

场景三:离线表单/巡检上报

需求:山区/工地无网填写,回城自动同步;记录一旦提交不可随意覆盖。

做法

  • 新增只增不改:提交后 tombstone=false不再更新 payload,修改通过创建新记录(带 parentId)。
  • 服务端做“不可变记录”校验,避免 LWW 覆盖历史。

示例代码(提交只新增):

import { genId } from '../features/sync/types'
await upsertLocal({
  id: genId(),
  payload: { form: 'inspect', fields: { site: 'A1', ok: true } },
  deviceId: 'inspector-007'
})

讲解:这类场景更适合追加式模型(append-only),同步引擎不改,冲突概率也低很多。

QA 环节

Q1:为什么不用“时间戳”当唯一排序,而还要 version
A:时间戳容易受设备时钟漂移影响;version 是本地单调计数,更可控。我们用“先比 version,再比时间戳”减少时钟差对结果的影响。

Q2:如果出现“回滚覆盖”(旧版本覆盖了新版本)怎么办?
A:服务端要做“写入幂等 + 版本校验”。当收到 version 更小的写入时拒绝或降级为“历史版本”;客户端重试也不会覆盖新写入。

Q3:脏队列越来越大怎么办?
A:可加分片提交(比如每次带 100 条)、指数退避重试;成功一部分就删一部分;同时对“超旧 tombstone”做归档/清理。

Q4:如何把 Demo 的内存远端替换成真实后端?
A:把 /features/sync/transport.etssendSync 改成 http.request,请求体就是 SyncRequest;服务端实现“应用 dirty → 计算 delta → 返回 SyncResponse”。签名用 HMAC-SHA256;加密用 AES-GCM;强制 HTTPS。

Q5:多集合/多表怎么同步?
A:为每个集合维护独立水位与脏队列,请求时带 collection 字段;不要混在一个水位里,否则容易相互卡住。

总结

这套“增量 + 冲突解决 + 离线优先 + 背景补偿 + 安全签名”的同步工具,在鸿蒙端落地其实不复杂:

  • 本地用 Preferences/RDB,维护 records + dirty + watermark
  • 合并策略两端一致(LWW 或更高级如 CRDT);
  • 传输层抽象出来,Demo 可用内存服务,生产换 HTTP/直连/分布式;
  • 安全层用 HUKS + cryptoFramework 做密钥与加解密;
  • WorkScheduler 定时兜底,启动/恢复网络时做即时补偿。

你可以直接把上面的 Demo 拷进项目里跑起来,再按业务逐步替换传输、安全与持久化实现。需要我把它改造成“多集合版本 + RDB 存储 + 真 HUKS/cryptoFramework”的工程骨架,也可以现在就给你一版。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值