分布式文件同步,不止是复制粘贴
HarmonyOS NEXT 的分布式能力,在文件系统层面确实给了开发者一把利器:分布式文件标记。官方文档里说得很清楚,通过distributed标记,文件会自动在设备间同步。但实际做多端文件同步应用时,你会发现真正的难点不是“同步文件”,而是“什么时候同步”、“冲突了怎么办”、“用户怎么感知”。
很多人第一次用这个能力,会写成这样:监听本地文件变化,变化后直接调用fs.copyFile到分布式目录。但这样会遇到几个典型问题:
- 分布式文件标记不生效:文件虽然拷贝过去了,但远端设备看不到更新。
- watch 回调不触发:设备 A 改了文件,设备 B 的监听器没反应。
- 冲突时,直接覆盖:两端同时编辑,只有最后写入的生效,用户没有任何感知。
这篇文章就围绕这三个真实问题,拆解一个手机与平板间的文件同步应用。核心逻辑不复杂,但每个环节都有一些设计细节需要处理。
它解决什么问题
| 问题 | 简单粗暴的方案 | 本方案 |
|---|---|---|
| 文件内容同步 | 手动fs.copyFile到分布式路径 | 应用层标记 + 自动同步 |
| 编辑冲突 | 最后写入覆盖 | 记录冲突文件 + 提示用户选择 |
| 目录结构同步 | 无 | 监听子目录,递归同步 |
适用场景:笔记应用、文档编辑、配置文件同步等需要多端协同的场景。
不适合:大型二进制文件实时同步、超高频目录写入场景。
环境说明
DevEco Studio 版本:DevEco Studio 6.1.0 及以上 HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上 目标设备:[手机 / 平板]核心实现:三步构建同步应用
第一步:标记分布式目录
不是所有目录都会自动同步。只有通过fileIo.setLabel标记过的文件或目录,才会被系统纳入分布式管理。
// model/SyncDirManager.etsimport{fileIo}from'@kit.CoreFileKit';import{common}from'@kit.AbilityKit';exportclassSyncDirManager{privatecontext:common.UIAbilityContext;constructor(context:common.UIAbilityContext){this.context=context;}asyncsetupSyncDir(dirName:string):Promise<string>{// 获取应用私有沙箱路径constbaseDir=this.context.filesDir;constsyncDirPath=`${baseDir}/${dirName}`;// 创建目录awaitfileIo.mkdir(syncDirPath,true);// 重点:标记为分布式目录// 第二个参数必须为 "DISTRIBUTED"awaitfileIo.setLabel(syncDirPath,"DISTRIBUTED");console.info(`SyncDirManager: 目录已标记为分布式:${syncDirPath}`);returnsyncDirPath;}}注意事项:
setLabel必须在目录创建之后调用。- 标记后的目录,其下的子文件和子目录也会自动继承分布式属性。
- 不要对已存在的非分布式目录直接标记,部分设备上会报错(API 12 已验证)。
第二步:监听本地文件变化
这里用fileIo.createWatcher来监听目录变化。关键点:Watcher 必须与页面生命周期绑定,否则页面销毁后监听会丢失。
// viewmodel/SyncViewModel.etsimport{fileIo}from'@kit.CoreFileKit';import{emitter}from'@kit.BasicServicesKit';exportclassSyncViewModel{privatewatcher:fileIo.Watcher|null=null;privatesyncDirPath:string="";constructor(syncDirPath:string){this.syncDirPath=syncDirPath;}startWatching(){if(this.watcher){// 避免重复创建this.watcher.stop();}// 创建 Watcher,监听修改事件this.watcher=fileIo.createWatcher(this.syncDirPath,{recursive:true,// 监听子目录events:['change']});// 注册回调this.watcher.on('change',(event)=>{// event.filename: 变更的文件相对路径// event.eventType: 'change'console.info(`SyncViewModel: 文件变更:${event.filename}`);// 通过 emitter 通知 UI 刷新emitter.emit({eventId:'fileChanged',priority:emitter.EventPriority.IMMEDIATE},{data:{filename:event.filename,timestamp:Date.now()}});});}stopWatching(){if(this.watcher){this.watcher.stop();this.watcher=null;}}}为什么用 emitter 而不是直接回调:Watcher 的回调运行在独立线程,直接修改 UI 状态会导致@State刷新异常。使用emitter将事件抛回主线程,再由主线程更新 UI。
第三步:冲突检测与提示
冲突发生在:设备 A 和 B 同时对同一个文件进行了修改。我们的策略是:最后写入优先,但写入前会给用户一个通知,让用户决定是否合并。
// model/ConflictResolver.etsimport{fileIo}from'@kit.CoreFileKit';import{promptAction}from'@kit.ArkUI';exportclassConflictResolver{// 记录每个文件的最后修改时间戳privatelastModifiedMap:Map<string,number>=newMap();privatesyncDirPath:string;constructor(syncDirPath:string){this.syncDirPath=syncDirPath;}asynconFileChanged(filename:string){constfilePath=`${this.syncDirPath}/${filename}`;// 1. 获取当前文件的修改时间conststat=awaitfileIo.stat(filePath);constcurrentMtime=stat.mtime;// 2. 检查是否有冲突constlastMtime=this.lastModifiedMap.get(filename);if(lastMtime&&(currentMtime-lastMtime)<1000){// 如果在 1 秒内连续修改,认为可能冲突// 实际开发中,可以改成:对比两端修改时间差this.showConflictDialog(filename,filePath);}// 3. 更新最后修改时间this.lastModifiedMap.set(filename,currentMtime);}privateshowConflictDialog(filename:string,filePath:string){// 实际应用中,这里应该弹出对话框让用户选择// 这里使用 promptAction 简单提示promptAction.showToast({message:`文件${filename}可能存在编辑冲突,已使用最新版本`,duration:3000});}}冲突处理策略说明:
- 这里采用的是简化版:检测到 1 秒内两次修改,就提示用户。
- 更完善的方案:远端设备写入时,在文件名后加上设备 ID 和版本号,通过对比版本号来决定是否冲突。
- 最后写入优先:在提示后,始终以当前设备的修改为准。如果有专门的文件合并需求,需要额外的业务逻辑。
踩坑记录
坑1:Watcher 回调不触发
现象:在设备 A 上修改了文件,设备 B 的 Watcher 没有收到change事件。
原因:Watcher 监听的是底层文件系统的inotify(类 Linux 机制),而分布式文件的同步是上层应用层完成的。系统从远端同步文件到本地后,文件的属性(如 mtime)会更新,但 inotify 事件可能不会触发。
解决方案:
- 不要依赖 Watcher 来做全量同步通知。
- 改为:本地修改后,主动向对端设备发送一条广播或通知(通过
@kit.DistributedNotificationKit或featureAbility的跨设备调用)。 - 对端收到通知后,手动调用
fileIo.access检查文件是否存在,并读取最新内容。
坑2:setLabel后文件不自动同步
现象:对目录标记DISTRIBUTED后,把文件复制进去,其他设备看不到。
原因:分布式同步不是实时的。系统有同步窗口期(大约 30 秒到 2 分钟),且部分低端设备、平板端同步延迟较大。
解决方案:
- 文件写入后,调用
fileIo.sync强制刷盘(写缓存到磁盘),但无法强制分发到其他设备。 - 最佳做法:在文件写入后,等待 3-5 秒,再检查对端文件是否存在。如果是关键同步,建议用分布式数据库记录同步状态,由应用层发起同步确认。
最佳实践
- Watcher 生命周期绑定到页面:页面
onPageShow时创建,onPageHide时销毁。不要尝试全局单例持有 Watcher,页面销毁后回调会崩溃。 - 不要重复修改分布式目录的权限:同一个目录只能调用一次
setLabel,多次调用会导致BUSY错误。 - 冲突处理不能只依赖时间戳:设备间时钟可能存在几秒的偏差。建议在写入文件头或元数据中记录设备 ID + 自增版本号,通过版本号决定谁的修改是最近的。
Demo 入口
// pages/Index.etsimport{SyncDirManager}from'../model/SyncDirManager';import{SyncViewModel}from'../viewmodel/SyncViewModel';import{ConflictResolver}from'../model/ConflictResolver';@Entry@Componentstruct Index{@Statemessage:string='等待初始化...';privatesyncDirPath:string='';privatesyncVM:SyncViewModel|null=null;privateconflictResolver:ConflictResolver|null=null;aboutToAppear(){this.initSync();}aboutToDisappear(){this.syncVM?.stopWatching();}asyncinitSync(){constcontext=getContext(this)ascommon.UIAbilityContext;constmanager=newSyncDirManager(context);this.syncDirPath=awaitmanager.setupSyncDir('mySyncDir');this.conflictResolver=newConflictResolver(this.syncDirPath);this.syncVM=newSyncViewModel(this.syncDirPath);this.syncVM.startWatching();this.message=`同步目录初始化成功:${this.syncDirPath}`;}build(){Column(){Text(this.message).fontSize(16).padding(10)}.width('100%').height('100%')}}FAQ
Q:为什么真机同步成功,但模拟器上文件在远端看不到?
A:模拟器不支持真实的分布式文件同步。setLabel在模拟器上不会报错,但文件不会真正同步到其他设备。必须用真机测试。
Q:页面返回后 Watcher 失效,再次打开页面需要重新创建吗?
A:需要。页面销毁时 Watcher 会被系统回收,重新进入时务必调用startWatching()。推荐在aboutToAppear中重新初始化。
Q:同步目录可以跨应用访问吗?
A:不行。setLabel标记的分布式文件只在同一应用的不同设备实例间同步。不同应用之间的文件隔离依然存在。
示例代码地址:项目地址