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 这一块,所以单元测试的这部分就靠各位读者自己去研究了。


<<:  Navigation (1)

>>:  DAY17-前後端合体 建立打卡页面-前端服务篇1

D-13, Ruby 正规表达式(一) Regexp && Valid Palindrome

刚开始看不懂那些乱码时,真的很痛苦。 Regular Expression常简写regexp,也是R...

Day 22. Zabbix 通知设定 - SMTP - Mail

今天跟大家介绍 Mail 通知,其实就是 SMTP server ~ 首先就是要准备一组帐号密码,通...

Swift纯Code之旅 Day11. 「TableView(3) - 实作Delegate & DataSource」

前言 昨天已经将 addAlarmTableViewCell 在 addAlarmTableView...

awk - 简介 Linux 制表好工具

awk Linux文字处理工具中, 有另一个杀器awk 但awk是个程序语言, 所以它很灵活且功能强...

CIA-资安的目标

在Wentz Wu网站上说明,CIA是美国法定目标(PUBLIC LAW 107–347—DEC. ...