随着用户对自身数据保护意识的加强,让用户自己维护自己的数据也成了独立开发产品时的一个卖点。若只针对少量的文件进行同步,则实现起来比较简单。当针对一个多层级目录同步时,情况就复杂多了。鉴于相关的文章甚少,本文我分享下我的设计思路。
本文是我在开发言叶(一个基于文件系统的 Markdown 笔记软件)过程中整理出的设计思路。这里的方案是我设计的第二套方案,在第一个方案的基础上弥补了很多不足。比之前的版本,同步的速率大幅提升,流量的消耗也大幅降低。
针对文件目录的同步不像基于数据库的同步那样灵活。对于文件同步,同步的对象是普通的文件,我们无法通过为其增加时间戳、版本号等信息来判断哪个文件是最新的。
对多级文件目录的移动操作的同步也是一个难点。因为移动操作可能会同时移动大量的文件,导致它们文件目录的变更。若处理不好则容易导致文件丢失或者文件重复。
文件同步设计的另一个难点是对云服务器的兼容。言叶支持的是基于 WebDAV 的同步,将来我还考虑支持更多云服务器。所以,我需要设计一个针对不同服务器的方案而不只是针对 WebDAV 协议的。即便针对 WebDAV 协议进行设计,我们也无法保证所有云提供商都会严格按照 WebDAV 协议进行支持。
第一个版本方案的流程图如下。
这个版本方案的基本思路如下。
通过对比本地和远程文件的 md5 来判断文件是否发生了变更。在每次同步完成之后会将所有文件的路径和 md5 值的映射关系以如下格式写入到服务器的一个文本文件中。
/测试/test.txt:ADBF5A778175EE757C34D0EBA4E932BC
/jjsskizs.log:D41D8CD98F00B204E9800998ECF8427E
/Hello.txt:D064F3519426DCD30114B900431FC044
...
如果服务器中的一个文件不在上述记录中,我们可以判断这个文件是服务器新增的(相对于本地);如果本地的一个文件不在上述记录中,我们则可以判断这个文件是本地新增的;如果一个文件存在于上述记录中而不存在于云服务器,我们可以判断该文件是被服务器删除;如果一个文件存在于上述记录而不存在于本地,则可以判断为被本地删除。对于文件的移动操作,这种方案会将其分解成删除和新增两个操作。
这种方案存在两个问题:1).该方案需要通过网络读取远程的每个文件的 md5 值。这导致该方案流量消耗比较多以及同步耗时比较长。2).在服务器中维护状态文件还存在当用户在两个设备上同步的时候会出现行为冲突问题。比如,一个设备新增一个文件并写入映射关系到该状态文件,另一个设备会将该文件判断为本地删除,从而在远程删除该文件。
对于用户在设备上的删除、移动行为,在这种方案中会先将这些行为以如下格式写入到本地的文本中,
DIR:DELETE:/测试::false:true
DIR:DELETE:/测试目录::false:true
DIR:DELETE:/新目录::false:true
...
然后尝试立即同步该行为,如果成功就擦除本地行为记录,否则会在下一次对整个文件目录同步的时候进行同步。由于对用户的行为的同步被放在对整个目录同步之前。因此,在该方案中,这些用户操作的时序性是无法保证的。
第一种方案的槽点比较多,作为踩坑的方案,最初我并没有考虑多设备同步等情况。不过,它也有一些值得借鉴的地方。比如,通过文件的 md5 来判断文件是否发生了修改;引入垃圾箱机制,本地删除的时候将文件移动到垃圾箱而不是直接删除,由此可以避免误删导致的数据丢失等。
首先,我们对用户在软件内外(用户有可能直接通过文件管理器操作笔记文件)的行为进行抽象。由此,可得以下五种行为:新增、删除、修改、重命名和移动。重命名操作可以被视为在当前目录内进行移动,因此移动和重命名可以归为一类。所以,用户的行为总计 4 种。另外,根据用户是对本地文件进行操作还是对服务器上的文件进行操作,又可以分成两类。所以,这里需要的考虑的用户行为共 8 种。
提前考虑好各种情况,有助于防止我们在设计流程的时候出现遗漏。
考虑到维护文件状态可能出现的复杂情况,比如用户在软件内做了移动操作,然后又通过文件管理器对文件进行了移动等情况。最好的方式是当用户在软件内操作完成后立即进行同步。同步完成之后再将本地维护的状态擦除掉。这样既能够体现同步的实时性,又能够尽可能避免出现意外的情况。所以,新的同步方案采用了实时同步和整个目录同步相结合的方式。
在产品的设计上,本次改动在设置里直接取消了用户关闭实时同步的选项。这是为了避免引入复杂的逻辑,造成用户费解。在这种情况下,帮用户做决策比给用户很多选择更好。
第一种方案的问题之一是它的文件状态的维护。按照之前的分析,将文件的状态维护在服务器并非最理想的选择。因此,新的方案采用了将状态维护在本地的方案。新方案中,文件的状态被记录在数据库而不是文件中。这里有两点考虑:1).为避免一次性读取大量数据,减少内存占用;2).使用数据库可以进行结构化查询,方便灵活。
对本地文件的状态,我设计了如下数据结构。新的同步方案中,我选用了 Room 作为数据库框架。因此,以下数据结构也大致对应数据库中的 Shcema,
/** 笔记上次同步状态 */
@Entity class NoteLastSyncState: Serializable {
@PrimaryKey(autoGenerate = true) var id: Long? = null
/** 笔记的路径 */
var path: String? = null
/** 文件相对路径,直接父路径,用来根据父路径找子路径 */
var parent: String? = null
/** 如果文件时移动过来的话,记录从哪里移动过来的 */
var movedFrom: String? = null
/** 服务器返回的上次修改的时间,如果有的话,用来判断远程是否修改过 */
var serverLastModifiedTime: Date? = null
/** 上次同步时的 Md5 值,用来判断上次同步完成之后是否又被改动过 */
var lastSyncMd5: String? = null
/** 备注信息,冗余字段,用 json 存储 */
var remark: String? = null
/** 上次同步的时间 */
var lastSyncTime: Date? = null
}
这里的 path
字段是该文件相对于笔记根目录的路径。parent
是它的父目录相对于笔记根目录的路径。parent
的作用是用来根据父目录查找其所有的子文件/目录。比如下面的 SQL 就是基于前缀的匹配方式查询父目录的子文件/目录的状态,
@Query("SELECT * FROM NoteLastSyncState WHERE path LIKE :parent || '%' ")
fun getUnderParent(parent: String): List<NoteLastSyncState>
在实际编码之前应该先做技术方案。parent
等字段是在方案确定了基础之上,确定需要用到该字段,才将它们加入到数据结构中的。
这里的 movedFrom
用来记录该文件是从哪个位置移动过来的。在最初设计方案的时候,我本打算让移动行为走删除和新增的逻辑。这种思路虽然可行,但是性能会低。因为每个文件的删除和新增都要请求一次网络。当一个目录下存在很多子孙文件/目录的时候,请求的数量会非常多。因此,这里我使用 movedFrom
标记文件从何处移动而来。然后,在同步的时候,再根据该字段,调用服务器的移动接口,直接在服务器进行移动操作。这样一个请求即可完成同步。对于用户直接通过文件管理器移动目录或者文件的情况,由于不存在 movedFrom
标记,会走删除和新增的逻辑(被移动的位置删除,移动到的位置新增)。
这里的 serverLastModifiedTime
用来记录服务器返回的文件的上次修改时间。因为当我们请求一个目录的信息的时,可以获取到该目录下所有子文件的状态,其中就可能包含文件的上次修改时间。因此,每次同步完成之后,我们会记录该文件的上次修改时间。这样,下次同步的时候,通过对比服务器和本地数据库中的上次修改时间,我们就可以判断远程是否对文件做了修改,而无需使用文件的 md5. 这样就可以大幅提升同步的速率并降低流量的消耗。需要注意的是,这里用到的是服务器的修改时间,因为本地时间是不可靠的。
需要注意的是,我们不能假设服务器一定返回文件的上次修改时间字段。因此,它在新的同步方案中是作为判断逻辑的第一道防线。只有确保该字段一定存在的情况下才会使用它作为判断依据。代码如下所示,
/** Check is file changed remotely by last modified time. */
private fun isFileChangedRemotely(
syncState: NoteLastSyncState,
remoteFile: CloudResource
): Boolean = syncState.serverLastModifiedTime != null
&& remoteFile.lastUpdate != null
&& remoteFile.lastUpdate.after(syncState.serverLastModifiedTime)
/** Check is file not changed remotely by last modified time. */
private fun isFileNotChangedRemotely(
syncState: NoteLastSyncState,
remoteFile: CloudResource
): Boolean = syncState.serverLastModifiedTime != null
&& remoteFile.lastUpdate != null
&& !remoteFile.lastUpdate.after(syncState.serverLastModifiedTime)
最后值得一提的字段是 lastSyncMd5
,顾名思义,它是文件的 md5 值,是在文件被写入到本地磁盘之后记录到数据库中的。使用该字段,在远程和本地文件的 md5 不一致的时候,我们可以和之前的方案一样,判断文件是本地还是远程的文件发生了改动。
整个流程图比较长,大致可以几个部分,我已经在图中标出。
顶部是对之前生成的一些文件的删除和对图片信息的同步,属于本软件特有的部分,可以忽略。然后是整体的循环结构。流程图比较复杂,实际编码会清晰一些。即,我是通过 BFS 算法遍历本地文件树进行同步的。在对目录进行遍历的时候会先读取其对应的服务器目录下所有文件的状态以及本地存储的所有子文件的状态到 remoteFiles
。然后,通过对比本地的文件状态和远程的文件状态进行同步。一个文件或者目录同步完成之后会从 remoteFiles
中移除。
runBackground(onFinished, onInterrupted) { failures ->
val visitors = mutableListOf(File(path))
val count = AtomicInteger(0)
while (visitors.isNotEmpty() && !interrupted) {
try {
val directory = visitors.removeAt(0)
val dirRelativePath = sm.relativePathOf(directory.path)
// Read contents of directory from cloud.
val listResult = server.list(dirRelativePath)
val remoteFiles: MutableMap<String, CloudResource> = if (listResult.isFailed) {
log { "failed to read contents of directory [$dirRelativePath] from cloud, code [${listResult.code}], msg [${listResult.message}] ." }
val synced = syncDirectoryWhenFailedReadRemotely(directory, failures)
if (synced) {
continue
}
mutableMapOf()
} else {
insertDirectoryLastSyncState(directory)
listResult.data.toMutableMap()
}
// Read last sync records from local database.
val syncRecords = mutableMapOf<String, NoteLastSyncState>()
DB.get().noteLastSyncStateDao().getByParent(dirRelativePath).forEach {
syncRecords[it.path ?: ""] = it
}
// Travel under directory and handle files.
directory.listFiles()?.forEach { file ->
if (interrupted) {
return@forEach
}
val fileRelativePath = sm.relativePathOf(file.path)
// Files should be ignored.
if (fileRelativePath == "/$SETTING_FOLDER_NAME/$SETTING_IMAGE_MODEL_DATA") {
return@forEach
}
val syncRecord = syncRecords[fileRelativePath]
if (file.isDirectory) {
visitors.add(file)
// The directory exists in cloud: remove from remote files.
if (remoteFiles.containsKey(fileRelativePath)) {
remoteFiles.remove(fileRelativePath)
}
} else if (file.isFile) {
Thread.sleep(timeDelayMillis.toLong())
val remoteFile = remoteFiles[fileRelativePath]
// Sync a single file.
syncFile(file, syncRecord, remoteFile, failures)
if (remoteFile != null) {
remoteFiles.remove(fileRelativePath)
}
notifyProgressChanged(count.addAndGet(1), onProgress)
}
}
// Handle left remote resources.
syncRemoteResourcesNotFoundLocally(remoteFiles, failures, count, onProgress)
} catch (t: Throwable) {
t.printStackTrace()
log { "failed to sync folder with exception: $t" }
}
}
}
remoteFiles
剩下的部分就是远程存在而本地不存在的文件或者目录。它们又可能存在几种情况,被本地删除、远程新增或者被本地移动到其他目录。然后,再根据数据库中的状态记录,对三种情况进行判断。
具体同步流程代码比较长,不便于贴出,后续我会将文件同步逻辑提取出来,开源出一个通用的框架。
由于后续考虑支持更多的云服务器,所以,在新的同步方案中,我也对类结构进行了设计。首先是针对服务器的设计,
/** 云同步服务器接口封装 */
interface ICloudServer {
/** 读取文件内容 */
fun readText(rp: String): Resources<String>
/** Write text to given file with relative path [rp]. */
fun writeText(text: String, rp: String): Resources<Boolean>
/** Read bytes of a cloud file. */
fun readBytes(rp: String): Resources<ByteArray>
// .....
}
这个类中定义了服务器需要实现的方法。比如,WebDAV 对应的实现是 WebDAVServer. 当后续需要支持 OneDrive 同步的时候,基于该接口进行实现即可。
另外是同步工作类,也是以上流程图逻辑存在的地方。这里定义了 ICloudSyncWorker 这个接口,
interface ICloudSyncWorker {
/**
* Sync a file.
*
* @param file the file to sync
* @param syncRecord note last sync state, might be null
* @param remoteFile the file info in cloud server
* @param failures the failures to report, failures will be added to this list.
*/
fun syncFile(
file: File,
syncRecord: NoteLastSyncState?,
remoteFile: CloudResource?,
failures: MutableList<ISyncManager.SyncFailure>
)
// ...
}
ICloudSyncWorker 中再引用 ICloudServer 进行网络请求。这样,我们就提高了以上同步流程的拓展性。类结构如下,
根据以上分析和实际测试结果,第一次同步的时候,两个方案速率相近,而第一次同步完成之后,新的方案效率就高得多。因为第一次同步的时候,两种同步方案可能都需要对远程的全部文件进行拉取。
而第一次之后,新的同步方案只需要判断文件的上次修改时间,因此请求的数量和所有目录、子孙目录的数量相近(每次至少请求一次目录下的文件/目录信息)。
实际测试结果表明,600 个文件同步一次只需要 60s (其中,为避免向服务器请求过于频繁,每个文件处理延时时间为 50ms).
以上就是基于 Android 系统的文件同步设计思路的分享。
本文由哈喽比特于1年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/tRupBggyGIQLu2-8WEcA8g
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。