
文章同步發表在我的個人 Blog
App 加上英文,只要把字串翻一遍就好嗎?
昨天 Day 26 處理簽章到期的重新安裝。今天把 App 的語系從繁體中文一種,加到繁體中文和英文兩種。
先講結果:手機語言是英文,App 就顯示英文;是繁體中文,或其他任何語言,就顯示繁體中文。沒有 App 內的切換選項,也沒有新增任何套件。290 個字串都有英文和說明,由測試檢查;日期、時間、通知文案、Firebase 登入的語言,全部讀同一個地方決定。./scripts/verify.sh 最後一行是 VERIFY: PASS。
String Catalog 是 Xcode 的字串翻譯檔,副檔名 .xcstrings,內容是 JSON。每個字串有一個 key,底下依語言放翻譯。這個專案有兩份:App 本體 288 個 key,分享功能的擴充 2 個 key,合計 290 個。
SwiftUI 的 Text("home.title") 會用 key 去查目前語言的翻譯,所以畫面程式碼不需要改。要補的是檔案裡每個 key 的英文,以及一段 comment。comment 是給翻譯的人看的說明,例如:
"home.header.date %@ %@": {
"comment": "Date under the home title; %1 is month-day, %2 is weekday",
"localizations": {
"zh-Hant": { "stringUnit": { "value": "%1$@ %2$@" } },
"en": { "stringUnit": { "value": "%2$@, %1$@" } }
}
}
這個 key 有兩個變數:月日和星期。中文是「10 月 11 日 週日」,英文要寫成「Sun, October 11」,順序相反。%1$@、%2$@ 指定第幾個變數,兩個語言就可以各自排順序。如果把順序寫在 Swift 程式碼裡,每加一個語言就要多一段 if;寫在 catalog 裡,新增語言只動 catalog。
所以組句子的規則是:Swift 只傳變數,句型留在 catalog。
同一個畫面,左邊是繁體中文,右邊是英文:

290 個字串用看的對不完。我先寫了 LocalizationCoverageTests,直接讀兩份 catalog 的 JSON,檢查四件事:
en。comment。%@ 對 %@、%lld 對 %lld,位置編號不算。測試一開始是紅的:290 個 key 都缺英文和 comment,列出 580 項問題,每一項都帶 catalog 路徑和 key 名稱,補完再跑到綠。
空測試也會顯示通過,所以補完之後我又故意弄壞三次:刪掉一個 en 值、把一個 %@ 改成 %lld、刪掉一個 comment。三次測試都指出正確的 key,然後把檔案還原。
另外有一個保護:key 只要有整數變數(數量),就必須有英文的單複數寫法(1 item、2 items)。目前沒有任何 key 帶數量,這個測試現在不會有任何東西可以檢查,是替之後加的字串先擋著。
字串翻完,畫面上還有一批不在 catalog 裡的文字:日期、時間、通知裡「明天 9:30」這類文案。這些是 Swift 程式碼用 DateFormatter 之類組出來的,語言要另外處理。
做法是建立一個 AppLocale,所有格式化的地方都讀它:
nonisolated enum AppLocale {
static var current: Locale { resolve(preferred: Bundle.main.preferredLocalizations) }
static func resolve(preferred: [String], twentyFourHour: Bool = false) -> Locale {
let suffix = twentyFourHour ? "@hours=h23" : ""
if let first = preferred.first, first == "en" || first.hasPrefix("en-") {
return Locale(identifier: "en_US" + suffix)
}
return Locale(identifier: "zh-Hant-TW" + suffix)
}
}
Bundle.main.preferredLocalizations 是 iOS 依手機語言設定,從 App 實際有的語系裡選出的結果,和 String Catalog 選字串用的是同一個來源。resolve 拿陣列當參數,是為了測試可以直接丟 ["en"]、["ja"] 進去,不用去改模擬器語言。
三個細節:
en_US,其他一律 zh-Hant-TW,所以日文手機看到的是繁體中文。@hours=h23 是 locale 的 24 小時制選項。繁體中文預設會顯示「上午9:30」,英文會是 9:30 AM,時間軸上的欄寬和對齊都不一樣。統一用 24 小時制,兩個語言都顯示 09:30。locale 參數,預設值是 AppLocale.current。畫面程式碼不用改,測試可以傳指定的 locale 進去比對中英文的輸出。這一輪改到的格式程式碼有首頁、行事曆、通知文案、邀請碼到期時間、分享時間,共五處。LocalizedFormatTests 有 6 個測試,對每一處比對英文輸出,並確認中文輸出和改動前一樣。
英文版加進來之後,有一個風險:UI 測試的斷言都是中文,例如 app.buttons["知道了"]。模擬器語言只要被改成英文,整批都會失敗。
最直接的做法是改每個啟動 App 的地方,加上 -AppleLanguages (zh-Hant)。UI 測試裡有 25 處 XCUIApplication(),沒有共用的啟動函式,要改 25 處。
我改在 project.yml,也就是 XcodeGen 的設定檔,測試的 scheme 設定裡一次指定語言和地區:
test:
config: Debug
language: zh-Hant
region: TW
xcodegen generate 之後,Xcode 的 scheme 就帶了這兩項,所有測試預設都是繁體中文。英文測試(EnglishLocalizationUITests)則在自己的啟動參數寫 -AppleLanguages (en) -AppleLocale en_US 覆寫。
這裡有一個要注意的地方:新增測試檔案之後,要重新執行 xcodegen generate。不重新產生,新檔案不在專案裡,測試會顯示執行 0 個、也不報錯。
英文通常比中文長。App 要支援 Dynamic Type,也就是使用者調大系統字級時,文字會跟著放大。系統字級最大一級是 ax5,這次檢查的是 ax3(docs/DESIGN.md 規定的檢查級別)。
EnglishLocalizationUITests 有 5 個測試:
UICTContentSizeCategoryAccessibilityXL(ax3)啟動,每一個 staticText 的範圍都在視窗之內。四個畫面的英文版在 light 和 ax3 各截了一張,放在 docs/screenshots/L10N-en-*.png,看過沒有被截斷的字,長句都換行。
設定頁在 ax3 字級的英文版,帳號說明那一段從三行變成七行,沒有截斷:

Firebase Auth 有一個 languageCode,控制它寄出的驗證信、它自己產生的登入頁用什麼語言。我在取得 Auth 實例的地方,每次都把它設成 AppLocale.firebaseLanguageCode(en 或 zh-TW)。
這只管 Firebase 這一側。Day 25 綁定 Google 時跳出的授權畫面,是 Google 自己的網頁,語言由手機或瀏覽器的設定決定,不是 Auth.languageCode 控制的。實際會顯示哪一種語言,要在實機上切換手機語言驗過才算數。
| 項目 | 結果 |
|---|---|
| 字串 | 290 個 key(App 288、分享擴充 2),都有英文和 comment |
| 漏翻檢查 | LocalizationCoverageTests,缺英文、缺 comment、變數不一致、英文含中文字都會失敗 |
| 日期與時間 | 五處格式讀 AppLocale,時間 24 小時制 |
| 既有測試 | 在 project.yml 固定繁體中文 |
| 英文 UI 測試 | 5 個通過,含 ax3 版面檢查 |
verify.sh |
VERIFY: PASS(TeammateKit 59 個測試、安全規則 99 個測試、App 的 xcodebuild 測試全過) |
| Google 授權畫面的語言 | 還沒驗證,要實機 |
| 手機桌面的 App 名稱 | 沒有處理,維持中文 |
| 英文翻譯的人工審閱 | 還沒有,是 Claude 翻的 |
Day 28 收集回饋並修一個問題:把拿到的回饋分類、決定先改哪一個、實際修掉一個並寫出驗證方式。
官方文件
@hours 之類的選項