iT邦幫忙

2021 iThome 鐵人賽

DAY 17
1
Mobile Development

Jetpack Compose X Android Architecture X Functional Reactive Programming系列 第 17

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 也不是一個完美的架構,其最大的缺點就是效能,在使用上還是要好好的考慮在專案上各方面的取捨。


上一篇
ViewModel 中的 UI 狀態 - 以 Selection state 為例
下一篇
Jetpack Compose - Stateful and Stateless
系列文
Jetpack Compose X Android Architecture X Functional Reactive Programming30

尚未有邦友留言

立即登入留言