iT邦幫忙

2021 iThome 鐵人賽

DAY 20
0
Mobile Development

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

專案檔案結構

第二階段也接近到尾聲了,現在便利貼已經有了比較豐富的功能了,可以拖曳便利貼、改變顏色、改變文字、新增以及刪除。那麼檔案的結構又會是什麼樣子呢?

這邊的分類方式是依使用的層級來分類,我們目前有 View, ViewModel 以及 Repository 這三層。所以我們就可以有相對應的三個分類(package)分別是 ui, domain 以及 data ,除了這些之外還有什麼呢?我們可以再有一個 model 用來放之前所定義好的 model ,像是 Note, Color, Position。model 也有人選擇放到 domain 裡面,但是我覺得放到外面一點的層級會比較容易瀏覽,而且幾乎不太會去更動它,所以把他們當作是同一個層級,最後我還有一個 di 用來放 Dependency injection 中所有的定義檔,所以專案結構就會像下圖這樣:

Screen Shot 2021-09-18 at 6.17.29 PM.png

另外由於 MainActivityNoteApplication 在這個專案中是屬於 Main Component ,不是 View 的一部分,所以將他們獨立出來。

還有最後一類就是屬於 utils 的部分,因為他們跟便利貼這個 Domain 沒有什麼關係,是可以重複使用的,所以我把它們放在 com.yanbin.utils 底下來管理。看完了大致上的專案檔案結構之後,接下來我們來討論一下在分類以及組織檔案結構時,通常會需要考慮的點:

By feature or by layer

現在我們這樣的分類方式是偏向 by layer 的方式,因為目前專案的檔案數量還很少,所以這樣分沒有什麼大問題,但萬一之後 feature 越來越多時,data, ui, domain 裡面的檔案數量也會越來越多,而這些在同一個 package 底下的檔案彼此也沒什麼關係的時候,就該是時候先照 feature 分類了,舉例來說可能以後會有登入功能,那就是會有一個 com.yanbin.reactivestickynote.login 的 package ,然後底下還會有相對應的 data, ui 跟 domain 這種偏向分層的分類方式。

在同一個 package 底下的檔案應該遵循著這樣的準則[1]:如果修改了同個資料夾中的一個檔案,通常其他同層級的檔案也會跟著修改,如果沒有的話,就表示這些檔案的聚合度很低,不適合放在一起。

ViewModel 該放在哪裡

依照 MVVM 最原始的定義,ViewModel 應該是要屬於 View 這一端的元件,跟 business logic 沒有關係,所以是會放在 com.yanbin.reactivestickynote.ui 才對,但是由於目前的 App 也沒有真的複雜到需要把 business logic 跟 ViewModel 切開,所以目前來說,ViewModel 是全部都放在 com.yanbin.reactivestickynote.domain 裡面的。

Repository 該放在哪裡

大家都會很正常的把 Repository 放在 data 這個 package 當中,但是如果以 clean architecture 的角度去思考的話,介面(interface)是屬於內層的元件,也就是 Use case ,實作才是屬於外層的元件,那我們要將實作以及介面分開放嗎?介面應該屬於 domain 這個 package 裡面嗎?

但是如果依上面所說的準則[1]來說,不應該將他們分開放,因為介面一但多了一個新函式,代表著他的實作也會跟著一起被修改,所以這兩個檔案之間的聚合度是高的。

但其實還有另一種案例,就是當專案大到需要切模組時,這時候介面就應該放在屬於 domain 這邊的模組,但是 package name 其實不用變。至於實作這邊,如果有一些模組相依性的需求而不得不將他與介面分開時,就會放在另一個模組中,但是通常這時候的介面不太會改動,也不應該太常改動,否則模組化就反而是個負擔了,所以剛剛所說的準則[1]在這種情況下就不是一個大問題。

到這階段為止的所有程式碼,如果想看完整的,請到這個 github repo 的 main branch 上查看:

https://github.com/hungyanbin/ReactiveStickyNote

如果少用一點 Subject ,會發生什麼事...?

這內容本來是想要獨立拉出一天的時間來講,但是今天的內容好像有點少所以就一起寫了XD。我們再回過頭來複習一下 EditorViewModel 裡面的程式碼:

class EditorViewModel(
    private val noteRepository: NoteRepository
): ViewModel() {

    private val disposableBag = CompositeDisposable()
    private val selectingNoteIdSubject = BehaviorSubject.createDefault("")
    private val selectingNoteSubject = BehaviorSubject.createDefault(Optional.empty<Note>())
    private val openEditTextSubject = PublishSubject.create<String>()

    val allNotes: Observable<List<Note>> = noteRepository.getAllNotes()
    val selectingNote: Observable<Optional<Note>> = selectingNoteSubject.hide()
    val selectingColor: Observable<YBColor> = selectingNote
        .mapOptional { it }
        .map { it.color }
    val openEditTextScreen: Observable<String> = openEditTextSubject.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 moveNote(noteId: String, positionDelta: Position) {
        Observable.just(Pair(noteId, positionDelta))
            .withLatestFrom(allNotes) { (noteId, positionDelta), notes ->
                val currentNote = notes.find { note -> note.id == noteId }
                Optional.ofNullable(currentNote?.copy(position = currentNote.position + positionDelta))
            }
            .mapOptional { it }
            .subscribe { note ->
                noteRepository.putNote(note)
            }
            .addTo(disposableBag)
    }

    fun addNewNote() {
        val newNote = Note.createRandomNote()
        noteRepository.createNote(newNote)
    }

    fun tapNote(note: Note) {
        val selectingNoteId = selectingNoteIdSubject.value
        if (selectingNoteId == note.id) {
            selectingNoteIdSubject.onNext("")
        } else {
            selectingNoteIdSubject.onNext(note.id)
        }
    }

    fun tapCanvas() {
        selectingNoteIdSubject.onNext("")
    }

    fun onDeleteClicked() {
        val selectingNoteId = selectingNoteIdSubject.value
        if (selectingNoteId.isNotEmpty()) {
            noteRepository.deleteNote(selectingNoteId)
            selectingNoteIdSubject.onNext("")
        }
    }

    fun onColorSelected(color: YBColor) {
        val optSelectingNote = selectingNoteSubject.value

        optSelectingNote
            .map { note -> note.copy(color = color) }
            .ifPresent { note ->
                noteRepository.putNote(note)
            }
    }

    fun onEditTextClicked() {
        selectingNoteSubject.value.ifPresent { note ->
            openEditTextSubject.onNext(note.id)
        }
    }

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

}

恩,相信大家一定都懶得看程式碼,我在這邊想跟大家討論的是,這邊的程式碼可不可以不要使用 selectingNoteIdSubject 還有 selectingNoteSubject 呢?照理來說應該可以,因為這邊的 subject 的主要用途是將它當成是 multicasting 的一種實現方式,舉例來說, selectingNoteSubject 在這段程式碼中就被使用了三次:selectingNote, onColorSelected() 還有 onEditTextClicked() ,其中因為 BehaviorSubject 的特性,所以可以直接使用 .value 的方式直接拿到最新的值(真是作弊),所以就不用在很多地方都要用 Observable stream 的方式來傳值。那如果我堅持所有的地方都要使用 Observable stream 呢?將 selectingNoteSubject 捨棄掉,最新的值必須都要從 selectingNote 來的這個方式呢?

class EditorViewModel(
    private val noteRepository: NoteRepository
): ViewModel() {

    val allNotes: Observable<List<Note>> = noteRepository.getAllNotes()
    val selectingNote: Observable<Optional<Note>> = 
        Observables.combineLatest(allNotes, selectingNoteIdSubject) { ... } // 只留這個
    val selectingColor: Observable<YBColor> = selectingNote
        .mapOptional { it }
        .map { it.color }
    
    ...

    fun onEditTextClicked() {
        selectingNote.mapOptional{ it }
            .subscribe { ... }
            .addTo(disposableBag)
    }

}

經過修改之後,onEditTextClicked 會改成需要透過 selectingNote 接成一個 Observable stream 才能做出相對應的動作,而不是像上一種作法一樣可以由 selectingNoteSubject.value 拿到資料。以新的作法來說,區域變數更少了,兩個變數(selectingNoteSubject, selectingNote)變成了一個變數 (selectingNote) ,這樣子是不是也代表程式碼比較乾淨了呢?但是以使用方面來說, Subject 比 Observable 來說好用很多,所以好像也說不上哪個真的有比較好。

但是這邊有一個要特別注意的地方,就是 selectingNote 是一個 Observable,所以在使用這個 Observable 的時候要小心是不是會做太多無用的計算,為了避免這件事情發生,selectingNote 應該要使用 share() 或是 cache() 來確保有 multicasting 的機制。

     val selectingNote: Observable<Optional<Note>> = 
        Observables.combineLatest(allNotes, selectingNoteIdSubject) { ... }
                   .share() // 請在最後加這個,才不會有過多無用的計算

所以到底是 Subject 比較好還是 Observable 比較好呢?這就留給大家自己去思考了,各有各的好處,另外如果讀者有興趣的話,歡迎自己完成這部分的修改,並驗證看看有沒有加 share() 的差別!


上一篇
Jetpack Compose navigation + Koin
下一篇
新需求與架構設計的演進
系列文
Jetpack Compose X Android Architecture X Functional Reactive Programming30

尚未有邦友留言

立即登入留言