> 編寫:[jdneo](https://github.com/jdneo) - 原文:[http://developer.android.com/training/cloudsave/conflict-res.html](http://developer.android.com/training/cloudsave/conflict-res.html)
這篇文章介紹了當應用使用[Cloud Save service](http://developers.google.com/games/services/common/concepts/cloudsave)存儲數據到云端時,如何設計一個魯棒性較高的沖突解決策略。云存儲服務允許你為每一個在Google服務上的應用用戶,存儲他們的應用數據。你的應用可以通過使用云存儲API,從Android設備,iOS設備或者web應用恢復或更新這些數據。
云存儲中的保存和加載過程非常直接:它只是一個數據和byte數組之間序列化轉換,并將這些數組存儲在云端的過程。然而,當你的用戶有多個設備,并且有兩個以上的設備嘗試將它們的數據存儲在云端時,這一保存可能會引起沖突,因此你必須決定應該如何處理這類問題。云端數據的結構在很大程度上決定了沖突解決方案的魯棒性,所以務必小心地設計你的數據存儲結構,使得沖突解決方案的邏輯可以正確地處理每一種情況。
本篇文章從一些有缺陷的解決方案入手,并解釋他們為何具有缺陷。之后會呈現一個可以避免沖突的解決方案。用于討論的例子關注于游戲,但解決問題的核心思想是可以適用于任何將數據存儲于云端的應用的。
### 沖突時獲得通知
[OnStateLoadedListener](http://developer.android.com/reference/com/google/android/gms/appstate/OnStateLoadedListener.html)方法負責從Google服務器下載應用的狀態數據。回調函數[OnStateLoadedListener.onStateConflict](http://developer.android.com/reference/com/google/android/gms/appstate/OnStateLoadedListener.html#onStateConflict)用來給你的應用在本地狀態和云端存儲的狀態發生沖突時,提供了一個解決機制:
~~~
@Override
public void onStateConflict(int stateKey, String resolvedVersion,
byte[] localData, byte[] serverData) {
// resolve conflict, then call mAppStateClient.resolveConflict()
...
}
~~~
此時你的應用必須決定要保留哪一個數據,或者它自己提交一個新的數據來表示合并后的數據狀態,解決沖突的邏輯由你自己來實現。
我們必須要意識到云存儲服務是在后臺執行同步的。所以你應該確保你的應用能夠在你創建這一數據的Context之外接收回調。特別地,如果Google Play服務應用在后臺檢測到了一個沖突,該回調函數會在你下一次加載數據時被調用,通常來說會是在下一次用戶啟動該應用時。
因此,你的云存儲代碼和沖突解決代碼的設計必須是和當前context無關的:也就是說當你拿到了兩個沖突的數據,你必須僅通過數據集內獲取的數據去解決沖突,而不依賴于任何其它任何外部Context。
### 處理簡單情況
下面列舉一些解決沖突的簡單例子。對于很多應用而言,用這些策略或者其變體就足夠解決大多數問題了:
**新的比舊的更有效:**在一些情況下,新的數據可以替代舊的數據。例如,如果數據代表了用戶所選擇角色的衣服顏色,那么最近的新的選擇就應該覆蓋老的選擇。在這種情況下,你可能會選擇在云存儲數據中存儲時間戳。當處理這些沖突時,選擇時間戳最新的數據(記住要選擇一個可靠的時鐘,并注意對不同時區的處理)。
**一個數據好于其他數據:**在一些情況下,我們是可以有方法在若干數據集中選取一個最好的。例如,如果數據代表了玩家在賽車比賽中的最佳時間,那么顯然,在沖突發生時,你應該保留成績最好的那個數據。
**進行合并:**有可能通過計算兩個數據集的合并版本來解決沖突。例如,你的數據代表了用戶解鎖關卡的進度,那么我們需要的數據就是兩個沖突數據的并集。通過這個方法,用戶的關卡解鎖進度就不會丟失了。這里的[例子](https://github.com/playgameservices/android-samples/tree/master/CollectAllTheStars)使用了這一策略的一個變形。
### 為更復雜的情況設計一個策略
當你的游戲允許玩家收集可交換物品時(比如金幣或者經驗點數),情況會變得更加復雜一些。我們來假想一個游戲,叫做“金幣跑酷”,游戲中的角色通過跑步不斷地收集金幣使自己變的富有。每個收集到的金幣都會加入到玩家的儲蓄罐中。
下面的章節將展示三種在多個設備間解決沖突的方案:有兩個看上去還不錯,可惜最終還是不能適用于所有情況,最后一個解決方案可以解決多個設備間的數據沖突。
### 第一個嘗試:只保存總數
首先,這個問題看上去像是說:云存儲的數據只要存儲金幣的數量就行了。但是如果就只有這些數據是可用的,那么解決沖突的方案將會嚴重受到限制。此時最佳的方案只能是在沖突發生時存儲數值最大的數據。
想一下表1中所展現的場景。假設玩家一開始有20枚硬幣,然后在設備A上收集了10個,在設備B上收集了15個。然后設備B將數據存儲到了云端。當設備A嘗試去存儲的時候,沖突發生了。“只保存總數”的沖突解決方案會存儲35作為這一數據的值(兩數之間最大的)。
表1. 值保存最大的數(不佳的策略)
| 事件 | 設備A的數據 | 設備B的數據 | 云端的數據 | 實際的總數 |
|-----|-----|-----|-----|-----|
| 開始階段 | 20 | 20 | 20 | 20 |
| 玩家在A設備上收集了10個硬幣 | 30 | 20 | 20 | 30 |
| 玩家在B設備上收集了15個硬幣 | 30 | 35 | 20 | 45 |
| 設備B將數據存儲至云端 | 30 | 35 | 35 | 45 |
| 設備A嘗試將數據存儲至云端,**發生沖突** | 30 | 35 | 35 | 45 |
| 設備A通過選擇兩數中最大的數來解決沖突 | 35 | 35 | 35 | 45 |
這一策略顯然會失敗:玩家的金幣數從20變成35,但實際上玩家總共收集了25個硬幣(A設備10個,B設備15個),所以有10個硬幣丟失了。只在云端存儲硬幣的總數是不足以實現一個健壯的沖突解決算法的。
### 第二個嘗試:存儲總數和變化值
另一個方法是在存儲數據中包括一些額外的數據,如:自上次提交后硬幣增加的數量(delta)。在這一方法中,存儲的數據可以用一個二元組來表示(T, d),其中T是硬幣的總數,而d是硬幣增加的數量。
通過這樣的數據存儲結構,你的沖突檢測算法在魯棒性上會有更大的提升空間。但是這個方法在某些情況下依然會存在問題。
下面是包含delta數值的沖突解決算法過程:
- **本地數據**:(T, d)
- **云端數據**:(T', d')
- **解決后的數據**:(T'+d, d)
例如,當你在本地狀態(T, d)和云端狀態(T', d)之間發生了沖突時,你可以將它們合并成(T'+d, d)。意味著你從本地拿出delta數據,并將它和云端的數據結合起來,乍一看,這種方法可以很好的計量多個設備所收集的金幣。
該方法看上去很可靠,但它在具有移動網絡的環境中難以適用:
- 用戶可能在設備不在線時存儲數據。這些改變會以隊列形式等待手機聯網后提交。
- 這個方法的同步機制是用最新的變化覆蓋掉任何之前的變化。換句話說,第二次寫入的變化會提交到云端(當設備聯網了以后),而第一次寫入的變化就被忽略了。
為了進一步說明,我們考慮一下表2所列的場景。在表2列出的一系列操作發生后,云端的狀態將是(130, +5),最終沖突解決后的狀態是(140, +10)。這是不正確的,因為從總體上而言,用戶一共在A上收集了110枚硬幣而在B上收集了120枚硬幣。總數應該為250。
表2. “總數+增量”策略的失敗案例
| 事件 | 設備A的數據 | 設備B的數據 | 云端的數據 | 實際的總數 |
|-----|-----|-----|-----|-----|
| 開始階段 | (20, x) | (20, x) | (20, x) | 20 |
| 玩家在A設備上收集了100個硬幣 | (120, +100) | (20, x) | (20, x) | 120 |
| 玩家在A設備上又收集了10個硬幣 | (130, +10) | (20, x) | (20, x) | 130 |
| 玩家在B設備上收集了115個硬幣 | (130, +10) | (125, +115) | (20, x) | 245 |
| 玩家在B設備上又收集了5個硬幣 | (130, +10) | (130, +5) | (20, x) | 250 |
| 設備B將數據存儲至云端 | (130, +10) | (130, +5) | (130, +5) | 250 |
| 設備A嘗試將數據存儲至云端,**發生沖突** | (130, +10) | (130, +5) | (130, +5) | 250 |
| 設備A通過將本地的增量和云端的總數相加來解決沖突 | (140, +10) | (130, +5) | (140, +10) | 250 |
_注:x代表與該場景無關的數據_
你可能會嘗試在每次保存后不重置增量數據來解決此問題,這樣的話在每個設備上第二次存儲的數據就能夠代表用戶至今為止收集到的所有硬幣。此時,設備A在第二次本地存儲完成后,數據將是(130, +110)而不是(130, +10)。然而,這樣做的話就會發生如表3所述的情況:
表3. 算法改進后的失敗案例
| 事件 | 設備A的數據 | 設備B的數據 | 云端的數據 | 實際的總數 |
|-----|-----|-----|-----|-----|
| 開始階段 | (20, x) | (20, x) | (20, x) | 20 |
| 玩家在A設備上收集了100個硬幣 | (120, +100) | (20, x) | (20, x) | 120 |
| 設備A將狀態存儲到云端 | (120, +100) | (20, x) | (120, +100) | 120 |
| 玩家在A設備上又收集了10個硬幣 | (130, +110) | (20, x) | (120, +100) | 130 |
| 玩家在B設備上收集了1個硬幣 | (130, +110) | (21, +1) | (120, +100) | 131 |
| 設備B嘗試向云端存儲數據,**發生沖突** | (130, +110) | (21, +1) | (120, +100) | 131 |
| 設備B通過將本地的增量和云端的總數相加來解決沖突 | (130, +110) | (121, +1) | (121, +1) | 131 |
| 設備A嘗試將數據存儲至云端,**發生沖突** | (130, +110) | (121, +1) | (121, +1) | 131 |
| 設備A通過將本地的增量和云端的總數相加來解決沖突 | (231, +110) | (121, +1) | (231, +110) | 131 |
_注:x代表與該場景無關的數據_
現在你碰到了另一個問題:你給予了玩家過多的硬幣。這個玩家拿到了211枚硬幣,但實際上他只收集了111枚。
### 解決辦法:
分析之前的幾次嘗試,我們發現這些策略存在這樣的缺陷:無法知曉哪些硬幣已經計數了,哪些硬幣沒有被計數,尤其是當多個設備連續提交的時候,算法會出現混亂。
該問題的解決辦法是將你在云端的數據存儲結構改為字典類型,使用字符串+整形的鍵值對。每一個鍵值對都代表了一個包含硬幣的“委托人”,而總數就應該是將所有記錄的值加起來。這一設計的宗旨是每個設備有它自己的“委托人”,并且只有設備自己可以把硬幣放到它的“委托人”當中。
字典的結構是:(A:a, B:b, C:c, ...),其中a代表了“委托人”A所擁有的硬幣,b是“委托人”B所擁有的硬幣,以此類推。
這樣的話,新的沖突解決策略算法將如下所示:
- **本地數據**:(A:a, B:b, C:c, ...)
- **云端數據**:(A:a', B:b', C:c', ...)
- **解決后的數據**:(A:max(a,a'), B:max(b,b'), C:max(c,c'), ...)
例如,如果本地數據是(A:20, B:4, C:7)并且云端數據是(B:10, C:2, D:14),那么解決沖突后的數據將會是(A:20, B:10, C:7, D:14)。當然,應用的沖突解決邏輯可以根據具體的需求而有所差異。比如,有一些應用你可能希望挑選最小的值。
為了測試新的算法,將它應用于任何一個之前提到過的場景。你將會發現它都能取得正確地結果。
表4闡述了這一點,它使用了表3中所提到的場景。注意下面所列出的關鍵點:
在初始狀態,玩家有20枚硬幣。該數據準確體現在了所有設備和云端中,我們用字典:(X:20)來代表它,其中X我們不用太多關心,初始化的數據是哪兒來對該問題沒有影響。
當玩家在設備A上收集了100枚硬幣,這一變化會作為一個字典保存到云端。字典的值是100是因為這就是玩家在設備A上收集的硬幣數量。在這一過程中,沒有要執行數據的計算(設備A僅僅是將玩家所收集的數據匯報給了云端)。
每一個新的硬幣提交會打包成一個與設備關聯的字典并保存到云端。例如,假設玩家又在設備A上收集了100枚硬幣,那么對應字典的值被更新為110。
最終的結果就是,應用知道了玩家在每個設備上收集硬幣的總數。這樣它就能輕易地計算出實際的總數了。
表4. 鍵值對策略的成功應用案例
| 事件 | 設備A的數據 | 設備B的數據 | 云端的數據 | 實際的總數 |
|-----|-----|-----|-----|-----|
| 開始階段 | (X:20, x) | (X:20, x) | (X:20, x) | 20 |
| 玩家在A設備上收集了100個硬幣 | (X:20, A:100) | (X:20) | (X:20) | 120 |
| 設備A將狀態存儲到云端 | (X:20, A:100) | (X:20) | (X:20, A:100) | 120 |
| 玩家在A設備上又收集了10個硬幣 | (X:20, A:110) | (X:20) | (X:20, A:100) | 130 |
| 玩家在B設備上收集了1個硬幣 | (X:20, A:110) | (X:20, B:1) | (X:20, A:100) | 131 |
| 設備B嘗試向云端存儲數據,**發生沖突** | (X:20, A:110) | (X:20, B:1) | (X:20, A:100) | 131 |
| 設備B解決沖突 | (X:20, A:110) | (X:20, A:100, B:1) | (X:20, A:100, B:1) | 131 |
| 設備A嘗試將數據存儲至云端,**發生沖突** | (X:20, A:110) | (X:20, A:100, B:1) | (X:20, A:100, B:1) | 131 |
| 設備A解決沖突 | (X:20, A:110, B:1) | (X:20, A:100, B:1) | (X:20, A:110, B:1),**total 131** | 131 |
### 清除你的數據
在云端允許存儲數據的大小是有限制的,所以在后續的論述中,我們將會關注如何避免創建過大的詞典。一開始,看上去每個設備只會有一條詞典記錄,即使是非常激進的用戶也不太會擁有上千種不同的設備(對應上千條字典記錄)。然而, 獲取設備ID的方法很難,并且我們認為這是一種不好的實踐方式,所以你應該使用一個安裝ID,這更容易獲取也更可靠。這樣的話就意味著,每一次用戶在每臺設備安裝一次就會產生一個ID。假設每個鍵值對占據32字節,由于一個個人云存儲緩存最多可以有128K的大小,那么你最多可以存儲4096條記錄。
在現實場景中,你的數據可能更加復雜。在這種情況下,存儲數據的記錄條數也會進一步受到限制。具體而言則需要取決于實現,比如可能需要添加時間戳來指明每條記錄是何時修改的。當你檢測到有一條記錄在過去幾個禮拜或者幾個月的時間內都沒有被修改,那么就可以安全地將金幣數據轉移到另一條記錄中并刪除老的記錄。
- 序言
- Android入門基礎:從這里開始
- 建立第一個App
- 創建Android項目
- 執行Android程序
- 建立簡單的用戶界面
- 啟動其他的Activity
- 添加ActionBar
- 建立ActionBar
- 添加Action按鈕
- 自定義ActionBar的風格
- ActionBar的覆蓋層疊
- 兼容不同的設備
- 適配不同的語言
- 適配不同的屏幕
- 適配不同的系統版本
- 管理Activity的生命周期
- 啟動與銷毀Activity
- 暫停與恢復Activity
- 停止與重啟Activity
- 重新創建Activity
- 使用Fragment建立動態的UI
- 創建一個Fragment
- 建立靈活動態的UI
- Fragments之間的交互
- 數據保存
- 保存到Preference
- 保存到文件
- 保存到數據庫
- 與其他應用的交互
- Intent的發送
- 接收Activity返回的結果
- Intent過濾
- Android分享操作
- 分享簡單的數據
- 給其他App發送簡單的數據
- 接收從其他App返回的數據
- 給ActionBar增加分享功能
- 分享文件
- 建立文件分享
- 分享文件
- 請求分享一個文件
- 獲取文件信息
- 使用NFC分享文件
- 發送文件給其他設備
- 接收其他設備的文件
- Android多媒體
- 管理音頻播放
- 控制音量與音頻播放
- 管理音頻焦點
- 兼容音頻輸出設備
- 拍照
- 簡單的拍照
- 簡單的錄像
- 控制相機硬件
- 打印
- 打印照片
- 打印HTML文檔
- 打印自定義文檔
- Android圖像與動畫
- 高效顯示Bitmap
- 高效加載大圖
- 非UI線程處理Bitmap
- 緩存Bitmap
- 管理Bitmap的內存
- 在UI上顯示Bitmap
- 使用OpenGL ES顯示圖像
- 建立OpenGL ES的環境
- 定義Shapes
- 繪制Shapes
- 運用投影與相機視圖
- 添加移動
- 響應觸摸事件
- 添加動畫
- View間漸變
- 使用ViewPager實現屏幕側滑
- 展示卡片翻轉動畫
- 縮放View
- 布局變更動畫
- Android網絡連接與云服務
- 無線連接設備
- 使得網絡服務可發現
- 使用WiFi建立P2P連接
- 使用WiFi P2P服務
- 執行網絡操作
- 連接到網絡
- 管理網絡
- 解析XML數據
- 高效下載
- 為網絡訪問更加高效而優化下載
- 最小化更新操作的影響
- 避免下載多余的數據
- 根據網絡類型改變下載模式
- 云同步
- 使用備份API
- 使用Google Cloud Messaging
- 解決云同步的保存沖突
- 使用Sync Adapter傳輸數據
- 創建Stub授權器
- 創建Stub Content Provider
- 創建Sync Adpater
- 執行Sync Adpater
- 使用Volley執行網絡數據傳輸
- 發送簡單的網絡請求
- 建立請求隊列
- 創建標準的網絡請求
- 實現自定義的網絡請求
- Android聯系人與位置信息
- Android聯系人信息
- 獲取聯系人列表
- 獲取聯系人詳情
- 使用Intents修改聯系人信息
- 顯示聯系人頭像
- Android位置信息
- 獲取最后可知位置
- 獲取位置更新
- 顯示位置地址
- 創建和監視地理圍欄
- Android可穿戴應用
- 賦予Notification可穿戴特性
- 創建Notification
- 在Notifcation中接收語音輸入
- 為Notification添加顯示頁面
- 以Stack的方式顯示Notifications
- 創建可穿戴的應用
- 創建并運行可穿戴應用
- 創建自定義的布局
- 添加語音功能
- 打包可穿戴應用
- 通過藍牙進行調試
- 創建自定義的UI
- 定義Layouts
- 創建Cards
- 創建Lists
- 創建2D-Picker
- 創建確認界面
- 退出全屏的Activity
- 發送并同步數據
- 訪問可穿戴數據層
- 同步數據單元
- 傳輸資源
- 發送與接收消息
- 處理數據層的事件
- Android TV應用
- 創建TV應用
- 創建TV應用的第一步
- 處理TV硬件部分
- 創建TV的布局文件
- 創建TV的導航欄
- 創建TV播放應用
- 創建目錄瀏覽器
- 提供一個Card視圖
- 創建詳情頁
- 顯示正在播放卡片
- 幫助用戶在TV上探索內容
- TV上的推薦內容
- 使得TV App能夠被搜索
- 使用TV應用進行搜索
- 創建TV游戲應用
- 創建TV直播應用
- TV Apps Checklist
- Android企業級應用
- Ensuring Compatibility with Managed Profiles
- Implementing App Restrictions
- Building a Work Policy Controller
- Android交互設計
- 設計高效的導航
- 規劃屏幕界面與他們之間的關系
- 為多種大小的屏幕進行規劃
- 提供向下和橫向導航
- 提供向上和歷史導航
- 綜合:設計樣例 App
- 實現高效的導航
- 使用Tabs創建Swipe視圖
- 創建抽屜導航
- 提供向上的導航
- 提供向后的導航
- 實現向下的導航
- 通知提示用戶
- 建立Notification
- 當啟動Activity時保留導航
- 更新Notification
- 使用BigView風格
- 顯示Notification進度
- 增加搜索功能
- 建立搜索界面
- 保存并搜索數據
- 保持向下兼容
- 使得你的App內容可被Google搜索
- 為App內容開啟深度鏈接
- 為索引指定App內容
- Android界面設計
- 為多屏幕設計
- 兼容不同的屏幕大小
- 兼容不同的屏幕密度
- 實現可適應的UI
- 創建自定義View
- 創建自定義的View類
- 實現自定義View的繪制
- 使得View可交互
- 優化自定義View
- 創建向后兼容的UI
- 抽象新的APIs
- 代理至新的APIs
- 使用舊的APIs實現新API的效果
- 使用版本敏感的組件
- 實現輔助功能
- 開發輔助程序
- 開發輔助服務
- 管理系統UI
- 淡化系統Bar
- 隱藏系統Bar
- 隱藏導航Bar
- 全屏沉浸式應用
- 響應UI可見性的變化
- 創建使用Material Design的應用
- 開始使用Material Design
- 使用Material的主題
- 創建Lists與Cards
- 定義Shadows與Clipping視圖
- 使用Drawables
- 自定義動畫
- 維護兼容性
- Android用戶輸入
- 使用觸摸手勢
- 檢測常用的手勢
- 跟蹤手勢移動
- Scroll手勢動畫
- 處理多觸摸手勢
- 拖拽與縮放
- 管理ViewGroup中的觸摸事件
- 處理鍵盤輸入
- 指定輸入法類型
- 處理輸入法可見性
- 兼容鍵盤導航
- 處理按鍵動作
- 兼容游戲控制器
- 處理控制器輸入動作
- 支持不同的Android系統版本
- 支持多個控制器
- Android后臺任務
- 在IntentService中執行后臺任務
- 創建IntentService
- 發送工作任務到IntentService
- 報告后臺任務執行狀態
- 使用CursorLoader在后臺加載數據
- 使用CursorLoader執行查詢任務
- 處理查詢的結果
- 管理設備的喚醒狀態
- 保持設備的喚醒
- 制定重復定時的任務
- Android性能優化
- 管理應用的內存
- 代碼性能優化建議
- 提升Layout的性能
- 優化layout的層級
- 使用include標簽重用layouts
- 按需加載視圖
- 使得ListView滑動順暢
- 優化電池壽命
- 監測電量與充電狀態
- 判斷與監測Docking狀態
- 判斷與監測網絡連接狀態
- 根據需要操作Broadcast接受者
- 多線程操作
- 在一個線程中執行一段特定的代碼
- 為多線程創建線程池
- 啟動與停止線程池中的線程
- 與UI線程通信
- 避免出現程序無響應ANR
- JNI使用指南
- 優化多核處理器(SMP)下的Android程序
- Android安全與隱私
- Security Tips
- 使用HTTPS與SSL
- 為防止SSL漏洞而更新Security
- 使用設備管理條例增強安全性
- Android測試程序
- 測試你的Activity
- 建立測試環境
- 創建與執行測試用例
- 測試UI組件
- 創建單元測試
- 創建功能測試
- 術語表