过去一段时间遇到了一些异步操作导致的撤销恢复问题,想了一些解决方案,但考虑到任务排期并没有使用比较好的解决方案。不过这是个很有意思的问题,就想着写篇文章来记录一下。

撤销恢复

撤销恢复简单来讲就是:假设有一个状态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时可以发现,这个状态是处于typemesh无法匹配的一个错误状态——由于请求还未结束就执行其他操作而保存下来的状态。

还有种情况是确保异步操作能被单次撤销,那么不记录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

要实现这种方法撤销恢复最好是记录变更的实现,不然实现会比较复杂。

除了复杂了一点外,还有一些问题需要优化:

  1. 异步结束后当前的状态在请求时之前怎么办?
  2. 异步时间长的话大量状态的变更可能会触发视图大面积更新,如何去避免?

回溯同样无法处理那种影响后续交互的状态。

等待

那么只能祭出最终解决方案,等你完成不就行了。这块就需要在交互上做一番设计了,要做到尽量避免中断用户的行为。
比如减少全局的loading、短时间的等待不显示loading等。

对于上面的例子来说,对于每个模型单独设置loading状态来禁止交互也是比较常用的方法,结合上面的方法也能做到正确撤销恢复

总结

对于不同的应用会有不同的最优解,实际还是需要根据业务形态来选择最适合方案。

发烧了一天有时候忘记接下来要写啥了,头大 /(ㄒoㄒ)/~~