注:本文翻译自Google官方的Android Developers Training文档,译者技术一般,由于喜爱安卓而产生了翻译的念头,纯属个人兴趣爱好。
原文链接:http://developer.android.com/training/cloudsave/conflict-res.html
在云存储中保存和加载过程是很直接的:它只是将用于数据和byte数组之间的序列化转换,并将这些数组存储在云端。然而, 当你的用户有多个设备,并且两个以上的设备尝试将它们的数据存储在云端时,这一保存可能会引起冲突,因此你必须决定应该如何处理。你在云端存储的数据结构在很大程度上决定了你的冲突解决方案的鲁棒性,所以小心地设计你的数据,使得你的冲突检测解决方案逻辑可以正确地处理每一种情况。
本片文章从描述一些有缺陷的方法入手,并解释他们为何具有缺陷。之后呈现一个解决方案来避免冲突。用于讨论的例子关注于游戏,但解决问题的宗旨是可以适用于任何将数据存储于云端的应用的。
一). 冲突时获得通知
OnStateLoadedListener方法负责从Google服务器下载应用的状态数据。回调函数
OnStateLoadedListener.onStateConflict为你的应用在本地状态和云端存储的状态发生冲突时,提供了一个解决机制:
@Override public void onStateConflict(int stateKey, String resolvedVersion, byte[] localData, byte[] serverData) { // resolve conflict, then call mAppStateClient.resolveConflict() ... }
此时你的应用必须决定要保留哪一个数据,或者它自己提交一个新的数据来表示合并后的数据状态(译者注:是不是有点像git/svn呢?),解决冲突的逻辑由你来实现。
我们必须要意识到云存储服务是在后台执行同步的。所以你应该确保你的应用能够在你创建这一数据的context之外接收回调。特别地,如果Google Play服务应用在后台检测到了一个冲突,该回调函数可以在你下一次加载数据时被调用,而不是下一次用户启动该应用时。
因此,你的云存储代码和冲突解决代码的设计必须是和当前context无关的:即给两个冲突的数据,你必须仅通过数据集中获取的数据区解决冲突,而不依赖于任何其它外部环境。
二). 处理简单地情况
下面列举一些冲突解决的简单例子。对于很多应用而言,用这些策略或者其变体就足够解决大多数问题了:
新的比旧的更有效:在一些情况下,新的数据总是替代老数据。例如,如果数据代表了用户选择角色的衣服颜色,那么最近的新的选择就应该覆盖老的选择。在这种情况下,你可能会选择在云存储数据中存储时间戳。当处理这些冲突时,选择时间戳最新的数据(记住要选择一个可靠的时钟,并注意对不同时区的处理)。
有一个数据集中的数据比其它的更好:在一些情况下,我们是可以有方法在若干数据集中选取一个最好的。例如,如果数据代表了玩家在赛车比赛中的最佳时间,那么显然,在冲突发生时,你应该保留成绩最好的那个数据。
进行合并:有可能通过计算两个数据集的合并版本来解决冲突。例如,如果你的数据代表了用户解锁关卡的进度,那么解决的数据就是冲突集的并集。通过这个方法,用户不会丢失任何他的游戏进度。这里的例子使用了这一操作的一个变形。
三). 为更复杂的情况设计一个策略
一个更复杂的情况是当你的游戏允许玩家收集可以互换的东西时(比如金币或者经验点数),我们来假想一个游戏,叫做“金币跑酷”,一个无限跑步的角色其目标是不断地收集金币是自己变的富有。每个收集到的金币都会加入到玩家的储蓄罐中。
下面的章节将展示三种在多个设备间解决冲突的方案:有两个听上去很不错,可惜最终还是不能适用于所有的场景,最后一个解决方案可以解决多个设备间的冲突。
第一个尝试:只保存总数
首先,这个问题看上去像是说:云存储的数据只要存储金币的数量就行了。但是如果就只有这些数据是可用的,那么解决冲突的方案将会严重受到限制。此时最佳的方案就是在冲突发生时存储最大数值得数据。
想一下表一中所展现的场景。假设玩家一开始有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个字段。
在现实场景中,你的数据可能更加复杂。在这种情况下,存储的数据字段数也会进一步受到限制。具体而言则需要取决于实现,比如可能需要添加时间戳来指明每个字段是何时修改的。当你检测到有一个字段在过去几个礼拜或者几个月的时间内都没有被修改,那么就可以安全地将它转移到另一个字段中并删除老的字段。