Flutter 多國語系與在地化

(圖說:地方料理使用了各種當地風味的香料,影響了世代人的味覺與口感。Photo by Christian Burri on Unsplash)

打算將手上的 Flutter app 做國際化支援,加入多國語系訊息字串們。

最先參考的是 Flutter 官方文件的 Internationalizing Flutter apps 這份文件,在裡頭打轉了好一陣子(年紀大了),摸索出結果之後趕緊記錄下來,難保下次要用的時候還記不記得 :p



簡介

官方文件提了兩個方法:

  • 簡單法:維護與管理上相對簡單(但也相對沒有作業流程上的彈性),文件上有提供簡單版的範例程式。大致上將範例程式的 lib/main.dart 看過一次就開始開始依樣畫葫蘆對字串進行擴充。
  • 使用 intl 套件法:文件裡頭有著詳盡的描述,且可以將 ARB 語系檔案分離出來,方便分發翻譯作業,較有作業流程上的彈性作業流程上的彈性。本篇記錄採用此方法。

紀錄一下此時開發機上的 Flutter 版本資訊備查:

Flutter 1.12.13+hotfix.9 • channel stable • https://github.com/flutter/flutter.git
Framework • revision f139b11009 (4 weeks ago) • 2020-03-30 13:57:30 -0700
Engine • revision af51afceb8
Tools • Dart 2.7.2

步驟一: flutter_localizations 套件

(突然覺得有點像在寫食譜 XD)

將幾個會用到的相依套件加入 pubspec.yaml 檔案中,記得存擋後跑一下 pub get

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter
  # internationalization and localization facilities
  intl: ^0.16.0

dev_dependencies:
  # message extraction and code generation from translated messages for the Intl package.
  intl_translation: ^0.17.9

會用到的套件分別為:

  • flutter_localizations 函式庫使得 MaterialApp 可以載入 localizationsDelegates 與 supportedLocales。
  • intl 套件由 Dart team 維護,提供了 internationalization (國際化) 與 localization (在地化) 的基礎。除了協助處理語言訊息字串之外,也包含了日期數字等格式對應。
  • intl_translation 套件由 Dart team 維護,是個工具套組,可以從 Dart 抽取出訊息(以便分工翻譯) 以及 從翻譯後訊息產出對應的 Dart 程式碼 使其他 Dart 程式可以運用這些翻譯訊息字串。

步驟二: 打造 AppLocalizations class

這個步驟,我們假設將所有在地化相關的檔案集中放在 Flutter 專案的 ./lib/l10n/ 裡頭。

接著來編輯一個 ./lib/l10n/app_localizations.dart 檔案,存放兩個 classes:

  • AppLocalizations class: 裡頭的 get 系列,在下一步驟將用來抽取出訊息字串,產出 ARB (Application Resource Bundle) 檔案。AppLocalizations 可以自己視專案需求自由創造。
  • AppLocalizationsDelegate class: 延伸自 LocalizationsDelegate widget,是給整個 Flutter app 存取 AppLocalizations class 的窗口。AppLocalizations 將訊息封裝起來,AppLocalizationsDelegate 提供這些訊息給 Flutter app 使用。

這個 AppLocalizations 的命名原則可以依照專案需求來做調整,例如 MyLocalizationsProjectNameLocalizationsProject1Module2Localizations。如此一來,未來可以彈性移動至其他專案使用。在本文簡單起見,先使用 AppLocalizations 為名稱。

./lib/l10n/app_localizations.dart:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:intl/intl.dart';

import 'messages_all.dart';

class AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
  const AppLocalizationsDelegate();

  @override
  bool isSupported(Locale locale) {
    return ['en', 'zh'].contains(locale.languageCode);
  }

  @override
  Future<AppLocalizations> load(Locale locale) {
    return AppLocalizations.load(locale);
  }

  @override
  bool shouldReload(LocalizationsDelegate<AppLocalizations> old) {
    return false;
  }
}

class AppLocalizations {
  static Future<AppLocalizations> load(Locale locale) {
    final String name = locale.countryCode == null ? locale.languageCode : locale.toString();
    final String localeName = Intl.canonicalizedLocale(name);

    return initializeMessages(localeName).then((bool _) {
      Intl.defaultLocale = localeName;
      return new AppLocalizations();
    });
  }

  static AppLocalizations of(BuildContext context) {
    return Localizations.of<AppLocalizations>(context, AppLocalizations);
  }

  String get workout {
    return Intl.message('Workout');
  }

  String get unitMinute {
    return Intl.message('Minute', desc: 'The singular form of minute');
  }

  String get unitMinutes {
    return Intl.message('Minutes', name: 'unitMinutes', desc: 'The plural form of minute');
  }
}

此時引入的 messages_all.dart 尚未產生,將於下一步驟說明。

第 27 行的 AppLocalizations class 有四個主要部分要留意:

  • load
    • 用來載入指定 Locale 的訊息字串。
  • of
    • 方便接下來在 Flutter app 各處叫用訊息字串,例如這樣 AppLocalizations.of(context).workout
    • 參閱官方文件 Loading and retrieving localized values 段落有提到這個 Localizations.of()
  • get 系列
    • 列出可使用的語系資源(訊息字串)。
    • 回傳 Intl.message() 以供 Intl 套件工具找到並請 initializeMessages() 載入對應的翻譯訊息字串。
  • initializeMessages
    • 在第 6 行載入 messages_all.dart message catalog 訊息目錄檔案,包含有 initializeMessages() 初始化支援各個訊息字串檔案們。訊息目錄檔案由 intl 套件工具掃描指定的 class 原始碼中有包含 Intl.message() 者而產生。
    • 參閱官方文件 Defining a class for the app’s localized resources 段落結尾提到。

再來回頭說明,第 8 行的 AppLocalizationsDelegate class,這裡有三個重點:

  • load
    • 官方文件 Loading and retrieving localized values 段落有提到這個 load 方法,但是範例程式碼段落沒有提到使用 Intl 套件的 LocalizationsDelegate 該如何實作,只有在 An alternative class for the app’s localized resources 段落,提到如果使用簡單版(無使用 Intl 套件)的 LocalizationsDelegate 要改成回傳 SynchronousFuture
    • 所以在此就直接呼叫對應的 Localizations 的 load(),也就是 AppLocalizations.load()
  • isSupported
    • 檢查 Flutter app 層想要調用某個語系時,這個 LocalizationsDelegate 是否支援該語系。
    • 這裡可以列出有支援的語系 list。
  • shouldReload
    • 一般情況下回傳 false 即可。

我選擇將 AppLocalizationsDelegate class 置於 AppLocalizations class 的上方,是考量到 AppLocalizations class 預計會隨著 get 越多而增加長度,但 AppLocalizationsDelegate class 同時也需要顧及列出有支援的語系清單 isSupported()

第二步驟快速總結一下,Flutter app 會透過 AppLocalizationsDelegate 叫用 AppLocalizations 裡頭的 get 們。

接下來,來看怎麼處理這些 get 們。


步驟三: 抽取出 ARB 檔案

ARB 全名 Application Resource Bundle,架構上是個 JSON 檔案,內容定義可以參閱這份規格文件

我們先跑個指令,將 app_localizations.dart 內容(主要是 Intl.message())抽取出一個 .arb 檔案,再來細細觀察產出的 .arb 檔案內容。

1
2
flutter pub run intl_translation:extract_to_arb \ 
  --output-dir=lib/l10n lib/l10n/app_localizations.dart
  • 如果你的目錄結構或檔案名稱不同,記得修改對應。
  • 最後那個參數,記得對應到有包含 Intl.message() 的 Localizations class 檔案。不然待會兒下面就不用玩了。
  • 這個指令會在 lib/l10n 目錄中產生一個 intl_messages.arb 檔案。可以將這個檔案當作英文範本檔案。(建議選英文,你也可以選別的語言作為範本,視你的專案與場景而定,記得跟大家分享後續翻譯的成本有沒有差別。)
  • 我做了個 Makefile 方便執行指令(年紀大了,參數多的都記不得了),放在 Flutter 專案根目錄即可 make l10n-extract-to-arb

此時 l10n 目錄裡頭應該總共會有兩個檔案,且 app_localizations.dart 會出現錯誤說 messages_all.dart 不存在,無法載入。

接著,cp 複製 intl_messages.arb 成本範例的兩個語系檔案 intl_en.arbintl.zh.arb,你若有其他語系就繼續複製出對應的語系 intl_*.arb 檔案,然後即可送交翻譯(可能是翻譯公司、翻譯系統等等),以下假設完成翻譯後的結果:

./lib/l10n/intl_en.arb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
{
  "@@locale": "en",
  "@@last_modified": "2020-05-01T22:44:50.183241",
  "Workout": "Workout",
  "@Workout": {
    "type": "text",
    "placeholders": {}
  },
  "Minute": "Minute",
  "@Minute": {
    "description": "The singular form of minute",
    "type": "text",
    "placeholders": {}
  },
  "unitMinutes": "Minutes",
  "@unitMinutes": {
    "description": "The plural form of minute",
    "type": "text",
    "placeholders": {}
  }
}

./lib/l10n/intl_zh.arb:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
{
  "@@locale": "zh",
  "@@last_modified": "2020-05-01T22:44:50.183241",
  "Workout": "訓練",
  "@Workout": {
    "type": "text",
    "placeholders": {}
  },
  "Minute": "分鐘",
  "@Minute": {
    "description": "The singular form of minute",
    "type": "text",
    "placeholders": {}
  },
  "unitMinutes": "分鐘",
  "@unitMinutes": {
    "description": "The plural form of minute",
    "type": "text",
    "placeholders": {}
  }
}
  • 這兩個 .arb 檔案,我依照 ARB 規格,多加入了 @@locale 資料,在檔案第 2 行處,也可以使用 en_US, fr_CA 這類標註語言區域碼的形式。
  • .arb 檔案中的 "Minute""unitMinutes" 這兩個 key name 是想讓大家對照第二步驟 AppLocalizations 三種 get 的不同寫法,會造成的不同結果。我自己的話,Intl.messagename: 屬性會盡可能使用。

然後即可刪除這次的 intl_messages.arb 檔案。


步驟四: 從 ARB 檔案產出 Dart 檔案

接下來就是期盼已久的 messages_all.dart 檔案終於要生成了。

1
2
3
4
flutter pub run intl_translation:generate_from_arb \ 
  --output-dir=lib/l10n --no-use-deferred-loading \ 
  lib/l10n/app_localizations.dart \
  lib/l10n/intl_*.arb
  • 如果你的目錄結構或檔案名稱不同,記得修改對應。
  • 會產生 n+1 個檔案,n 是你的 intl_*.arb 檔案數量。1 是那個失散多時,誒不是,是期盼已久的 messages_all.dart
  • messages_all.dart 裡面有我們需要的 initializeMessages() 讓 AppLocalizations 得以載入翻譯後的訊息字串們,並讓 Intl.message() 完成對應查找。
  • 我做了個 Makefile 方便執行指令(年紀大了,參數多的都記不得了,是要講幾次 =.=),放在 Flutter 專案根目錄即可 make l10n-generate-from-arb

此時 app_localizations.dart 檔案的紅色提醒底線消失了 :)

苦力做完了(第一次的苦力做完了,就先不討論後續維護翻譯的辛苦),剩下讓 Flutter app 認得這個 LocalizationsDelegate。


步驟五: 載入 LocalizationsDelegate

此時回到官方文件的第一個段落 Setting up an internation­alized app: the flutter_localizations package (請不要責怪官方文件為什麼如此安排先後順序,寫文件、寫書其中一個挑戰點,即是要講的事情就是如此多,A 先講 B 後講、或是 B 先講 A 後講,都會有人看不懂或抱怨,所以對知識分享來說,先寫下來再來逐步修正比較實在。)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';

import 'package:my_project/l10n/app_localizations.dart';

import 'package:my_project/screens/screen_main.dart';

class App extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      //
      // Internationalizing Flutter apps:
      //   https://flutter.dev/docs/development/accessibility-and-localization/internationalization
      //
      // The elements of the localizationsDelegates list are factories that produce collections
      // of localized values.
      //
      localizationsDelegates: [
        AppLocalizationsDelegate(),
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
      ],
      //
      // https://api.flutter.dev/flutter/widgets/WidgetsApp/supportedLocales.html
      //
      supportedLocales: [
        const Locale('en'),
        const Locale('zh'),
      ],
      //
      // First screen to be loaded
      //
      home: ScreenMain(),
    );
  }
}

官方文件開宗明義有先說,這是以 MaterialApp 為主的文件說明,若是以更底層的 WidgetsApp 實作,也可以參考相同類別與邏輯。

這裡兩個重點

  • localizationsDelegates
    • 依照文件,建議載入順序是將我們自製的 AppLocalizationsDelegate() 置於 Global*Localizations.delegate 之前。
  • supportedLocales
    • 是 MaterialApp 這一層有支援的 Locale 清單。

完成後,就可以開心地拿 AppLocalizations.of(context).workout 去各個地方使用囉 :)

  • 記得 import 'package:my_project/l10n/app_localizations.dart';

結論

喜歡的話,請幫我按讚、分享、開啟小鈴鐺。誒不是 :p

快速總結:

  1. Flutter app 會透過 AppLocalizationsDelegate 叫用 AppLocalizations 裡頭的 get 們。
  2. flutter pub run intl_translation:extract_to_arb 產生對應語系 intl_*.arb 檔案們。
  3. flutter pub run intl_translation:generate_from_arb 產生期盼已久的 messages_all.dart
  4. messages_all.dart 裡面有我們需要的 initializeMessages() 讓 AppLocalizations 得以載入翻譯後的訊息字串們,並讓 Intl.message() 完成對應查找。
  5. MaterialApp 載入 LocalizationsDelegate 。
  6. 開心使用 AppLocalizations.of(context).workout,Flutter 多國語系不再是遙不可及的夢想!

結案 :)

最後,喜歡這篇文章的話,歡迎幫我按讚、分享、留言、開啟小鈴鐺 XDD

Loading comments…