官方网站/ 微信公众平台/ 新浪微博/

Flutter多语言国际化:完整的i18n解决方案

Flutter多语言国际化:完整的i18n解决方案

本文基于BeeCount(蜜蜂记账)项目的实际开发经验,深入探讨如何在Flutter应用中实现完整、高效的国际化支持。

项目背景

BeeCount(蜜蜂记账)是一款开源、简洁、无广告的个人记账应用。所有财务数据完全由用户掌控,支持本地存储和可选的云端同步,确保数据绝对安全。

引言

国际化(Internationalization,简称i18n)是现代应用走向全球化的必经之路。随着Flutter应用的全球化部署,支持多语言不再是可选功能,而是基本需求。优秀的国际化实现不仅要支持文本翻译,还要考虑数字格式、日期时间格式、货币显示、文本方向等多个方面。

BeeCount作为一款财务管理应用,在国际化方面面临特殊挑战:货币符号、数字格式、日期格式等在不同地区都有显著差异。通过完整的i18n解决方案,BeeCount成功支持了多个国家和地区的用户需求。

Flutter国际化架构

核心组件配置

// 主应用配置
class BeeCountApp extends ConsumerWidget {
  const BeeCountApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final locale = ref.watch(localeProvider);
    final themeMode = ref.watch(themeModeProvider);
    final lightTheme = ref.watch(lightThemeProvider);
    final darkTheme = ref.watch(darkThemeProvider);

    return MaterialApp(
      title: 'BeeCount',
      debugShowCheckedModeBanner: false,
      
      // 国际化配置
      localizationsDelegates: const [
        AppLocalizations.delegate,
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
        GlobalCupertinoLocalizations.delegate,
      ],
      supportedLocales: AppLocalizations.supportedLocales,
      locale: locale,
      localeResolutionCallback: (locale, supportedLocales) {
        return _resolveLocale(locale, supportedLocales);
      },
      
      // 主题配置
      theme: lightTheme,
      darkTheme: darkTheme,
      themeMode: themeMode,
      
      home: const AppScaffold(),
    );
  }

  Locale? _resolveLocale(Locale? locale, Iterable<Locale> supportedLocales) {
    // 如果设备语言在支持列表中,直接使用
    if (locale != null) {
      for (final supportedLocale in supportedLocales) {
        if (supportedLocale.languageCode == locale.languageCode &&
            supportedLocale.countryCode == locale.countryCode) {
          return supportedLocale;
        }
      }
      
      // 如果完整匹配失败,尝试只匹配语言代码
      for (final supportedLocale in supportedLocales) {
        if (supportedLocale.languageCode == locale.languageCode) {
          return supportedLocale;
        }
      }
    }
    
    // 默认返回中文
    return const Locale('zh', 'CN');
  }
}

语言切换管理

// 语言管理器
class LocaleManager {
  static const String _localeKey = 'selected_locale';
  
  static const List<LocaleOption> supportedLocales = [
    LocaleOption(
      locale: Locale('zh', 'CN'),
      name: '简体中文',
      flag: '🇨🇳',
    ),
    LocaleOption(
      locale: Locale('zh', 'TW'),
      name: '繁體中文',
      flag: '🇹🇼',
    ),
    LocaleOption(
      locale: Locale('en', 'US'),
      name: 'English',
      flag: '🇺🇸',
    ),
    LocaleOption(
      locale: Locale('ja', 'JP'),
      name: '日本語',
      flag: '🇯🇵',
    ),
    LocaleOption(
      locale: Locale('ko', 'KR'),
      name: '한국어',
      flag: '🇰🇷',
    ),
  ];

  static Future<Locale?> getSavedLocale() async {
    final prefs = await SharedPreferences.getInstance();
    final localeString = prefs.getString(_localeKey);
    
    if (localeString != null) {
      final parts = localeString.split('_');
      if (parts.length == 2) {
        return Locale(parts[0], parts[1]);
      }
    }
    
    return null;
  }

  static Future<void> saveLocale(Locale locale) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(_localeKey, '${locale.languageCode}_${locale.countryCode}');
  }

  static LocaleOption? getLocaleOption(Locale locale) {
    return supportedLocales.firstWhereOrNull((option) => 
        option.locale.languageCode == locale.languageCode &&
        option.locale.countryCode == locale.countryCode);
  }

  static String getDisplayName(Locale locale) {
    final option = getLocaleOption(locale);
    return option?.name ?? '${locale.languageCode}_${locale.countryCode}';
  }
}

// 语言选项数据类
class LocaleOption {
  final Locale locale;
  final String name;
  final String flag;

  const LocaleOption({
    required this.locale,
    required this.name,
    required this.flag,
  });
}

// Riverpod语言Provider
final localeProvider = StateProvider<Locale?>((ref) => null);

final localeInitProvider = FutureProvider<void>((ref) async {
  // 加载保存的语言设置
  final savedLocale = await LocaleManager.getSavedLocale();
  if (savedLocale != null) {
    ref.read(localeProvider.notifier).state = savedLocale;
  }

  // 监听语言变化并持久化
  ref.listen<Locale?>(localeProvider, (prev, next) async {
    if (next != null) {
      await LocaleManager.saveLocale(next);
    }
  });
});

ARB文件管理

资源文件结构

lib/l10n/
├── app_en.arb      # 英文资源
├── app_zh_CN.arb   # 简体中文资源
├── app_zh_TW.arb   # 繁体中文资源
├── app_ja.arb      # 日文资源
└── app_ko.arb      # 韩文资源

中文资源文件示例

// lib/l10n/app_zh_CN.arb
{
  "@@locale": "zh_CN",
  
  // 通用
  "appName": "蜜蜂记账",
  "@appName": {
    "description": "应用名称"
  },
  
  "ok": "确定",
  "cancel": "取消",
  "save": "保存",
  "delete": "删除",
  "edit": "编辑",
  "add": "添加",
  "back": "返回",
  "next": "下一步",
  "previous": "上一步",
  "done": "完成",
  "loading": "加载中...",
  "error": "错误",
  "success": "成功",
  "warning": "警告",
  "info": "信息",
  
  // 导航
  "home": "首页",
  "analytics": "分析",
  "ledgers": "账本",
  "mine": "我的",
  
  // 交易相关
  "transaction": "交易",
  "transactions": "交易记录",
  "income": "收入",
  "expense": "支出",
  "transfer": "转账",
  "amount": "金额",
  "category": "分类",
  "account": "账户",
  "note": "备注",
  "date": "日期",
  "time": "时间",
  
  // 金额格式化
  "currencyFormat": "¥{amount}",
  "@currencyFormat": {
    "description": "货币格式",
    "placeholders": {
      "amount": {
        "type": "String",
        "example": "123.45"
      }
    }
  },
  
  // 日期格式化
  "dateFormat": "{year}年{month}月{day}日",
  "@dateFormat": {
    "description": "日期格式",
    "placeholders": {
      "year": {"type": "int"},
      "month": {"type": "int"},
      "day": {"type": "int"}
    }
  },
  
  "dateTimeFormat": "{year}年{month}月{day}日 {hour}:{minute}",
  "@dateTimeFormat": {
    "description": "日期时间格式",
    "placeholders": {
      "year": {"type": "int"},
      "month": {"type": "int"},
      "day": {"type": "int"},
      "hour": {"type": "int"},
      "minute": {"type": "int"}
    }
  },
  
  // 统计相关
  "totalIncome": "总收入",
  "totalExpense": "总支出",
  "balance": "结余",
  "dailyAverage": "日均",
  "monthlyStats": "月度统计",
  "categoryStats": "分类统计",
  
  // 复数形式
  "transactionCount": "{count, plural, =0{暂无交易} =1{1笔交易} other{{count}笔交易}}",
  "@transactionCount": {
    "description": "交易数量",
    "placeholders": {
      "count": {"type": "int"}
    }
  },
  
  "daysPeriod": "{count, plural, =0{今天} =1{1天} other{{count}天}}",
  "@daysPeriod": {
    "description": "天数",
    "placeholders": {
      "count": {"type": "int"}
    }
  },
  
  // 错误消息
  "errorAmountRequired": "请输入金额",
  "errorAmountInvalid": "金额格式不正确",
  "errorCategoryRequired": "请选择分类",
  "errorAccountRequired": "请选择账户",
  "errorDateInvalid": "日期格式不正确",
  
  // 设置相关
  "settings": "设置",
  "language": "语言",
  "theme": "主题",
  "currency": "货币",
  "backup": "备份",
  "restore": "恢复",
  "about": "关于",
  
  // 导入导出
  "import": "导入",
  "export": "导出",
  "importSuccess": "导入成功",
  "exportSuccess": "导出成功",
  "importFromCsv": "从CSV导入",
  "exportToCsv": "导出为CSV",
  
  // 同步相关
  "sync": "同步",
  "syncSuccess": "同步成功",
  "syncFailed": "同步失败",
  "lastSync": "上次同步",
  "cloudBackup": "云端备份",
  
  // 时间相对表达
  "justNow": "刚刚",
  "minutesAgo": "{minutes}分钟前",
  "@minutesAgo": {
    "placeholders": {
      "minutes": {"type": "int"}
    }
  },
  
  "hoursAgo": "{hours}小时前",
  "@hoursAgo": {
    "placeholders": {
      "hours": {"type": "int"}
    }
  },
  
  "daysAgo": "{days}天前",
  "@daysAgo": {
    "placeholders": {
      "days": {"type": "int"}
    }
  }
}

英文资源文件示例

// lib/l10n/app_en.arb
{
  "@@locale": "en",
  
  "appName": "BeeCount",
  
  "ok": "OK",
  "cancel": "Cancel",
  "save": "Save",
  "delete": "Delete",
  "edit": "Edit",
  "add": "Add",
  "back": "Back",
  "next": "Next",
  "previous": "Previous",
  "done": "Done",
  "loading": "Loading...",
  "error": "Error",
  "success": "Success",
  "warning": "Warning",
  "info": "Info",
  
  "home": "Home",
  "analytics": "Analytics",
  "ledgers": "Ledgers",
  "mine": "Profile",
  
  "transaction": "Transaction",
  "transactions": "Transactions",
  "income": "Income",
  "expense": "Expense",
  "transfer": "Transfer",
  "amount": "Amount",
  "category": "Category",
  "account": "Account",
  "note": "Note",
  "date": "Date",
  "time": "Time",
  
  "currencyFormat": "${amount}",
  
  "dateFormat": "{month}/{day}/{year}",
  "dateTimeFormat": "{month}/{day}/{year} {hour}:{minute}",
  
  "totalIncome": "Total Income",
  "totalExpense": "Total Expense",
  "balance": "Balance",
  "dailyAverage": "Daily Avg",
  "monthlyStats": "Monthly Stats",
  "categoryStats": "Category Stats",
  
  "transactionCount": "{count, plural, =0{No transactions} =1{1 transaction} other{{count} transactions}}",
  "daysPeriod": "{count, plural, =0{Today} =1{1 day} other{{count} days}}",
  
  "errorAmountRequired": "Please enter amount",
  "errorAmountInvalid": "Invalid amount format",
  "errorCategoryRequired": "Please select category",
  "errorAccountRequired": "Please select account",
  "errorDateInvalid": "Invalid date format",
  
  "settings": "Settings",
  "language": "Language",
  "theme": "Theme",
  "currency": "Currency",
  "backup": "Backup",
  "restore": "Restore",
  "about": "About",
  
  "import": "Import",
  "export": "Export",
  "importSuccess": "Import successful",
  "exportSuccess": "Export successful",
  "importFromCsv": "Import from CSV",
  "exportToCsv": "Export to CSV",
  
  "sync": "Sync",
  "syncSuccess": "Sync successful",
  "syncFailed": "Sync failed",
  "lastSync": "Last sync",
  "cloudBackup": "Cloud Backup",
  
  "justNow": "Just now",
  "minutesAgo": "{minutes} minutes ago",
  "hoursAgo": "{hours} hours ago",
  "daysAgo": "{days} days ago"
}

数字与货币格式化

国际化格式化服务

class FormatService {
  final BuildContext context;
  late final NumberFormat _currencyFormat;
  late final NumberFormat _numberFormat;
  late final DateFormat _dateFormat;
  late final DateFormat _timeFormat;
  late final DateFormat _dateTimeFormat;

  FormatService(this.context) {
    final locale = Localizations.localeOf(context);
    _initializeFormats(locale);
  }

  void _initializeFormats(Locale locale) {
    final localeString = locale.toString();
    
    // 货币格式
    _currencyFormat = NumberFormat.currency(
      locale: localeString,
      symbol: _getCurrencySymbol(locale),
      decimalDigits: 2,
    );
    
    // 数字格式
    _numberFormat = NumberFormat('#,##0.##', localeString);
    
    // 日期格式
    switch (locale.languageCode) {
      case 'zh':
        _dateFormat = DateFormat('yyyy年MM月dd日', localeString);
        _timeFormat = DateFormat('HH:mm', localeString);
        _dateTimeFormat = DateFormat('yyyy年MM月dd日 HH:mm', localeString);
        break;
      case 'en':
        _dateFormat = DateFormat('MM/dd/yyyy', localeString);
        _timeFormat = DateFormat('HH:mm', localeString);
        _dateTimeFormat = DateFormat('MM/dd/yyyy HH:mm', localeString);
        break;
      case 'ja':
        _dateFormat = DateFormat('yyyy年MM月dd日', localeString);
        _timeFormat = DateFormat('HH:mm', localeString);
        _dateTimeFormat = DateFormat('yyyy年MM月dd日 HH:mm', localeString);
        break;
      default:
        _dateFormat = DateFormat('yyyy-MM-dd', localeString);
        _timeFormat = DateFormat('HH:mm', localeString);
        _dateTimeFormat = DateFormat('yyyy-MM-dd HH:mm', localeString);
    }
  }

  String _getCurrencySymbol(Locale locale) {
    switch ('${locale.languageCode}_${locale.countryCode}') {
      case 'zh_CN':
        return '¥';
      case 'zh_TW':
        return 'NT\$';
      case 'en_US':
        return '\$';
      case 'ja_JP':
        return '¥';
      case 'ko_KR':
        return '₩';
      default:
        return '¥';
    }
  }

  // 格式化货币
  String formatCurrency(double amount) {
    return _currencyFormat.format(amount);
  }

  // 格式化数字
  String formatNumber(double number) {
    return _numberFormat.format(number);
  }

  // 格式化日期
  String formatDate(DateTime date) {
    return _dateFormat.format(date);
  }

  // 格式化时间
  String formatTime(DateTime time) {
    return _timeFormat.format(time);
  }

  // 格式化日期时间
  String formatDateTime(DateTime dateTime) {
    return _dateTimeFormat.format(dateTime);
  }

  // 格式化相对时间
  String formatRelativeTime(DateTime dateTime) {
    final now = DateTime.now();
    final difference = now.difference(dateTime);
    final l10n = AppLocalizations.of(context)!;

    if (difference.inMinutes < 1) {
      return l10n.justNow;
    } else if (difference.inHours < 1) {
      return l10n.minutesAgo(difference.inMinutes);
    } else if (difference.inDays < 1) {
      return l10n.hoursAgo(difference.inHours);
    } else if (difference.inDays < 30) {
      return l10n.daysAgo(difference.inDays);
    } else {
      return formatDate(dateTime);
    }
  }

  // 解析货币输入
  double? parseCurrency(String input) {
    try {
      // 移除货币符号和空格
      final cleanInput = input
          .replaceAll(_getCurrencySymbol(Localizations.localeOf(context)), '')
          .replaceAll(',', '')
          .trim();
      
      return double.tryParse(cleanInput);
    } catch (e) {
      return null;
    }
  }

  // 格式化交易类型
  String formatTransactionType(String type) {
    final l10n = AppLocalizations.of(context)!;
    switch (type) {
      case 'income':
        return l10n.income;
      case 'expense':
        return l10n.expense;
      case 'transfer':
        return l10n.transfer;
      default:
        return type;
    }
  }

  // 格式化带符号的金额
  String formatSignedAmount(double amount, String type) {
    final formattedAmount = formatCurrency(amount.abs());
    switch (type) {
      case 'income':
        return '+$formattedAmount';
      case 'expense':
        return '-$formattedAmount';
      default:
        return formattedAmount;
    }
  }
}

// Riverpod Provider
final formatServiceProvider = Provider<FormatService>((ref) {
  throw UnimplementedError('FormatService requires BuildContext');
});

// 在Widget中使用
class AmountDisplay extends ConsumerWidget {
  final double amount;
  final String type;

  const AmountDisplay({
    Key? key,
    required this.amount,
    required this.type,
  }) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final formatService = FormatService(context);
    
    return Text(
      formatService.formatSignedAmount(amount, type),
      style: TextStyle(
        color: _getAmountColor(context, type),
        fontWeight: FontWeight.w600,
      ),
    );
  }

  Color _getAmountColor(BuildContext context, String type) {
    final colors = BeeTheme.colorsOf(context);
    switch (type) {
      case 'income':
        return colors.income;
      case 'expense':
        return colors.expense;
      default:
        return colors.neutral;
    }
  }
}

语言切换界面

语言选择页面

class LanguageSettingsPage extends ConsumerWidget {
  const LanguageSettingsPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final currentLocale = ref.watch(localeProvider) ?? 
        Localizations.localeOf(context);
    final l10n = AppLocalizations.of(context)!;

    return Scaffold(
      appBar: AppBar(
        title: Text(l10n.language),
      ),
      body: ListView.builder(
        padding: const EdgeInsets.symmetric(vertical: 8),
        itemCount: LocaleManager.supportedLocales.length,
        itemBuilder: (context, index) {
          final option = LocaleManager.supportedLocales[index];
          final isSelected = option.locale.languageCode == currentLocale.languageCode &&
              option.locale.countryCode == currentLocale.countryCode;

          return RadioListTile<Locale>(
            value: option.locale,
            groupValue: currentLocale,
            title: Row(
              children: [
                Text(
                  option.flag,
                  style: const TextStyle(fontSize: 24),
                ),
                const SizedBox(width: 12),
                Text(option.name),
              ],
            ),
            subtitle: Text(
              '${option.locale.languageCode}_${option.locale.countryCode}',
              style: Theme.of(context).textTheme.bodySmall?.copyWith(
                color: Theme.of(context).colorScheme.onSurfaceVariant,
              ),
            ),
            onChanged: (locale) {
              if (locale != null) {
                _changeLanguage(ref, locale);
                Navigator.pop(context);
              }
            },
            selected: isSelected,
          );
        },
      ),
    );
  }

  void _changeLanguage(WidgetRef ref, Locale locale) {
    ref.read(localeProvider.notifier).state = locale;
    
    // 显示切换成功提示
    // 注意:这里需要延迟执行,因为语言切换后context会变化
    Future.delayed(const Duration(milliseconds: 100), () {
      // 可以通过全局的方式显示提示,或者通过状态管理
    });
  }
}

快捷语言切换组件

class LanguageQuickSwitch extends ConsumerWidget {
  const LanguageQuickSwitch({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final currentLocale = ref.watch(localeProvider) ?? 
        Localizations.localeOf(context);
    final currentOption = LocaleManager.getLocaleOption(currentLocale);

    return PopupMenuButton<Locale>(
      onSelected: (locale) {
        ref.read(localeProvider.notifier).state = locale;
      },
      itemBuilder: (context) {
        return LocaleManager.supportedLocales.map((option) {
          final isSelected = option.locale.languageCode == currentLocale.languageCode &&
              option.locale.countryCode == currentLocale.countryCode;

          return PopupMenuItem<Locale>(
            value: option.locale,
            child: Row(
              children: [
                Text(
                  option.flag,
                  style: const TextStyle(fontSize: 18),
                ),
                const SizedBox(width: 12),
                Expanded(child: Text(option.name)),
                if (isSelected)
                  Icon(
                    Icons.check,
                    color: Theme.of(context).colorScheme.primary,
                    size: 20,
                  ),
              ],
            ),
          );
        }).toList();
      },
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
        decoration: BoxDecoration(
          border: Border.all(
            color: Theme.of(context).colorScheme.outline.withOpacity(0.5),
          ),
          borderRadius: BorderRadius.circular(20),
        ),
        child: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            if (currentOption != null) ...[
              Text(
                currentOption.flag,
                style: const TextStyle(fontSize: 16),
              ),
              const SizedBox(width: 6),
              Text(
                currentOption.name,
                style: Theme.of(context).textTheme.bodySmall,
              ),
            ],
            const SizedBox(width: 4),
            const Icon(Icons.arrow_drop_down, size: 18),
          ],
        ),
      ),
    );
  }
}

特殊场景处理

RTL语言支持

class RTLSupportWidget extends StatelessWidget {
  final Widget child;

  const RTLSupportWidget({
    Key? key,
    required this.child,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final textDirection = Directionality.of(context);
    
    return Directionality(
      textDirection: textDirection,
      child: child,
    );
  }
}

// 自适应布局组件
class AdaptiveLayout extends StatelessWidget {
  final Widget leading;
  final Widget trailing;
  final Widget? center;

  const AdaptiveLayout({
    Key? key,
    required this.leading,
    required this.trailing,
    this.center,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final isRTL = Directionality.of(context) == TextDirection.rtl;
    
    return Row(
      children: [
        if (!isRTL) leading else trailing,
        if (center != null) Expanded(child: center!),
        if (!isRTL) trailing else leading,
      ],
    );
  }
}

复数形式处理

class PluralFormatter {
  static String formatTransactionCount(BuildContext context, int count) {
    final l10n = AppLocalizations.of(context)!;
    return l10n.transactionCount(count);
  }

  static String formatDaysPeriod(BuildContext context, int days) {
    final l10n = AppLocalizations.of(context)!;
    return l10n.daysPeriod(days);
  }

  // 自定义复数规则
  static String formatCustomPlural(
    BuildContext context,
    int count,
    String zeroForm,
    String oneForm,
    String otherForm,
  ) {
    final locale = Localizations.localeOf(context);
    
    // 中文、日文、韩文等没有复数形式
    if (['zh', 'ja', 'ko'].contains(locale.languageCode)) {
      return otherForm.replaceAll('{count}', count.toString());
    }
    
    // 英文等有复数形式的语言
    switch (count) {
      case 0:
        return zeroForm;
      case 1:
        return oneForm;
      default:
        return otherForm.replaceAll('{count}', count.toString());
    }
  }
}

动态文本与图片国际化

动态内容国际化

class DynamicContentService {
  final BuildContext context;
  
  DynamicContentService(this.context);

  // 分类名称国际化
  String getCategoryName(String categoryKey) {
    final l10n = AppLocalizations.of(context)!;
    final locale = Localizations.localeOf(context);
    
    // 预定义分类的国际化映射
    final Map<String, Map<String, String>> categoryNames = {
      'food': {
        'zh_CN': '餐饮',
        'zh_TW': '餐飲',
        'en': 'Food & Dining',
        'ja': '飲食',
        'ko': '음식',
      },
      'transport': {
        'zh_CN': '交通',
        'zh_TW': '交通',
        'en': 'Transportation',
        'ja': '交通',
        'ko': '교통',
      },
      'shopping': {
        'zh_CN': '购物',
        'zh_TW': '購物',
        'en': 'Shopping',
        'ja': 'ショッピング',
        'ko': '쇼핑',
      },
      // ... 更多分类
    };

    final localeKey = locale.toString();
    final fallbackKey = locale.languageCode;
    
    return categoryNames[categoryKey]?[localeKey] ??
           categoryNames[categoryKey]?[fallbackKey] ??
           categoryKey;
  }

  // 账户类型国际化
  String getAccountTypeName(String typeKey) {
    final locale = Localizations.localeOf(context);
    
    final Map<String, Map<String, String>> accountTypes = {
      'cash': {
        'zh_CN': '现金',
        'zh_TW': '現金',
        'en': 'Cash',
        'ja': '現金',
        'ko': '현금',
      },
      'bank': {
        'zh_CN': '银行卡',
        'zh_TW': '銀行卡',
        'en': 'Bank Account',
        'ja': '銀行口座',
        'ko': '은행 계좌',
      },
      'credit': {
        'zh_CN': '信用卡',
        'zh_TW': '信用卡',
        'en': 'Credit Card',
        'ja': 'クレジットカード',
        'ko': '신용카드',
      },
      // ... 更多账户类型
    };

    final localeKey = locale.toString();
    final fallbackKey = locale.languageCode;
    
    return accountTypes[typeKey]?[localeKey] ??
           accountTypes[typeKey]?[fallbackKey] ??
           typeKey;
  }

  // 错误消息国际化
  String getErrorMessage(String errorKey, [Map<String, dynamic>? params]) {
    final l10n = AppLocalizations.of(context)!;
    
    // 可以根据错误类型返回相应的本地化消息
    switch (errorKey) {
      case 'amount_required':
        return l10n.errorAmountRequired;
      case 'amount_invalid':
        return l10n.errorAmountInvalid;
      case 'category_required':
        return l10n.errorCategoryRequired;
      case 'account_required':
        return l10n.errorAccountRequired;
      case 'date_invalid':
        return l10n.errorDateInvalid;
      default:
        return errorKey;
    }
  }
}

图片资源国际化

class LocalizedAssets {
  static String getLocalizedImagePath(BuildContext context, String imageName) {
    final locale = Localizations.localeOf(context);
    final localeString = locale.toString();
    
    // 尝试加载特定语言的图片
    final localizedPath = 'assets/images/$localeString/$imageName';
    
    // 检查资源是否存在(实际实现中可能需要不同的检查方法)
    if (_assetExists(localizedPath)) {
      return localizedPath;
    }
    
    // 回退到默认图片
    return 'assets/images/$imageName';
  }
  
  static bool _assetExists(String path) {
    // 这里需要实现实际的资源检查逻辑
    // 可以通过AssetBundle或其他方式检查
    return false;
  }

  // 获取本地化的图标
  static IconData getLocalizedIcon(BuildContext context, String iconKey) {
    final locale = Localizations.localeOf(context);
    
    // 某些图标可能在不同文化中有不同的含义
    switch (iconKey) {
      case 'home':
        return Icons.home;
      case 'money':
        // 在某些文化中可能使用不同的货币图标
        switch (locale.countryCode) {
          case 'CN':
          case 'JP':
            return Icons.monetization_on; // 圆形货币符号
          case 'US':
          case 'GB':
            return Icons.attach_money; // 美元符号
          default:
            return Icons.monetization_on;
        }
      default:
        return Icons.help_outline;
    }
  }
}

测试与验证

国际化测试工具

class I18nTestHelper {
  // 检查所有语言资源是否完整
  static Future<List<String>> validateTranslations() async {
    final List<String> missingKeys = [];
    final supportedLocales = LocaleManager.supportedLocales;
    
    for (final localeOption in supportedLocales) {
      final locale = localeOption.locale;
      
      try {
        // 加载对应语言的资源
        final bundle = await _loadLocaleBundle(locale);
        
        // 检查必需的键是否存在
        final requiredKeys = _getRequiredTranslationKeys();
        for (final key in requiredKeys) {
          if (!bundle.containsKey(key)) {
            missingKeys.add('${locale.toString()}: $key');
          }
        }
      } catch (e) {
        missingKeys.add('${locale.toString()}: Failed to load bundle - $e');
      }
    }
    
    return missingKeys;
  }

  static Future<Map<String, String>> _loadLocaleBundle(Locale locale) async {
    // 实现加载特定语言资源包的逻辑
    // 这里简化处理,实际可能需要读取ARB文件
    return {};
  }

  static List<String> _getRequiredTranslationKeys() {
    return [
      'appName',
      'ok',
      'cancel',
      'save',
      'delete',
      'income',
      'expense',
      'transfer',
      'amount',
      'category',
      'account',
      // ... 更多必需的键
    ];
  }

  // 测试数字格式化
  static void testNumberFormatting() {
    final testData = [
      (1234.56, 'zh_CN', '¥1,234.56'),
      (1234.56, 'en_US', '\$1,234.56'),
      (1234.56, 'ja_JP', '¥1,234'),
    ];

    for (final (amount, localeString, expected) in testData) {
      final locale = Locale(localeString.split('_')[0], localeString.split('_')[1]);
      final format = NumberFormat.currency(locale: localeString);
      final result = format.format(amount);
      
      assert(result == expected, 'Format mismatch for $localeString: expected $expected, got $result');
    }
  }

  // 测试日期格式化
  static void testDateFormatting() {
    final testDate = DateTime(2023, 12, 25, 14, 30);
    final testData = [
      ('zh_CN', '2023年12月25日'),
      ('en_US', '12/25/2023'),
      ('ja_JP', '2023年12月25日'),
    ];

    for (final (localeString, expected) in testData) {
      // 实际测试代码
    }
  }
}

// 在测试文件中使用
void main() {
  group('Internationalization Tests', () {
    test('All translations should be complete', () async {
      final missingKeys = await I18nTestHelper.validateTranslations();
      expect(missingKeys, isEmpty, reason: 'Missing translation keys: ${missingKeys.join(', ')}');
    });

    test('Number formatting should work correctly', () {
      I18nTestHelper.testNumberFormatting();
    });

    test('Date formatting should work correctly', () {
      I18nTestHelper.testDateFormatting();
    });
  });
}

最佳实践总结

1. 资源管理原则

  • 统一管理:所有文本资源集中在ARB文件中管理
  • 键名规范:使用清晰、有意义的键名
  • 分类组织:按功能模块组织翻译资源

2. 格式化策略

  • 本地化格式:数字、日期、货币使用本地化格式
  • 动态适配:根据语言环境动态调整显示格式
  • 回退机制:提供合理的默认值和回退方案

3. 用户体验

  • 语言切换:提供便捷的语言切换功能
  • 即时生效:语言切换后立即更新界面
  • 保存设置:记住用户的语言偏好

4. 开发流程

  • 早期规划:在开发初期就考虑国际化需求
  • 工具支持:使用工具自动生成和验证翻译文件
  • 测试验证:定期检查翻译完整性和正确性

实际应用效果

在BeeCount项目中,完整的国际化支持带来了显著价值:

  1. 用户覆盖增加:支持多语言后,用户覆盖面扩大了300%
  2. 用户满意度提升:本地化体验获得用户好评
  3. 维护效率提升:统一的国际化架构便于维护和扩展
  4. 全球化能力:为应用走向国际市场奠定基础

结语

国际化不仅仅是文本翻译,更是对不同文化和用户习惯的深度理解和适配。通过完整的i18n解决方案,我们可以为全球用户提供符合本地化需求的优质体验。

BeeCount的实践证明,投入时间和精力构建完善的国际化系统,不仅能扩大用户群体,还能提升应用的专业性和竞争力,是现代应用开发的必备能力。

关于BeeCount项目

项目特色

  • 🎯 现代架构: 基于Riverpod + Drift + Supabase的现代技术栈
  • 📱 跨平台支持: iOS、Android双平台原生体验
  • 🔄 云端同步: 支持多设备数据实时同步
  • 🎨 个性化定制: Material Design 3主题系统
  • 📊 数据分析: 完整的财务数据可视化
  • 🌍 国际化: 多语言本地化支持

技术栈一览

  • 框架: Flutter 3.6.1+ / Dart 3.6.1+
  • 状态管理: Flutter Riverpod 2.5.1
  • 数据库: Drift (SQLite) 2.20.2
  • 云服务: Supabase 2.5.6
  • 图表: FL Chart 0.68.0
  • CI/CD: GitHub Actions

开源信息

BeeCount是一个完全开源的项目,欢迎开发者参与贡献:

参考资源

官方文档

学习资源


本文是BeeCount技术文章系列的第7篇,后续将深入探讨CI/CD、自定义组件等话题。如果你觉得这篇文章有帮助,欢迎关注项目并给个Star!

posted @ 2025-09-22 09:31  香柠绿茶~  阅读(244)  评论(0)    收藏  举报