ViewModel 的 Single source of truth

以往我们所熟悉的 Single source of truth 都是在针对资料层,概念上基本上这样的:我们 App 的资料来源通常来说都有两个,一个是网路,另一个是本地端资料库。如果使用者处於离线状态时,还是有可能会更新资料,这时候只会更新到本地端的资料库,但是在这段时间里,网路端的资料也有可能被别人改变,那下一次连上网路时,我们应该要怎麽整合资料?关於资料同步问题一直都是个难题,但是不管我们打算采取哪种做法,Single source of truth 建议我们应该要永远使用单一的资料来源来取值,通常来说都是本地端资料库,我们就因此解决了读取资料的部分,采用了这个准则之後,解决问题的复杂度就会大大的降低。下图示范了从网路获取资源,塞资料到资料库中,最後再将资料送给 ViewModel (图片来源:https://www.fatalerrors.org/a/0th81zw.html)。

https://www.fatalerrors.org/images/blog/44112504cbe1015707b036d4192712c3.jpg

说明完了大家所熟悉的 Single source of truth 之後,我们再回来看看我们的便利贴 App。

从 UI 来的资料

我们再回忆一下上一篇中 ViewModel 的实作:

class EditorViewModel( 
    private val noteRepository: NoteRepository
): ViewModel() {

	val allNotes: Observable<List<Note>> = noteRepository.getAllNotes()
  private val selectingNoteSubject = BehaviorSubject.create<Optional<Note>>()
  val selectingNote: Observable<Optional<Note>> = selectingNoteSubject.hide()

  fun moveNote(noteId: String, positionDelta: Position) { ... }

	fun tapNote(note: Note) { 
	    selectingNoteSubject.onNext(Optional.of(note))
	} 

  fun tapCanvas() { 
    selectingNoteSubject.onNext(Optional.empty())
  } 

}

这边的 selectingNote 其实有个问题,请看 tapNote 这边,我们将 UI 传送过来的 Note 完完全全的当作资料传送出去了,但是,我们能够确定这个 note 中所有的资料状态都能信任吗?从 UI 来的 note 搞不好还保留在建立 View 时最原始的状态,经过一段时间後,颜色可能也改了,位置可能也变了,改变资料的人搞不好还不是现在这个使用者,可能是另外一个使用者,在另外一个地方透过 Firebase,改了这个 note 的状态。

那这又有什麽问题呢?没错,以目前来看,没有出什麽大问题,因为外面的 View 对於 selectingNote 的使用方式就是只有拿出 id 来做比对而已,就算位置改了、颜色改了、文字内容改了,只要 id 一样,就不会选错目标。但是的资料不一致还是一个潜在的问题,等等我们就会遇到了。

实作:改变颜色

由於新增便利贴跟删除便利贴的功能实在太过简单,我就直接跳过他们的实作了,现在让我们看看实作改变便利贴颜色会发生什麽事吧!在 EditorViewModel 新增一个函式如下:

fun onColorSelected(color: YBColor) { }

依据我们的直觉,只要使用 selectingNoteSubject.value 拿到现在正在选择中的 note ,就可以藉由更改这个 note 中的 color 拿到新的 note,再将更新後的 note 放到 NoteRepository 里去上传资料,最後 firebase 将会回传最新的资料,藉由 noteRepository.getAllNotes() 的资料绑定来更新画面,以下是实作。

fun onColorSelected(color: YBColor) {
    val optSelectingNote = selectingNoteSubject.value

    optSelectingNote
        .map { note -> note.copy(color = color) }
        .ifPresent { note ->
            noteRepository.putNote(note)
        }
}

至於 View 层的实作就非常直觉了,就只是传递 lambda 而已,这边就不占版面了,最後会一起提供完整的实作。View 层也实作完成後,以下是目前实作的结果:

https://user-images.githubusercontent.com/7949400/132991307-776f61d0-3209-4fa3-bc3f-ff77fec0d076.gif

如果我们拖曳到其他地方之後再改变颜色,颜色是改了没错,但是位置也变了!这是为什麽呢?请看下方的流程图:

Screen Shot 2021-09-12 at 11.07.42 PM.png

现在比之前的流程更复杂、更多条了,之前在看流程时只有最上面的 Gesture 以及最下面 Draw 这两条,为了完成第二阶段的需求,现在在中间多了点击便利贴以及选择颜色这两条流程。

初始状态如上图所示,在订阅时有一笔资料,id 是 A ,位置为 (60, 60)

Screen Shot 2021-09-12 at 11.07.47 PM.png

便利贴拖曳事件被触发,x 跟 y 移动的距离分别是 (10, 10),之後将会使用这边的资讯产生新的 note 资料。

Screen Shot 2021-09-12 at 11.07.54 PM.png

最後产生新的资料的位置是 (70, 70),到目前为止跟之前一样,接下来就是重点了:

Screen Shot 2021-09-12 at 11.07.59 PM.png

使用者点击了 A 这个便利贴,并且将 note 的完整资讯送到了 ViewModel ,ViewModel 将会把这完整的资讯储存起来当作 selectingNote 。但是请注意这边的事件,从 View 传送过来的位置竟然还是 (60, 60) !不是已经更新为 (70, 70)了吗?经过调查後发现,原来因为我们在 View 层使用的 lambda 把最一开始的初始状态记起来了!所以这个 lambda 送出来的永远都会是初始状态的 note 。

Screen Shot 2021-09-12 at 11.08.06 PM.png

接下来,改变颜色的按钮被点选了,之後将依照 ViewModel 实作的逻辑,与 selectingNote 合并起来产生一个新的 note 资料。

Screen Shot 2021-09-12 at 11.08.12 PM.png

最後就发生影片中的现象,虽然颜色改了没错,但是位置却不是最新的,而是初始状态中的位置。

探讨解决方案

那我们要怎麽解决这问题呢?其中有一个最快想到的解法是:更改 View 层的实作,点击便利贴所送出来的 note 都一定要是最新,最正确的资料,的确,如果是这样的解法的话,目前的问题可以被解决。但是!还有一个但是!万一有其他人从远端修改同一个 note 要怎麽办?依据我们目前的机制,只要 Firebase 的资料改了,我们就会马上更新并显示在 View 上面,然而在这时候本地端早已经选择了该张便利贴,所以 View 的状态跟 ViewModel 的 selectingNote 状态又不一致了!因此更改 View 层实作并不是一个一劳永逸的解法,反而会让便利贴的资料状态在各个地方不同步,View 层是最新的状态,ViewModel 层的 selectingNote 却是之前在选择瞬间的状态。

因此这时候,Single source of truth 就是我们的救星了!资料分散在 View 跟 ViewModel 这件事本身就是造成混乱的根源,我们本来就不应该完全接受由 View 层来的 note 资料!要实作选择状态其实只需要一个栏位就够了,而那个栏位就是 id ,对於同一个便利贴来说,id 是完全不会改变的!

从 View 层拿到 note id 之後,为了确保资料是最新状态,我们可以再一次的使用之前所用过的 combineLatest 这个 operator 来跟 allNote Observable 做资料的结合:

class EditorViewModel(
    private val noteRepository: NoteRepository
): ViewModel() {

    private val selectingNoteIdSubject = BehaviorSubject.createDefault("")
    private val selectingNoteSubject = BehaviorSubject.createDefault(Optional.empty<Note>())

    val allNotes: Observable<List<Note>> = noteRepository.getAllNotes()
    val selectingNote: Observable<Optional<Note>> = selectingNoteSubject.hide()

    init {
        Observables.combineLatest(allNotes, selectingNoteIdSubject) { notes, id ->
            Optional.ofNullable<Note>(notes.find { note -> note.id == id })
        }.fromComputation()
            .subscribe { optNote ->
                selectingNoteSubject.onNext(optNote)
            }
            .addTo(disposableBag)
    }

    fun tapNote(note: Note) {
        val selectingNoteId = selectingNoteIdSubject.value
        if (selectingNoteId == note.id) {
            selectingNoteIdSubject.onNext("")
        } else {
            selectingNoteIdSubject.onNext(note.id)
        }
    }

    ...
}

在这个新版的实作里,新增了一个 selectingNoteIdSubject ,当 tapNote 被呼叫时就会更新里面的内容,然後呢,在 EditorViewModel init 的时候,将 allNotesselectingNoteIdSubject 这两个 Observable 结合起来,将最後的产出再送给 selectingNoteSubject ,如此一来,我们就能确保 selectingNote 中所有的栏位都是最新、最正确的状态,同时也是 View 跟 ViewModel 层中,对於选择状态唯一的读取来源,如此一来就不会因为资料不一致而产生出很多奇怪的现象。

小结

今天的例子中我们看到了 Single source of truth 这概念不是只能套用在资料层中,只要是在开发过程中,你发现了任何因为资料不一致而产生的 bug ,都应该要想想以下这件事:对於目前这个使用案例来说,可以信任的,单一的资料来源应该是哪边?当你找到了该资料来源的位置,这问题其实就已经解决一半了!其实这也不是一个很新的概念了,有另外一个叫做 MVI 的 architecture pattern,其实就是将这种概念延伸到整个页面的状态,因为整个页面的状态已经被封装成了一个单一的物件,所以任何操作或是後端的更新最终都会反映到同一个终点,如此一来,整个页面的状态就会很好管理。不过 MVI 也不是一个完美的架构,其最大的缺点就是效能,在使用上还是要好好的考虑在专案上各方面的取舍。


<<:  准备Django环境

>>:  [13th][Day12] struct

.NET Core第18天_InputTagHelper的使用

InputTagHelper: 是针对原生HTML 的封装 新增InputController.cs...

Day 27: 人工智慧在音乐领域的应用 (索尼-Flow Machine、谷歌-Magenta )

今天开始我们来介绍一些已经有公开发布成果或是已经有成熟软件提供用户使用的公司产品。 索尼 (Sony...

Day 04:专案01 - 超简单个人履历03 | CSS简介

CSS是什麽? CSS,全名为Cascading Style Sheets,中文为阶层式样式表。跟H...

[2021铁人赛 Day29] Binary Exploitation (Pwn) Pwn题目 01

引言 昨天介绍了 pwntools 这个好用工具的基本使用方式, 有了这几个函式,其实就已经可以对...

Day 5 - 虚拟机配置&实体手机测试

补充 因为昨天忘了讲,今天先补充一下。 如果你觉得前几代的iphone不是全萤幕很丑的话,是可以换的...