昨天的 iOS,是寫作 session 的 AI 讀過規格之後寫的。今天換一個問法:只給它一份契約和一段需求,它能從零寫出第三個前端嗎?寫出來的東西,又要怎麼驗?
這個專案原本不做 Android —— 非目標清單第 7 條寫得很清楚。今天把它改掉,正好也是 Day 25 那件事的延續:規格改了,看 AI 跟不跟得上。
先講結論:一個 prompt,它交出一千三百行、三個畫面的 Android 初版。編不起來的不是它的程式,是契約裡一行 OpenAPI 3.1 的寫法,讓產生器(OpenAPI Generator,build 時從契約產生 API client 程式碼的工具)產出了錯的程式碼;金額沒踩到 iOS 那個語系的坑,但整數元多補了 .00;座位狀態一開始就有給螢幕閱讀器的說明。但同樣「換環境、再弄壞一個地方」的驗證方式,還是在 Android 上抓到兩件事:小螢幕加大字級時,鍵盤把登入按鈕擠出畫面;UI 測試對著一張空清單,照樣通過。
最後我換了一組條件,從頭再做一次:讓它自己 build、自己跑測試,還給它看得到模擬器畫面的工具。它把被要求跑的檢查一路修到全綠才交卷,在這兩次的條件下,還比我貼錯誤給它便宜;但小螢幕那一關,它一樣沒過 —— 因為沒有人叫它去那裡跑。
改的範圍很窄:跟 iOS 一樣的三個畫面(登入、活動與座位圖、我的票券),唯讀,只跑模擬器,不上架 Google Play。目的只有一個 —— 看契約先行,能不能讓第三個前端「一個 prompt 就寫出初版」。
無菌室裡,跟專案有關的只有兩樣東西:openapi.yaml,和一段需求 —— 不給 repo、不給規格文件、不給測試,也不給 iOS 和 web 的原始碼。我要隔離的不是 Android,而是既有實作的上下文:
Kotlin + Jetpack Compose;API client 用 OpenAPI Generator 的 Gradle plugin 從
openapi.yaml產生,不要手寫 HTTP 呼叫;三個畫面;金額欄位一律是整數「分」,顯示成像NT$4,444.20這樣;寫一支 instrumented UI 測試,走一遍登入 → 活動 → 票券。
第一次生成時,跟這個系列的其他實驗一樣,我沒給它執行指令的權限。後端用本機的 wrangler dev 和示範資料,不碰線上。
「無菌室」到底乾不乾淨,我另外做了一次側錄(不是生成 Android 的那一次):把 Claude Code 的 API 請求導到本機的 proxy,在同樣的空目錄問一句話,看它實際送出什麼。請求裡沒有任何專案的 CLAUDE.md,也沒有記憶檔;有的是 Claude Code 自己的系統提示、我的全域 CLAUDE.md(產出留在本地、skill 清單),和 82 個工具的說明 —— 包括 Bash,和我帳號連著的幾個外部服務。權限擋的是呼叫,不會讓工具從清單上消失。整份請求裡,「金額」「Android」「Kotlin」都出現 0 次。
40 輪、$2.64、12 分半。它交回 34 個檔案,Kotlin 1,348 行:產生 client 的 api/ 模組、三個畫面、存 token 的地方、遇到 401 自動換 token(還加了鎖,避免兩個請求同時換、觸發後端的重放保護把人整個登出)、10 個單元測試、一支 UI 測試。
回覆的第一句是「完全沒有 build 過,也沒跑過任何測試」,接著列出「第一次 build 最可能要修的地方」。Gradle wrapper 它寫不出來 —— 那是一個二進位的 jar,請我自己跑 gradle wrapper。
第一次 build 就紅了,錯在產生出來的程式碼:
e: api/build/generated/.../Health200Response.kt:48:35
Argument type mismatch: actual type is 'kotlin.String', but 'kotlin.Boolean' was expected.
契約裡 /health 的回應寫的是 ok: { type: boolean, const: true } —— OpenAPI 3.1 的寫法,意思是「這個欄位永遠是 true」。產生器把它翻成一個只有一個值的 enum,型別宣告成 Boolean,值卻填了字串 "true"。
這是 Day 23 那件事的第二次。那天 iOS 的產生器被契約裡一個半形逗號弄壞,而 JS 的 YAML 解析器讀得很開心。同一份契約,每多一個讀者,就多一次被讀出問題的機會。 契約本身沒有錯,錯的是產生器;但前端編不起來,是一樣的。
我把錯誤訊息原樣貼回去,讓它修。第一輪它用產生器的 ignore 機制跳過那個檔案,沒生效,第二輪改用 Gradle 把兩個檔案排除在編譯之外 —— 那個 model,和引用它的 DefaultApi(/health 是契約裡唯一沒有 tag 的 endpoint,產生器把它單獨放一個檔)—— 第三次 build 就過了,它寫的 10 個單元測試全綠。
要講清楚:這是繞過,不是修好。這次用的是 AI 選的 OpenAPI Generator 7.10.0,這個 schema 在這一版會產出錯的程式碼(上游在 7.24.0 修了 Kotlin 的這個問題,我沒有用新版重跑);只是 App 剛好不呼叫 /health,而它又剛好獨占一個 API 檔,整個排除掉就編得起來。build 綠了,不代表契約的每一部分都被正確讀懂了。
後面修正的兩輪各花了 $3 多(合計 $6.53),比第一次寫整個專案還貴 —— 接續同一個對話,要帶著前面所有的內容。AI 沒辦法自己 build 的時候,「貼錯誤、等它改、再 build」這個迴圈的成本,是你在付。
昨天 iOS 的第一版用了 NumberFormatter,在德文語系的手機上把 NT$9,999.99 顯示成 NT$9.999.99。這次我拿同一份 14 組共用向量,在 JVM 單元測試裡換語系跑兩次,逐組比對期望值:
zh_TW 9/14 相同
de_DE 9/14 相同
✗ 100000: got NT$1,000.00 want NT$1,000
沒有跟著語系走。 它自己拆千分位,回覆裡還寫了「不吃裝置 locale(避免小數點變逗號)」。紅的 5 組全是整數元多了 .00 —— 我給的是一個有小數的例子,不是一條顯示規則;它就把例子當成規則,一律顯示兩位。跟昨天 iOS 無菌室的 B 組一模一樣:坑它會繞開,空白它會自己填。
它寫的 UI 測試對本機連跑 10 次,10 次都過,每次 2.5 秒。
然後照慣例弄壞一個地方:讓票券的 API 永遠回空清單。測試照樣通過。 原因寫在它的測試裡:
"orders_empty" -> {
// demo 帳號沒有訂單時,至少確認空狀態畫面正常
compose.onNodeWithTag("orders_empty").assertIsDisplayed()
return
}
這一次它不是不小心 —— 它在回覆裡事先講了:「demo 帳號如果沒有訂單,只會檢查空狀態畫面,不算失敗。」它照實說了,而那正是假綠燈的入口。iOS 那支只檢查了「沒有出現離線資料」;這支是把「什麼都沒有」也算成功。結果一樣:票券不見了,測試是綠的。
要驗的是「票券頁有沒有拿到預期的資料」,所以測試不能靠空清單過關:先準備一張已確認的票,再等它真的出現在列表上 —— 昨天 iOS 改的就是這一行。
我把這個結果交給無菌室的 AI,只准改測試:「訂單回空清單,測試照樣過,請讓它在這種情況下失敗。」它把提早結束改成直接判失敗,還要求列表上一定要找到那張訂單 —— 秋季音樂會、C3 到 C6、NT$4,444.20 —— 點進去再比總計。第一輪編不過(它 import 了一個不存在的函式),貼回錯誤之後第二輪就過了,兩輪合計不到 $1。正常版連跑 10 次都綠,空清單的版本紅在「票券列表是空的,但 demo 帳號應該有『秋季音樂會』的訂單」。告訴它是怎麼弄壞的,它補得起來;沒有人弄壞,它不會自己想到。
接著跑跟 iOS 同一套環境矩陣。Android 這邊的稽核用 Google 的 Accessibility Test Framework:五種環境,每種環境都用 UI 測試依序走過五個畫面(三個功能實際拆成登入、活動列表、座位圖、我的票券、票券明細),每到一個畫面就稽核一次、截一張圖 —— 走不下去,後面的畫面就稽核不到:
| 環境 | 稽核問題 |
|---|---|
| 預設 | 2 |
| 字級 2.0 | 1 |
| 深色 | 2 |
| 小螢幕 | 1 |
| 小螢幕 + 字級 2.0 | 走不完 |
座位圖從一開始就照顧到無障礙:字跟著字級放大;每一格除了顏色,還有「保/售/我」的字樣和圖例;給螢幕閱讀器的說明是「C3 我的」,不是只念座號:

(條件不同 —— iOS 那份是寫作 session 寫的,這份是無菌室 —— 所以不拿來比誰比較好。)
稽核抓到的是兩類問題,在不同環境重複出現,都在座位圖:左上角那格空白是一個沒有文字的無障礙節點;「已售」的白字配灰底,對比只有 2.68。
真正的問題,稽核沒抓到。小螢幕加大字級時,輸入完密碼、鍵盤升起,「登入」按鈕被擠到畫面外面,測試點不到,20 秒內進不了活動列表:

它其實想到了鍵盤 —— 表單有加 imePadding,鍵盤上的 ✓ 也能送出,使用者不至於完全卡住。但表單不能捲動,按鈕就看不到了。它自己寫的 UI 測試在這個環境下也是紅的,其他四種環境都綠。這個錯不是稽核找到的,是一支走流程的測試,在使用者的環境下走不完。
兩種裁判回答的是不同的問題:無障礙稽核看的是「這個元件本身有沒有問題」,走流程的 UI 測試看的是「使用者能不能真的完成這件事」。誰也替代不了誰。
再加一個常見的靜態裁判,Android Lint:10 筆警告,大多是相依套件有新版;鍵盤、對比、假綠燈,一筆都沒報。它看的是程式和設定,不是使用者。
一樣的流程:把結果交給無菌室的 AI,「稽核也會誤報,你覺得不是問題、或需要我決定的,不要改,列出來」。23 輪、$0.80、兩分半:空白格換成不會產生節點的佔位;「已售」的灰換深一階,對比約 6.2;登入表單改成可捲動,按鈕固定在底部、貼著鍵盤上方。

重跑:五種環境的稽核都歸零,UI 測試在五種環境都走得完。它也照例列了兩件沒改、要我決定的事 —— 登入按鈕的位置換了,外觀跟原本不同;螢幕閱讀器可能把座位的字樣讀兩次,它沒辦法驗證,所以沒動。
前面是第一個實驗:AI 只寫、不跑,跑的那一步由我接手。這一節是第二個實驗:把跑的那一步也交給它。
Android 跟 iOS 有一個不一樣的地方:整套工具都是指令。build 是 ./gradlew,裝 App、看 log 是 adb,Google 還有一個專門給 AI agent 用的 Android CLI,android screen capture 截圖、android layout 讀畫面的元件樹 —— AI 等於有了眼睛。
所以我以同一段需求為基礎,在新的無菌室從頭再生成一份 —— 不是把第一份交給它接手。這次另外給了它三樣東西:開放 ./gradlew、gradle、adb、android 這四種指令(其他指令一律不准);裝上 Android CLI 的 skill;prompt 裡多告訴它模擬器和本機後端都開著、示範帳號已經有一張訂單,並要求「都通過了再交卷」。
125 輪、$4.25、13 分鐘。它自己建 wrapper;撞到同一個產生器問題,自己把有問題的產生檔排除在編譯之外;UI 測試紅了兩次(一次是 App 在背景執行緒做畫面切換,一次是測試自己寫錯),它看 log、修掉;它還截圖看了四個畫面,發現明細頁的訂單編號跟標籤疊在一起,順手修了。最後從頭 clean 跑一次,它自己寫的 14 個單元測試全過(另一份專案,跟第一次的 10 個無關)、UI 測試通過,才交卷,附上它實際跑過的每一個指令。
| 寫完交卷(第一次) | 自己跑(第二次) | |
|---|---|---|
| 花費(到各自交卷) | $9.17(寫 $2.64 + 我貼錯誤、修到能 build 的兩輪 $6.53;之後補測試、修無障礙另計) | $4.25(寫到所有指定檢查通過) |
| 交卷時 | 沒 build 過 | build、單元測試、UI 測試都過 |
| 空清單突變(我事後弄壞) | 照樣過 | 紅(prompt 已說帳號有訂單) |
| 無障礙稽核(前四種環境;第五種走不完) | 座位圖兩類問題 | 只剩停用按鈕的灰字(誤報) |
| 小螢幕 + 大字級 | 走不完 | 一樣走不完 |

有一點要照實講:空清單那一列,prompt 有告訴它「帳號已經有一張訂單」,第一次沒有 —— 這個差別不能全算在「讓它自己跑」頭上。
讓它自己跑,最大的差別不是便宜(雖然這兩次的條件下也更便宜),而是迴圈裡不再需要我:它自己 build、看 log、改、重跑,不用等我把錯誤貼給它;看得到畫面,它就修掉了我沒要求的重疊。但它跑的,是它被放進去的那個環境:沒有人叫它換成小螢幕、放大字級,它就不會去 —— 我事後把它這份 App 放進那個環境重跑,鍵盤一樣蓋住登入,它自己寫的 UI 測試也是紅的。它在自己的環境裡跑到全綠,使用者的環境,還是要你給它。
這次最有意思的,不是 Android 寫得多快,而是契約替我省掉了多少決定。API 的型別、HTTP 呼叫、金額的單位、座位的四種狀態 —— 契約寫了的,AI 就照著接,一行都不用我交代;要做哪幾個畫面、怎麼驗,是需求那段話給的。
契約和需求沒寫清楚的,它一樣會自己決定:整數元要不要補 .00、空清單算不算成功、表單遇到小螢幕該不該能捲。第一個實驗抓到的問題,幾乎都長在空白裡,而且是三種不同的空白:規格沒寫,它自己決定;測試沒驗,錯了也會綠;環境沒給,它根本不會去看。唯一的例外是產生器 —— 它是把契約寫了的東西讀錯了。
這次刻意沒給 iOS 的程式碼。發文前我補跑了一次:同一段需求,多放一份 repo 裡現行的 iOS 原始碼(含 UI 測試),說「可以參考」。44 輪、$2.53,跟第一次差不多;產生器的坑一樣撞上 —— 那是契約和產生器的事,iOS 幫不上。不一樣的是空白:金額照 iOS 的規則,整數元不補 .00,兩種語系各 14 組全對;它讀到 iOS 測試註解裡「畫面全空也會綠」,UI 測試改成等票券真的出現,空清單直接紅。但座位格子也跟著 iOS 只用顏色分狀態,第一次它自己加的「保/售/我」不見了。參考實作會替你填空白:做得好的、沒做好的,都可能一起抄過去。 要給它參考,先把那一份修好。

所以契約先行,不是把所有事情寫進規格,而是先把跨前端一定要一致的東西鎖住;剩下的空白,要靠驗證去找。今天用了很多工具,它們各自回答不同的問題:
| 裁判 | 回答的問題 | 這次抓到 |
|---|---|---|
| 編譯器、Lint | 程式和設定本身有沒有問題 | 產生器產出的程式碼型別不符;Lint 一個真問題都沒報 |
| 無障礙稽核 | 元件本身有沒有問題 | 空白節點、「已售」格對比不足 |
| 換環境跑流程 | 使用者能不能真的走完 | 小螢幕大字級點不到登入 |
| 故意弄壞 | 測試壞掉時會不會紅 | 空清單照樣綠 |
| iOS(昨天) | Android(今天) | |
|---|---|---|
| 誰寫的 | 寫作 session(讀過規格) | 無菌室(只有契約和需求) |
| 契約讓產生器出錯 | 半形逗號(Day 23) | 3.1 的 const: true |
| 金額踩到語系的坑 | 第一版踩了;無菌室 6 次沒踩 | 沒踩 |
| 規則沒寫的空白 | 無菌室 B 組自己決定兩位小數 | 一樣 |
| 座位狀態只靠顏色 | 是(修完才補上) | 一開始就有字樣與說明 |
| 環境矩陣抓到的 | 座位字固定 9pt、對比 | 小螢幕大字級點不到登入、對比 |
| UI 測試假綠燈 | 只檢查沒有「離線資料」 | 把空清單算成功 |
第一個實驗裡,兩邊踩到的坑不一樣,但抓到它們的方法一樣:換到使用者的環境去跑,再弄壞一個地方,看測試會不會紅。
契約先行的好處,在第三個前端最明顯:只給一份 openapi.yaml 和一段需求,AI 一個 prompt 就寫出 Android 的初版,型別和 HTTP 呼叫一行都不用手寫。語系、螢幕閱讀器這些它也想到了,比我預期的多。再給它一份 iOS 當參考,契約沒寫的空白會照那一份填 —— 做得好的跟著好,沒做好的也可能跟著抄。
但每一關都要在真的環境裡跑過才算數。第一次,它只寫不跑:編不起來的是產生器讀錯了契約,走不完的是小螢幕加大字級,照樣綠的是一張空清單,跑的那一步全由我接手。第二次,我把執行權和畫面交給它,它自己 build、跑測試、看畫面,把被要求的檢查修到全綠,那個迴圈也便宜了。但小螢幕加大字級仍然沒被抓到 —— 那個環境沒有交給它。AI 可以自己跑驗證,但不會替你決定要驗哪一個環境。
這一篇留下的心法:
契約先行,多一個前端,一個 prompt 就能寫出初版;但「編得起來、走得完、測得到」,每一關都要在真的環境裡跑過才算數。AI 可以自己跑驗證,但驗哪一個環境、什麼算失敗,要你給它。
明天:web 畫面還沒有人驗過。我讓 AI 去走查,它說五個檢查點全過 —— 而畫面上的金額錯了十倍。
50b45c8):github.com/n913239/event-signup-lab/tree/50b45c8/devlog/raw/exp-09-android
48f4a20):github.com/n913239/event-signup-lab/tree/48f4a20/android
2751396):github.com/n913239/event-signup-lab/tree/2751396/devlog/raw/exp-10-android-agent
bc0f3d9):github.com/n913239/event-signup-lab/tree/bc0f3d9/devlog/raw/exp-11-android-ref
const: true 產成字串的 PR(7.24.0):github.com/OpenAPITools/openapi-generator/pull/24022