配合分支:day23/overabstracted-review,Repo: 按我
昨天把搜尋和心情過濾都做完了,功能正常,測試全過,照理說應該要把心情哼成歌。結果晚上在屋頂,我突然想到:getDisplayEntries() 裡頭現在有兩個過濾條件了欸。今天兩個,那明天呢?後天會不會五個?以後要加「照日期區間找」呢?
工程師總是有被害妄想症。於是我把書架上的設計模式書翻了出來(文藝復興又來),隔天早上交出了一包「重構」。
先講結論:今天這包程式碼是反例教材。 這也是為何它躺在一個叫 day23/overabstracted-review 的分支上,而且根目錄還被我放了一個 DAY23_REVIEW_NOTE.md 寫著「這不是正解,不要從這裡接下去」。正式版的 Day 23 程式碼,和昨天的 day22/mood-filter 一行都沒有差。
請先不要急著說它壞壞。今天來做:先 trace,再判斷。

由上往下:過濾標題、過濾心情、排序。從 PhotoDiaryApp 呼叫 viewModel.getDisplayEntries() 進來,看完這一個 Function 就結束了,不用跳到別的檔案。
然後我做了以下這些事情(深呼吸):

每一個過濾條件都是在回答同一個問題:「這則日記過不過?」那不就可以定一個契約嗎!

注意最後那個 AndDiaryEntryQuery,它是一個「裝著一堆條件的條件」,自己也實作了 DiaryEntryQuery。當下還覺得酷斃了!(這招其實有名字,叫 Composite Pattern,組合模式)。以後要加 OrDiaryEntryQuery、NotDiaryEntryQuery 都超容易,對吧?對吧?
過濾跟排序這件事,放在 ViewModel 裡好像有點「商業邏輯混在畫面狀態裡」的味道。抽出來!而且要抽就抽好,前面加一個 interface!


資料要離開 domain 層送到畫面去,中間總要有個轉換層吧(快開啟心中的美學細胞)。所以:
class DiaryEntryMapper {
fun toDisplayEntry(entry: DiaryEntry): DiaryEntry {
return entry.copy(
id = entry.id,
photo = entry.photo,
title = entry.title,
note = entry.note,
mood = entry.mood,
createdAt = entry.createdAt
)
}
}
這個 Mapper 做的事情是:把一個 DiaryEntry 的六個欄位,一個一個抄到一個新的 DiaryEntry 上面。輸入 DiaryEntry,輸出 DiaryEntry,內容一模一樣。
(還記得 Day 06 講 data class 的 copy() 嗎?entry.copy() 什麼都不寫就是複製一份了,這裡狂暴似的示範把每個欄位都手動寫了一次。 =. =)
ViewModel 的 getDisplayEntries() 現在變成這樣:
fun getDisplayEntries(): List<DiaryEntry> {
return displayEntriesProvider.getDisplayEntries(
sourceEntries = entries,
searchQuery = searchQuery,
selectedMoodFilter = selectedMoodFilter,
isNewestFirst = isNewestFirst
)
}
很乾淨!但是這個 displayEntriesProvider 是誰給的?Day 18 學過的依賴注入(Dependency Injection)派上用場了,要從建構子送進來。ViewModel 的建構子要加一格、工廠要加一格、MainActivity 也要負責生一個出來。



來對照一下按下去之後,資料到底走過幾個地方。

👆🏼 直接版

👆🏼 反例版
這就是今天最麻煩的地方了。把昨天那七個 Android Unit Test 拿過來跑,七個歐趴!

而且測試的內容一個字都沒改,測試方法名稱一樣、Then 的驗證一樣,唯一的差別只有準備階段多注入一個東西:
// before
val viewModel = PhotoDiaryViewModel(application(), diaryStore)
// after
val viewModel = PhotoDiaryViewModel(application(), diaryStore, DisplayEntriesUseCase())
行為完全相同、測試完全相同、檔案多了四個、追蹤路徑長了五站。
這就帶出今天真正要說的事情:「改完之後測試還是過」不代表這次重構有價值。它只證明沒把東西改壞而已。
要判斷一個抽象站不站得住腳,我習慣拿這四個問題去拷問它。
DisplayEntriesProvider 這個 interface,目前的實作者有幾個?一個。DisplayEntriesUseCase。
那測試呢?測試也是塞 DisplayEntriesUseCase() 進去。也就是說,這個契約從頭到尾只有一個實作者,而且沒有第二個人想換掉它。這個 interface 是在防一件到現在都還沒發生的事。講難聽一點,超前部署。
Day 19 講過單一職責原則(SRP),一個 class 應該只有一個改變的理由。那反過來問:TitleContainsQuery 會因為什麼理由改變?「搜尋規則變了」。那 MoodFilterQuery 呢?「篩選規則變了」。那 DisplayEntriesUseCase 呢?「顯示規則變了」。
⋯⋯這三個其實是同一件事。目前這三個檔案永遠會一起改,它們沒有各自獨立的改變理由,只是被切成三份而已。
一個都沒有。七個測試都是呼叫 viewModel.updateSearchQuery()、viewModel.updateMoodFilter() 這些公開行為,然後看 getDisplayEntries() 吐什麼。這些在昨天的直接版本就能寫,而且已經在跑了。
以後有人(很可能就是三個月後的我自己)要查「為什麼搜尋『晨光』會搜到這則」,他要開幾個檔案?要不要先搞懂 AndDiaryEntryQuery 是什麼?DiaryEntryMapper 那一圈到底做了什麼、會不會偷改資料?——這些時間是真的要花下去的,而上面三題我一題都答不出來。
沒有人付錢,只有人付出 QQ。這就是 Overengineering(過度設計),中間多出來的那幾層,術語叫 indirection(間接層)。
DiaryStore 為什麼可以?回頭看 Day 18 抽出來的 DiaryStore,用同樣四個問題跑一次:
LocalDiaryStorage 真的寫 JSON 到手機,測試裡是 FakeDiaryStore 用記憶體的 List。這是兩個真實存在、現在就在跑的實作者。同樣是一個 interface,同樣是依賴注入,DiaryStore 講得出理由,DisplayEntriesProvider 講不出來。差別不在寫法,在講不講得出理由。
看到這裡,可能有讀者已經準備好拿著刀去把公司專案裡所有的 interface 都砍掉了,請等一下好嗎,血氣方剛的。=. =
今天的結論不是「抽象不好」,也不是「檔案越少越好」。用檔案數量來評分,跟用設計模式數量來評分,是同一種病的兩面。
Day 19 那句話還是有效的:過早的最佳化,是萬惡之源。差別只在於,那天講的是「還沒有需求就先抽」,今天講的是「怎麼判斷需求到底來了沒」。答案是回去翻現有的程式碼跟測試:有沒有第二個實作者?有沒有獨立的改變理由?有沒有寫不出來的測試?
關係很大!
現在請 Agent 幫忙重構,它非常樂意給你 UseCase、Repository、Mapper 跟一整套 Clean Architecture,而且看起來都很專業、很像那麼一回事、跑起來也都對。今天這包程式碼要是沒有我在前面自首,它看起來跟「業界最佳實務(Best Practice)」根本沒兩樣。
模型不會知道你的專案現在有幾個實作者、有沒有真的要換儲存空間、三個月後是誰來維護。它只知道「這種程式碼在網路上長這樣」。所以判斷值不值得,還是要你自己來。
這時候可以這樣問:
「先不要改程式。針對你提議的每一層,告訴我現有的程式碼或測試裡,哪一段可以證明它需要存在?找不到就直說。」
分得出來,你就不會被一包漂漂亮亮的資料夾結構騙走三個月。ㄏ
DiaryEntryMapper 輸入輸出都是 DiaryEntry,等於什麼都沒做。AndDiaryEntryQuery 這種「裝著一堆同類東西、自己也是同類」的寫法;很漂亮,但漂亮不等於現在需要。DiaryStore:同樣是 interface + 依賴注入,它有兩個真實實作者、有獨立的改變理由、有寫不出來的測試,這筆錢花得值。