iT邦幫忙

2026 iThome 鐵人賽

DAY 23
1

配合分支:day23/overabstracted-review,Repo: 按我

昨天把搜尋和心情過濾都做完了,功能正常,測試全過,照理說應該要把心情哼成歌。結果晚上在屋頂,我突然想到:getDisplayEntries() 裡頭現在有兩個過濾條件了欸。今天兩個,那明天呢?後天會不會五個?以後要加「照日期區間找」呢?

工程師總是有被害妄想症。於是我把書架上的設計模式書翻了出來(文藝復興又來),隔天早上交出了一包「重構」。

先講結論:今天這包程式碼是反例教材。 這也是為何它躺在一個叫 day23/overabstracted-review 的分支上,而且根目錄還被我放了一個 DAY23_REVIEW_NOTE.md 寫著「這不是正解,不要從這裡接下去」。正式版的 Day 23 程式碼,和昨天的 day22/mood-filter 一行都沒有差

請先不要急著說它壞壞。今天來做:先 trace,再判斷

先看看昨天那一段

https://ithelp.ithome.com.tw/upload/images/20260911/20141615DM3ouT2E6U.png

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

走火入魔的模樣

然後我做了以下這些事情(深呼吸):

https://ithelp.ithome.com.tw/upload/images/20260911/201416155ng0uUpalS.png

一、把「條件」抽成一個家族

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

https://ithelp.ithome.com.tw/upload/images/20260911/201416157CnpQSbY9T.png

注意最後那個 AndDiaryEntryQuery,它是一個「裝著一堆條件的條件」,自己也實作了 DiaryEntryQuery。當下還覺得酷斃了!(這招其實有名字,叫 Composite Pattern,組合模式)。以後要加 OrDiaryEntryQueryNotDiaryEntryQuery 都超容易,對吧?對吧?

二、把整段挑資料的邏輯抽成一個 UseCase,還附贈一個 interface

過濾跟排序這件事,放在 ViewModel 裡好像有點「商業邏輯混在畫面狀態裡」的味道。抽出來!而且要抽就抽好,前面加一個 interface!

https://ithelp.ithome.com.tw/upload/images/20260911/20141615Mo1TwHkvaQ.png

https://ithelp.ithome.com.tw/upload/images/20260911/20141615rHeqfExOC1.png

三、加一個 Mapper

資料要離開 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 也要負責生一個出來。

https://ithelp.ithome.com.tw/upload/images/20260911/20141615PtrQq1MkGu.png

https://ithelp.ithome.com.tw/upload/images/20260911/20141615QD2GRoNNOO.png

https://ithelp.ithome.com.tw/upload/images/20260911/20141615STf0O19TYM.png

資料流

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

https://ithelp.ithome.com.tw/upload/images/20260911/20141615dr89xP3V6W.png
👆🏼 直接版

https://ithelp.ithome.com.tw/upload/images/20260911/20141615zTEZlEv4h9.png
👆🏼 反例版

但是測試全過欸

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

https://ithelp.ithome.com.tw/upload/images/20260911/20141615p5RtuMsk2f.png

而且測試的內容一個字都沒改,測試方法名稱一樣、Then 的驗證一樣,唯一的差別只有準備階段多注入一個東西:

// before
val viewModel = PhotoDiaryViewModel(application(), diaryStore)

// after
val viewModel = PhotoDiaryViewModel(application(), diaryStore, DisplayEntriesUseCase())

行為完全相同、測試完全相同、檔案多了四個、追蹤路徑長了五站。

這就帶出今天真正要說的事情:「改完之後測試還是過」不代表這次重構有價值。它只證明沒把東西改壞而已。

四個問題

要判斷一個抽象站不站得住腳,我習慣拿這四個問題去拷問它。

1. 有誰需要換掉它?

DisplayEntriesProvider 這個 interface,目前的實作者有幾個?一個。DisplayEntriesUseCase

那測試呢?測試也是塞 DisplayEntriesUseCase() 進去。也就是說,這個契約從頭到尾只有一個實作者,而且沒有第二個人想換掉它。這個 interface 是在防一件到現在都還沒發生的事。講難聽一點,超前部署

2. 它有自己獨立的「改變理由」嗎?

Day 19 講過單一職責原則(SRP),一個 class 應該只有一個改變的理由。那反過來問:TitleContainsQuery 會因為什麼理由改變?「搜尋規則變了」。那 MoodFilterQuery 呢?「篩選規則變了」。那 DisplayEntriesUseCase 呢?「顯示規則變了」。

⋯⋯這三個其實是同一件事。目前這三個檔案永遠會一起改,它們沒有各自獨立的改變理由,只是被切成三份而已。

3. 有哪個測試,是不抽出來就寫不出來的?

一個都沒有。七個測試都是呼叫 viewModel.updateSearchQuery()viewModel.updateMoodFilter() 這些公開行為,然後看 getDisplayEntries() 吐什麼。這些在昨天的直接版本就能寫,而且已經在跑了。

4. 多出來的追蹤成本,誰付?

以後有人(很可能就是三個月後的我自己)要查「為什麼搜尋『晨光』會搜到這則」,他要開幾個檔案?要不要先搞懂 AndDiaryEntryQuery 是什麼?DiaryEntryMapper 那一圈到底做了什麼、會不會偷改資料?——這些時間是真的要花下去的,而上面三題我一題都答不出來。

沒有人付錢,只有人付出 QQ。這就是 Overengineering(過度設計),中間多出來的那幾層,術語叫 indirection(間接層)

對照組:那 DiaryStore 為什麼可以?

回頭看 Day 18 抽出來的 DiaryStore,用同樣四個問題跑一次:

  1. 有誰需要換掉它? 有。App 裡是 LocalDiaryStorage 真的寫 JSON 到手機,測試裡是 FakeDiaryStore 用記憶體的 List。這是兩個真實存在、現在就在跑的實作者。
  2. 有獨立的改變理由嗎? 有。「日記存在哪裡」跟「日記長什麼樣」是兩件事,以後要存到 Google 雲端硬碟,ViewModel 一行都不用改。
  3. 有哪個測試是不抽就寫不出來的? 有。不抽的話,每跑一次測試就真的往手機裡寫一筆日記,還要手動清。
  4. 成本誰付? 上面三題都付了。

同樣是一個 interface,同樣是依賴注入,DiaryStore 講得出理由,DisplayEntriesProvider 講不出來。差別不在寫法,在講不講得出理由。

不要矯枉過正啦

看到這裡,可能有讀者已經準備好拿著刀去把公司專案裡所有的 interface 都砍掉了,請等一下好嗎,血氣方剛的。=. =

今天的結論不是「抽象不好」,也不是「檔案越少越好」。用檔案數量來評分,跟用設計模式數量來評分,是同一種病的兩面。

Day 19 那句話還是有效的:過早的最佳化,是萬惡之源。差別只在於,那天講的是「還沒有需求就先抽」,今天講的是「怎麼判斷需求到底來了沒」。答案是回去翻現有的程式碼跟測試:有沒有第二個實作者?有沒有獨立的改變理由?有沒有寫不出來的測試?

這件事跟 AI 有啥關係?

關係很大!

現在請 Agent 幫忙重構,它非常樂意給你 UseCase、Repository、Mapper 跟一整套 Clean Architecture,而且看起來都很專業、很像那麼一回事、跑起來也都對。今天這包程式碼要是沒有我在前面自首,它看起來跟「業界最佳實務(Best Practice)」根本沒兩樣。

模型不會知道你的專案現在有幾個實作者、有沒有真的要換儲存空間、三個月後是誰來維護。它只知道「這種程式碼在網路上長這樣」。所以判斷值不值得,還是要你自己來。

這時候可以這樣問:

「先不要改程式。針對你提議的每一層,告訴我現有的程式碼或測試裡,哪一段可以證明它需要存在?找不到就直說。」

分得出來,你就不會被一包漂漂亮亮的資料夾結構騙走三個月。ㄏ

TL;DR

  • Overengineering(過度設計):拿現在根本用不到的結構,去解現在的問題;今天的反例功能一樣、測試一樣,只是多了四個檔案和五站路。
  • indirection(間接層):資料從出發到目的地之間多繞的那幾層;每多一層,追資料流就多開一個檔案。
  • 抽象是有成本的:檔案、依賴、建構子參數、追蹤路徑都要有人付;沒有需求支付,它就是純虧損。
  • 四個拷問:有誰需要換掉它?它有獨立的改變理由嗎?有哪個測試不抽就寫不出來?多出來的追蹤成本誰付?四題都答不出來,就是還不該抽。
  • 測試全過 ≠ 重構有價值:七個測試一字不改 All Pass,只證明沒改壞行為,不證明新結構比較好。
  • UseCase / Mapper / Repository:常見的分層名稱;名稱本身不會讓設計變好,今天的 DiaryEntryMapper 輸入輸出都是 DiaryEntry,等於什麼都沒做。
  • Composite Pattern(組合模式):像 AndDiaryEntryQuery 這種「裝著一堆同類東西、自己也是同類」的寫法;很漂亮,但漂亮不等於現在需要。
  • 對照 DiaryStore:同樣是 interface + 依賴注入,它有兩個真實實作者、有獨立的改變理由、有寫不出來的測試,這筆錢花得值。
  • 差別在講不講得出理由:判斷一個抽象站不站得住腳,看的是現有程式碼和測試講不講得出理由,不是看它用了什麼模式。
  • AI 給的「最佳實務(Best Practice)」要查核:Agent 不知道你專案現在有幾個實作者,記得請它為每一層說明理由。

上一篇
[Day22] 搜尋及過濾
下一篇
[Day24] 再次整理日記的資料流
系列文
我的第一個手作りAndroid App!快樂學習物件導向程式設計!25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
AndyAWD
iT邦研究生 5 級 ‧ 2026-09-11 23:44:10

沒想到還講重構和抽象化,太專業了吧

我要留言

立即登入留言