基于Android實(shí)現(xiàn)的文件同步設(shè)計(jì)方案
1、背景
隨著用戶對自身數(shù)據(jù)保護(hù)意識的加強(qiáng),讓用戶自己維護(hù)自己的數(shù)據(jù)也成了獨(dú)立開發(fā)產(chǎn)品時(shí)的一個(gè)賣點(diǎn)。若只針對少量的文件進(jìn)行同步,則實(shí)現(xiàn)起來比較簡單。當(dāng)針對一個(gè)多層級目錄同步時(shí),情況就復(fù)雜多了。鑒于相關(guān)的文章甚少,本文我分享下我的設(shè)計(jì)思路。
本文是我在開發(fā)言葉(一個(gè)基于文件系統(tǒng)的 Markdown 筆記軟件)過程中整理出的設(shè)計(jì)思路。這里的方案是我設(shè)計(jì)的第二套方案,在第一個(gè)方案的基礎(chǔ)上彌補(bǔ)了很多不足。比之前的版本,同步的速率大幅提升,流量的消耗也大幅降低。
1.1 文件目錄同步的難點(diǎn)
針對文件目錄的同步不像基于數(shù)據(jù)庫的同步那樣靈活。對于文件同步,同步的對象是普通的文件,我們無法通過為其增加時(shí)間戳、版本號等信息來判斷哪個(gè)文件是最新的。
對多級文件目錄的移動(dòng)操作的同步也是一個(gè)難點(diǎn)。因?yàn)橐苿?dòng)操作可能會(huì)同時(shí)移動(dòng)大量的文件,導(dǎo)致它們文件目錄的變更。若處理不好則容易導(dǎo)致文件丟失或者文件重復(fù)。
文件同步設(shè)計(jì)的另一個(gè)難點(diǎn)是對云服務(wù)器的兼容。言葉支持的是基于 WebDAV 的同步,將來我還考慮支持更多云服務(wù)器。所以,我需要設(shè)計(jì)一個(gè)針對不同服務(wù)器的方案而不只是針對 WebDAV 協(xié)議的。即便針對 WebDAV 協(xié)議進(jìn)行設(shè)計(jì),我們也無法保證所有云提供商都會(huì)嚴(yán)格按照 WebDAV 協(xié)議進(jìn)行支持。
1.2 第一個(gè)版本的方案及其局限性
第一個(gè)版本方案的流程圖如下。
這個(gè)版本方案的基本思路如下。
通過對比本地和遠(yuǎn)程文件的 md5 來判斷文件是否發(fā)生了變更。在每次同步完成之后會(huì)將所有文件的路徑和 md5 值的映射關(guān)系以如下格式寫入到服務(wù)器的一個(gè)文本文件中。
/測試/test.txt:ADBF5A778175EE757C34D0EBA4E932BC /jjsskizs.log:D41D8CD98F00B204E9800998ECF8427E /Hello.txt:D064F3519426DCD30114B900431FC044 ...
如果服務(wù)器中的一個(gè)文件不在上述記錄中,我們可以判斷這個(gè)文件是服務(wù)器新增的(相對于本地);如果本地的一個(gè)文件不在上述記錄中,我們則可以判斷這個(gè)文件是本地新增的;如果一個(gè)文件存在于上述記錄中而不存在于云服務(wù)器,我們可以判斷該文件是被服務(wù)器刪除;如果一個(gè)文件存在于上述記錄而不存在于本地,則可以判斷為被本地刪除。對于文件的移動(dòng)操作,這種方案會(huì)將其分解成刪除和新增兩個(gè)操作。
這種方案存在兩個(gè)問題:1).該方案需要通過網(wǎng)絡(luò)讀取遠(yuǎn)程的每個(gè)文件的 md5 值。這導(dǎo)致該方案流量消耗比較多以及同步耗時(shí)比較長。2).在服務(wù)器中維護(hù)狀態(tài)文件還存在當(dāng)用戶在兩個(gè)設(shè)備上同步的時(shí)候會(huì)出現(xiàn)行為沖突問題。比如,一個(gè)設(shè)備新增一個(gè)文件并寫入映射關(guān)系到該狀態(tài)文件,另一個(gè)設(shè)備會(huì)將該文件判斷為本地刪除,從而在遠(yuǎn)程刪除該文件。
對于用戶在設(shè)備上的刪除、移動(dòng)行為,在這種方案中會(huì)先將這些行為以如下格式寫入到本地的文本中,
DIR:DELETE:/測試::false:true DIR:DELETE:/測試目錄::false:true DIR:DELETE:/新目錄::false:true ...
然后嘗試立即同步該行為,如果成功就擦除本地行為記錄,否則會(huì)在下一次對整個(gè)文件目錄同步的時(shí)候進(jìn)行同步。由于對用戶的行為的同步被放在對整個(gè)目錄同步之前。因此,在該方案中,這些用戶操作的時(shí)序性是無法保證的。
第一種方案的槽點(diǎn)比較多,作為踩坑的方案,最初我并沒有考慮多設(shè)備同步等情況。不過,它也有一些值得借鑒的地方。比如,通過文件的 md5 來判斷文件是否發(fā)生了修改;引入垃圾箱機(jī)制,本地刪除的時(shí)候?qū)⑽募苿?dòng)到垃圾箱而不是直接刪除,由此可以避免誤刪導(dǎo)致的數(shù)據(jù)丟失等。
2、方案設(shè)計(jì)
2.1 行為抽象
首先,我們對用戶在軟件內(nèi)外(用戶有可能直接通過文件管理器操作筆記文件)的行為進(jìn)行抽象。由此,可得以下五種行為:新增、刪除、修改、重命名和移動(dòng)。重命名操作可以被視為在當(dāng)前目錄內(nèi)進(jìn)行移動(dòng),因此移動(dòng)和重命名可以歸為一類。所以,用戶的行為總計(jì) 4 種。另外,根據(jù)用戶是對本地文件進(jìn)行操作還是對服務(wù)器上的文件進(jìn)行操作,又可以分成兩類。所以,這里需要的考慮的用戶行為共 8 種。
新增 | 刪除 | 修改 | 移動(dòng)/重命名 | |
---|---|---|---|---|
本地 | ||||
服務(wù)器 |
提前考慮好各種情況,有助于防止我們在設(shè)計(jì)流程的時(shí)候出現(xiàn)遺漏。
2.2 實(shí)時(shí)同步
考慮到維護(hù)文件狀態(tài)可能出現(xiàn)的復(fù)雜情況,比如用戶在軟件內(nèi)做了移動(dòng)操作,然后又通過文件管理器對文件進(jìn)行了移動(dòng)等情況。最好的方式是當(dāng)用戶在軟件內(nèi)操作完成后立即進(jìn)行同步。同步完成之后再將本地維護(hù)的狀態(tài)擦除掉。這樣既能夠體現(xiàn)同步的實(shí)時(shí)性,又能夠盡可能避免出現(xiàn)意外的情況。所以,新的同步方案采用了實(shí)時(shí)同步和整個(gè)目錄同步相結(jié)合的方式。
在產(chǎn)品的設(shè)計(jì)上,本次改動(dòng)在設(shè)置里直接取消了用戶關(guān)閉實(shí)時(shí)同步的選項(xiàng)。這是為了避免引入復(fù)雜的邏輯,造成用戶費(fèi)解。在這種情況下,幫用戶做決策比給用戶很多選擇更好。
2.3 狀態(tài)維護(hù)
第一種方案的問題之一是它的文件狀態(tài)的維護(hù)。按照之前的分析,將文件的狀態(tài)維護(hù)在服務(wù)器并非最理想的選擇。因此,新的方案采用了將狀態(tài)維護(hù)在本地的方案。新方案中,文件的狀態(tài)被記錄在數(shù)據(jù)庫而不是文件中。這里有兩點(diǎn)考慮:1).為避免一次性讀取大量數(shù)據(jù),減少內(nèi)存占用;2).使用數(shù)據(jù)庫可以進(jìn)行結(jié)構(gòu)化查詢,方便靈活。
對本地文件的狀態(tài),我設(shè)計(jì)了如下數(shù)據(jù)結(jié)構(gòu)。新的同步方案中,我選用了 Room 作為數(shù)據(jù)庫框架。因此,以下數(shù)據(jù)結(jié)構(gòu)也大致對應(yīng)數(shù)據(jù)庫中的 Shcema,
/** 筆記上次同步狀態(tài) */ @Entity class NoteLastSyncState: Serializable { @PrimaryKey(autoGenerate = true) var id: Long? = null /** 筆記的路徑 */ var path: String? = null /** 文件相對路徑,直接父路徑,用來根據(jù)父路徑找子路徑 */ var parent: String? = null /** 如果文件時(shí)移動(dòng)過來的話,記錄從哪里移動(dòng)過來的 */ var movedFrom: String? = null /** 服務(wù)器返回的上次修改的時(shí)間,如果有的話,用來判斷遠(yuǎn)程是否修改過 */ var serverLastModifiedTime: Date? = null /** 上次同步時(shí)的 Md5 值,用來判斷上次同步完成之后是否又被改動(dòng)過 */ var lastSyncMd5: String? = null /** 備注信息,冗余字段,用 json 存儲 */ var remark: String? = null /** 上次同步的時(shí)間 */ var lastSyncTime: Date? = null }
這里的 path
字段是該文件相對于筆記根目錄的路徑。parent
是它的父目錄相對于筆記根目錄的路徑。parent
的作用是用來根據(jù)父目錄查找其所有的子文件/目錄。比如下面的 SQL 就是基于前綴的匹配方式查詢父目錄的子文件/目錄的狀態(tài),
@Query("SELECT * FROM NoteLastSyncState WHERE path LIKE :parent || '%' ") fun getUnderParent(parent: String): List<NoteLastSyncState>
在實(shí)際編碼之前應(yīng)該先做技術(shù)方案。parent
等字段是在方案確定了基礎(chǔ)之上,確定需要用到該字段,才將它們加入到數(shù)據(jù)結(jié)構(gòu)中的。
這里的 movedFrom
用來記錄該文件是從哪個(gè)位置移動(dòng)過來的。在最初設(shè)計(jì)方案的時(shí)候,我本打算讓移動(dòng)行為走刪除和新增的邏輯。這種思路雖然可行,但是性能會(huì)低。因?yàn)槊總€(gè)文件的刪除和新增都要請求一次網(wǎng)絡(luò)。當(dāng)一個(gè)目錄下存在很多子孫文件/目錄的時(shí)候,請求的數(shù)量會(huì)非常多。因此,這里我使用 movedFrom
標(biāo)記文件從何處移動(dòng)而來。然后,在同步的時(shí)候,再根據(jù)該字段,調(diào)用服務(wù)器的移動(dòng)接口,直接在服務(wù)器進(jìn)行移動(dòng)操作。這樣一個(gè)請求即可完成同步。對于用戶直接通過文件管理器移動(dòng)目錄或者文件的情況,由于不存在 movedFrom
標(biāo)記,會(huì)走刪除和新增的邏輯(被移動(dòng)的位置刪除,移動(dòng)到的位置新增)。
這里的 serverLastModifiedTime
用來記錄服務(wù)器返回的文件的上次修改時(shí)間。因?yàn)楫?dāng)我們請求一個(gè)目錄的信息的時(shí),可以獲取到該目錄下所有子文件的狀態(tài),其中就可能包含文件的上次修改時(shí)間。因此,每次同步完成之后,我們會(huì)記錄該文件的上次修改時(shí)間。這樣,下次同步的時(shí)候,通過對比服務(wù)器和本地?cái)?shù)據(jù)庫中的上次修改時(shí)間,我們就可以判斷遠(yuǎn)程是否對文件做了修改,而無需使用文件的 md5. 這樣就可以大幅提升同步的速率并降低流量的消耗。需要注意的是,這里用到的是服務(wù)器的修改時(shí)間,因?yàn)楸镜貢r(shí)間是不可靠的。
需要注意的是,我們不能假設(shè)服務(wù)器一定返回文件的上次修改時(shí)間字段。因此,它在新的同步方案中是作為判斷邏輯的第一道防線。只有確保該字段一定存在的情況下才會(huì)使用它作為判斷依據(jù)。代碼如下所示,
/** 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 值,是在文件被寫入到本地磁盤之后記錄到數(shù)據(jù)庫中的。使用該字段,在遠(yuǎn)程和本地文件的 md5 不一致的時(shí)候,我們可以和之前的方案一樣,判斷文件是本地還是遠(yuǎn)程的文件發(fā)生了改動(dòng)。
3、同步方案
3.1 流程圖
整個(gè)流程圖比較長,大致可以幾個(gè)部分,我已經(jīng)在圖中標(biāo)出。
頂部是對之前生成的一些文件的刪除和對圖片信息的同步,屬于本軟件特有的部分,可以忽略。然后是整體的循環(huán)結(jié)構(gòu)。流程圖比較復(fù)雜,實(shí)際編碼會(huì)清晰一些。即,我是通過 BFS 算法遍歷本地文件樹進(jìn)行同步的。在對目錄進(jìn)行遍歷的時(shí)候會(huì)先讀取其對應(yīng)的服務(wù)器目錄下所有文件的狀態(tài)以及本地存儲的所有子文件的狀態(tài)到 remoteFiles
。然后,通過對比本地的文件狀態(tài)和遠(yuǎn)程的文件狀態(tài)進(jìn)行同步。一個(gè)文件或者目錄同步完成之后會(huì)從 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
剩下的部分就是遠(yuǎn)程存在而本地不存在的文件或者目錄。它們又可能存在幾種情況,被本地刪除、遠(yuǎn)程新增或者被本地移動(dòng)到其他目錄。然后,再根據(jù)數(shù)據(jù)庫中的狀態(tài)記錄,對三種情況進(jìn)行判斷。
具體同步流程代碼比較長,不便于貼出,后續(xù)我會(huì)將文件同步邏輯提取出來,開源出一個(gè)通用的框架。
3.2 類設(shè)計(jì)
由于后續(xù)考慮支持更多的云服務(wù)器,所以,在新的同步方案中,我也對類結(jié)構(gòu)進(jìn)行了設(shè)計(jì)。首先是針對服務(wù)器的設(shè)計(jì),
/** 云同步服務(wù)器接口封裝 */ interface ICloudServer { /** 讀取文件內(nèi)容 */ 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> // ..... }
這個(gè)類中定義了服務(wù)器需要實(shí)現(xiàn)的方法。比如,WebDAV 對應(yīng)的實(shí)現(xiàn)是 WebDAVServer. 當(dāng)后續(xù)需要支持 OneDrive 同步的時(shí)候,基于該接口進(jìn)行實(shí)現(xiàn)即可。
另外是同步工作類,也是以上流程圖邏輯存在的地方。這里定義了 ICloudSyncWorker 這個(gè)接口,
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 進(jìn)行網(wǎng)絡(luò)請求。這樣,我們就提高了以上同步流程的拓展性。類結(jié)構(gòu)如下,
總結(jié)
根據(jù)以上分析和實(shí)際測試結(jié)果,第一次同步的時(shí)候,兩個(gè)方案速率相近,而第一次同步完成之后,新的方案效率就高得多。因?yàn)榈谝淮瓮降臅r(shí)候,兩種同步方案可能都需要對遠(yuǎn)程的全部文件進(jìn)行拉取。而第一次之后,新的同步方案只需要判斷文件的上次修改時(shí)間,因此請求的數(shù)量和所有目錄、子孫目錄的數(shù)量相近(每次至少請求一次目錄下的文件/目錄信息)。實(shí)際測試結(jié)果表明,600 個(gè)文件同步一次只需要 60s (其中,為避免向服務(wù)器請求過于頻繁,每個(gè)文件處理延時(shí)時(shí)間為 50ms).
以上就是基 Android系統(tǒng)的文件同步設(shè)計(jì)思路的分享。
到此這篇關(guān)于基于Android實(shí)現(xiàn)的文件同步設(shè)計(jì)方案的文章就介紹到這了,更多相關(guān)Android文件同步內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
android手機(jī)獲取gps和基站的經(jīng)緯度地址實(shí)現(xiàn)代碼
android手機(jī)如何獲取gps和基站的經(jīng)緯度地址,疑問,于是網(wǎng)上搜集整理一些,拿出來和大家分享下,希望可以幫助你們2012-12-12Android自定義控件實(shí)現(xiàn)方向盤效果
這篇文章主要為大家詳細(xì)介紹了Android自定義控件實(shí)現(xiàn)方向盤效果,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2020-04-04MT6589平臺通話錄音時(shí)播放提示音給對方功能的具體實(shí)現(xiàn)
MT6589平臺通話錄音時(shí)如何播放提示音給對方,可以通過修改以下文件即可,希望對你有所幫助2013-06-06Android判斷手機(jī)是否聯(lián)網(wǎng)及自動(dòng)跳轉(zhuǎn)功能(收藏版)
這篇文章主要介紹了Android判斷手機(jī)是否聯(lián)網(wǎng)及自動(dòng)跳轉(zhuǎn)功能(收藏版),在一些手機(jī)端連接wifi我們經(jīng)常會(huì)遇到這樣的功能,今天小編通過實(shí)例截圖給大家介紹下,需要的朋友可以參考下2019-11-11Bootstrap 下拉菜單.dropdown的具體使用方法
這篇文章主要介紹了Bootstrap 下拉菜單.dropdown的具體使用方法,詳細(xì)講解下拉菜單的交互,有興趣的可以了解一下2017-10-10Android開發(fā)框架之自定義ZXing二維碼掃描界面并解決取景框拉伸問題
這篇文章主要介紹了Android開發(fā)框架之自定義ZXing二維碼掃描界面并解決取景框拉伸問題的相關(guān)資料,非常不錯(cuò)具有參考借鑒價(jià)值,需要的朋友可以參考下2016-06-06