iT邦幫忙

2021 iThome 鐵人賽

DAY 29
0
Mobile Development

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

Re-architect - StickyNoteView

上一次我們完成了 ContextMenu 的部分,ContextMenu 也有了屬於自己的 ViewModel,架構圖如下:

417238B4-35BB-457B-8BCA-0E0E4E32F841.jpg

今天我們將要完成 Re-architect 的最後一哩路,StickyNoteView 的 ViewModel!

實做各別更新

在開始之前先複習一下接下來我將要做的事:ViewPort 將只會知道每一個 StickyNote 的 Id,不會有完整的 Model,所以 ViewPort 將會控制所有 StickyNoteView 的建立與刪除,當然,在 Jetpack Compose 的世界中,這些事情都很簡單,只要使用一個 For 迴圈來建立所有應該建立的 StickyNoteView 就好了,剩下的新增以及刪除機制交給內建的 Diff 與 Composition 機制去處理即可。接下來,擁有 id 的 StickyNoteView 就可以使用這個 id 去拿相對應的 ViewModel 就可以實現各別更新了,以下是 ViewPortView 的程式碼:

@Composable
fun ViewPortView(
    noteIds: List<String>
) {
    Box(Modifier.fillMaxSize()) {
        noteIds.forEach { id ->
            key(id) { // [1]
                StatefulStickyNoteView(
                    modifier = Modifier.align(Alignment.Center),
                    id = id
                )
            }
        }
    }
}

超級簡短,對吧?這個 ViewPortView 的參數只有 noteIds ,使用 For 迴圈就可以一個一個拿到這些便利貼的 id ,接著使用這個 id 去建立 StatefulStickyNoteView ,至於 StickyNoteView 為什麼是 Stateful 的呢?請回憶昨天的內容,StickyNoteView 的情況跟 ContextMenuView 是一樣的!

另外這個函式中還有一個需要解說的地方 [1]:key 可以使得這個 Composable function 在 Recompose 的時候記住之前呼叫過的內容,只要 key 裡面的 id 是一樣的,就不會重新執行 key{} 裡面的內容,也就是說這樣的操作等於是在執行兩個 List 的 Diff ,當 noteIds 的內容改變時,只會針對有新增或是刪除的 id 去做相對應的動作:

有 key

        noteIds      |       Recomposition 行為
["A", "B", "C", "D"] => 建立 "A", "B", "C", "D" 四個 StatefulStickyNoteView
["A", "B", "D"]      => 對 id 是 "C" 的 StatefulStickyNoteView 做回收
["A", "B", "D", "E"] => 建立 id 是 "E" 的 StatefulStickyNoteView

沒 key

        noteIds      |       Recomposition 行為
["A", "B", "C", "D"] => 建立 "A", "B", "C", "D" 四個 StatefulStickyNoteView
["A", "B", "D"]      => 建立 "A", "B", "D" 三個 StatefulStickyNoteView,並回收之前所有的 StatefulStickyNoteView
["A", "B", "D", "E"] => 建立 "A", "B", "D", "E" 四個 StatefulStickyNoteView,並回收之前所有的 StatefulStickyNoteView

取代 allNotes

ViewPortView 這邊完成之後,我們就可以拿掉之前在 Domain 層以及 ViewModel 層的 allNotes 了,使用 allVisibleNoteIds 來取代,於是 ViewPortView 就可以拿到這些 id 了:

@Composable
fun CoEditorScreen(viewModel: CoEditorViewModel) {
//  val allNotes by viewModel.allNotes().subscribeAsState(initial = listOf())
    val noteIds by viewModel.allVisibleNoteIds.subscribeAsState(initial = listOf())
    
    ...
    
   ViewPortView(noteIds)

    ...
}

class CoEditorViewModel(coEditor: CoEditor) {
//  val allVisibleNoteIds: Observable<List<Note>> = coEditor.allNotes
    val allVisibleNoteIds: Observable<List<String>> = coEditor.allVisibleNoteIds
}

class CoEditor() {
//  val allNotes: Observable<List<Note>> = noteRepository.getAllNotes()
    val allVisibleNoteIds: Observable<List<String>> = noteRepository.getAllVisibleNoteIds()
}

StatefulStickyNoteView

@Composable
fun StatefulStickyNoteView(
    id: String,
    modifier: Modifier = Modifier,
) {
    val stickyNoteViewModel by LocalViewModelStoreOwner.current!!.viewModel<StickyNoteViewModel>()
    val onPositionChanged: (Position) -> Unit = { delta ->
        stickyNoteViewModel.moveNote(id, delta)
    }
    val note by stickyNoteViewModel.getNoteById(id).subscribeAsState(initial = StickyNote.createEmptyNote(id))
    val selected: Boolean by stickyNoteViewModel.isSelected(id).subscribeAsState(false)

    StickyNoteView(
        modifier = modifier,
        onPositionChanged = onPositionChanged,
        onClick = stickyNoteViewModel::tapNote,
        stickyNote = note,
        selected = selected)
}

以上是 StatefuleStickyNoteView ,基本上沒有什麼新東西,但如同之前說的,StickyNoteView 有一個屬於自己的 ViewModel ,但是很可惜的每個便利貼都需要共用同一個 ViewModel ,這是因為現在的 ViewModelStoreOwner 永遠都會是同一個,所以一但建立了第一個 ViewModel,之後將會共用同一個。因為這樣的限制,所以 id 目前不會綁定在 ViewModel 裡,而且這個 ViewModel 也因此不應該擁有自己的狀態,不然會有狀態管理上的問題。

StickyNoteViewModel

class StickyNoteViewModel(
    private val coEditor: CoEditor
): ViewModel() {

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

    fun tapNote(stickyNote: StickyNote) {
        coEditor.selectNote(stickyNote.id)
    }

    fun getNoteById(id: String) = coEditor.getNoteById(id)

    fun isSelected(id: String): Observable<Boolean> {
        return coEditor.selectedNote
            .map { optNote ->
                optNote.fold( // [1]
                    someFun = { note -> note.id == id},
                    emptyFun = { false }
                )
            }
    }
}

有了這個 ViewModel , CoEditorViewModel 的負擔再次的減輕了,一些比較屬於單個便利貼的行為放到了這個類別:moveNotetapNoteisSelected 。這邊的實作也都是相對的簡單,除了 [1] 之外,fold 是我自己替 Optional 寫的 extension function,fold 操作在 functional programming 是很常見的,他的作用可以讓我們一次寫完所有可能的條件處理,如果對 functional programming 不是很熟的讀者,你可以把它想像成是 streaming style 的 when ,該實做如下:

fun <T, R> Optional<T>.fold(someFun: (T) -> R, emptyFun: () -> R): R {
    return if (this.isPresent) {
        someFun(this.get())
    } else {
        emptyFun()
    }
}

FirebaseNoteRepository

到目前為止 view 層完成了,但是 Repository 還是使用以前的方式,必須要換成符合新架構的實作,這邊其實就比較偏向是技術面的實作,所以我不打算多加解說,但要注意一點:因為動態的新增以及刪除單張便利貼將導致相對應的 listener 以及 Observable 的生命週期會有變化, 所以要好好回收用不到的 listener 以及 Observable。

程式碼連結:https://github.com/hungyanbin/ReactiveStickyNote/blob/DDD_implementation/app/src/main/java/com/yanbin/reactivestickynote/data/FirebaseNoteRepository.kt

Note → StickyNote

最後一步了!由於之前設計的 model 我們都稱呼它為 Note ,但是為了名稱的一致性,將它重新命名為 StickyNote 比較好。

小結

經過漫長的過程,到這部分我們終於真正的完成所有的 Re-architect 了,這邊再次強調過去這幾篇所說的“小步快跑”,所有的重新架構過程都是有經過規劃的,並不是隨意的下手,在這中間的過程中其實可以看到應用程式隨時處於一個可以運行的狀態,不會因為要“大規模的修改”,而讓應用程式整個打掉重練,希望經由分享這樣的案例,可以讓各位讀者對於架構要“大規模的修改”有著不一樣的想像,作為一個開發人員,確保應用程式隨時處於一個可執行的狀態才是我們專業的表現!

到目前為止的所有專案程式碼,可以在這個 github repository 中的 branch: DDD_implementation 中找到:https://github.com/hungyanbin/ReactiveStickyNote/tree/DDD_implementation


上一篇
Re-architect - ContextMenuView
下一篇
架構總覽與閒聊
系列文
Jetpack Compose X Android Architecture X Functional Reactive Programming30

尚未有邦友留言

立即登入留言