iT邦幫忙

2026 iThome 鐵人賽

DAY 27
2

配合分支:day27/start,Repo: 按我

直接進入主題,昨天新增了兩種輸出格式:純文字、Markdown。但從 DiaryExporter 來看,未免也過於複雜了。是想像未來新增更多的格式,底下在組成輸出文字後,在 when 標籤就會越來越多。而且,如果未來越來越複雜,裡面的判斷應該會變得很嚇人。

今天來處理這個 Formater 未來可能暴增的問題,解方?interface。
就不在 DiaryExporter 裡頭做組出最終結果字串。用 interface 讓實作的人自行實作即可

以目前的狀況來說,需要有兩個 Formatter:

  1. 處理「純文字」
  2. 處理「Markdown」

兩者實作同一套契約,透過之前學過的里氏替代法則(Liskov Substitution Principle),把 interface 傳入 DiaryExporter 讓它執行契約的方法就好(他不需要知道後面怎麼做的,只要有回傳 String 回來就可以交差了事)。

先來處理這個契約

interface DiaryFormatter {
	// 👇🏼 是的 interface 也可以要求要有「變數」
    val displayName: String  // 👈🏼 提供這個 formatter 的名字
    fun format(entries: List<DiaryEntry>): String  // 👈🏼 本章重點
}

Okay!接下來需要兩份 Formatter,分別處理上述提到的兩個格式。

純文字

class PlainTextDiaryFormatter : DiaryFormatter {
    override val displayName: String = "純文字"

    override fun format(entries: List<DiaryEntry>): String {
        return entries.joinToString(separator = "\n\n") { diaryEntry ->
            "${diaryEntry.title}\n${diaryEntry.note}"
        }
    }
}

Markdown

class MarkdownDiaryFormatter : DiaryFormatter {
    override val displayName: String = "Markdown"

    override fun format(entries: List<DiaryEntry>): String {
        return entries.joinToString(separator = "\n\n") { diaryEntry ->
			// 👇🏼 多了一個井字號 ㄏ
            "# ${diaryEntry.title}\n${diaryEntry.note}"
        }
    }
}

如此一來,透過 class 的 type 就可以判斷這是哪一種格式了!(再不行,displayName 也可以判斷)。也就是說 DiaryExportFormat 這個 enum class 可以退場移除了,已不再需要。

調整 DiaryExporter

程式碼變得超級簡單:

//                     👇🏼 丟 interface 即可
fun export(formatter: DiaryFormatter): String {
	val loadedEntries = diaryStore.load()
	if (loadedEntries == null || loadedEntries.isEmpty()) {
		return "目前沒有已儲存的日記"
	}

	return formatter.format(loadedEntries) // 👈🏼 format 由個自實作
}

從程式碼來看,enum class 已經不使用,外部從 UI 選擇改選「Formatter」。先調整 ViewModel 糨糊層,可以先將可用的 Formatter 格式注進去。

https://ithelp.ithome.com.tw/upload/images/20260915/20141615eapxe57aGI.png

接著 ViewModel 工廠、MainActivity 需要調整。

https://ithelp.ithome.com.tw/upload/images/20260915/20141615fXdp18OmRL.png

MainActivity 建立 ViewModel 之處,跟著更新。

private val photoDiaryViewModel: PhotoDiaryViewModel by viewModels {
        val diaryStore = LocalDiaryStorage(applicationContext)
        val diaryExporter = DiaryExporter(diaryStore)
		// 👇🏼 塞入各類的 Formatter
        val diaryFormatters: List<DiaryFormatter> = listOf(
            PlainTextDiaryFormatter(),
            MarkdownDiaryFormatter()
        )
		// 🤗 注入工廠
        PhotoDiaryViewModelFactory(diaryStore, diaryExporter, diaryFormatters)
    }

這樣資料層就完成了,最後來調整 UI:

UI 調整

該改的地方就集中在 PhotoDiaryApp.kt 一個檔案,資料流和 Day 22 做心情過濾時一模一樣:ViewModel 給狀態,ComposeView 拿到後畫出來,使用者點了什麼再用 callback function 丟回去。差別只在這次傳來傳去的東西,從 enum 變成了 DiaryFormatter

先從最外層的 PhotoDiaryApp 開始。昨天只傳「選了哪個格式」,今天要多傳一份「有哪些可以選」:

// before
selectedExportFormat = viewModel.selectedExportFormat,
onExportFormatChange = { format -> viewModel.updateExportFormat(format) },

// after
availableFormatters = viewModel.availableFormatters,  // 👈🏼 多了這個
selectedFormatter = viewModel.selectedFormatter,
onFormatterChange = { formatter -> viewModel.updateFormatter(formatter) },

DiaryList 的參數跟著改名,這邊只是把水管接上,沒什麼新東西。

https://ithelp.ithome.com.tw/upload/images/20260915/20141615lKVmGrHaJb.png

重點在下拉選單。昨天的 ExportFormatDropdown 選項是直接去問 enum 的 DiaryExportFormat.entries,enum 都被砍了,當然要換人問:

@Composable
private fun FormatterDropdown(
    availableFormatters: List<DiaryFormatter>,  // 👈🏼 選項改由外面給
    selectedFormatter: DiaryFormatter,
    onFormatterChange: (DiaryFormatter) -> Unit
) {
    var expanded by remember { mutableStateOf(false) }

    Box {
        Button(
            onClick = { expanded = true }
        ) {
            // 👇🏼 interface 有規定要給 displayName,這裡直接拿來用
            Text(text = "預覽格式:${selectedFormatter.displayName}")
        }
        DropdownMenu(
            expanded = expanded,
            onDismissRequest = { expanded = false }
        ) {
            // 👇🏼 以前是 DiaryExportFormat.entries,現在 List 裡有幾個就列幾個
            for (formatterOption in availableFormatters) {
                DropdownMenuItem(
                    text = { Text(text = formatterOption.displayName) },
                    onClick = {
                        expanded = false
                        onFormatterChange(formatterOption)
                    }
                )
            }
        }
    }
}

有沒有發現,這個下拉選單從頭到尾不知道「純文字」和「Markdown」的存在?它只知道手上有一個 List,裡頭每一個都保證有 displayName 可以顯示,點下去就把那一個丟回去,交差。這也是為什麼一開始 interface 硬要多塞一個 val displayName,不然 UI 這邊就得自己想辦法生名字,大概會長成 when (formatter) { is PlainTextDiaryFormatter -> "純文字" ... } 這樣,不就又回到最初的起點嗎 =. =

最後是預覽對話框的標題,昨天拿的是 enum 的 displayName,今天換成 Formatter 的:

@Composable
private fun DiaryPreviewDialog(
    previewText: String,
    formatter: DiaryFormatter,  // 👈🏼 從 DiaryExportFormat 改成 DiaryFormatter
    onClose: () -> Unit
) {
    AlertDialog(
        onDismissRequest = { onClose() },
        title = {
            Text(text = "已儲存日記預覽(${formatter.displayName})")
        },
        // ... 省略
    )
}

PhotoDiaryApp 呼叫它的地方,把 viewModel.selectedFormatter 傳進去就完成了。跑起來跟昨天長得一模一樣,選單一樣兩個選項,預覽出來的文字也一字不差:

P.S:測試裡頭建構 ViewModel 的地方,也要跟著多塞一個 listOf(PlainTextDiaryFormatter(), MarkdownDiaryFormatter()),和 Day 26 一樣請各位自行調整。(直接去 Repo 複製也行!)

目前為止只是把東西搬家而已,還沒證明這樣搬到底有什麼好處。真正的考驗是:加第三種格式的時候,要動幾個檔案?


配合分支:day27/formatters-separated,Repo: 按我

貪婪の me 決定要再加一個「摘要」Formatter,只萃取各個照片日記的標題。經過本次的超魔改之後變得超容易,只需要新增一個實作 DiaryFormatter 的 class,把想要輸出的格式實作於 format(),一切就做完啦!94 這麽簡單!(ViewModel/UI 層已經完全透過 DiaryFormatter 提供的資訊進行調整過了)

class SummaryDiaryFormatter : DiaryFormatter {
    override val displayName: String = "摘要"

    override fun format(entries: List<DiaryEntry>): String {
        return entries.joinToString(separator = "\n") { diaryEntry ->
            diaryEntry.title
        }
    }
}

剩下臨門一腳就是將這個 Formatter 注入到 ViewModel 之中,ViewModel 由工廠而來,而工廠的建造地址在 MainActivity

val diaryFormatters: List<DiaryFormatter> = listOf(
	PlainTextDiaryFormatter(),
	MarkdownDiaryFormatter(),
	SummaryDiaryFormatter()   // 👈🏼 加入即可
)
PhotoDiaryViewModelFactory(diaryStore, diaryExporter, diaryFormatters)

補充

開放封閉原則(Open-Closed Principle, OCP)

今天做的事情其實有個名字,叫做開放封閉原則。經典的說法是:對擴充開放,對修改封閉(open for extension, closed for modification)。乍聽之下很玄,翻成白話就是:需求來了,用「新增」的方式應付,不用「改舊程式碼」。

用今天的例子對照一下就很清楚。先回想昨天的做法,如果要加「摘要」格式需要:

  1. 打開 DiaryExportFormat enum 加一個 SUMMARY
  2. 打開 DiaryExporterwhen 多加一個分支

也就是每加一種格式,就要把 DiaryExporter 拆開來動調整。今天調整後呢?總共只動兩個檔案:

  • SummaryDiaryFormatter.kt —— 新增的
  • MainActivity.kt —— listOf 多一行

DiaryExporterDiaryFormatter、原本的兩個 Formatter、ViewModel、工廠、PhotoDiaryApp全部沒改!這就是「對修改封閉」。

而「對擴充開放」就是那個 SummaryDiaryFormatter,想要新格式?寫一個新 class 實作 DiaryFormatter 就好。

給新手專區

聽到「對修改封閉」,很多人第一個反應是:所以程式寫好就不用再改了?錯!這個職場是不會放過我們的。今天 MainActivity 那個 listOf 還是多了一行。而且今天擋得住的只有「又要加一種格式」這件事而已,哪天 DiaryEntry 多了一個資料要印出來,三個 Formatter 照樣全部要改。

那不就跟 Day 19 說的「預先做的最佳化是萬惡之源」打架了嗎?並沒有!
Day 19 是說不要看到幾個 if 就去大改;今天則是 when 已經有兩個分支,第三個都排隊排到門口了,這時候抽 interface 是講得出理由的。反過來說,如果照片日記這輩子就只會有純文字一種輸出,那今天這一整篇都是超前部署,關掉 Android Studio 下班比較實在。

TL;DR

  • interface 裡的 val:契約也可以要求「變數」,像 val displayName,讓 UI 保證拿得到名字,不必自己寫 when 生出來。
  • 里氏替代法則(LSP):把 interface 傳進 DiaryExporter,它只管呼叫 format() 拿回 String 交差,不用知道背後是哪個實作。
  • enum 退場:有了不同 class 的 type(加上 displayName)就能分辨格式,DiaryExportFormat 這個 enum class 可以刪掉。
  • when 暴增是訊號:格式一多、when 分支會長得很嚇人,這時候抽 interface 才講得出理由,而不是看到幾個 if 就大改!切記無三不成禮。
  • 開放封閉原則(OCP):對擴充開放、對修改封閉——加「摘要」格式只動兩個檔案:新增 SummaryDiaryFormatterlistOf 多一行,其餘全沒改。
  • 超前部署的分寸:跟 Day 19「預先做的最佳化是萬惡之源」不打架——是第三種格式都排到門口才抽 interface;若一輩子只有純文字一種輸出,今天整篇就是超前部署。

上一篇
[Day26] 將日記文字做輸出
下一篇
[Day28] 想要更多安全感?加測試
系列文
我的第一個手作りAndroid App!快樂學習物件導向程式設計!28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中
1
chiaominchang222
iT邦新手 4 級 ‧ 2026-09-15 22:05:24

說發就發

發!

0
饅頭
iT邦新手 5 級 ‧ 2026-09-15 22:14:05

剩下最後幾天囉!!

老闆!我要退出!

0
AndyAWD
iT邦研究生 5 級 ‧ 2026-09-15 23:49:53

說來就來,我這不就來了

這梗... 還有人知道!?

我要留言

立即登入留言