
摘要
随着物联网设备与多端应用越来越常见,数据如何在设备之间、以及本地与云端之间保持一致,变成了开发中的常见需求。本文以鸿蒙(HarmonyOS)为平台,带你从原理到实战,讲清楚怎么用本地 preferences
/ RDB
存储配合云端 HTTP 接口,实现一个稳健的双向同步方案。文章包含可运行的 Demo 模块、冲突解决策略、性能与离线设计建议,并结合真实场景给出代码示例和解释。
引言
现在很多应用都要支持多设备场景:手机修改数据,手表/平板也要同步,甚至离线情况下仍能读写。本地存储(离线可用)和云端持久化(多端共享)是常态。鸿蒙提供了 @ohos.data.preferences
、@ohos.data.rdb
等本地数据能力,同时能用常规网络库与云端交互。如何把它们拼成一个可靠的同步系统,是本文的目标。
设计思路
总体上我们把同步拆成几部分:
- 本地层:负责读写和本地缓存(preferences 或 RDB)。
- 云端层:提供 RESTful 接口(或 WebSocket)供设备上传/下载变更。云端也记录版本或时间戳。
- 同步引擎:负责检测本地变更并上报、轮询或长连接获取云端变更、冲突解决、重试与节流等。
下面我们一步步实现一个轻量但可运行的双向同步 Demo。
需要的能力
@ohos.data.preferences
(示例中用于简单键值)@ohos.net.http
(发送 HTTP 请求)- 一个云端 API(示例使用
https://api.example.com
占位) - 可选:WebSocket 推送(若云端支持可减少轮询)
核心模块实现
我们把客户端拆成 3 个文件:localStore.ts
、cloudApi.ts
、syncManager.ts
,外加一个演示 ability
中的使用示例。
localStore.ts
负责统一封装本地读写,附带版本/时间戳。这里用 preferences
做示例:
ts
// localStore.ts
import data_preferences from '@ohos.data.preferences';
const PREF_NAME = 'sync_demo_db';
async function getPreferences() {
// `globalThis.context` 假设在 ability 中注入 context
return await data_preferences.getPreferences(globalThis.context, PREF_NAME);
}
export async function saveLocal(key: string, value: string) {
const pref = await getPreferences();
// 存 json 包含 value 与 metadata
const payload = {
value,
updatedAt: Date.now()
};
await pref.put(key, JSON.stringify(payload));
await pref.flush();
return payload;
}
export async function readLocal(key: string) {
const pref = await getPreferences();
const raw = await pref.get(key, '');
if (!raw) return null;
try {
return JSON.parse(raw);
} catch (e) {
return { value: raw, updatedAt: 0 };
}
}
export async function listAllLocalKeys() {
const pref = await getPreferences();
// preferences api 没有直接列出所有 key 的统一方法,具体实现视 SDK 版而定。
// 这里示范:如果没有列出 API,需要在单独 key 中维护索引(示例省略)。
return [];
}
cloudApi.ts
封装与云端的交互:上传变更、拉取更新、查询修改列表。
ts
// cloudApi.ts
import http from '@ohos.net.http';
const BASE = 'https://api.example.com/sync';
function createRequest() {
return http.createHttp();
}
export async function uploadItem(key: string, payload: { value: string; updatedAt: number }) {
const req = createRequest();
try {
const resp = await req.request(BASE + '/upload', {
method: http.RequestMethod.POST,
extraData: { key, item: payload },
expectDataType: http.HttpDataType.STRING
});
return resp;
} finally {
req.destroy();
}
}
export async function fetchItem(key: string) {
const req = createRequest();
try {
const resp = await req.request(BASE + `/item?key=${encodeURIComponent(key)}`, {
method: http.RequestMethod.GET,
expectDataType: http.HttpDataType.STRING
});
// 假设云端返回 JSON 字符串
return JSON.parse(resp.result || 'null');
} finally {
req.destroy();
}
}
export async function fetchChanges(sinceTimestamp: number) {
const req = createRequest();
try {
const resp = await req.request(BASE + `/changes?since=${sinceTimestamp}`, {
method: http.RequestMethod.GET,
expectDataType: http.HttpDataType.STRING
});
return JSON.parse(resp.result || '[]');
} finally {
req.destroy();
}
}
注意:示例中使用
extraData
直接发送对象,实际上传时通常需要 setHeader、setBody 等方式按云端 API 要求来做,且需做鉴权(token)处理。
syncManager.ts
核心同步逻辑:会监听本地写(通过统一 API)并上报云端;同时以轮询方式拉取云端变更并合并到本地。冲突用 updatedAt
(时间戳)解决:新时间戳胜出。
ts
// syncManager.ts
import * as localStore from './localStore';
import * as cloudApi from './cloudApi';
let running = false;
let pollInterval = 10 * 1000; // 10s
let lastPulledAt = 0;
export function setPollInterval(ms: number) { pollInterval = ms; }
// 当本地修改时,调用此函数(例如 saveLocal 的封装层会调用)
export async function onLocalChange(key: string, payload: { value: string; updatedAt: number }) {
try {
await cloudApi.uploadItem(key, payload);
} catch (e) {
// 上传失败:放入队列/重试机制。这里简单打印
console.warn('upload failed, will retry later', e);
}
}
async function pullOnce() {
try {
const changes = await cloudApi.fetchChanges(lastPulledAt);
// changes 预期为 [{key, item:{value, updatedAt}}]
for (const ch of changes) {
const local = await localStore.readLocal(ch.key);
if (!local || local.updatedAt < ch.item.updatedAt) {
// 云端更新更晚,覆盖本地
await localStore.saveLocal(ch.key, ch.item.value);
} else {
// 本地更新更晚,可能需要再次上报云端
if (local.updatedAt > ch.item.updatedAt) {
await cloudApi.uploadItem(ch.key, { value: local.value, updatedAt: local.updatedAt });
}
}
}
lastPulledAt = Date.now();
} catch (e) {
console.warn('pull failed', e);
}
}
export function startSync() {
if (running) return;
running = true;
// 启动定时轮询
(async function loop() {
while (running) {
await pullOnce();
await new Promise(res => setTimeout(res, pollInterval));
}
})();
}
export function stopSync() {
running = false;
}
演示:如何在 Ability 中使用
下面给出一个简单演示:当页面点击 "保存" 时,使用 localStore.saveLocal
,并通知 syncManager
上传;同时在 onStart
时启动轮询同步。
ts
// demoAbility.ts
import * as localStore from './localStore';
import * as syncManager from './syncManager';
export default class MainAbility extends Ability {
onStart(want) {
globalThis.context = this.context; // 让 localStore 能拿到 context
syncManager.startSync();
}
async onSaveButtonClick(key: string, value: string) {
const payload = await localStore.saveLocal(key, value);
await syncManager.onLocalChange(key, payload);
}
onStop() {
syncManager.stopSync();
}
}
应用场景与示例代码
下面举 2-3 个典型应用场景,说明如何把上面的框架套进去。
场景一:跨设备设置同步
应用:用户在手机上改了应用设置(例如主题、通知开关),希望平板/手表也同步。
实现要点:设置项通常为小量 key/value,preferences
很合适。每次修改都走 saveLocal
+ onLocalChange
上传。云端记录 updatedAt
。
示例:同上 demoAbility.ts
即可满足。若设备多、设置量小,轮询间隔可以调大(比如 60s)以节省流量。
场景二:离线日记/笔记同步
应用:用户在离线时写下笔记,联网后自动同步到云端并合并到其它设备。
实现要点:笔记是有版本与冲突风险的数据结构。建议每条笔记使用 id
+ updatedAt
,并在本地保留操作日志(oplog)以便出现冲突时做手动合并或三方合并(merge)。
示例代码片段(伪代码):
ts
// 新增笔记
const id = generateUUID();
await localStore.saveLocal('note:' + id, JSON.stringify({title, body}));
await syncManager.onLocalChange('note:' + id, { value: JSON.stringify({title, body}), updatedAt: Date.now() });
冲突场景举例:手机和平板同时离线编辑同一条笔记。解决策略有三种:最后写入胜出(LWW)、操作记录合并(OT/CRDT)、或提示用户手动合并。本文示例采用 LWW(时间戳较新的胜出),简单易实现但有丢失风险。
场景三:大数据量同步(例如媒体文件)
应用:相册同步、录音等大文件无法直接放进 preferences。建议:
- 文件内容上传到对象存储(OSS),只在
preferences
/RDB 中同步文件元信息(URL、hash、updatedAt)。 - 断点续传、分片上传与校验必须由云端/客户端配合实现。
示例:当用户添加一张图片,先上传文件到云端返回 URL,然后保存 metadata 到本地并做同步:
ts
// 上传图片流程示意:先上传二进制到文件服务 -> 得到 url -> saveLocal('photo:123', JSON.stringify({url, hash, updatedAt})) -> sync
性能与可靠性建议
- 使用节流与退避:本地频繁写入时,不要每次都立刻上报,考虑合并短时间内的多次改动(debounce)或按优先级批量上报。
- 离线队列:上传失败时将变更放入本地队列,启动重试策略(指数退避)。
- 安全与鉴权:使用 OAuth2 或 token 鉴权,HTTPS 必须开启,敏感信息加密存储。
- 冲突策略:根据数据类型选择 LWW/CRDT/OT 或人工合并策略。
- 数据一致性窗口:明确同步时延(例如最终一致性),并在 UI 上提示数据可能为"最近一次同步状态"。
QA 环节
Q: preferences 和 RDB 哪个更适合?
A: 偏小量键值用 preferences
,复杂结构或关系型数据用 RDB
。RDB 支持 SQL 查询,适合需要检索与索引的场景。
Q: 如何保证跨端时间同步一致性(时钟不准问题)?
A: 不要完全信任设备本地时间。推荐云端在接收变更时记录服务器 updatedAt
并返回给设备,设备使用云端时间做比较。若必须本地比较,可使用基于版本号或递增序列号(由云端发放)来避免依赖时间。
Q: 是否必须用轮询?
A: 不必须。轮询是最通用但耗流量。可用 WebSocket/Push(云端推送)来实现实时更新,或者结合消息队列/推送服务减少轮询频率。
总结
本文从原理到代码给出了在鸿蒙上实现本地与云端双向同步的实战方案,覆盖了本地存储封装、云端交互、同步引擎与冲突解决的实现思路与示例代码。实际工程中还需要考虑鉴权、离线队列、退避重试、以及大文件分片上传等细节。如果你愿意,我可以:
- 把上面的示例整理成一个完整的鸿蒙工程骨架(Ability + Demo)。
- 把
preferences
示例改成RDB
版本,并加入本地索引与查询示例。 - 增加 WebSocket 推送实现和服务器端示例(Node.js)。