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