Chuyển đến nội dung chính

Đa ngôn ngữ trong Flutter với file ARB: số nhiều, giống, và những chỗ cắn người

Commit đa ngôn ngữ đầu tiên trong đa số ứng dụng Flutter là một Map<String, String> đánh khoá theo mã ngôn ngữ, tra qua một biến toàn cục. Nó chạy được với hai ngôn ngữ và một trăm chuỗi. Nó vỡ ngay lần đầu ai đó cần “1 item” so với “2 items”, và vỡ nặng ngay lần đầu một người dịch hỏi home_screen_label_2 là để làm gì.

Câu trả lời chính thức của Flutter là file ARB được gen_l10n biên dịch thành một class Dart sinh tự động. Phần cài đặt rất ngắn. Thứ đáng hiểu là những phần mà bài quickstart không nhắc: plural theo ICU, kiểu của placeholder, cách phân giải locale, và chuyện gì xảy ra vào ngày có người thêm tiếng Ả Rập.

Cài đặt, một lần

# pubspec.yaml
dependencies:
  flutter_localizations:
    sdk: flutter
  intl: any

flutter:
  generate: true
# l10n.yaml ở thư mục gốc dự án
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
nullable-getter: false

nullable-getter: false đáng để đặt một cách có chủ ý. Với nó, AppLocalizations.of(context) trả về object không nullable và bạn viết AppLocalizations.of(context).greeting thay vì AppLocalizations.of(context)!.greeting. Cái giá là một lỗi lúc chạy thay vì một giá trị null nếu bạn quên khai báo delegate — mà đó chính là kiểu hỏng bạn muốn, vì nó xảy ra ngay và rất rõ ràng.

MaterialApp(
  localizationsDelegates: AppLocalizations.localizationsDelegates,
  supportedLocales: AppLocalizations.supportedLocales,
  home: const HomePage(),
);

Chuỗi sau đó lấy từ AppLocalizations.of(context), vốn là một lượt tra InheritedWidget — nghĩa là nó cần một context nằm dưới MaterialApp, và nghĩa là đổi locale sẽ dựng lại mọi widget đã đọc nó.

Định dạng ARB, vượt qua phần hiển nhiên

File ARB là JSON, trong đó mỗi khoá là một thông điệp và mỗi @khoá là siêu dữ liệu của nó.

{
  "@@locale": "en",

  "appTitle": "FlutterCook",
  "@appTitle": {
    "description": "Hiện trên app bar và trong trình chuyển ứng dụng"
  },

  "unreadCount": "{count, plural, =0{No new messages} one{1 new message} other{{count} new messages}}",
  "@unreadCount": {
    "description": "Chữ trên huy hiệu của tab hộp thư",
    "placeholders": {
      "count": { "type": "int" }
    }
  },

  "lastSeen": "Last seen {when}",
  "@lastSeen": {
    "placeholders": {
      "when": {
        "type": "DateTime",
        "format": "yMMMd"
      }
    }
  }
}

Ba thứ ở đây quyết định năm tới của bạn khổ tới mức nào.

Trên thực tế description không phải tuỳ chọn. Đó là ngữ cảnh duy nhất người dịch có được. “Open” mà không có mô tả sẽ quay về dưới dạng động từ ở ngôn ngữ này và tính từ ở ngôn ngữ khác, và bạn chỉ biết khi có người dùng báo lỗi.

type của placeholder quyết định chữ ký hàm sinh ra. int cho bạn int count; DateTime kèm format cho bạn DateTime when và sinh sẵn lời gọi DateFormat. Không có kiểu thì nó là Object và bạn nhận toString() — đó là cách những chuỗi ISO thô lọt ra giao diện.

Các nhóm số nhiều là theo từng ngôn ngữ. Tiếng Anh dùng oneother. Tiếng Việt chỉ dùng other. Tiếng Ba Lan dùng one, few, many, other. Tiếng Nga và tiếng Ả Rập có bộ riêng. Viết count == 1 ? 'item' : 'items' trong Dart là nướng cứng ngữ pháp tiếng Anh vào code; cú pháp plural của ICU cho phép mỗi file dịch tự khai báo nhóm của nó. Đây là lý do mạnh nhất để dùng ARB thay vì một cái map.

Lời gọi sinh ra là Dart bình thường:

final l10n = AppLocalizations.of(context);
Text(l10n.unreadCount(inbox.unreadCount));
Text(l10n.lastSeen(user.lastSeenAt));

select cho giống và các biến thể liệt kê

plural có một người anh em mà ít ai dùng tới:

{
  "invitedYou": "{gender, select, male{He invited you} female{She invited you} other{They invited you}}",
  "@invitedYou": {
    "placeholders": { "gender": { "type": "String" } }
  }
}

Hãy dùng nó cho bất cứ chỗ nào cấu trúc câu thay đổi theo một giá trị, không chỉ riêng giống — gói thuê bao, trạng thái tài liệu, phương thức giao hàng. Cách thay thế là ba khoá riêng và một chuỗi if trong Dart, tức là đẩy một quyết định ngữ pháp vào phần code mà người dịch không với tới được.

Số, ngày tháng và tiền tệ

intl định dạng những thứ này; đừng tự viết tay.

final locale = Localizations.localeOf(context).toString();

NumberFormat.currency(locale: locale, symbol: '₫').format(120000);
NumberFormat.compact(locale: locale).format(1250000);       // 1.25M / 1,25 Tr
DateFormat.yMMMMd(locale).format(order.placedAt);
DateFormat.Hm(locale).format(order.placedAt);

Khác biệt ở đây không chỉ là hình thức. Dấu thập phân đổi giữa .,. Thứ tự ngày tháng khác nhau. Một số locale dùng hệ chữ số hoàn toàn khác. Một chuỗi cứng '${d.day}/${d.month}/${d.year}' là sai với khoảng một nửa thế giới.

Hai ghi chú thực dụng. DateFormat cần dữ liệu locale được khởi tạo cho bất cứ ngôn ngữ nào ngoài mặc định; trong ứng dụng Flutter, các delegate của flutter_localizations lo việc đó cho những locale mà ứng dụng hỗ trợ. Và hãy định dạng theo giờ địa phương — lưu UTC, chuyển bằng toLocal() ở rìa hệ thống, nếu không câu “đăng 2 giờ trước” của bạn sẽ lệch đúng bằng độ lệch múi giờ.

Phân giải locale: chuyện gì xảy ra với fr-CA

supportedLocales là một danh sách, và thiết bị có thể báo về thứ không nằm trong đó. Cách phân giải mặc định thử khớp chính xác, rồi khớp theo ngôn ngữ, rồi lùi về phần tử đầu tiên của danh sách. Vế cuối làm nhiều người bất ngờ: một locale không được hỗ trợ sẽ nhận supportedLocales.first, nên hãy đặt ngôn ngữ mặc định thật sự của bạn lên đầu.

Khi bạn cần kiểm soát — một biến thể vùng cần ánh xạ vào một file cụ thể, hoặc ngôn ngữ do người dùng chọn được lưu trong cấu hình:

MaterialApp(
  locale: settings.overrideLocale, // null = theo thiết bị
  supportedLocales: AppLocalizations.supportedLocales,
  localeResolutionCallback: (deviceLocale, supported) {
    if (deviceLocale == null) return supported.first;
    for (final l in supported) {
      if (l.languageCode == deviceLocale.languageCode) return l;
    }
    return supported.first;
  },
);

Đặt locale tường minh sẽ ghi đè hoàn toàn thiết bị — đó là cách một bộ chọn ngôn ngữ trong ứng dụng hoạt động. Hãy lưu lại lựa chọn đó, và nhớ rằng null phải là một giá trị hợp lệ mang nghĩa “theo hệ thống”.

Phải-sang-trái, và nó không chỉ là lật gương

Thêm tiếng Ả Rập hay tiếng Do Thái sẽ đảo hướng bố cục. Flutter lo được phần lớn nếu bạn đã dùng các API có hướng:

DùngĐừng dùng
EdgeInsetsDirectional.only(start: 16)EdgeInsets.only(left: 16)
AlignmentDirectional.centerStartAlignment.centerLeft
BorderRadiusDirectionalBorderRadius
Positioned.directional(start: ...)Positioned(left: ...)

Row tự đảo khi ở chế độ RTL. Đa số icon nên lật gương — mũi tên quay lại phải chỉ hướng ngược — nhưng không phải tất cả: nút play, đồng hồ, logo thì không. Dùng Transform.flip cho những cái cần lật, và kiểm tra phần còn lại.

Kiểm thử nó mà không cần biết ngôn ngữ đó:

Directionality(textDirection: TextDirection.rtl, child: MyScreen())

Bất cứ thứ gì nhìn thấy vẫn đứng yên đều là một chỗ left/right cứng.

Kiểm thử widget đa ngôn ngữ

Một widget test không có delegate sẽ ném lỗi ngay khi có gì đó gọi AppLocalizations.of. Hãy cấp cho nó:

Widget wrap(Widget child, {Locale locale = const Locale('en')}) => MaterialApp(
      locale: locale,
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      supportedLocales: AppLocalizations.supportedLocales,
      home: child,
    );

testWidgets('huy hiệu hộp thư chia số nhiều đúng', (tester) async {
  await tester.pumpWidget(wrap(const InboxBadge(count: 1)));
  expect(find.text('1 new message'), findsOneWidget);

  await tester.pumpWidget(wrap(const InboxBadge(count: 5)));
  expect(find.text('5 new messages'), findsOneWidget);
});

Assert vào chuỗi tiếng Anh nguyên văn trong test là một đánh đổi: nó bắt được hồi quy thật nhưng vỡ khi câu chữ đổi. Điểm cân bằng hợp lý là assert vào giá trị đã dịch tính từ chính nguồn đó (l10n.unreadCount(5)), để test kiểm tra phần đấu nối chứ không kiểm tra câu chữ.

Quy trình giữ cho người dịch còn tỉnh táo

  1. Chỉ app_en.arb được thêm khoá mới bằng tay. Nó là template; trình sinh sẽ đối chiếu các file khác với nó.
  2. Đừng bao giờ tái dùng một khoá với nghĩa khác. Bản dịch gắn với khoá; đổi chữ tiếng Anh dưới một khoá sẽ âm thầm làm mọi bản dịch của nó thành sai.
  3. Xoá khoá chết. Một khoá cũ còn sót là một chuỗi mà người ta vẫn đang được trả tiền để dịch.
  4. Kiểm tra khoá thiếu trong CI. flutter gen-l10n báo cáo các thông điệp chưa dịch; hãy ghi chúng ra file bằng untranslated-messages-file trong l10n.yaml và cho build fail nếu danh sách đó dài ra.

Câu hỏi thường gặp

Tôi lấy chuỗi ngoài widget, trong repository hoặc trong isolate nền được không?

Không lấy qua AppLocalizations.of(context) được — nó cần context. Hoặc truyền chuỗi từ tầng UI xuống, hoặc trả về một mã lỗi rồi dịch ở nơi hiển thị. Cách thứ hai thường đúng: một repository không nên biết người dùng đọc ngôn ngữ nào.

Còn intl_translation@@last_modified thì sao?

intl_translation là bộ công cụ cũ, tách rời, trích thông điệp từ Dart có annotation. gen_l10n được tích hợp sẵn trong công cụ Flutter và là thứ tài liệu hiện hành nhắm tới. @@last_modified là siêu dữ liệu do một số công cụ ghi vào ARB; nó vô hại.

Đổi ngôn ngữ có phải khởi động lại ứng dụng không?

Không. Đặt locale trên MaterialApp sẽ dựng lại nhánh đó và mọi lượt đọc AppLocalizations.of(context) sẽ nhận giá trị mới.

Vì sao file sinh ra bị thiếu?

generate: true phải nằm dưới flutter: trong pubspec.yaml, và file chỉ xuất hiện sau một lần build hoặc flutter gen-l10n. Mặc định nó nằm trong thư mục build, nên nó không có trong quản lý mã nguồn.

File dịch nên nằm trong app hay tải từ máy chủ?

ARB đóng gói sẵn thì đơn giản hơn, chạy được khi offline, và phát hành nguyên khối cùng đoạn code dùng nó. Chuỗi tải từ máy chủ cho phép sửa một lỗi chính tả mà không cần phát hành bản mới, đổi lại phải có trạng thái đang tải, một lớp cache và một đường dự phòng. Mặc định hãy đóng gói; chỉ thêm phần ghi đè từ xa khi nhịp phát hành thật sự đòi hỏi.


Cú pháp ARB, các tuỳ chọn l10n.yaml, thứ tự phân giải và các API widget có hướng mô tả ở đây được ghi trong hướng dẫn quốc tế hoá của Flutter và tài liệu gói intl đã dẫn. Bộ quy trình, khuyến nghị assert vào chuỗi được tính ra trong test, và lập trường đóng gói-so-với-tải-từ-xa là đánh giá riêng của tôi. Hành vi công cụ thay đổi giữa các bản Flutter — hãy chạy flutter gen-l10n --help với SDK của bạn trước khi sao chép tuỳ chọn.


Bài viết gốc đăng tại FlutterCook. Bản trên đó là bản được cập nhật mới nhất.

Nhận xét

Bài đăng phổ biến từ blog này

5 concepts every Flutter dev should know

  Phụ lục: State management architecture Testing IDE Shortcuts Platform channel Maintaining a project Tôi đã làm việc với Flagship trong một thời gian dài, và đây là những điều mà tôi phát hiện ra là điều cần phải có đối với bất kỳ nhà phát triển Flagship nào, về tổng thể nó sẽ khiến bạn trở thành một nhà phát triển Flagship giỏi trong thời gian dài. 1. State management architecture Đây là một trong những chủ đề quan trọng nhất trong cộng đồng thiết bị rung, nó khá quan trọng nếu bạn muốn duy trì một dự án rung kích thước trung bình hoặc lớn. Nó sẽ giúp tạo một dự án suôn sẻ và thêm các tính năng mới một cách hoàn hảo.  2. Testing Đây là một chủ đề duy nhất mà tôi không hiểu tại sao nó lại quan trọng trước đó trong sự nghiệp của tôi, nhưng khi tôi tiến lên trong sự nghiệp của mình và có kinh nghiệm với nhiều dự án và vấn đề xảy ra trong môi trường sản xuất. Tôi đã nhận ra một cách khó khăn, tại sao điều này lại quan trọng như vậy. Nếu bạn vẫn muốn có thêm lý do để cân nhắc thử...

Thiết kế giao diện với DotNetBar (Phần 1)

Đây là phiên bản DotNetBar hỗ trợ C# và Visual Basic https://www.dropbox.com/s/wx80jpvgnlrmtux/DotNetBar.rar  , phiên bản này hỗ trợ giao diện Metro cực kỳ “dễ thương” Các bạn load về và cài đặt, khi cài đặt xong sẽ có source code mẫu của tất cả các control. Để sử dụng được các control của DotNetBar các bạn nhớ add item vào controls box. Thiết kế giao diện với DotNetBar, giao diện sẽ rất đẹp. Link các video hướng dẫn chi tiết cách sử dụng và coding: http://www.devcomponents.com/dotnetbar/movies.aspx Hiện tại DotNetBar có rất nhiều công cụ cực mạnh, trong đó có 3 công cụ dưới đây: DotNetBar for Windows Forms Requires with Visual Studio 2003, 2005, 2008, 2010 or 2012.   DotNetBar for WPF Requires with Visual Studio 2010 or 2012 and Windows Presentation Foundation.   DotNetBar for Silverlight Requires with Visual Studio 2010 or 2012 and Silverlight. Dưới đây là một số hình ảnh về các control trong DotnetBar.   Metro User Interface  controls with Metro Tiles, toolba...

2026 👏 Google miễn phí một năm gói AI Plus cho sinh viên Việt Nam 🇻🇳🇻🇳🇻🇳

Chúc mừng anh em 👏 Google miễn phí một năm gói AI Plus cho sinh viên Việt Nam 🇻🇳🇻🇳🇻🇳 Đối tượng là sinh viên đại học từ 18–24 tuổi (bao gồm sinh viên mới và sinh viên từng dùng gói AI Pro 2025). Lưu ý là cần xác minh tư cách sinh viên hàng năm. Khi nhận gói, anh em có được các ưu đãi sau: 🔖 Nâng cấp bộ nhớ đám mây lên 400GB. 🔖 Nhân đôi hạn mức truy cập mô hình AI. 🔖 Mở quyền trải nghiệm tính năng tạo video bằng Gemini Omni. Lưu ý chương trình chỉ dành cho sinh viên đủ điều kiện. Hạn nhận ưu đãi: 31 tháng 12, 2026. Các bạn cần có phương thức thanh toán hợp lệ khi đăng ký. Google AI Plus sẽ tự động tính phí 132.000 ₫/tháng sau khi thời gian dùng thử kết thúc, trừ phi bạn đã huỷ trước đó. Huỷ bất cứ lúc nào. CÁC BƯỚC THỰC HIỆN CHI TIẾT 🔹Bước 1: Truy cập cổng đăng ký chương trình 🔖 Mở trình duyệt và truy cập vào trang chính thức: gemini.google/students/ 🔖 Đăng nhập vào Tài khoản Google cá nhân của bạn. 🔖 Nhấn vào nút "Claim your student plan at no cost" (hoặc Nhận gó...