iT邦幫忙

2021 iThome 鐵人賽

DAY 27
0
Mobile Development

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

Re-architect - Domain Layer (二)

上一次介紹完了介面,今天就要來說說實作的部分了,從這裡開始我要採取一種“小步快跑”的方式,原本 EditorViewModel 負責處理所有的商業邏輯,現在將原本的做的事情慢慢減少,將部分的職責一個一個的交給 CoEditor ,然後每次完成之後再建置、執行、測試、commit,確定沒問題了之後再進行下一次的迭代。

moveNote, addNewNote

class EditorViewModel(
    private val coEditor: CoEditor,
    private val noteRepository: NoteRepository //[1]
): ViewModel() {

    init {
        coEditor.start() // [2]
    }

    fun moveNote(noteId: String, positionDelta: Position) {
        coEditor.moveNote(noteId, positionDelta) // [3]
    }

    fun addNewNote() {
        coEditor.addNewNote() // [3]
    }

    // ..others

    override fun onCleared() {
        coEditor.stop() // [4]
        disposableBag.clear()
    }
}

class CoEditor(private val noteRepository: NoteRepository) {

    // 從 EditorViewModel 搬過來的
    fun addNewNote() {
        val newNote = Note.createRandomNote()
        noteRepository.addNote(newNote)
    }

    fun moveNote(noteId: String, positionDelta: Position) {
        Observable.just(positionDelta)
            .withLatestFrom(noteRepository.getNoteById(noteId)) { delta, note ->
                note.copy(position = note.position + delta)
            }
            .subscribe { note ->
                noteRepository.putNote(note)
            }
            .addTo(disposableBag)
    }
}

首先請看 [3] , addNewNote 以及 moveNote ,這兩個函式是最好實作的,因為他們可以透過 NoteRepository 直接去做呼叫,所以直接把所有程式碼從 EditorViewModel 搬過去即可。與此同時,在 re-architect 的過程中,我們沒有想要一步到位,所以在建構子 [1] 這邊暫時會有 NoteRepositoryCoEditro 這兩個相依的類別,等到 re-architect 完成後, 會將 NoteRepository 拿掉。最後是 [2] 跟 [4] ,因為 CoEditro 是一個擁有生命週期的元件,所以必須要在對的地方呼叫 start 跟 stop,否則在 CoEditro 中的 Reactive stream 會因為沒有正確的回收而造成 memory leak。

以上完成了之後,建置、執行、測試都沒問題,因此可以放心的進行下一步:

selectingNote

class EditorViewModel(
    private val coEditor: CoEditor,
    private val noteRepository: NoteRepository 
): ViewModel() {
    
    val selectingNote: Observable<Optional<Note>> = coEditor.selectedNote

    fun tapNote(note: Note) {
        coEditor.selectNote(note.id)
    }

    fun tapCanvas() {
        coEditor.clearSelection()
    }
    
    // ..others
}

class CoEditor(private val noteRepository: NoteRepository) {

    // 這邊基本上也是從 EditorViewModel 搬過來,只有做點小修改
    private val selectedNoteId = BehaviorSubject.createDefault(Optional.empty<String>())
    val selectedNote: Observable<Optional<Note>> = selectedNoteId
        .flatMap { optId ->
            if (optId.isPresent) {
                noteRepository.getNoteById(optId.get())
                    .map { Optional.ofNullable(it) }
            } else {
                Observable.just(Optional.empty())
            }
        }

    fun selectNote(noteId: String) {
        selectedNoteId.onNext(Optional.of(noteId))
    }

    fun clearSelection() {
        selectedNoteId.onNext(Optional.empty())
    }
    
    // ..others
}

接下來是選擇狀態,其實這邊的邏輯跟之前也是一樣的,只要搬程式碼就好,但是比較棘手的是:刪除、更改顏色、更改文字,這些功能都需要這個狀態的值。這邊繼續秉持著採用“小步快跑”的精神,不要一次改太多,所以先讓這幾個功能壞掉,之後再補上即可。

建置、執行、測試之後發現選擇狀態是 OK 的,這邊沒問題之後,就來補上刪除、更改顏色、更改文字這些功能了:

class EditorViewModel(
    private val coEditor: CoEditor,
    private val noteRepository: NoteRepository 
): ViewModel() {

    val selectingColor: Observable<YBColor> = noteEditor.contextMenu.selectedColor
    val openEditTextScreen: Observable<String> = noteEditor.openEditTextScreen
    
    fun onDeleteClicked() {
        coEditor.contextMenu.onDeleteClicked()
    }

    fun onColorSelected(color: YBColor) {
        coEditor.contextMenu.onColorSelected(color)
    }

    fun onEditTextClicked() {
        coEditor.contextMenu.onEditTextClicked()
    }

    // ..others
}

由於這些功能都是掛在 contextMenu 底下,所以這邊還有再經過一層 coEditor.contextMenu 的呼叫來使用它們。但是, ContextMenu 要怎麼來實作 onDeleteClickedonColorSelectedonEditTextClicked 這些函式呢?事情開始有趣起來了,依我們現在的設計,刪除、選擇顏色、 編輯文字這些按鈕的行為應該都要屬於 ContextMenu ,但是現在 ContextMenu 既不認識 selectingNote 也不認識 NoteRepository ,有了 selectingNote 才可以拿到最新的狀態去更新,有了 NoteRepository 才有辦法更新資料在雲端上,那我該讓 ContextMenu 認識他們嗎?

仔細思考過後,我覺得所有核心的邏輯操作還是放在 CoEditor 比較好,原因是因為他們都跟“共編器”這個概念有相關,全部都放在同一個地方之後要再做重構或整理也比較方便,於是我就想到了一個方案:讓 ContextMenu 丟出事件,這些被丟出來的事件會被傳送到 CoEditor ,並且讓它處理該做的核心商業邏輯運算。

sealed interface ContextMenuEvent {
    object NavigateToEditTextPage: ContextMenuEvent
    object DeleteNote: ContextMenuEvent
    class ChangeColor(val color: YBColor): ContextMenuEvent
}

這些事件只會存在於 Domain 層,由 ContextMenu 傳給 CoEditor,用 PublishSubject 可以輕鬆的幫我們完成這件事:

class ContextMenu(
    private val selectedNote: Observable<Optional<Note>>
) {

    private val _contextMenuEvents = PublishSubject.create<ContextMenuEvent>()

    val contextMenuEvents: Observable<ContextMenuEvent> = _contextMenuEvents.hide()

    fun onColorSelected(color: YBColor) {
        _contextMenuEvents.onNext(ContextMenuEvent.ChangeColor(color))
    }

    fun onDeleteClicked() {
        _contextMenuEvents.onNext(ContextMenuEvent.DeleteNote)
    }

    fun onEditTextClicked() {
        _contextMenuEvents.onNext(ContextMenuEvent.NavigateToEditTextPage)
    }

}

接著只要在 CoEditor 中綁定 contextMenuEvents 即可:

class CoEditor(private val noteRepository: NoteRepository) {

    fun start() {
        contextMenu.contextMenuEvents
            .subscribe { menuEvent ->
                when(menuEvent) {
                    ContextMenuEvent.NavigateToEditTextPage -> navigateToEditTextPage()
                    is ContextMenuEvent.ChangeColor -> changeColor(menuEvent.color)
                    ContextMenuEvent.DeleteNote -> deleteNote()
                }
            }
            .addTo(disposableBag)
    }

    // ..others

    // 相關的實作細節有興趣的可以去 github 看:https://github.com/hungyanbin/ReactiveStickyNote/blob/DDD_implementation/app/src/main/java/com/yanbin/reactivestickynote/domain/CoEditor.kt
    private fun navigateToEditTextPage() { ... }
    private fun deleteNote() { ... }
    private fun changeColor(color: YBColor) { ... }
}

到這個階段告一段落,一樣建置、執行、測試,一切順利!

allNotes

現在還是使用全部的 Note 來顯示便利貼的位置以及其他相關狀態比較好,還未能實現每個獨立的便利貼各自更新,因為如果為了要達到各自更新這個目的,還得要有資料層與顯示層的支援才行。以現在狀態來說還不是一個適合的時機點,先把搬運程式碼的部分告一段落才行,一次做一件事才能好好的把事情做好,所以現在要做的是,暫時的CoEditor 上加一個成員變數,用來拿所有的便利貼,當然,這邊的實作方式也是跟在 EditorViewModel 是一模一樣的:

class EditorViewModel(
    private val coEditor: CoEditor,
    private val noteRepository: NoteRepository 
): ViewModel() {

    val allNotes: Observable<List<Note>> = coEditor.allNotes
}

class CoEditor(private val noteRepository: NoteRepository) {

    @Deprecated("Will remove later")
    val allNotes = noteRepository.getAllNotes()
}

至今為止, EditorViewModel 已經沒有任何的商業邏輯了,將核心的商業邏輯全搬到了 CoEditorContextMenu ,這步完成了之後,我們在未來就可以放心的在 View 層進行較大規模的重組。以下是最後 EditorViewModel 的樣子:


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

    private val disposableBag = CompositeDisposable()

    val allNotes: Observable<List<Note>> = coEditor.allNotes
    val selectingNote: Observable<Optional<Note>> = coEditor.selectedNote
    val selectingColor: Observable<YBColor> = coEditor.contextMenu.selectedColor
    val openEditTextScreen: Observable<String> = coEditor.openEditTextScreen

    init {
        coEditor.start()
    }

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

    fun addNewNote() {
        coEditor.addNewNote()
    }

    fun tapNote(note: Note) {
        coEditor.selectNote(note.id)
    }

    fun tapCanvas() {
        coEditor.clearSelection()
    }

    fun onDeleteClicked() {
        coEditor.contextMenu.onDeleteClicked()
    }

    fun onColorSelected(color: YBColor) {
        coEditor.contextMenu.onColorSelected(color)
    }

    fun onEditTextClicked() {
        coEditor.contextMenu.onEditTextClicked()
    }

    override fun onCleared() {
        coEditor.stop()
        disposableBag.clear()
    }

}

小結

今天分享了“小步慢跑”的手法,讓程式碼隨時處於一個可以運行的狀態,首先產生一個空殼的 Domain 層物件,從最簡單的使用案例開始,慢慢把核心邏輯從 ViewModel 層搬運到了 Domain 層,直到 ViewModel 層的職責幾乎完全脫離商業邏輯為止,當然,這時候你可能會覺得我們在浪費時間,只是在左手換右手,但是根據我們之前畫的架構圖,這個步驟是看得出價值的,因為在之後,這些 Domain 層物件將會各自有屬於他們的 ViewModel ,職責會更加的單一,慢慢的去形成屬於我們 App 商業行為的形狀,而不是過往所看到的,開發人員為了迎合某個框架而變成“框架的形狀”。一但 App 的架構與“模型”還有“共通語言”是高度相關的,要增加新功能也只是一塊小蛋糕而已,其維護的成本會大大的降低,開發速度會有顯著的提升!

注:如果 EditorViewModel 原本有寫單元測試的話我們 re-architect 就會更加的放心,但是單元測試並不在這系列文章的範圍裡,也並不是說單元測試對於架構來說不重要,只是我覺得測試這一塊再加進來就有點太多了,我想專注的寫好架構以及 RP 這一塊,所以單元測試的這部分就靠各位讀者自己去研究了。


上一篇
Re-architect - Domain Layer (一)
下一篇
Re-architect - ContextMenuView
系列文
Jetpack Compose X Android Architecture X Functional Reactive Programming30

尚未有邦友留言

立即登入留言