大家好,歡迎來到第四天!在 Day 3,我們建立了精美的 UI 元件。今天,我們要來討論如何讓我們的 App 支援多國語言,為全球使用者提供在地化的體驗。
國際化(i18n)和在地化(l10n)是讓 App 支援多國語言的核心技術。本章將說明如何在 Flutter 專案中實作多語言支援,讓全球使用者都能享受在地化體驗。
國際化(i18n)和在地化(l10n)是多語言 App 開發的兩個核心概念:
設計和建立一個 App,讓它能夠適應各種語言和地區,而不需要對程式碼進行大幅修改。
重點包括:
將 App 調整為適合特定地區或語言的過程,不只是文字翻譯而已。
涵蓋範圍:
💡 白話來說: i18n 是「讓程式架構能支援多語言」,l10n 是「針對特定地區進行細節調整」。
市面上有很多多語言解決方案,為什麼我們要選擇 Flutter 官方的 l10n 呢?
方案 | 優點 | 缺點 | 適合情境 |
---|---|---|---|
Flutter 官方 l10n | 官方維護、型別安全、IDE 整合 | 學習門檻較高 | 長期專案、團隊合作 |
第三方套件 | 設定簡單、彈性高 | 維護風險、型別安全較弱 | 快速原型、小型專案 |
自行開發 | 完全掌控 | 開發維護成本高 | 特殊需求、資源充足 |
對於 Crew Up 這樣的長期專案來說,官方 l10n 是最好的選擇:
🎯 核心優點
🌏 中文專案友好
我們的專案採用官方的 Flutter l10n 解決方案,以中文為主要語言,同時支援國際化:
// l10n.yaml
arb-dir: lib/l10n # ARB 檔案存放資料夾
template-arb-file: app_en.arb # 英文預設檔案(符合國際化標準)
output-localization-file: app_localizations.dart # 產生統一的在地化類別檔案
output-class: S # 簡化類別名稱,使用 S.of(context)
nullable-getter: false # 確保所有翻譯都有值,避免 null 錯誤
required-resource-attributes: true # 強制要求每個翻譯項目都要有說明
untranslated-messages-file: l10n_errors.txt # 自動追蹤未翻譯項目
// lib/app.dart
MaterialApp(
// 國際化設定 - 依系統語言自動切換
localizationsDelegates: S.localizationsDelegates,
supportedLocales: S.supportedLocales,
// ...
)
提醒:預設不需要手動設定
locale
,Flutter 會自動依據系統語言從supportedLocales
(例如:en
、zh
)挑選最佳符合的語系。
如需在特定情境下「強制語言」(例如 Demo 或除錯),才建議暫時加入以下設定:
// 不建議做為預設,僅於特定情境使用
MaterialApp(
locale: const Locale('zh'), // 強制顯示中文
localizationsDelegates: S.localizationsDelegates,
supportedLocales: S.supportedLocales,
)
// pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
intl: ^0.20.2
目前專案支援以下語言:
💡 Flutter 語言機制:
Flutter 的 locale resolution 機制會按照優先順序尋找合適的語言資源:
zh → zh.arb → en.arb
這樣就能確保中文使用者都能獲得完整的在地化體驗!
1. 主要語言:中文(完整翻譯)
// lib/l10n/app_zh.arb
{
"@@locale": "zh",
"appTitle": "Crew Up",
"@appTitle": {
"description": "App 標題"
},
"login": "登入",
"@login": {
"description": "登入按鈕文字"
},
"googleLogin": "Google 登入",
"@googleLogin": {
"description": "Google 登入按鈕文字"
},
"createActivity": "建立活動",
"@createActivity": {
"description": "建立活動按鈕文字"
}
// ... 包含所有功能的完整翻譯
}
2. 預設語言:英文(國際化標準)
// lib/l10n/app_en.arb
{
"@@locale": "en",
"appTitle": "Crew Up",
"@appTitle": {
"description": "The title of the application"
},
"login": "Login",
"@login": {
"description": "Login button text"
}
// ... 包含所有功能的完整翻譯
}
1. 預留位置(Placeholders)
// lib/l10n/app_en.arb
{
"daysAgo": "{count} days ago",
"@daysAgo": {
"description": "Days ago time label",
"placeholders": {
"count": {
"type": "int"
}
}
}
}
2. 複數形式處理(可選進階功能)
// lib/l10n/app_en.arb
{
"reviewCount": "{count, plural, =0 {no reviews} =1 {1 review} other {{count} reviews}}",
"@reviewCount": {
"description": "Number of reviews with plural support",
"placeholders": {
"count": {
"type": "int",
"description": "The number of reviews"
}
}
}
}
💡 實務建議: 複數形式處理雖然功能強大,但對中文來說通常沒必要。我們更推薦使用 enum extension 的 displayName 方法來處理動態文字,這樣更簡潔也更有型別安全。
3. 格式化支援
// lib/l10n/app_en.arb
{
"activityDays": "{days} days",
"@activityDays": {
"description": "Activity duration in days",
"placeholders": {
"days": {
"type": "int",
"description": "Number of days"
}
}
}
}
// 在 Widget 中使用(S 是由 l10n.yaml 中 output-class: S 設定自動產生的在地化資源類別)
Text(S.of(context).appTitle)
// 有參數的文字
Text(S.of(context).daysAgo(5))
// 複數形式(可選進階功能,中文專案通常不需要)
Text(S.of(context).reviewCount(reviewCount))
// 推薦:使用 enum displayName 方法處理動態文字
Text(ActivityStatus.registering.displayName(context))
// 相對時間處理
String getRelativeTime(int hours) {
if (hours < 1) {
return S.of(context).justNow;
} else if (hours < 24) {
return S.of(context).hoursAgo(hours);
} else {
final days = hours ~/ 24;
return S.of(context).daysAgo(days);
}
}
// 使用 intl 套件進行格式化
String formatCurrency(double amount, BuildContext context) {
final formatter = NumberFormat.currency(
locale: Localizations.localeOf(context).toString(),
symbol: '\$',
);
return formatter.format(amount);
}
String formatDate(DateTime date, BuildContext context) {
final formatter = DateFormat.yMd(
Localizations.localeOf(context).toString(),
);
return formatter.format(date);
}
專案設定了未翻譯訊息追蹤:
// l10n.yaml
untranslated-messages-file: l10n_errors.txt
這個檔案會自動記錄:
// l10n.yaml
required-resource-attributes: true
確保每個翻譯項目都有合適的說明和上下文資訊。
想像一下,我們有一個活動狀態需要在多個地方顯示。如果我們在每個 Widget 的 build 方法裡都這樣寫:
// ❌ 問題:邏輯分散各處,難以維護
Widget build(BuildContext context) {
String statusText;
switch (activity.status) {
case ActivityStatus.registering:
statusText = S.of(context).statusRegistering;
break;
case ActivityStatus.inProgress:
statusText = S.of(context).statusInProgress;
break;
case ActivityStatus.completed:
statusText = S.of(context).statusCompleted;
break;
}
return Text(statusText);
}
很快就會發現:
為了解決這個問題,我們在 Crew Up 專案中使用 enum extension 搭配 displayName 方法,將國際化邏輯集中管理:
// lib/features/activity/domain/entities/activity_status.dart
/// 活動狀態
enum ActivityStatus {
registering,
inProgress,
completed,
}
/// 使用 extension 統一管理國際化文字
extension ActivityStatusExtension on ActivityStatus {
String displayName(BuildContext context) {
switch (this) {
case ActivityStatus.registering:
return S.of(context).statusRegistering;
case ActivityStatus.inProgress:
return S.of(context).statusInProgress;
case ActivityStatus.completed:
return S.of(context).statusCompleted;
}
}
}
現在,原本複雜的國際化邏輯變得如此簡潔:
// ✅ 解決方案:一行搞定!
Widget build(BuildContext context) {
return Text(activity.status.displayName(context));
}
// ✅ 條件顯示 - 型別安全
if (activity.status == ActivityStatus.registering) {
return Text(activity.status.displayName(context));
}
// ✅ 批次處理 - 自動完成支援
...ActivityStatus.values.map(
(status) => DropdownMenuItem(
value: status,
child: Text(status.displayName(context)),
)
).toList()
// ✅ 在任何地方都能一致使用
AppBar(title: Text(currentStep.displayName(context)))
🏆 這個作法的優勢
對比:傳統方法的痛點
💡 實際效果
在 Crew Up 專案中,採用這個作法後:
# 產生在地化檔案
flutter gen-l10n
# 檢查翻譯完整性
cat l10n_errors.txt
# 建置時包含在地化
flutter build apk --target-platform android-arm64
// .github/workflows/ci.yml
# GitHub Actions 範例
- name: Generate localizations
run: flutter gen-l10n
- name: Check translation completeness
run: |
if [ -s l10n_errors.txt ]; then
echo "Missing translations found:"
cat l10n_errors.txt
exit 1
fi
透過 Flutter 官方 l10n 方案,我們為 Crew Up 建立了完整的多語言架構:
🎯 核心成果
📋 實作重點
flutter_localizations
和 ARB 格式enum.displayName(context)
取代分散的 switch 邏輯明天,我們將深入探討導航架構設計,學習如何透過 go_router
建立路由系統,並結合本專案的實際需求進行應用。
期待與您在 Day 5 相見!