过去一段时间遇到了一些异步操作导致的撤销恢复问题,想了一些解决方案,但考虑到任务排期并没有使用比较好的解决方案。不过这是个很有意思的问题,就想着写篇文章来记录一下。
撤销恢复
撤销恢复简单来讲就是:假设有一个状态A
,经过一个操作action
变为了状态B
,此时我们可以通过撤销(undo)
操作退回状态A
,然后可以通过恢复(redo)
操作变为状态B
。
graph LR A--action-->B--undo-->A--redo-->B
最简单粗暴的实现方法就是将每个节点状态都给保存下来,撤销恢复时全量覆盖即可。实际使用时由于要求保存的数据得是Immutable的,不一定适合所有应用,如果每次保存会有比较多的数据复制开销以及内存增长风险的话建议使用后面的方法。
还有种方法是记录每次的变更,比如哪些字段进行了变更,变更前的值等,这样更适合Mutable的数据,开销更小。下文中的一些方法会基于该实现。
异步操作会带来的问题
以一个比较实际的例子来说明,现在我们有一个描述模型的状态,上面有一些position、rotation、type等用户输入的数据,还有类似mesh的后端生成的数据。
interface Model {
type: string;
position: Vector3;
mesh: Mesh;
}
对于这个模型会有一些常见操作,比如用户修改type
后向后端请求,返回了新的mesh
;还有就是用户拖拽模型导致位置变更,直接更新position
。
graph LR async((Waiting)) model2--change position-->model3 model1--change type & request-->async async-.change mesh.->model2
这里请求是个异步操作,如果用户在请求过程中修改了状态会怎么样呢?
graph LR async((Waiting)) model1--change type & request-->async async-.change mesh.->model3 model2-->model3 async--change position-->model2
一种情况是用户修改了position
后记录了节点model2,当状态从model3撤销到model2时可以发现,这个状态是处于type
与mesh
无法匹配的一个错误状态——由于请求还未结束就执行其他操作而保存下来的状态。
还有种情况是确保异步操作能被单次撤销,那么不记录model2会导致丢失很多中间状态,不是个好方法。
解决方案
剥离异步状态
如果异步结果干扰了撤销恢复那把它拿出来不就行了? 这是个可行又不可行的方案。
以上面的Model
为例,可以建立Model => Mesh
的缓存体系,每次type
变更获取时从缓存或者接口中获取。
graph LR subgraph data async((Waiting)) model1--change type & request-->async async-.change mesh.->model3 model2-->model3 async--change position-->model2 end subgraph view model1--type fetch-->View model2--type cached-->View model3--type fetch-->View MeshCacher--getOrFetch-->View end
另外对于MeshCacher
需要做好内存管理,及时回收没用的Mesh
。
这种方法的限制就是只能处理这种不会阻塞后续操作的异步生成状态,上面例子中的mesh
就是如此。如果有那么些会影响后续交互的字段由后端(建模服务)控制,比如哪些某些type
无法修改尺寸等,就不太合适了。
回溯
这个想法来源于在线实时游戏里为了保证客户端实时性的预测与回溯技术,这个技术简单讲就是遇到网络延迟时,客户端可以根据当前人物行为预测未来几帧的结果,然后等网络实际返回后修正为正确的结果。
我们这里的话就是在请求返回前,允许用户的修改,等请求结束后将状态退回请求开始时的样子并应用结果,然后再应用之前退回的修改。
graph LR async((Waiting)) async1((Waiting)) model1--change type & request-->async async--change position-->model2 model2-.请求结束退回position修改.->async1 async1--change mesh-->model3 model3--应用退回的修改-->model4
最终的撤销恢复栈为
graph LR model1-->model3-->model4
要实现这种方法撤销恢复最好是记录变更的实现,不然实现会比较复杂。
除了复杂了一点外,还有一些问题需要优化:
- 异步结束后当前的状态在请求时之前怎么办?
- 异步时间长的话大量状态的变更可能会触发视图大面积更新,如何去避免?
回溯同样无法处理那种影响后续交互的状态。
等待
那么只能祭出最终解决方案,等你完成不就行了。这块就需要在交互上做一番设计了,要做到尽量避免中断用户的行为。
比如减少全局的loading、短时间的等待不显示loading等。
对于上面的例子来说,对于每个模型单独设置loading状态来禁止交互也是比较常用的方法,结合上面的方法也能做到正确撤销恢复
总结
对于不同的应用会有不同的最优解,实际还是需要根据业务形态来选择最适合方案。
发烧了一天有时候忘记接下来要写啥了,头大 /(ㄒoㄒ)/~~