上一次介紹完了介面,今天就要來說說實作的部分了,從這裡開始我要採取一種“小步快跑”的方式,原本 EditorViewModel
負責處理所有的商業邏輯,現在將原本的做的事情慢慢減少,將部分的職責一個一個的交給 CoEditor
,然後每次完成之後再建置、執行、測試、commit,確定沒問題了之後再進行下一次的迭代。
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] 這邊暫時會有 NoteRepository
與 CoEditro
這兩個相依的類別,等到 re-architect 完成後, 會將 NoteRepository
拿掉。最後是 [2] 跟 [4] ,因為 CoEditro
是一個擁有生命週期的元件,所以必須要在對的地方呼叫 start 跟 stop,否則在 CoEditro
中的 Reactive stream 會因為沒有正確的回收而造成 memory leak。
以上完成了之後,建置、執行、測試都沒問題,因此可以放心的進行下一步:
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
要怎麼來實作 onDeleteClicked
、onColorSelected
、onEditTextClicked
這些函式呢?事情開始有趣起來了,依我們現在的設計,刪除、選擇顏色、 編輯文字這些按鈕的行為應該都要屬於 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) { ... }
}
到這個階段告一段落,一樣建置、執行、測試,一切順利!
現在還是使用全部的 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
已經沒有任何的商業邏輯了,將核心的商業邏輯全搬到了 CoEditor
與 ContextMenu
,這步完成了之後,我們在未來就可以放心的在 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 這一塊,所以單元測試的這部分就靠各位讀者自己去研究了。