摘要
做多端/多设备应用时,数据同步是逃不掉的一个核心问题:怎么保证多设备数据一致、冲突不至于把用户数据冲坏、网络不稳也能用、隐私安全不掉链子、同步还得够快。实际开发里,我们通常落一个“微型同步引擎”:本地有“离线存储 + 待发队列 + 版本水位”,云端有“增量拉取 + 冲突合并 + 校验签名”。这篇文章给出一套在 HarmonyOS/ArkTS 上能落地的做法,配一份能跑的 Demo,帮助你把概念变成工程代码。
引言
HarmonyOS 的应用生态里,跨设备和多端协同越来越常见:记事/待办、健身/家居、教育/表单收集、企业移动办公……这些都需要“离线可写、恢复自动同步、冲突可控、同步尽可能增量”。本文不依赖特定云厂商,走“本地存储 + HTTP 传输(可替换为分布式或直连)”的通用方案;安全性方面,Demo 用简化加密保证可运行,另提供一份生产级的 HUKS/cryptoFramework落地建议。
总体设计
同步模型与组件
-
数据模型:每条记录都有
id
、payload
、version
(单调递增)、updatedAt
(毫秒时间戳)、deviceId
、tombstone
(软删除)。 -
本地层:
Preferences
做轻量 KV 存储;维护:localStore
:记录集合dirtyQueue
:待上传增量watermark
:上次成功同步的版本号(或时间戳)
-
传输层:
Transport
负责签名/加密、发送/接收;Demo 用内存模拟远端服务(方便直接跑)。 -
合并策略:默认 LWW(Last-Write-Wins)+ 设备优先级:
- 先比
version
; - 再比
updatedAt
; - 若还相同,用
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-Timestamp
和X-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.ets
的 sendSync
改成 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”的工程骨架,也可以现在就给你一版。