iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0

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

昨天把「改心情」收回 ViewModel 裡頭,封裝得漂漂亮亮的,來驗收一下。隨便挑一張卡片,把心情從「震撼」改成「專注」,畫面馬上變了,用 Day 22 做的心情過濾去篩「專注」也找得到。看起來一切都很完美,差不多是該出事的時候了。

把 App 強制停止(Force stop)再打開⋯ 心情變回「震撼」了 =. =

照慣例,先不要叫 Agent 修,先來做 trace。

查證

改心情的資料流,昨天才走過一次:

https://ithelp.ithome.com.tw/upload/images/20260913/201416151StEdfKkCO.png

終點是 entries,也就是黏在 ViewModel 上的那份日記列表。再看一次 updateMood 本人:

fun updateMood(id: String, mood: Mood) {
    val updatedEntries = entries.map { diaryEntry ->
        if (diaryEntry.id == id) {
            diaryEntry.copy(mood = mood)
        } else {
            diaryEntry
        }
    }
    entries = updatedEntries  // 👈🏼 到這裡就結束了
}

用 id 找到那一篇、copy() 一份新心情的(還記得 Day 06 的 copy() 嗎)、塞回 entries。畫面看的就是 entries(經過 getDisplayEntries() 過濾排序之後),所以畫面當然是對的。

假設

那重開之後的 entries 是從哪裡來的?Day 12 做過:ViewModel 一建立,會透過 diaryStore.load()diaries.json 讀回來。所以重開後看到的,是裝置儲存空間裡的那一份,不是剛剛記憶體裡改好的那一份。

假設:改心情的時候,根本沒有寫回 diaries.json!?

再查證

找 ViewModel 裡頭其他會動到 entries 的方法來對照。一個是新增:

fun saveDiary(title: String, note: String) {
    // ... 省略
    entries = listOf(entryToSave) + entries
    diaryStore.save(entries)  // 👈🏼 有存
    // ... 省略
}

一個是刪除:

fun deleteDiary(id: String) {
    // ... 省略
    entries = updatedEntries
    if (wasDeleted) {
        diaryStore.save(entries)  // 👈🏼 也有存
    }
}

兩個都一樣:改完 entries,把整份列表丟給 diaryStore.save()。再回頭看 updateMood,改完 entries 就下班了ㄏ。

假設成立。

先不要修,先寫測試

修 bug 之前,先把「預期的行為」寫成測試。打開 PhotoDiaryViewModelTest,發現昨天已經有一條在測 updateMood 了,雖然我昨天都沒提到但原始碼的部分,我是很認真在更新 😭。

看一下名字:

@Test
fun updateMood_changesTargetByStableId_preservesOtherEntriesAndDoesNotSave() {
    // ... 省略
    // Then:確認 target 保留 ID 並更新心情,其他日記完整值與順序不變,且沒有保存
    assertEquals(0, diaryStore.saveCallCount)  // 👈🏼 =. =
}

DoesNotSave。「且沒有保存」。昨天寫測試的時候,還很認真的驗證了「改心情之後不會存檔」,等於親手幫這隻臭蟲(bug)寫了一張保證書,測試還是 Pass。小丑竟是我自己 🤡

這是測試的一個小陷阱:測試通過,只代表程式跟測試講的一樣,不代表測試講的是對的。 所以今天要改的是這條測試的「預期」,不是再多加一條跟它打架的。

改測試

新的預期有兩件事:

  1. 改完心情,save() 要被叫剛好一次
  2. 用同一個 FakeDiaryStore 再 new 一個 ViewModel,讀回來的那篇日記要是新心情

第 2 點是重點。測試裡沒辦法真的把 App 關掉重開,但可以模仿:重開說穿了就是「新的 ViewModel 去跟同一個 store 要資料」,那再 PhotoDiaryViewModel(application(), diaryStore) 一次就好了。

https://ithelp.ithome.com.tw/upload/images/20260913/20141615BL5S1w1pMr.png

不過還卡在一個地方:Day 18 做的 FakeDiaryStoreload() 永遠回傳建構時給的 initialEntries,就算 save() 過了它也不記得。假的也要假得夠像,所以補一個「目前的列表」讓 save() 更新、load() 回傳:

https://ithelp.ithome.com.tw/upload/images/20260913/20141615VR6gdiJFEq.png

跑!

https://ithelp.ithome.com.tw/upload/images/20260913/20141615irZyYuvufT.png

expected:<1> but was:<0>

Failed 了,而且錯誤在對的地方:save() 被叫了 0 次。這個「測試改好、程式還沒修」的狀態留在分支 day25/persistence-contract-fails,想看純測試的 diff 可以切過去。


配合分支:day25/fixed,Repo: 按我

修正

講了這麼多,修正只有一行:

fun updateMood(id: String, mood: Mood) {
    val updatedEntries = entries.map { diaryEntry ->
        // ... 省略
    }
    entries = updatedEntries
    diaryStore.save(entries)  // 👈🏼 加這行
}

跟新增、刪除長得一模一樣,改完記憶體,整份列表寫回去。再跑一次測試:

https://ithelp.ithome.com.tw/upload/images/20260913/20141615c4p7iEgUUY.png

ProTips (AI 時代激推)

想確認測試真的有在保護,故意把剛加的那行拿掉再跑一次,應該只有這條 Failed、其他 7 條照樣 Pass,確認完記得加回來。這招叫 Mutation Testing(變異測試):故意把程式弄壞,看測試會不會叫。

眼見為憑

測試裡的 FakeDiaryStore 終究是假的,真的 JSON 有沒有寫進去,用 Day 11 學過的 Device Explorer 看 diaries.json 最實在:

https://ithelp.ithome.com.tw/upload/images/20260913/20141615SYOiMFYJB0.png

再強制停止、重開:

再強調一次

畫面對了,只代表記憶體(RAM)裡的東西對了;Day 11 就說過,App 一關記憶體就清空,能活下來的只有寫進裝置儲存空間的那份。所以 ViewModel 改資料的時候,不能只顧好自己手上的 entries,還要把 Day 18 用依賴注入塞進來的 DiaryStore 用完。新增和刪除都乖乖用了,只有改心情忘了。

有讀者可能會問:為什麼不幫 DiaryStore 加一個 update(id, mood) 就可以只改一篇?

e.g.

interface DiaryStore {
    fun load(): List<DiaryEntry>?
    fun save(entries: List<DiaryEntry>)
    fun update(id: String, mood: Mood)   // 👈🏼 只更新那一篇
}

每次整份重新倒進去聽起來很笨。(現在只能整份 save)
但但但 Day 23 才走火入魔過,現成的 save() 已經可以解決今天的問題,就先不要超前部署,等哪天日記多到整份重寫會卡再說(Day 19 講過的提早最佳化 ^_<~* )。

補充彙整

Day 21 到今天這五天,其實都在做同一件事:讓「改資料」這件事站得住腳。

  • Day 21:心情從隨便打的字串,改成 enum class,只能選合法的值
  • Day 22:搜尋、過濾只是在 getDisplayEntries() 換一份「給畫面看的」列表,來源 entries 不動
  • Day 23:想幫未來超前部署,結果抽象生太多,走火入魔
  • Day 24:改資料收回 ViewModel 裡做,畫面只負責喊一聲
  • Day 25:改資料不只改記憶體,還要把注入進來的 DiaryStore 用完

TL;DR

  • 記憶體 vs 裝置儲存空間:畫面對了只代表記憶體對了,App 一關就清空;今天的 bug 就是改心情只改了記憶體,沒寫回 diaries.json
  • 找同類行為對照:trace 卡住時,找做類似事情的方法來比;saveDiarydeleteDiary 改完都有 diaryStore.save(),只有 updateMood 沒有。
  • diaryStore.save(entries):整份列表寫回內部儲存空間;今天的修正就是在 updateMood 補上這一行。
  • 測試 Pass 不等於正確:測試只證明程式跟測試講的一樣;Day 24 的測試 assert 了「沒有儲存」,等於幫臭蟲(bug)寫保證書,預期錯了就直接改它,別再加一條打架的。另外,現代 Agent 開發這點尤為重要,Agent 很擅長寫 無意義的測試
  • 用同一個 Fake 再 new 一個 ViewModel:測試裡模仿「重開 App」的方法;讀回來還是新心情,才算存到。
  • Fake 也要記得狀態FakeDiaryStoresave() 要把列表留給下一次 load(),不然重開的模仿不成立。
  • Mutation Testing(變異測試):故意拿掉修正再跑測試,只有那條變紅,才證明測試有在保護。
  • 眼見為憑:Fake 通過不代表真的 JSON 有寫,用 Device Explorer 打開 diaries.json 確認同一個 id 的 mood 真的變了。

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

1 則留言

0
chiaominchang222
iT邦新手 4 級 ‧ 2026-09-13 13:48:02

我要哥吉拉

我要留言

立即登入留言