基于 Android 的文件同步设计方案

news2025/1/10 3:42:41

1、背景

随着用户对自身数据保护意识的加强,让用户自己维护自己的数据也成了独立开发产品时的一个卖点。若只针对少量的文件进行同步,则实现起来比较简单。当针对一个多层级目录同步时,情况就复杂多了。鉴于相关的文章甚少,本文我分享下我的设计思路。

本文是我在开发言叶(一个基于文件系统的 Markdown 笔记软件)过程中整理出的设计思路。这里的方案是我设计的第二套方案,在第一个方案的基础上弥补了很多不足。比之前的版本,同步的速率大幅提升,流量的消耗也大幅降低。

1.1 文件目录同步的难点

针对文件目录的同步不像基于数据库的同步那样灵活。对于文件同步,同步的对象是普通的文件,我们无法通过为其增加时间戳、版本号等信息来判断哪个文件是最新的。

对多级文件目录的移动操作的同步也是一个难点。因为移动操作可能会同时移动大量的文件,导致它们文件目录的变更。若处理不好则容易导致文件丢失或者文件重复。

文件同步设计的另一个难点是对云服务器的兼容。言叶支持的是基于 WebDAV 的同步,将来我还考虑支持更多云服务器。所以,我需要设计一个针对不同服务器的方案而不只是针对 WebDAV 协议的。即便针对 WebDAV 协议进行设计,我们也无法保证所有云提供商都会严格按照 WebDAV 协议进行支持。

1.2 第一个版本的方案及其局限性

第一个版本方案的流程图如下。

这个版本方案的基本思路如下。

通过对比本地和远程文件的 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 来判断文件是否发生了修改;引入垃圾箱机制,本地删除的时候将文件移动到垃圾箱而不是直接删除,由此可以避免误删导致的数据丢失等。

2、方案设计

2.1 行为抽象

首先,我们对用户在软件内外(用户有可能直接通过文件管理器操作笔记文件)的行为进行抽象。由此,可得以下五种行为:新增、删除、修改、重命名和移动。重命名操作可以被视为在当前目录内进行移动,因此移动和重命名可以归为一类。所以,用户的行为总计 4 种。另外,根据用户是对本地文件进行操作还是对服务器上的文件进行操作,又可以分成两类。所以,这里需要的考虑的用户行为共 8 种。

新增删除修改移动/重命名
本地
服务器

提前考虑好各种情况,有助于防止我们在设计流程的时候出现遗漏。

2.2 实时同步

考虑到维护文件状态可能出现的复杂情况,比如用户在软件内做了移动操作,然后又通过文件管理器对文件进行了移动等情况。最好的方式是当用户在软件内操作完成后立即进行同步。同步完成之后再将本地维护的状态擦除掉。这样既能够体现同步的实时性,又能够尽可能避免出现意外的情况。所以,新的同步方案采用了实时同步和整个目录同步相结合的方式。

在产品的设计上,本次改动在设置里直接取消了用户关闭实时同步的选项。这是为了避免引入复杂的逻辑,造成用户费解。在这种情况下,帮用户做决策比给用户很多选择更好。

2.3 状态维护

第一种方案的问题之一是它的文件状态的维护。按照之前的分析,将文件的状态维护在服务器并非最理想的选择。因此,新的方案采用了将状态维护在本地的方案。新方案中,文件的状态被记录在数据库而不是文件中。这里有两点考虑: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 不一致的时候,我们可以和之前的方案一样,判断文件是本地还是远程的文件发生了改动。

3、同步方案

3.1 流程图

整个流程图比较长,大致可以几个部分,我已经在图中标出。

顶部是对之前生成的一些文件的删除和对图片信息的同步,属于本软件特有的部分,可以忽略。然后是整体的循环结构。流程图比较复杂,实际编码会清晰一些。即,我是通过 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 剩下的部分就是远程存在而本地不存在的文件或者目录。它们又可能存在几种情况,被本地删除、远程新增或者被本地移动到其他目录。然后,再根据数据库中的状态记录,对三种情况进行判断。

具体同步流程代码比较长,不便于贴出,后续我会将文件同步逻辑提取出来,开源出一个通用的框架。

3.2 类设计

由于后续考虑支持更多的云服务器,所以,在新的同步方案中,我也对类结构进行了设计。首先是针对服务器的设计,

/** 云同步服务器接口封装 */
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 系统的文件同步设计思路的分享。

Android 学习笔录

Android 性能优化篇:https://qr18.cn/FVlo89
Android Framework底层原理篇:https://qr18.cn/AQpN4J
Android 车载篇:https://qr18.cn/F05ZCM
Android 逆向安全学习笔记:https://qr18.cn/CQ5TcL
Android 音视频篇:https://qr18.cn/Ei3VPD
Jetpack全家桶篇(内含Compose):https://qr18.cn/A0gajp
OkHttp 源码解析笔记:https://qr18.cn/Cw0pBD
Kotlin 篇:https://qr18.cn/CdjtAF
Gradle 篇:https://qr18.cn/DzrmMB
Flutter 篇:https://qr18.cn/DIvKma
Android 八大知识体:https://qr18.cn/CyxarU
Android 核心笔记:https://qr21.cn/CaZQLo
Android 往年面试题锦:https://qr18.cn/CKV8OZ
2023年最新Android 面试题集:https://qr18.cn/CgxrRy
Android 车载开发岗位面试习题:https://qr18.cn/FTlyCJ
音视频面试题锦:https://qr18.cn/AcV6Ap

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1125618.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

MODIS数据产品预处理方法

1 MCTK重投影 第一步&#xff1a;安装ENVI的MCTK扩展工具 解压压缩包&#xff0c;将其中的mctk.sav与modis_products.scsv文件复制到如图所示&#xff0c;相应的ENVI安装路径中去。 第二步&#xff1a;打开ENVI5.3标准版如图所示 在右边的工具栏处打开最下方的Extensions工具…

代码随想录笔记--单调栈篇

1--单调栈 使用单调栈的特征&#xff1a;寻找第一个比当前元素大或者小的元素。 2--每日温度 主要思路&#xff1a; 基于单调栈&#xff0c;单调栈从栈顶开始递增&#xff1b;单调栈存储的是元素对应的索引。 当遇到一个元素大于栈顶元素i时&#xff0c;计算 answer[i]。 #incl…

腾讯待办关停之后还能用吗?可替代的待办提醒APP

如果你之前喜欢用“腾讯待办”这款微信小程序设置待办提醒&#xff0c;那么接下来不得不面对一个事实&#xff1a;腾讯待办将于2023年的12月20日全面停止运营并下架。如果在这款小程序中记录了很多的待办事项&#xff0c;现在应该尽快导出数据&#xff0c;避免数据丢失。 还有…

Rowset Class

Rowset类在PeopleCode中非常常见&#xff0c;以下将Rowset翻译成行集&#xff0c;顾名思义&#xff0c;行的集合 目录 Understanding Rowset Class Shortcut Considerations Data Type of a Rowset Object Scope of a Rowset Object Rowset Class Built-In Functions Row…

CUDA学习笔记(十二) CUDA库简介

CUDA Libraries简介 上图是CUDA 库的位置&#xff0c;本文简要介绍cuSPARSE、cuBLAS、cuFFT和cuRAND&#xff0c;之后会介绍OpenACC。 cuSPARSE线性代数库&#xff0c;主要针对稀疏矩阵之类的。cuBLAS是CUDA标准的线代库&#xff0c;不过没有专门针对稀疏矩阵的操作。cuFFT傅里…

YOLOv5算法改进(20)— 如何去写YOLOv5相关的论文(包括论文阅读+规律总结+写作方法)

前言:Hello大家好,我是小哥谈。最近一直在阅读关于YOLOv5的相关论文,读着读着我发现一条可以发论文的规律,特此简单总结一下,希望能够对同学们有所启迪!🌈 前期回顾: YOLOv5算法改进(1)— 如何去改进YOLOv5算法

CanIf Transmit Buffering 机制说明

目录 前言未使能场景的报文发送机制使能场景的报文发送机制如何配置前言 在AUTOSAR CanIf 中,提供了发送的Buffering机制 。对于一个报文来说,Buffering起始于CanIf_Transmit,结束于CanIf_TxConfirmation 。 主要应用与一个MailBox 发送多个报文的场景,用于解决因为硬件发…

scp通过跳板机向服务器传文件的方法

scp上传 scp -P 端口号 要传的文件 服务器用户名服务器IP:服务器目录scp下载 scp -P 端口号 服务器用户名服务器IP:服务器目录 要下载的文件在实际情况下如果目标服务器无法通过ssh直接连接&#xff0c;需要跳板机才能连接&#xff0c;如何使用scp呢&#xff1f; 跳板机host…

​​​​​​​Python---练习:打印直角三角形(利用wihle循环嵌套)

案例&#xff1a; 打印直角三角形&#xff0c;特征&#xff1a;一共有5行&#xff0c;第1行&#xff0c;有1列。第2行&#xff0c;有2列&#xff0c;第3&#xff0c;有3列。 思考&#xff1a; pycharm里面&#xff0c;输出三角形&#xff0c;因为本来控制台就是长方形&#…

PLC单按钮启停算法汇总

单按钮启停在三菱PLC里可以通过简单的取反指令"ALT"实现,西门子PLC如何实现ALT指令,请参考下面文章链接,这篇博客我们汇总常用的单按钮启停实现方法,希望大家读了本篇博客后有所收获。 博途ALT指令 博途S7-1200/1500PLC 取反指令(ALT)-CSDN博客SMART PLC的ALT指…

62 最小路径和

最小路径和 题解1 DP 给定一个包含非负整数的 m x n 网格 grid &#xff0c;请找出一条 从左上角到右下角的路径&#xff0c;使得路径上的 数字总和为最小。 说明&#xff1a;每次只能向下或者向右移动一步。 题解1 DP class Solution { public:int minPathSum(vector&l…

从传统云架构到云原生生态体系架构的演进

文章目录 概述传统云架构&#xff1a;虚拟化的时代云原生生态体系架构的兴起容器化和微服务架构自动化和自动伸缩基础设施即代码云原生存储和数据库 云原生的影响结语 概述 随着科技的不断发展&#xff0c;云计算领域也经历了巨大的变革。这一演进的核心焦点是从传统云架构过渡…

活动回顾∣企企通亮相高质量企业数字化活动,深入探讨各领域采购数字化转型与变革

当前&#xff0c;以数字技术为代表的新一轮科技革新正在加速兴起&#xff0c;数字经济已成为推动我国社会经济发展的重要引擎&#xff0c;而数字化转型也成为构筑企业竞争新优势的有力支撑。 作为企业数字化采购与供应链协同服务的优秀厂商&#xff0c;企企通近期受邀参加了多场…

Redis为什么变慢了

一、Redis为什么变慢了 1.Redis真的变慢了吗? 对 Redis 进行基准性能测试 例如,我的机器配置比较低,当延迟为 2ms 时,我就认为 Redis 变慢了,但是如果你的硬件配置比较高,那么在你的运行环境下,可能延迟是 0.5ms 时就可以认为 Redis 变慢了。 所以,你只有了解了你的…

TechSmith Camtasia 2023 for Mac 屏幕录像视频录制编辑软件

​ TechSmith Camtasia for Mac 2023中文破解版 是一款专业的屏幕录像视频录制编辑软件&#xff0c;非常容易就可以获得精彩的截屏视频。创建引人注目的培训&#xff0c;演示和演示视频。Camtasia 屏幕录制软件简化&#xff0c;直观&#xff0c;让您看起来像专业人士。利用Camt…

图(graph)的遍历----深度优先(DFS)遍历

目录 前言 深度优先遍历&#xff08;DFS&#xff09; 1.基本概念 2.算法思想 3.二叉树的深度优先遍历&#xff08;例子&#xff09; 图的深度优先遍历 1.图(graph)邻接矩阵的深度优先遍历 思路分析 代码实现 2.图(graph)邻接表的深度优先遍历 思路分析 代码实现 递…

为什么需要it企业知识库?it企业知识库能带来什么?

在企业运营过程中&#xff0c;会产生大量的经营数据、管理规范、资料和文档等数据&#xff0c;但这些数据的产生时间和空间碎片化&#xff0c;数据来源和结构多种多样&#xff0c;信息关系也较为复杂。 it企业知识库 正是因为这些问题的存在&#xff0c;导致了企业信息管理零散…

【API篇】七、Flink窗口

文章目录 1、窗口2、分类3、窗口API概览4、窗口分配器 在批处理统计中&#xff0c;可以等待一批数据都到齐后&#xff0c;统一处理。但是在无界流的实时处理统计中&#xff0c;是来一条就得处理一条&#xff0c;那么如何统计最近一段时间内的数据呢&#xff1f; ⇒ 窗口的概念&…

ImportError: DLL load failed while importing MPI: 找不到指定的模块

在运行下面这行python代码时会报错 from mpi4py import MPI 原因就是缺少MPI模块 解决方法如下&#xff1a; 1.在MPI官网下载msmpisetup.exe和msmpisdk.msi两个文件&#xff0c;并且安装到默认路径下 2.添加环境变量 进入“控制面板——>高级系统设置——>环境变量”…

QWidget快速美化-蓝色分割线

将代码复制进Line的样式表 效果: 代码: Line{height:5background-color: rgba(255, 255, 255, 0);border-top:2px solid #52DCFE; }