专案档案结构

第二阶段也接近到尾声了,现在便利贴已经有了比较丰富的功能了,可以拖曳便利贴、改变颜色、改变文字、新增以及删除。那麽档案的结构又会是什麽样子呢?

这边的分类方式是依使用的层级来分类,我们目前有 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() 的差别!


<<:  [Day 17] Mattermost - 介绍与安装

>>:  Day11 Vue directives(v-on & v-bind)

资讯安全管理制度导入范围与验证范围

导入范围与验证范围差异比较如下: 范围 导入 验证 订定1~4阶程序书 ○ ○ 落实资讯资产资讯蒐集...

浮点数的二进位表达方法

浮点数的二进位表达方法 浮点运算知识点 小数二进制表达 与整数的二进制表达相同我们可以假设任意小数的...

【Day29】this - DOM

今天要来讲解 DOM 与 this 的关系, 对於 DOM 的操作有两种方式, 第一种是直接将方法写...

DAY19 - 在win10家用版上安装Docker Desktop

前言 铁人赛进入第十九天,今天要来讲讲如何用Docker 打造程序开发环境 Docker 的维基百科...

Day 18动画原理

前言 我们平常手机拍摄的视讯,看起来虽然是一个连续播放的视讯,但其实他是由一连串的图片组成的。动画的...