實用的 Flutter 國際化指南

Loong_T發表於2019-02-20

作為一個 Android 開發者,Flutter 上來就讓我把各類字串寫在 widget 裡,其實我心裡是拒絕的。硬編碼是不可能硬編碼的。國際化又不會,就是隻能去看看文件,才能學點新姿勢這樣子。看了文件之後,覺得國際化這部分,還是有點麻煩的,我覺得有必要拎出來單獨寫寫。

個人希望能把應用的字串資源獨立出來,以方便管理。至於支援多語言這種,反而是順帶完成的結果。本文以實用優先,因為我認為這部分內容是每個應用都需要使用的。

切入點

首先簡單認識一下 Flutter 國際化相關的知識點。

新增 flutter_localizations 依賴,讓 Flutter 知道我們需要使用國際化相關的包。Flutter 自帶的 widget 中,也用到了一些字串資源,比如,showSearch() 方法開啟的搜尋欄提示。而這個包可以提供英文之外的,被 Flutter 內部預設使用的國際化字串資源。

dependencies:
flutter:
  sdk: flutter
flutter_localizations:
  sdk: flutter
複製程式碼

然後在建立 App 時,加入 LocalizationsDelegate,國際化的內容就由這些類來提供。GlobalMaterialLocalizations.delegate 提供了 Material 元件庫所使用的字串資源;GlobalWidgetsLocalizations.delegate 則定義了在當前的語言中,文字預設的排列方向。

之後我們定義了自己的國際化內容後,也需要加入到這個列表的頭部。

還要宣告要支援什麼語言,supportedLocales 這裡新增了英文和中文兩種。如果說使用者的語言不在這個列表內,則會預設使用列表第一項指定的語言。假如你對這個規則不滿意,可以使用 localeResolutionCallback 引數來自定義自己想要的規則。

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

class ThisApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      localizationsDelegates: [
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
      ],
      supportedLocales: [
        const Locale('en', 'US'),
        const Locale('zh', 'CN'),
      ],
      title: 'App Title',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: HomePage(),
    );
  }
}
複製程式碼

現在,我們將一些自帶的國際化資源加入到了應用中,Flutter 自身已經能夠使用它們了。但我們要怎麼使用它們呢?

通過 MaterialLocalizations.of(context) 獲取到 MaterialLocalizations 的例項,然後訪問裡面的字串。比如上面的 title 一行,可以替換為:

onGenerateTitle: (context) => MaterialLocalizations.of(context).closeButtonLabel,
複製程式碼

注意這裡將 title 替換成 onGenerateTitle 了,因為此時還在初始化 App 中,無法獲取到 context,更無法通過 context 獲取字串了。

自定義的國際化內容

現在來考慮怎麼將我們自己的國際化加入到其中。也就是,需要在 localizationsDelegates 中加入自己的 LocalizationsDelegate

檢視文件,LocalizationsDelegate 需要一個泛型引數。參考官方的文件,可知這裡指定的型別就是我們存放字串的類。在這裡,有兩種選擇:第一是基於 map 的,非常簡單的實現;第二個則是通過 Dart 語言中專門負責國際化的 intl 包來實現。接下來我們按次來看看。

基於 Map

class SimpleLocalizations {
  SimpleLocalizations(this.locale);

  final Locale locale;

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

  static Map<String, Map<String, String>> _localizedValues = {
    'en': {
      'app_name': 'App Name',
      'hello_world': 'Hello World',
    },
    'zh': {
      'app_name': '應用名',
      'hello_world': '你好世界',
    },
  };

  Map<String, String> get _stringMap {
    return _localizedValues[locale.languageCode];
  }

  String get helloWorld {
    return _stringMap['hello_world'];
  }

  String get appName {
    return _stringMap['app_name'];
  }
}
複製程式碼

從上面的程式碼可以看到,這種方法的原理非常簡單,就是將所有字串放進 map,然後通過應用的 Locale 來取出對應語言的字串。使用時,就是 SimpleLocalizations.of(context).helloWorld 這樣來引用字串。

其對應的 LocalizationsDelegate 如下:

class SimpleLocalizationsDelegate
    extends LocalizationsDelegate<SimpleLocalizations> {
  const SimpleLocalizationsDelegate();

  @override
  bool isSupported(Locale locale) => ['en', 'zh'].contains(locale.languageCode);

  @override
  Future<SimpleLocalizations> load(Locale locale) {
    return SynchronousFuture<SimpleLocalizations>(SimpleLocalizations(locale));
  }

  @override
  bool shouldReload(SimpleLocalizationsDelegate old) => false;
}
複製程式碼

只要將這個 SimpleLocalizationsDelegate 加入到上面的 delegates 列表中,國際化就算完成了。

回看一下整個流程,並不算複雜,需要經手部分的原理也非常簡單,只是一個 map 的使用。使用這個方法可以將整個應用的字串都集中到一起管理。但是,維護起來還是很不方便。

基於 intl

接下來看看基於 intl 包的實現方法是怎麼樣的。

第一步,新增依賴:

dependencies:
  intl: ^0.15.7

dev_dependencies:
  intl_translation: ^0.17.3
複製程式碼

通過檢視官方的例子,可以知道 Intl.message() 方法是我們管理字串的關鍵。於是去看相關的文件,會發現——嗯,沒有卵用(甚至沒解釋每個引數有什麼作用)。

接下來還是一樣新增一個跟 SimpleLocalizations 差不多類:

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

  String get appName {
    return Intl.message('App Name');
  }

  String get helloWorld {
    return Intl.message('Hello world');
  }
}
複製程式碼

從命令列中執行 flutter pub pub run intl_translation:extract_to_arb --output-dir=你想要的輸出目錄 IntlLocalizations所在檔案。這一操作將會在指定目錄裡生成一個名為 intl_messages.arb 的檔案,內容大致如下:

{
  "@@last_modified": "2019-02-17T15:57:00.554988",
  "App Name": "App Name",
  "@App Name": {
    "type": "text",
    "placeholders": {}
  },
  "Hello world": "Hello world",
  "@Hello world": {
    "type": "text",
    "placeholders": {}
  }
}
複製程式碼

將這個檔案複製一份,命名為 intl_en.arb,作為英文版本使用。接著再複製一份,命名為 intl_zh.arb 作為中文版本使用。將 intl_zh.arb 的內容修改為對應中文的內容:

{
  "@@last_modified": "2019-02-17T15:57:00.554988",
  "App Name": "應用名",
  "@App Name": {
    "type": "text",
    "placeholders": {}
  },
  "Hello world": "你好世界",
  "@Hello world": {
    "type": "text",
    "placeholders": {}
  }
}
複製程式碼

如果需要其他語言的版本,請自行新增並修改。

再來輸入一段長長的命令列:flutter pub pub run intl_translation:generate_from_arb --output-dir=輸出目錄 --no-use-deferred-loading IntlLocalizations所在檔案 所有arb檔案。這樣會生成幾個 messages_ 開頭的 dart 檔案。可以自行檢視一下里面的內容,我現在的 Dart 水平還比較菜,就先不分析其中的原理了。

其中名為 messages_all.dart 的檔案裡,生成了 initializeMessages(String localeName) 這個方法,將會在下面的步驟中使用到。

IntlLocalizations 中新增如下的方法:

static Future<IntlLocalizations> load(Locale locale) {
  final name =
    locale.countryCode.isEmpty ? locale.languageCode : locale.toString();
  final localeName = Intl.canonicalizedLocale(name);
  return initializeMessages(localeName).then((_) {
    Intl.defaultLocale = localeName;
    return IntlLocalizations();
  });
}
複製程式碼

IntlLocalizations 就準備完畢了。然後,開始實現 delegate,內容很簡單:

class IntlLocalizationsDelegate
    extends LocalizationsDelegate<IntlLocalizations> {
  const IntlLocalizationsDelegate();

  @override
  bool isSupported(Locale locale) => ['en', 'zh'].contains(locale.languageCode);

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

  @override
  bool shouldReload(IntlLocalizationsDelegate old) => false;
}
複製程式碼

之後就是正常使用流程了,這裡不贅訴。回想整個流程,真正國際化的內容在 arb 檔案中,對於集中管理字串來說,比使用 map 還是好一點。但是整個流程還是顯得異常麻煩,尤其是兩次長得過分的命令列,明顯應該由工具來改進。我相信 Flutter/Dart 團隊應該會在這一點上做出優化。

flutter_i18n

那麼,有沒有一款工具可以解救我們呢?您好,有的。

Android Studio(IDEA)上有一款名為 flutter_i18n 的外掛,可以幫助簡化這個過程。其原理是通過 arb 檔案來自動生成所需要的程式碼。

外掛的使用非常簡單,安裝後會出現一個新的按鈕。一旦你按下這個按鈕——boom——外掛就會根據 res/values 資料夾(Android 開發者覺得很親切)中的 arb 檔案,在 lib/generated 中生成 Dart 程式碼。

那麼我們的重心就放在了 arb 檔案上。Arb 檔案全稱是 Application Resource Bundle,是基於 JSON 的 balabala 接下去的我也不想接著說了,因為並不實用。還是來看下 Flutter 國際化中切實相關的部分。

雖然我們知道了 arb 檔案是類 JSON 格式,但我們還並不清楚檔案裡具體需要什麼樣的內容。這裡我們通過 Intl.message() 方法再重新認識一下。

String get appName {
  return Intl.message(
    'App Name',
    desc: 'Name for the application',
    name: 'IntlLocalizations_appName',
  );
}

String hello(String name) {
  return Intl.message(
    'Hello $name',
    name: 'IntlLocalizations_hello',
    desc: 'Say hello to someone',
    args: [name],
    locale: 'en',
    examples: const {'name': 'Someone'},
    meaning: 'What is this?',
    skip: false,
  );
}
複製程式碼

這裡有兩個更為詳細的實現,其中 hello 方法將全部的引數都賦值了,以方便觀察通過 intl_translation 包處理後的 arb 檔案會是什麼樣的。

不過這之前簡單介紹一下 Intl.message() 的部分引數。

  • name 引數必須與函式名一致,或者是類名_方法名這個形式——建議使用後者避免衝突;
  • args 就是重複一遍引數;
  • 如果方法沒有引數,那麼 nameargs 可以省略;
  • desc 引數就是描述這個字串的字串,必須是一個字串字面量;
  • examples 是引數的示例;
  • descexamples 在執行時不會被使用,但會被提取出來作為額外的資訊提供給翻譯人員作為參考;
  • skip 如果為 true,那麼這條記錄就不會被提取出來;
  • 其他的文件裡並沒有提。

然後我們再執行一下那個很長的命令列,將其處理成 arb 檔案看看:

{
  "@@last_modified": "2019-02-18T21:31:28.750455",
  "IntlLocalizations_appName": "App Name",
  "@IntlLocalizations_appName": {
    "description": "Name for the application",
    "type": "text",
    "placeholders": {}
  },
  "IntlLocalizations_hello": "Hello {name}",
  "@IntlLocalizations_hello": {
    "description": "Say hello to someone",
    "type": "text",
    "placeholders": {
      "name": {
        "example": "Someone"
      }
    }
  }
}
複製程式碼

首先,meaning 似乎沒有用處。其核心就是 "IntlLocalizations_appName": "App Name" 這樣的一條一條的記錄。以 @ 開頭的部分,並不會真正在程式中使用,而是給翻譯人員作為參考使用的。

這麼一來,我們接下來就可以在 res/values 資料夾中建立需要的 arb 檔案了。這個外掛還提供了快捷建立 arb 檔案的功能,只需要在 res/values 目錄右鍵選擇 New -> Arb File 就可以選擇這個 arb 檔案的 locale 了。

需要注意的是,在這個外掛中,如果字串內需要包含變數,使用的語法是 $var_name,而不是上面例子裡使用大括號的形式。

這裡我建立了兩個 arb 檔案:

// strings_en.arb
{
  "appName": "App Name",
  "hello": "Hello $name"
}
// strings_zh_CN.arb
{
  "appName": "應用名",
  "hello": "你好${name}"
}
複製程式碼

使用外掛生成程式碼後,將 delegate 加入到應用的列表中,使用時也只要直接利用 S 這個類名來引用就好:

MaterialApp(
    localizationsDelegates: [
        S.delegate,
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
    ],
    supportedLocales: [
        const Locale('en', 'US'),
        const Locale('zh', 'CN'),
    ],
    onGenerateTitle: (context) => S.of(context).appName,
    ...
複製程式碼

使用了這個外掛之後,國際化就算得上方便了。生成的程式碼也可以稍微看一眼,或許有你用得到的其他方法。

最後提醒一句,由於生成程式碼是由外掛完成的,所以依賴中的 intl_translation 可以刪掉了。

其他與總結

可能有的人會問,不使用 IDEA 的開發者,有沒有什麼更好的選擇呢?或許有。現在還有一個名為 rosetta 的庫,致力於解決 Flutter 國際化太過複雜的問題。我嘗試過,但並沒有跑通正常的流程,無法更多評價。有興趣的朋友可以試試看。

到此,這篇指南就結束了,希望能對一些人有幫助。

相關文章