iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
Vibe Coding

神隊友.swift:30 天 Vibe Coding 打造育兒 iOS App 系列 第 13 篇

[Day 13] 投票第一週結果:說出口還不夠,所以第一版重做

  • 分享至 

  • xImage
  •  

Day 13

文章同步發表在我的個人 Blog

投票第一週結果:說出口還不夠,所以第一版重做

你有沒有這種經驗:忙了一整天,另一半卻不知道你今天做了哪些事?

昨天把第一版改成記下「還沒有結論的事」,今天投票頁上線滿一週,回頭看大家投了什麼。

先講結果:15 個人投了 43 票,前兩名各有 10 個人選,是「做了好多事,另一半不知道」和「今天吃什麼,永遠臨時才有人想」。這兩件事都需要讓另一半知道,一個人用的 App 做不到,所以第一版重做:每一件事都記錄對方知道了沒有,兩支手機用 Firebase 同步。

中間講同步為什麼從 Day 10 規劃的 D1 換成 Firebase,後半講本機通知的兩件事,新舊兩個版本都用得到。

1. 一週收到 15 個人的票

Day 6 提到上線測試留下的資料會先清掉,實際上還留在正式資料庫裡,這裡更正。今天也先不刪,改成查詢時排除 9/19 凌晨 02:06 到 02:10 的 4 筆測試:

-- Skip the 4 smoke-test sessions (before 2026-09-19 02:11 +08:00)
WITH test AS (
  SELECT session_id FROM votes
  GROUP BY session_id HAVING MIN(created_at) < '2026-09-18 18:11'
)
SELECT o.content, COUNT(v.id) AS votes, COUNT(DISTINCT v.session_id) AS voters
FROM survey_options o
LEFT JOIN votes v ON v.option_id = o.id
  AND v.session_id NOT IN (SELECT session_id FROM test)
WHERE o.section = 'original'
GROUP BY o.id ORDER BY votes DESC;

時間寫成前一天的 18:11,是因為 D1 底層是 SQLite,欄位預設值 CURRENT_TIMESTAMP 存的是 UTC,台灣時間要減 8 小時。

選項 票數 投的人數
做了好多事,另一半不知道,會覺得分工不均 13 10
今天吃什麼、要煮什麼,永遠是臨時才有人想 11 10
週末排了什麼課程,忘記紀錄、提醒,到當天才發現 8 7
想休息,但沒先講,等到快爆炸才說 8 6
出門前才發現東西沒帶,因為沒講好誰要準備 3 3

一個人有三票,可以集中投在同一個選項,所以看「投的人數」比較準:15 個人裡,前兩名各有 10 個人選。含測試資料時是 19 人、55 票,前兩名一樣。

最後一票在 9/23 晚上 11 點多,之後四天沒有新的票。15 個人不算多,這份結果我當成方向的參考,不當成統計結論。

「大家補充」有兩張便利貼通過審核,是同一個人寫的:「爸爸可以不要看手機嗎?」和「爸爸廁所要待多久?」。9/24 變成可以投的選項之後,還沒有人投。兩張都在講爸爸在家的時間,我讀起來跟第一名是同一件事:對方的時間花在哪裡,另一半看不到。

2. 說出口之後,對方要收得到

前兩名都不是記不住。做了的事,沒讓對方知道;要決定的事,沒有人先提出來。

Day 9 把「你怎麼沒跟我說」拆成四個原因,其中三個是資訊留不住、傳不到、沒有結果。改成記下「還沒有結論的事」之後,第一版可以把事情留住,但它只在我的手機上,沒辦法確定另一半收到了。

所以重做的版本只守一條規則:對方沒看到,就等於還沒講。 不管是誰丟出去的事,另一半還沒看到之前,都會留在另一半首頁的最上面,直到按下「知道了」;丟出去的人這邊,會顯示「對方已知」或「對方還沒看到」。

投票的五個選項,重做的版本這樣接:

選項 重做的版本怎麼接
做了好多事,另一半不知道 默默做了:點一下就記錄,對方看得到
今天吃什麼,永遠臨時才有人想 週菜單:沒排的日子從輪替菜補上,當天下午提醒負責煮的人
週末排了什麼課程,忘記紀錄 行事曆:前一週、前一天晚上、出門前一小時各提醒一次
想休息,但沒先講 能量燈號:綠、黃、紅,另外可以預約休息的時段
出門前才發現東西沒帶 出門清單:每個項目指定由誰準備

Day 9 決定不做計分,這一版也一樣。「默默做了」只排成時間軸,不做「這週你做了幾件」這類統計。

3. 重做的版本改了三件事

第一版的資料結構、儲存和首頁,都是照一個人記錄來設計的。要改成兩個人用、每件事都追蹤對方知道了沒有,資料結構、同步和首頁都要換,改的範圍跟重寫差不多,所以另外開了一個新專案重做。第一版的程式碼留在原本的專案裡,之後不再加功能。

重做的版本有三件事跟前面幾篇寫的不一樣。

第一件,同步變成第一個里程碑。Day 12 寫同步從第四個里程碑移到第二個,這裡更正:重做的版本第一個里程碑就包含兩支手機同步。少了同步,就沒辦法知道對方看到了沒有。

第二件,同步不自己寫,改用 Firebase。Day 10 寫同步要自己做、用 Cloudflare D1 轉送,這裡更正,原因和做法寫在下一節。

第三件,最低版本改成 iOS 26。我們家的兩支 iPhone 都能升級。Day 9 擔心的是裝置端模型要 iPhone 15 Pro 以後才跑得動,這一版沒有用到模型。

4. 同步為什麼從 D1 換成 Firebase

第一版的資料只存在手機裡,用的是 Day 11 介紹的 SwiftData,同步還沒寫。照 Day 10 的規劃,同步要另外架 Cloudflare Workers 加 D1,跟投票頁同一套。重做的版本改用 Firebase 的 Firestore,兩邊對照是這樣:

第一版(Day 10 的規劃,還沒做) 重做的版本(已完成)
手機上的資料 SwiftData Firestore 的本機快取
什麼時候同步 打開 App 時,先送出本機新增的,再拉回對方的 隨時。寫入先存本機,有網路就由 SDK 送出;對方的變更由即時監聽推過來
離線 自己寫待送佇列 SDK 內建,Apple 平台預設開啟
衝突 版本號,伺服器比對後拒絕,跳出來讓人選 最後寫入者為準;按「知道了」另外由權限規則把關
身分 配對時交換的家庭 ID 匿名登入,也就是不用帳號密碼,每支手機第一次打開時自動取得一個身分,再加上家庭成員名單
雲端要寫的程式 Workers 的 API,每支都要自己檢查權限 不用寫 API,只有一份權限規則

用 Laravel 對照,D1 的做法像是自己寫一套 API:controller、驗證、queue 重試、樂觀鎖都自己來。Firestore 比較像手機上直接有一個會自己同步的資料層,斷線時先存本機,恢復連線後自己補送。

換的原因有三個。

第一,「對方知道了沒有」要很快回到我的手機。打開 App 才同步的話,太太按了「知道了」,要等她的手機送出、我的手機拉回來,兩邊都要打開過一次。Firestore 的即時監聽在 App 開著的時候會直接推過來。在 Emulator 上跑的兩支手機整合測試,要求 5 秒內收到,目前有通過;實機還沒測。

第二,衝突變多了。Day 10 分析的時候,只有「兩個人同時勾同一筆待辦」會衝突。加上「知道了」之後,太太會去改我建立的卡片,例如我剛改了時間,她同時對舊的時間按了「知道了」。這種情況交給 Firestore 的權限規則處理:按「知道了」只能改確認的欄位,其他內容跟伺服器上不一樣,整筆就會被拒絕,不會把她沒看過的新版本誤標成已知。規則寫在一份檔案裡,有 79 個測試。

第三,同步是自己寫最花時間、也最容易出錯的部分。Firestore 有 Emulator,可以在自己電腦上模擬整個 Firebase,所以 AI 能在同一個測試裡開兩個獨立的連線,當成兩支手機,自動驗證雙人同步、斷線再連線,不用每次拿兩支實機來試。

做法沿用 Day 10 那條原則:同步層抽成介面,換後端只換那一層。畫面的程式不直接碰 Firebase,只認一個叫 CardRepository 的 protocol,接 Firestore 的程式放在 Persistence 資料夾。這個 protocol 有兩個實作,一個存在記憶體裡、一個接 Firestore,兩個都要通過同一組測試。之後如果要換回 D1,要重寫的是 Persistence 這一層,再加上自己寫的同步。

寫入的地方要注意一件事。Firestore 的 Swift SDK 有 async 版本的寫入:

// Waits for the server to confirm; offline it never returns
try await collection.document(id).setData(fields)

官方文件對寫入完成的 block 是這樣寫的:

A block to execute once the document has been successfully written to the server. This block will not be called while the client is offline, though local changes will be visible immediately.

async 版本等的就是這個 block,所以斷線時不會返回,畫面會卡在儲存中,跟「離線也能記」相反。所以改用 completion 版本,寫進本機快取就繼續往下走。伺服器事後拒絕的話,SDK 會把本機資料還原成伺服器上的版本,App 另外記下這次拒絕。

// Returns after the local write; the SDK sends it when online
collection.document(id).setData(fields, merge: true) { error in
    if error != nil { rejections?.report(...) }  // rejected later by the server
}

另外也看過 Supabase,免費專案閒置一週會自動暫停,也沒有內建離線快取,所以沒有選。

要接受的狀況是資料放在 Google,不在自己的帳號裡,還多了一個要學的平台。家庭的配對一樣是邀請碼:6 位數、24 小時內有效、用過一次就失效,家庭滿兩個人之後就不能再加入。

5. 沒有推播,靠打開 App 和 LINE

免費 Apple ID 的限制沒有變,還是沒有推播。App 本身能保證的只有一件事:對方打開 App,一定看得到我丟過去的事。其他提醒都是盡力而為,真的急的事就交給 LINE:

方式 可靠程度
打開 App,首頁最上面就是「待你知道」 保證
行事曆、菜單這類已知時間的事,排本機通知 高,只要資料已經同步到這支手機
每天固定時間提醒一次,預設 20:30 高
背景 App 重新整理:系統有空時叫醒 App 拉新資料,有新的事就發本機通知 低,什麼時候執行由 iOS 決定
卡片上的「用 LINE 告訴他」:產生一段文字和一個直接打開這張卡片的連結 保證,靠的是 LINE 自己的推播

最後一個等於把 LINE 當成推播的管道。連結是 teammate:// 開頭的網址,LINE 會不會把它變成可以點的連結,要裝到手機之後實測才算數。

重做的版本:首頁

這是模擬器上的展示資料,還沒有裝到手機。上面是對方丟過來、我還沒按「知道了」的事,下面是今天和明天的時間軸,右下角的「+」可以直接丟一件事給對方。這個版本怎麼做出來,明天繼續聊。

6. 通知的內容,在排程那一刻就決定了

重做之前,今天先在第一版上把每晚九點的提醒做完。這一節和下一節是實作時確認的兩件事,重做的版本一樣照這樣做。

Day 10 提到本機通知是自己觸發自己,不經過 Apple 的伺服器,免費 Apple ID 也可以用。今天實際排了一則之後才發現:通知的內容,在排程那一刻就寫好了。

用 Laravel 對照:queued notification 延遲寄出時,toMail() 是 queue worker 執行那一刻才呼叫,信的內容是當下組的;iOS 的本機通知,內容在排程時就固定,21:00 系統只負責送出,不會叫醒 App 重算。

let content = UNMutableNotificationContent()
content.title = reminder.title   // "還沒結論"
content.body = reminder.body     // "你有 3 件還沒結論", fixed now
content.badge = NSNumber(value: reminder.badge)
let trigger = UNCalendarNotificationTrigger(
    dateMatching: reminder.time.components, repeats: true)  // hour 21, minute 0
let request = UNNotificationRequest(
    identifier: reminder.identifier, content: content, trigger: trigger)  // "nightly-pending-reminder"
try await UNUserNotificationCenter.current().add(request)

repeats: true 的意思,官方文件寫的是:

Specify true to reschedule the notification request each time the system delivers the notification.

要在送出前改內容,Apple 提供的是 Notification Service Extension,文件寫明它處理的是 remote notification,也就是從伺服器推過來的通知,本機通知用不到。

件數要準,只能靠 App 自己在清單改變的時候重排:記下、標記、按「有結論了」之後,都用同一個 identifier 再排一次。文件寫同一個 identifier 會直接取代:

If the identifier matches a pending request, the new request replaces the existing one.

Claude Code 一開始的寫法是先 remove 再 add。removePendingNotificationRequests(withIdentifiers:) 的文件寫它「executes asynchronously on a secondary thread」,於是改成排程時直接覆蓋,只有件數是 0 或提醒關掉的時候才 remove。這個情況我沒有實際重現過,是照文件調整的。

第一版只有一支手機會改清單,每次改動都重排,21:00 那則通知的件數就不會過期。重做的版本是兩支手機,太太新增的事要先同步到我的手機,才會跟著重排,於是改成每次同步完成都重新計算一次。太太新增一件事的當下,我的手機不會因此跳出通知,這也是第 5 節說 App 能保證的只有「打開就看得到」的原因。

第一版晚上九點的通知

通知會出現在鎖定畫面,所以內容只放件數,不寫事情本身。

7. 通知權限只能問一次

官方文件〈Asking permission to use notifications〉寫:

The first time your app makes this authorization request, the system prompts the person to grant or deny the request and records that response. Subsequent authorization requests don't prompt the person. Make the request in a context that helps people understand why your app needs authorization.

第一版不在一打開就問,而是第一次有「還沒結論」的事、需要排提醒的時候才問。

麻煩的是按了「不允許」的情況。系統不會再跳出詢問,Claude Code 一開始的寫法在這個情況直接略過不排程,提醒設定的開關卻還是開著,看不出來 21:00 其實不會響。所以設定頁加了一句說明和「打開設定」按鈕,直接開到系統設定裡這個 App 的通知頁:

if let url = URL(string: UIApplication.openNotificationSettingsURLString) {
    await UIApplication.shared.open(url)
}

通知權限關閉時的提醒設定

從設定切回來,App 回到前景時會重新檢查一次權限。已經允許的話,說明就會消失,並依照現在的件數重新排程。

這就是 Day 7 那張圖第四層寫的症狀「權限被拒」,差別是這次不會報錯,只是晚上九點沒有響。

重做的版本把詢問放在第一次啟動的引導裡:先一頁說明沒有推播、為什麼需要通知和背景 App 重新整理,按「開啟通知」才跳出系統詢問,也可以選「稍後再說」。設定頁一樣有按鈕,可以直接打開系統裡這個 App 的通知設定。

8. 目前的狀態

項目 第一版 重做的版本
build 成功 成功
自動化測試 單元測試 219 個、UI 測試 3 個流程,全通過 共用邏輯 47 個、App 96 個(含 UI 測試)、權限規則 79 個,全通過
裝到手機 還沒 還沒

重做的版本已完成:首次啟動建立家庭或輸入邀請碼加入、首頁、行事曆、卡片詳情和「用 LINE 告訴他」、本機提醒和背景 App 重新整理。Codex 另外做了三份審查,分別看程式、畫面截圖、挑戰設計假設,沒有必須立刻修的問題,列出來應該修的項目正在處理。

還沒做的部分:

  • 建立正式的 Firebase 專案,裝到兩支手機
  • 待討論議題(從 LINE 分享進來)、出門清單、默默做了、能量燈號、週菜單、共同筆記
  • Codex 審查列出來的問題

接下來的排程:

Day 做什麼
14 重做的版本怎麼做出來
15 建立 Firebase 專案,裝到兩支手機,太太開始用
16~20 待討論議題、出門清單、默默做了、能量燈號
21 中場回顧;七天到期,覆蓋安裝
22~25 週菜單、共同筆記,在家實際使用、修問題
26~30 整理使用紀錄、找家庭試用、回顧

停損點也跟著換:如果到 Day 21,太太還是要我提醒才會打開,就停止新增功能,只修問題。

明天

Day 14 講重做的版本怎麼做出來:一份規則檔給 Claude Code 和 Codex 共用、Codex 負責審查和挑戰設計、Claude Code 負責畫面和整合、每個功能都要通過同一個驗收指令才算完成。

參考資源

官方文件


上一篇
[Day 12] Share Extension 接進主 App:兩個程序怎麼共用收件匣,和第一版為什麼改方向
下一篇
[Day 14] 讓 AI 自己跑一整天:規則寫成文件、腳本通過才算、兩個 AI 互審
系列文
神隊友.swift:30 天 Vibe Coding 打造育兒 iOS App 共 15 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言