在開發書庫管理 Flutter 應用的過程中,我們陷入了一個多語言專案幾乎必經的事故:i18n 訊息出現在不該出現的地方。Domain 層的 repository 開始直接拋出中文錯誤訊息,ViewModel 裡散落著硬編碼字串,UI 層還要自己判斷 errorCode 決定顯示什麼文字。每增加一種語言,就要在三個地方同時動手。

問題的根源

幾個典型的失控情境:

Domain 層的 Service 直接回傳中文字串作為錯誤訊息,要支援英文時就必須深入業務邏輯層去改。ViewModel 裡出現 errorMessage: '找不到書籍' 這樣的程式碼,看起來無害,但需要國際化時根本不知道有多少個地方要改。UI 層開始承擔它不應該承擔的邏輯,自行根據 errorCode 決定顯示什麼文字。

問題的共同根源:沒有清楚定義每一層對 i18n 的責任邊界。

核心原則

一個簡單的原則統治一切:Domain 層不知道 UI 呈現方式,UI 層不承擔訊息決策邏輯

具體化成三條規則:Domain 層使用技術語言(錯誤碼),ViewModel 層負責轉換為使用者語言,所有使用者可見的字串禁止硬編碼。

Domain 層:回傳錯誤碼,不回傳訊息

Domain 層用 ErrorCode 枚舉表達失敗狀態,它只知道「找不到書」這個事實,不知道這個事實要用哪種語言告訴使用者:

 1enum BookErrorCode {
 2  notFound,
 3  invalidIsbn,
 4  networkTimeout,
 5  serverError,
 6}
 7
 8class BookRepository {
 9  Future<Result<Book, BookErrorCode>> fetchBook(String id) async {
10    try {
11      final response = await api.getBook(id);
12      return Result.success(Book.fromJson(response));
13    } on NotFoundException {
14      return Result.failure(BookErrorCode.notFound);
15    } on TimeoutException {
16      return Result.failure(BookErrorCode.networkTimeout);
17    }
18  }
19}

不可違反的規則:不引入任何 i18n 相關的 import,不依賴 BuildContext,不出現任何使用者可見的字串。

ViewModel 層:轉換錯誤碼為使用者訊息

ViewModel 是整個 i18n 流程的轉換點。三個合法的訊息來源:i18n 系統(靜態訊息)、ErrorHandler 轉換器(集中管理錯誤碼對應)、Domain 層直接回傳的動態訊息。

 1class BookDetailViewModel extends Notifier<BookDetailState> {
 2  @override
 3  BookDetailState build() => BookDetailState.initial();
 4
 5  Future<void> loadBook(String id) async {
 6    state = state.copyWith(isLoading: true);
 7
 8    final result = await ref.read(bookRepositoryProvider).fetchBook(id);
 9
10    result.when(
11      success: (book) {
12        state = state.copyWith(isLoading: false, book: book);
13      },
14      failure: (errorCode) {
15        final l10n = ref.read(localizationsProvider);
16        final message = ErrorHandler.getMessage(errorCode, l10n: l10n);
17        state = state.copyWith(isLoading: false, errorMessage: message);
18      },
19    );
20  }
21}

ErrorHandler 集中管理所有錯誤碼到 i18n key 的映射,不讓這段邏輯散落在各個 ViewModel:

 1class ErrorHandler {
 2  static String getMessage(BookErrorCode code, {required AppLocalizations l10n}) {
 3    switch (code) {
 4      case BookErrorCode.notFound:
 5        return l10n.bookNotFound;
 6      case BookErrorCode.invalidIsbn:
 7        return l10n.invalidIsbn;
 8      case BookErrorCode.networkTimeout:
 9        return l10n.networkTimeout;
10      case BookErrorCode.serverError:
11        return l10n.serverError;
12    }
13  }
14}

動態參數的情況,ARB 檔案定義佔位符,ViewModel 負責組裝:

1{
2  "bookNotFoundWithId": "找不到書籍(ID: {bookId})",
3  "@bookNotFoundWithId": {
4    "placeholders": {
5      "bookId": { "type": "String" }
6    }
7  }
8}
1final message = l10n.bookNotFoundWithId(bookId);
2state = state.copyWith(errorMessage: message);

UI 層:只負責顯示

UI 層的工作極度單純:顯示 ViewModel 準備好的狀態,不判斷錯誤碼,不組裝字串:

 1class BookDetailScreen extends ConsumerWidget {
 2  @override
 3  Widget build(BuildContext context, WidgetRef ref) {
 4    final state = ref.watch(bookDetailViewModelProvider);
 5
 6    if (state.isLoading) return const CircularProgressIndicator();
 7
 8    if (state.errorMessage != null) {
 9      return ErrorDisplay(message: state.errorMessage!);
10    }
11
12    return BookDetailContent(book: state.book!);
13  }
14}

build 方法裡不應該出現任何 switch (errorCode)l10n.xxx 來組裝錯誤訊息。

常見反模式

Domain 層包含使用者訊息:repository 直接回傳 '找不到這本書',要改成英文就得進業務邏輯層動手,Domain 層不應該對 UI 語言有任何感知。

ViewModel 硬編碼訊息state.copyWith(errorMessage: '找不到書籍') 讓字串散落各處,修改時不知道有幾個地方要同步改動。

UI 層自行組裝訊息:Widget 的 build 方法裡出現 switch (state.errorCode),同一段決策邏輯開始在不同 Widget 裡複製出現。

跨層傳遞 BuildContext:把 BuildContext 傳入 Domain 層,Domain 層不應該依賴 Flutter 框架的任何概念,這樣的程式碼幾乎無法進行單元測試。

實際效益

新增語言支援時,只需要在 ARB 檔案裡補翻譯,確認 ErrorHandler 映射完整,不需要搜索整個 codebase 找散落的字串。

Domain 層的測試變乾淨,只需驗證正確的 ErrorCode 被回傳,不用比對任何字串內容。每次在 Domain 層看到中文字串,或在 UI 層看到 i18n key 被呼叫,就是一個警示信號——訊息的生成和轉換,只屬於 ViewModel 層。