
文章同步發表在我的個人 Blog
你有沒有遇過這種狀況:太太在 LINE 傳了一個課程連結,你回了「好」,三天後兩個人都忘了那件事?
昨天一句話可以存進手機了,今天做 Day 10 排的 M2:交接卡分享出去、別的 App 分享進來,再把一句一筆改成一段話拆成多筆。225 個單元測試、三個 UI 測試流程都通過。
先講結果:功能都在,但第一版要解決的事改了。Day 9 定的是記錄孩子的近況,現在改成記下兩個人之間「還沒有結論的事」。分類、拆句、確認卡拿掉,分享進來的入口留下。這篇前半講為什麼改,後半講 Share Extension 和 App Group 怎麼接,也就是 Day 7 學習地圖的第四層「平台」。
今天早上有一堆事要記,我最後還是打開 LINE 用語音一口氣講完,沒有打開 Teammate。
原因不難找。App 現在是一句一筆,每一筆都要看一次確認卡。今天把它改成一段話拆成多筆、一個畫面確認,畫面長這樣:

確認從一筆一次變成一次全部,但每一筆還是有分類、內容、時間三個欄位要看,急的時候沒有人會這樣做。
回頭看我們家的狀況,全部長這樣:
缺口在這裡:LINE 記得下來,但它沒有「這件事還沒有結論」這個狀態,所以訊息會滑走。 這件事育兒 App 不做,LINE 也不做。
所以第一版改成:記下兩個人之間「還沒有結論的事」。只需要三個動作:
| 動作 | 怎麼做 |
|---|---|
| 丟進去 | 打一段話送出,整段存一筆;或從別的 App 分享進來 |
| 看還沒結論的 | 首頁就是這份清單,跨日累積 |
| 關掉 | 討論完按「有結論了」 |
| 拿掉 | 留下 |
|---|---|
| 三條關鍵字解析規則 | 輸入與寫入架構 |
| 確認卡 | 從別的 App 分享進來的入口 |
| 一段話拆成多筆與批次確認 | 資料模型與狀態欄位 |
| 四種分類 | 交接卡的產生與分享 |
拿掉的理由有兩個。第一,現在沒有 AI 在判斷任何東西,解析只有三條關鍵字規則決定分類,沒有模型改寫內容、沒有抽取劑量、沒有推算時間,確認卡實際保護的只是「分類可能猜錯」,而分類錯了點一下就能改。第二,首頁依分類分區,分類猜錯的後果不只是標籤錯,是那件事從首頁不見了。換手的時候需要看到的資訊,反而容易掉出畫面。

里程碑也重排了:同步從第四個移到第二個。這個 App 的前提是兩個人都在裡面丟東西、看同一份清單,沒有同步就不成立。
後面三節講留下來的那一塊:分享進來的入口是怎麼做的。
Share Extension 是 iOS 分享表單裡那一排 App 圖示背後的東西。使用者在 Safari 按分享、點你的 App,系統會啟動一個獨立的小程式,不是你的主 App。
用 Laravel 來對照,它比較像 queue worker:跟 web 是兩個程序,各自啟動、各自有記憶體上限,只是這裡兩邊不共用資料庫。
| 主 App | Share Extension | |
|---|---|---|
| 誰啟動 | 使用者點圖示 | 系統從分享表單啟動 |
| 沙盒 | 自己一份 | 自己一份 |
| 記憶體 | 一般 | 上限很低 |
| 生命週期 | 使用者關掉才結束 | 投遞完成就被系統結束 |
兩個程序各有各的沙盒,要交換資料,需要 Day 10 提到的 App Group:兩個 target 宣告同一個 group ID,系統就給一個共用的資料夾。這個專案的 group ID 是 group.com.gooliya.Teammate,寫在兩個 target 的 entitlements 裡。
架構文件寫的是「Extension 只投進收件匣,主 App 才正式匯入」,做出來就是這樣:

Extension 不碰 SwiftData,原因有兩個。第一,Extension 的記憶體上限很低,開一個資料庫不划算。第二,兩個程序同時寫同一個資料庫,衝突處理會很麻煩,而且那是同步才要解的題目。
每一筆分享是一個獨立的 JSON 檔,用原子寫入:先寫暫存檔再改名,主 App 只會看到寫完整的檔案。檔案格式只有四個欄位:
{"schemaVersion":1,"id":"<UUID>","receivedAt":"2026-09-21T14:13:20Z","text":"週六去公園嗎?"}
Extension 接受兩種東西:純文字,或 http(s) 開頭的網址。圖片、PDF、多個附件、超過 1000 字,都直接拒絕,不截斷、不挑第一個。
主 App 匯入的順序是:一次一個檔,存進資料庫成功之後立刻刪掉來源檔,再處理下一個。刪不掉就顯示錯誤、檔案留著,下次開 App 會再匯入一次,多一筆重複的資料。這裡刻意不做去重,那是同步才需要的機制。
分享進來的內容一律存成「待整理」,不套 Day 11 那套規則。原因是分享進來的是別人講的話。太太傳「這個課程好嗎?」,如果套用「問句結尾是請求」的規則,會變成一筆要對方回覆的請求,可是那是她在問我,語意剛好反過來。改了方向之後,這一類就是「還沒有結論的事」,明天改首頁的時候會直接沿用。
匯入之後,首頁最上面會顯示收進幾筆,內容進「待整理」:

關掉 App 再打開,「已匯入」的提示不再出現,兩筆都還在,沒有重複:

交接卡取裝置本地時區「今天」的紀錄,依時間新到舊排,套一個固定模板:
交接摘要(當天的紀錄,共 3 筆)
• 待整理|https://example.com/
• 待整理|週六去公園嗎?
• 待辦|買尿布(時間:明天下午)
「今天」用日曆加一天算上界,不是固定加 86400 秒,日光節約那天才不會多算或少算一小時。上限 50 筆,超過的整筆省略,結尾標「還有 N 筆未列出」,不會切到半句。
產生之後是一個可以編輯的文字框,補一句再按分享,走系統的分享表單,要傳給誰自己選。取消分享,補的那句還在;關掉編輯頁,草稿就丟掉,下次重新產生。

分享進來是入口,交接卡是出口,兩邊都留著。方向改了之後,卡片上列的內容會跟著換,產生和分享的機制不動。
單元測試全綠,模擬器上跑端到端卻出現匯入不進來。
第一個:URL.path() 會把空格編成 %20。 App Group 的收件匣路徑裡有 Application Support,程式用 path() 轉成字串去列目錄,找的是一個叫 Application%20Support 的資料夾,當然找不到,回傳空清單,也不報錯。Apple 文件的原型是:
func path(percentEncoded: Bool = true) -> String
預設值就是 true。單元測試用的暫存目錄剛好沒有空格,所以一直是綠的。修法是改成 path(percentEncoded: false),並且補一個用有空格目錄的測試。
第二個:啟動時匯入跑了兩輪,第二輪把第一輪的結果蓋掉。 開 App 時,畫面出現觸發一輪匯入,App 進入前景又觸發一輪。第一輪匯入 2 筆,第二輪什麼都沒有,畫面上「已匯入 2 筆待整理」就被全零的結果蓋掉,看起來像沒匯入。修法是匯入筆數改成本次啟動累計,錯誤數才用最新一輪。
規格寫了「同時最多一輪」,沒寫「連續兩輪的顯示」。這種東西只有實際跑起來才看得到。
對照學習地圖,這兩個 bug 一個在第一層、一個在第二層:path() 是 Foundation 的 API 預設值,去官方文件查原型就有答案;兩輪匯入是 SwiftUI 的生命週期,畫面出現和回到前景是兩個事件。
寫功能的部分 Claude Code 照規格一路做完,時間反而花在模擬器上。三個坑都不是程式的問題,都是環境的。
第一輪:UI 測試卡住十分鐘。 測試要驗證「拷貝」之後剪貼簿的內容,測試程式直接讀 UIPasteboard。iOS 會跳一個「允許貼上」的詢問框,測試就停在那裡等,當時沒設逾時,只能等到十分鐘上限被強制中斷。所以重新調整:改成在 App 的輸入框長按選「貼上」,比對貼出來的文字,跟交接卡一字不差;UI 測試一律加 300 秒逾時。

第二輪:iOS 26 的 Safari 找不到分享按鈕。 自動化測試想從 Safari 分享到 Teammate,ShareButton 找不到;改從右下角的「更多」選單進去,還是找不到。試了兩次就停,這一步改成人工操作,Extension 有沒有登錄改用 pluginkit 查:
xcrun simctl spawn <udid> pluginkit -m -i com.gooliya.Teammate.Share
# → com.gooliya.Teammate.Share(1.0)
第三輪:simctl 拿不到 App Group 的路徑。 要往收件匣放測試檔,得先問模擬器那個資料夾在哪。這個指令:
xcrun simctl get_app_container <udid> com.gooliya.Teammate group.com.gooliya.Teammate
在 Xcode 26.6 只會印出用法說明,沒有錯誤訊息。一開始以為是模擬器還沒開好,加了等待和重試,還是一樣。後來發現改用 groups 列出全部容器再取那一行就正常:
xcrun simctl get_app_container <udid> com.gooliya.Teammate groups
# → group.com.gooliya.Teammate /…/Shared/AppGroup/<UUID>
規格能寫的是功能,寫不出來的是模擬器和系統對話框。這三個坑都在第四層,Day 7 那張圖寫的症狀是「裝不到手機、權限被拒」,今天多了一種:指令不報錯,只是沒有結果。
| 項目 | 結果 |
|---|---|
| build(含 Share Extension 內嵌) | 成功 |
| 單元測試 | 225 個,全通過 |
| UI 測試 | 3 個流程(M1 回歸、交接卡、收件匣匯入),全通過 |
| 靜態掃描 | Extension 編入的檔案沒有 import SwiftData;沒有 print、Logger、URLSession |
| App Group | 兩個 target 的 entitlements 都有 group.com.gooliya.Teammate |
還有三項要人工做:Safari 分享、備忘錄分享、開著確認卡切出去分享再切回來。
Day 13 開始改:拿掉解析與確認卡、一段話送出直接存、首頁換成「還沒有結論」的清單、加上每晚的提醒。
官方文件
%20