Flutter高階(二)——國際化與換膚

mnlin發表於2019-07-11

Flutter釋出到現在有一段時間了,目前為止,很多公司都還沒有接受Flutter進行開發,這原因是多方面的,畢竟在沒有足夠的“探索者”前,貿然使用遇到麻煩時,解決起來太過麻煩。

現在市面上感覺做的比較好的一款產品,除了官方demo:Gallery,再就是第一款Flutter開發的Github客戶端,詳細資訊請移步:gitme

參照著Flutter中文網以及,大牛正在編寫中的書籍:Flutter實戰,大致梳理了一下Flutter開發流程,不過由於架構的不同,之前iosAndroid最主要的還是操作dom,而Flutter則與RN類似的採用的響應式操作,這就導致在遷移開發平臺時,會不自主想將以前的經驗進行搬移時遇到麻煩。

android、ios以及web端,開發框架流程等都已經很純熟和完善了,而Flutter就目前的生態來說,還是有些“薄弱”,具體的開發還是需要進行系統的學習,這裡只是簡單給出一種方式來完成前端很常見的國際化與換膚功能

完整程式碼移步github-demo:flutter_skin_locale

效果圖如下:

Flutter高階(二)——國際化與換膚

一、基本思路解析

因為Flutter出身同為Androidgoogle,所以這裡暫時以Android實現方式進行比對;

1、安卓實現

國際化

安卓中國際化操作比較簡單,因為系統已經提供了這方面的解決方案,我們只要將對應的資原始檔放入不同的res資源目錄下面就可以了,系統會根據當前的locale值查詢對應的 res 資源目錄,然後根據資源id查詢資源名稱,最後根據資源名稱查詢到具體檔案。這個流程對於應用層開發人員來說是無感知的,所有要做的只是配置...然後打包。

換膚

安卓中原生是不支援換膚的,其實在安卓誕生時候也沒有這方面的需求,後來大廠商為了某些銷售活動,或者為了更好適應夜間模式及個性化,才有了這方面需求。就目前來看使用最多的是換膚框架,比如很多star的Android-skin-support;即便有框架的支援,在涉及大量自定義控制元件的情況下,仍需要做很多的適配工作。

2、Flutter實現

字串國際化

很有意思的是,在 Android 中只需要依照配置就可以完成的國際化功能,在Flutter中很難行得通,因為Flutter中沒有了Android的 res 系統,所有需要操作的圖片,圖示顏色值,都只能通過檔案或者程式碼硬性插入,這一點很不舒服,雖然官方給了flutter_localizations庫,但使用起來就知道,真的相當麻煩,我們不妨拋開Flutter部分平臺的機制,看自己搭輪子是否可以實現換膚功能;

在真正開始之前,還是得先了解一點Flutter中入口的邏輯,否則肯定找不到頭緒:

return MaterialApp(
  title: 'Flutter Title',
  routes: ...,
  localizationsDelegates: [
    S.delegate,
    GlobalMaterialLocalizations.delegate, //Material 元件庫所使用的字串
    GlobalWidgetsLocalizations.delegate, // 在當前的語言中,文字預設的排列方向
  ],
  supportedLocales: S.delegate.supportedLocales,
  localeResolutionCallback: S.delegate.resolution(fallback: const Locale('en', '')), 
  locale: mapLocales[SupportLocale.values[gCurrentSupportLocale]],
);
複製程式碼

Flutter 入口程式一般都是這個樣子,針對涉及國際化的每個欄位,簡單的說明一下:

欄位 含義
localizationsDelegates 國際化代理類,這個只需知道是國際化字串資源類的集合即可,一般我們會將自定義的國際化字串物件在這裡宣告;具體功能可以檢視[LocalizationsDelegate]類
supportedLocales 系統支援的語言環境,比如中文簡體,中文繁體等等,注意的是,locale要同時賦值語言和國家,以英文為例:Locale("en", "")
localeResolutionCallback 如果當前手機設定的語言環境或者說宿主app設定的語言環境不在 supportedLocales 中,那麼需要預設一個locale值,不預設也可以,系統會預設取支援列表supportedLocales中第一個值
locale 自己設定一個當前語言locale,如果不設定或者設定為null,就取宿主app當前的語言環境(等價於設定語言環境為:"跟隨系統")

我們大致只需要知道上面所說的部分即可,假設現在需要實現一個語言切換的功能,需要包含以下幾種型別:

  1. 跟隨系統
  2. 簡體中文
  3. 繁體臺灣
  4. 繁體香港
  5. 英文

對於跟隨系統來說,只要將MaterialApplocale欄位置為null,其他四種情況分別對應不同的 locale即可;至於MaterialApp中另外幾個欄位,也只需要根據支援列表一一填入;最重要的部分是國際化資源代理類:S的建立與更新。

雖說Flutter不支援,但只要程式設計師夠懶,總會有適合的工具出現的,這裡給出一個最簡單的用於國際化的外掛:Flutter i18n

有了這個外掛開發起來會方便的多,在安裝此外掛後,專案中會自動生成${project}/res/values/*.arb以及${project}/lib/generated/i18n.dart檔案;*.arb代表多個檔案(工具只會幫忙生成strings_en.arb),類似於安卓中多個目錄下針對字串的配置,這個可以自行新增或者刪除*.arb檔案,多新增一個正確的配置檔案,就相當於多一種語言支援,新增*.arb檔案後編輯器會自己處理剩下的邏輯,或者點選工具欄頂部的這個圖示:

Flutter高階(二)——國際化與換膚

Flutter i18n已經整合了快捷鍵來提取字串,這個和android中的這個功能使用方法相同:

Flutter高階(二)——國際化與換膚

Flutter高階(二)——國際化與換膚

繼續之前工具自動生成的兩部分檔案說,*.arb檔案類似下面的結構:

Flutter高階(二)——國際化與換膚

注意:strings_en.arb是預設的arb檔案,其他arb檔案需要根據預設的arb檔案生成對應的字串。

i18n.dart是國際化的核心程式碼,大致結構如下:

Flutter高階(二)——國際化與換膚

可以看到,裡面包含了不同國家地區(自己配置支援的國際語言,和*.arb相對應),同樣的,已經替我們生成了MaterialApp中所需的幾乎所有配置(具體使用配置可參照demo)。

最後使用的話也很簡單,在程式碼中需要字串的地方替換為這種:

S.of(context).label_soft_setting
複製程式碼

S類是i18n.dart自動幫助我們生成的,類似於一個代理類,根據不同的語言環境代理$zh_HK、$zh_TW、$en、$zh_CN這些具體實現類,達到國際化的目的

經過上面的總結,我們來看Flutter i18n到底完成了什麼:

  1. 自動幫助生成 strings_en.arb 預設字串模版
  2. 提供快捷方式,替換檔案出現的字串到*.arb檔案中
  3. 根據*.arb自動生成i18n.dart檔案,包含支援語言列表,國際化代理類等
  4. i18n.dart 提供在執行時提取不同國家語言字串的功能方法

整體來看,該工具沒有依賴任何庫,也就是說相對於官方提供的方法,Flutter i18n不需要對pubspec.yaml做出修改。

基本上,如果專案中沒有太過複雜的要求,只提供這種字串國際化足夠,但有些情況下, 針對不同的語言環境,圖片也需要動態進行更替,關於更換圖片的邏輯,放到文章下面介紹換膚功能的時候再考慮。

純顏色換膚

對比其他平臺換膚,我覺得Flutter換膚最為簡單(這裡換膚是指應用內換膚,不支援從網際網路下載皮膚包換膚),因為系統預設提供了Theme,不得不說,在Flutter中處處可見Android開發的影子,Theme表示主題,表示應用整體的風格,theme中可定義各種型別用途的背景顏色,文字顏色,高亮;甚至於可以修改文字的字型大小,字型庫,因此通過theme還可以實現應用內更改字型大小的功能,不過這篇文章先不考慮這個問題,在換膚後功能完成後,要實現應用內換膚是很容易的事情。

對於theme,可以擷取一部分檢視大致情況:

Flutter高階(二)——國際化與換膚

如上所見,對於分割線,主題色,按鈕,高亮等,可以分別定義不同的顏色值,然後我們可以在MaterialApp中設定不同的theme(跟國際化配置在相同的地方):

return MaterialApp(
  title: 'Flutter Mudule',
  theme: themes[gCurrentThemeIndex],
  routes: ...,
  localizationsDelegates: [
    S.delegate,
    GlobalMaterialLocalizations.delegate, //Material 元件庫所使用的字串
    GlobalWidgetsLocalizations.delegate, // 在當前的語言中,文字預設的排列方向
  ],
  supportedLocales: S.delegate.supportedLocales,
  localeResolutionCallback: S.delegate.resolution(fallback: const Locale('en', '')), // 不存對應locale時,預設取值英文
  locale: mapLocales[SupportLocale.values[gCurrentSupportLocale]],
);
複製程式碼

其中theme欄位即為當前應用或者說介面採用的主題,如果我們可以對其進行更改,就相當於對app進行換膚(這裡的換膚只是指顏色換膚,真正換膚可能還需要涉及圖片更換,情況與國際化類似,下面會提到這種解決方式)。

想要在程式碼中使用某個顏色時(有些顏色值為自定義,因此係統無法動態感知需要使用哪個樣式),可以使用如下方式:

Theme.of(context).textTheme.display1.color
複製程式碼

圖片換膚

到此為止,簡單的顏色換膚和國際化處理思路已經清晰,遵循上面的邏輯,基本可以完成大部分的需求,不過就如上面所提,如果涉及圖片部分,Flutter框架就無法直接處理了,事實上,flutter 如果需要獲取一個圖片,是需要知道具體路徑的,類似這樣:

Image.asset(
    "assets/images/icon_test.png",
    width: 45,
    height: 45,
),
複製程式碼

相比於android讀取圖片,Flutter這種方式麻煩的多,並且沒有任何的智慧提示;但也因為是這種呼叫方式,給了我們很大的自定義圖片讀取的空間,比如這樣:

Flutter高階(二)——國際化與換膚

定義同級兩個資料夾,裡面分別放置不同主題樣式,然後根據當前選中的主題,取圖片時,選擇不同的路徑,類似這樣:

/// 獲取圖片路徑(中轉,用於多環境等情況) [PlatformAssetBundle] 類檢視資源獲取邏輯
///
/// [useDefault] 是否使用預設的主題資源(當多theme使用相同image時,會有這種情況)
/// [picFormat] 圖片格式,預設為png,
String dispatcherPictureByName(String picName, {bool useDefault = false, String picFormat = "png"}) {
  RegExp filter = RegExp("^[^.]+\.(png)|(jpg)|(jpeg)|(gif)|(webp)|(bmp)|(wbmp)\$", caseSensitive: false, multiLine: false);

  // 新增字尾
  picName = filter.hasMatch(picName) ? picName : "$picName.$picFormat";

  // 取系統主題顏色
  String pathName = "assets/images-$gCurrentThemeIndex/$picName";

  // 返回需要的路徑
  return useDefault ? "assets/images-1/$picName" : pathName;
}
複製程式碼

其中$gCurrentThemeIndex表示當前主題下標序號,然後在程式碼中這樣使用:

Image.asset(
    dispatcherPictureByName("icon_test",useDefault: true),
    width: 45,
    height: 45,
),
複製程式碼

現在應該明白了,如果是想讓國際化時也取值不同的圖片,只要類似這樣定義不同的檔案包,然後將圖片放入即可,這個取值規則是自定義的,根據實際情況可以做出修改。

二、模組依賴項

上面給出了要實現國際化與換膚基本思路,但其中還有很多細節需要思考,比如:

  1. 如何定義主題包?
  2. 字串獲取時使用S.of(context).***,如果需要在沒有context的地方獲取字串值,該如何處理?
  3. 當想要修改主題和語言環境時,怎樣通知所有介面進行重新整理?
  4. 國際化與換膚是要保持狀態,如何在切換成功後記錄儲存?如果是以module的形式混入原生應用,該如何保證原生與Flutter層保持一致?

當然,最後一條可以可以不用管,那個屬於混合開發的範疇,真正使用的時候再考慮跨平臺混入bridge;對於記錄儲存,使用第三方的庫即可:shared_preferences

針對上面的問題,分別進行討論:

1、如何定義主題包

主題包的定義可以使用最簡單的方式,新建一個檔案app_theme_config.dart,將所有預定義的主題放在一起:

List<ThemeData> themes = [
  ThemeData(...),
  ThemeData(...),
  ThemeData(...),
  ThemeData(...),
]
複製程式碼

裡面每一個ThemeData表示一套風格,或者說是一個皮膚;

再新建一個檔案app_status_holder.dart,儲存當前選擇的皮膚的下標,取值範圍為:[0,length-1],int型別,這樣的話,每次需要更換皮膚時,只需要修改下標的值,然後從themes陣列中取出對應的主題,賦值給MaterialApptheme欄位即可。

app_status_holder.dart 檔案:

/// 當前系統主題(暫不考慮外部引入主題情況)
int gCurrentThemeIndex = 0;
複製程式碼

2、無context時處理字串?

跟上面的主題處理方式類似,我們也新建一個檔案,儲存當前app可能使用的字串類app_locale_config.dart

/// 某些地方無法 獲取context ,但又需要獲取國際化的字串時,但系統切換可能導致文字不會改變,因為字串沒有在 state方法中初始化
List<S> ss = [
    S(),
    $zh_CN(),
    $zh_TW(),
    $zh_HK(),
    $en(),
];

/// 當context 不存在時,通過SS而非S去獲取字串
S get SS {
    return ss[gCurrentSupportLocale];
}
複製程式碼

在沒有context的情況如果想要獲取到字串,就必須知道當前語言環境到底是什麼,我們模擬代理類S定義一個代理方法SS,然後在全域性記錄當前語言環境,間接的讀取到正確的字串值;

當然,只做這個是不夠的,如果設定了語言設定為跟隨系統,在系統語言進行切換時,呼叫方法SS獲取到的一直會是S(),即系統預設的英文形式,那肯定是不行的,因此,必須在合適的地方呼叫一次這樣的程式碼:

// 系統語言改變時,如果當前為跟隨系統,則需要修改字串讀取物件
if (gCurrentSupportLocale == 0) {
  print("當前系統語言為:${Localizations.localeOf(context)}");
  ss[0] = S.of(context);
}
複製程式碼

也就是說,需要動態的修改ss陣列列表中預設的語言。

3、如何通知介面重新整理?

一般來說,修改語言環境或皮膚後,除了當前介面,已開啟或建立的介面也都需要進行重新整理,對於安卓平臺來說,系統預設在config變化後重啟介面來達到重新整理的目的;

對於Flutter來說,重新整理介面不需要重建,只需要呼叫setState方法即可;方便是方便,但這涉及到事件的推送;訊息佇列是最簡單的推送方式,或者說是事件匯流排EventBus,這個框架在幾乎所有的平臺存在。

為了方便介面重新整理,我們最好在最頂層監聽事件,然後直接重新整理MaterialApp,確保所有的介面都可以觸發重繪操作,監聽操作可以類似這樣:

// 當通知系統時,重新整理一下狀態(換膚/切換語言/漲跌顏色)
eventBus.on<SystemThemeSwitch>().listen((it) {
    setState(() {
        gCurrentThemeIndex = it.currentThemeIndex;
    });
});
複製程式碼

在修改系統語言和皮膚切換的介面,如果修改成功,則需要觸發事件:

  /// 切換主題
eventBus.fire(SystemThemeSwitch(currentThemeIndex: news.index));
setState(() {});
複製程式碼

三、程式碼整合檢視效果

如上所述,結合了國際化、換膚、本地持久化、路由跳轉框架以及圖片更改等思路,入口程式應該像這樣:

/// 程式入口
void main() => runApp(CustomApp());

/// 自定義包裹 app, 實現換膚等功能
class CustomApp extends StatefulWidget {
    @override
    State createState() => _CustomAppState();
}

class _CustomAppState extends State<CustomApp> {

    @override
    void initState() {
        super.initState();
        // 初始化皮膚取值等全域性 所需 引數
        SharedPreferences.getInstance().then((it) {
            setState(() {
                gCurrentThemeIndex = it.getInt(KEY_THEME_MODE) ?? 0;
                gCurrentSupportLocale = it.getInt(KEY_SUPPORT_LOCALE) ?? 0;
            });
        });

        // 當通知系統時,重新整理一下狀態(換膚/切換語言/漲跌顏色)
        eventBus.on<SystemThemeSwitch>().listen((it) {
            setState(() {
                gCurrentThemeIndex = it.currentThemeIndex;
            });
        });
        eventBus.on<SupportLocaleSwitch>().listen((it) {
            setState(() {
                gCurrentSupportLocale = it.currentSupportLocale;
            });
        });
    }

    @override
    Widget build(BuildContext context) {
        return MaterialApp(
            title: 'Flutter Mudule',
            debugShowCheckedModeBanner: false,
            theme: themes[gCurrentThemeIndex],
            routes: gActivityRoutes,
            localizationsDelegates: [
                S.delegate,
                GlobalMaterialLocalizations.delegate, //Material 元件庫所使用的字串
                GlobalWidgetsLocalizations.delegate, //在當前的語言中,文字預設的排列方向
            ],
            supportedLocales: S.delegate.supportedLocales,
            localeResolutionCallback: S.delegate.resolution(fallback: const Locale('en', '')), // 不存對應locale時,預設取值英文
            locale: mapLocales[SupportLocale.values[gCurrentSupportLocale]],
        );
    }
}
複製程式碼

前面還提到,需要在程式第一個介面widgetbuild方法新增如下程式碼:

Flutter高階(二)——國際化與換膚

像上面這樣配置後,主流程基本已經完成了,剩下的程式碼就是編寫頁面,變數定義等等,事實上在變數少的情況下,使用event-bus尚可。

如果需要改變變數過多邏輯較大的情況下,可以嘗試使用flutter_redux,專案中有提供簡單的使用方式:main_redux.dart;

更多功能請提issues

完成專案請參照flutter_skin_locale

相關文章