diff --git a/dev/tools/localization/bin/gen_l10n.dart b/dev/tools/localization/bin/gen_l10n.dart index 81163be244a..a23580da348 100644 --- a/dev/tools/localization/bin/gen_l10n.dart +++ b/dev/tools/localization/bin/gen_l10n.dart @@ -81,6 +81,24 @@ void main(List arguments) { 'Alternatively, see the `header` option to pass in a string ' 'for a simpler header.' ); + parser.addFlag( + 'use-deferred-loading', + defaultsTo: false, + help: 'Whether to generate the Dart localization file with locales imported' + ' as deferred, allowing for lazy loading of each locale in Flutter web.\n' + '\n' + 'This can reduce a web app’s initial startup time by decreasing the ' + 'size of the JavaScript bundle. When this flag is set to true, the ' + 'messages for a particular locale are only downloaded and loaded by the ' + 'Flutter app as they are needed. For projects with a lot of different ' + 'locales and many localization strings, it can be an performance ' + 'improvement to have deferred loading. For projects with a small number ' + 'of locales, the difference is negligible, and might slow down the start ' + 'up compared to bundling the localizations with the rest of the ' + 'application.\n\n' + 'Note that this flag does not affect other platforms such as mobile or ' + 'desktop.', + ); final argslib.ArgResults results = parser.parse(arguments); if (results['help'] == true) { @@ -98,6 +116,7 @@ void main(List arguments) { final String preferredSupportedLocaleString = results['preferred-supported-locales'] as String; final String headerString = results['header'] as String; final String headerFile = results['header-file'] as String; + final bool useDeferredLoading = results['use-deferred-loading'] as bool; const local.LocalFileSystem fs = local.LocalFileSystem(); final LocalizationsGenerator localizationsGenerator = LocalizationsGenerator(fs); @@ -112,6 +131,7 @@ void main(List arguments) { preferredSupportedLocaleString: preferredSupportedLocaleString, headerString: headerString, headerFile: headerFile, + useDeferredLoading: useDeferredLoading, ) ..loadResources() ..writeOutputFile() diff --git a/dev/tools/localization/gen_l10n.dart b/dev/tools/localization/gen_l10n.dart index dac09d99517..8baf0cb6d2d 100644 --- a/dev/tools/localization/gen_l10n.dart +++ b/dev/tools/localization/gen_l10n.dart @@ -206,7 +206,10 @@ String generateBaseClassMethod(Message message) { .replaceAll('@(name)', message.resourceId); } -String _generateLookupByAllCodes(AppResourceBundleCollection allBundles, String className) { +String _generateLookupByAllCodes( + AppResourceBundleCollection allBundles, + String Function(LocaleInfo) generateSwitchClauseTemplate, +) { final Iterable localesWithAllCodes = allBundles.locales.where((LocaleInfo locale) { return locale.scriptCode != null && locale.countryCode != null; }); @@ -216,9 +219,8 @@ String _generateLookupByAllCodes(AppResourceBundleCollection allBundles, String } final Iterable switchClauses = localesWithAllCodes.map((LocaleInfo locale) { - return switchClauseTemplate - .replaceAll('@(case)', locale.toString()) - .replaceAll('@(class)', '$className${locale.camelCase()}'); + return generateSwitchClauseTemplate(locale) + .replaceAll('@(case)', locale.toString()); }); return allCodesLookupTemplate.replaceAll( @@ -227,7 +229,10 @@ String _generateLookupByAllCodes(AppResourceBundleCollection allBundles, String ); } -String _generateLookupByScriptCode(AppResourceBundleCollection allBundles, String className) { +String _generateLookupByScriptCode( + AppResourceBundleCollection allBundles, + String Function(LocaleInfo) generateSwitchClauseTemplate, +) { final Iterable switchClauses = allBundles.languages.map((String language) { final Iterable locales = allBundles.localesForLanguage(language); final Iterable localesWithScriptCodes = locales.where((LocaleInfo locale) { @@ -240,11 +245,9 @@ String _generateLookupByScriptCode(AppResourceBundleCollection allBundles, Strin return nestedSwitchTemplate .replaceAll('@(languageCode)', language) .replaceAll('@(code)', 'scriptCode') - .replaceAll('@(class)', '$className${LocaleInfo.fromString(language).camelCase()}') .replaceAll('@(switchClauses)', localesWithScriptCodes.map((LocaleInfo locale) { - return switchClauseTemplate - .replaceAll('@(case)', locale.scriptCode) - .replaceAll('@(class)', '$className${locale.camelCase()}'); + return generateSwitchClauseTemplate(locale) + .replaceAll('@(case)', locale.scriptCode); }).join('\n ')); }).where((String switchClause) => switchClause != null); @@ -258,7 +261,10 @@ String _generateLookupByScriptCode(AppResourceBundleCollection allBundles, Strin ); } -String _generateLookupByCountryCode(AppResourceBundleCollection allBundles, String className) { +String _generateLookupByCountryCode( + AppResourceBundleCollection allBundles, + String Function(LocaleInfo) generateSwitchClauseTemplate, +) { final Iterable switchClauses = allBundles.languages.map((String language) { final Iterable locales = allBundles.localesForLanguage(language); final Iterable localesWithCountryCodes = locales.where((LocaleInfo locale) { @@ -271,11 +277,9 @@ String _generateLookupByCountryCode(AppResourceBundleCollection allBundles, Stri return nestedSwitchTemplate .replaceAll('@(languageCode)', language) .replaceAll('@(code)', 'countryCode') - .replaceAll('@(class)', '$className${LocaleInfo.fromString(language).camelCase()}') .replaceAll('@(switchClauses)', localesWithCountryCodes.map((LocaleInfo locale) { - return switchClauseTemplate - .replaceAll('@(case)', locale.countryCode) - .replaceAll('@(class)', '$className${locale.camelCase()}'); + return generateSwitchClauseTemplate(locale) + .replaceAll('@(case)', locale.countryCode); }).join('\n ')); }).where((String switchClause) => switchClause != null); @@ -288,7 +292,10 @@ String _generateLookupByCountryCode(AppResourceBundleCollection allBundles, Stri .replaceAll('@(switchClauses)', switchClauses.join('\n ')); } -String _generateLookupByLanguageCode(AppResourceBundleCollection allBundles, String className) { +String _generateLookupByLanguageCode( + AppResourceBundleCollection allBundles, + String Function(LocaleInfo) generateSwitchClauseTemplate, +) { final Iterable switchClauses = allBundles.languages.map((String language) { final Iterable locales = allBundles.localesForLanguage(language); final Iterable localesWithLanguageCode = locales.where((LocaleInfo locale) { @@ -299,9 +306,8 @@ String _generateLookupByLanguageCode(AppResourceBundleCollection allBundles, Str return null; return localesWithLanguageCode.map((LocaleInfo locale) { - return switchClauseTemplate - .replaceAll('@(case)', locale.languageCode) - .replaceAll('@(class)', '$className${locale.camelCase()}'); + return generateSwitchClauseTemplate(locale) + .replaceAll('@(case)', locale.languageCode); }).join('\n '); }).where((String switchClause) => switchClause != null); @@ -314,12 +320,67 @@ String _generateLookupByLanguageCode(AppResourceBundleCollection allBundles, Str .replaceAll('@(switchClauses)', switchClauses.join('\n ')); } -String _generateLookupBody(AppResourceBundleCollection allBundles, String className) { +String _generateLookupBody( + AppResourceBundleCollection allBundles, + String className, + bool useDeferredLoading, + String fileName, +) { + final String Function(LocaleInfo) generateSwitchClauseTemplate = (LocaleInfo locale) { + return (useDeferredLoading ? + switchClauseDeferredLoadingTemplate : switchClauseTemplate) + .replaceAll('@(localeClass)', '$className${locale.camelCase()}') + .replaceAll('@(appClass)', className) + .replaceAll('@(library)', '${fileName}_${locale.languageCode}'); + }; return lookupBodyTemplate - .replaceAll('@(lookupAllCodesSpecified)', _generateLookupByAllCodes(allBundles, className)) - .replaceAll('@(lookupScriptCodeSpecified)', _generateLookupByScriptCode(allBundles, className)) - .replaceAll('@(lookupCountryCodeSpecified)', _generateLookupByCountryCode(allBundles, className)) - .replaceAll('@(lookupLanguageCodeSpecified)', _generateLookupByLanguageCode(allBundles, className)); + .replaceAll('@(lookupAllCodesSpecified)', _generateLookupByAllCodes( + allBundles, + generateSwitchClauseTemplate, + )) + .replaceAll('@(lookupScriptCodeSpecified)', _generateLookupByScriptCode( + allBundles, + generateSwitchClauseTemplate, + )) + .replaceAll('@(lookupCountryCodeSpecified)', _generateLookupByCountryCode( + allBundles, + generateSwitchClauseTemplate, + )) + .replaceAll('@(lookupLanguageCodeSpecified)', _generateLookupByLanguageCode( + allBundles, + generateSwitchClauseTemplate, + )); +} + +String _generateDelegateClass({ + AppResourceBundleCollection allBundles, + String className, + Set supportedLanguageCodes, + bool useDeferredLoading, + String fileName, +}) { + + final String lookupBody = _generateLookupBody( + allBundles, + className, + useDeferredLoading, + fileName, + ); + final String loadBody = ( + useDeferredLoading ? loadBodyDeferredLoadingTemplate : loadBodyTemplate + ) + .replaceAll('@(class)', className) + .replaceAll('@(lookupName)', '_lookup$className'); + final String lookupFunction = (useDeferredLoading ? + lookupFunctionDeferredLoadingTemplate : lookupFunctionTemplate) + .replaceAll('@(class)', className) + .replaceAll('@(lookupName)', '_lookup$className') + .replaceAll('@(lookupBody)', lookupBody); + return delegateClassTemplate + .replaceAll('@(class)', className) + .replaceAll('@(loadBody)', loadBody) + .replaceAll('@(supportedLanguageCodes)', supportedLanguageCodes.join(', ')) + .replaceAll('@(lookupFunction)', lookupFunction); } class LocalizationsGenerator { @@ -397,6 +458,23 @@ class LocalizationsGenerator { final Map> _unimplementedMessages = >{}; + /// Whether to generate the Dart localization file with locales imported as + /// deferred, allowing for lazy loading of each locale in Flutter web. + /// + /// This can reduce a web app’s initial startup time by decreasing the size of + /// the JavaScript bundle. When [_useDeferredLoading] is set to true, the + /// messages for a particular locale are only downloaded and loaded by the + /// Flutter app as they are needed. For projects with a lot of different + /// locales and many localization strings, it can be an performance + /// improvement to have deferred loading. For projects with a small number of + /// locales, the difference is negligible, and might slow down the start up + /// compared to bundling the localizations with the rest of the application. + /// + /// Note that this flag does not affect other platforms such as mobile or + /// desktop. + bool get useDeferredLoading => _useDeferredLoading; + bool _useDeferredLoading; + /// Initializes [l10nDirectory], [templateArbFile], [outputFile] and [className]. /// /// Throws an [L10nException] when a provided configuration is not allowed @@ -412,12 +490,14 @@ class LocalizationsGenerator { String preferredSupportedLocaleString, String headerString, String headerFile, + bool useDeferredLoading = false, }) { setL10nDirectory(l10nDirectoryPath); setTemplateArbFile(templateArbFileName); setOutputFile(outputFileString); setPreferredSupportedLocales(preferredSupportedLocaleString); _setHeader(headerString, headerFile); + _setUseDeferredLoading(useDeferredLoading); className = classNameString; } @@ -550,6 +630,13 @@ class LocalizationsGenerator { } } + void _setUseDeferredLoading(bool useDeferredLoading) { + if (useDeferredLoading == null) { + throw L10nException('useDeferredLoading argument cannot be null.'); + } + _useDeferredLoading = useDeferredLoading; + } + static bool _isValidGetterAndMethodName(String name) { // Public Dart method name must not start with an underscore if (name[0] == '_') @@ -746,13 +833,26 @@ class LocalizationsGenerator { } } - final Iterable localeImports = supportedLocales + final List sortedClassImports = supportedLocales .where((LocaleInfo locale) => isBaseClassLocale(locale, locale.languageCode)) .map((LocaleInfo locale) { - return "import '${fileName}_${locale.toString()}.dart';"; - }); + final String library = '${fileName}_${locale.toString()}'; + if (useDeferredLoading) { + return "import '$library.dart' deferred as $library;"; + } else { + return "import '$library.dart';"; + } + }) + .toList() + ..sort(); - final String lookupBody = _generateLookupBody(_allBundles, className); + final String delegateClass = _generateDelegateClass( + allBundles: _allBundles, + className: className, + supportedLanguageCodes: supportedLanguageCodes, + useDeferredLoading: useDeferredLoading, + fileName: fileName, + ); return fileTemplate .replaceAll('@(header)', header) @@ -761,9 +861,8 @@ class LocalizationsGenerator { .replaceAll('@(importFile)', '$directory/$outputFileName') .replaceAll('@(supportedLocales)', supportedLocalesCode.join(',\n ')) .replaceAll('@(supportedLanguageCodes)', supportedLanguageCodes.join(', ')) - .replaceAll('@(messageClassImports)', localeImports.join('\n')) - .replaceAll('@(lookupName)', '_lookup$className') - .replaceAll('@(lookupBody)', lookupBody); + .replaceAll('@(messageClassImports)', sortedClassImports.join('\n')) + .replaceAll('@(delegateClass)', delegateClass); } void writeOutputFile() { diff --git a/dev/tools/localization/gen_l10n_templates.dart b/dev/tools/localization/gen_l10n_templates.dart index 434e67c0aff..90cd6d89696 100644 --- a/dev/tools/localization/gen_l10n_templates.dart +++ b/dev/tools/localization/gen_l10n_templates.dart @@ -6,6 +6,7 @@ const String fileTemplate = ''' @(header) import 'dart:async'; +// ignore: unused_import import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; @@ -104,26 +105,7 @@ abstract class @(class) { @(methods)} -class _@(class)Delegate extends LocalizationsDelegate<@(class)> { - const _@(class)Delegate(); - - @override - Future<@(class)> load(Locale locale) { - return SynchronousFuture<@(class)>(@(lookupName)(locale)); - } - - @override - bool isSupported(Locale locale) => [@(supportedLanguageCodes)].contains(locale.languageCode); - - @override - bool shouldReload(_@(class)Delegate old) => false; -} - -@(class) @(lookupName)(Locale locale) { - @(lookupBody) - assert(false, '@(class).delegate failed to load unsupported locale "\$locale"'); - return null; -} +@(delegateClass) '''; const String numberFormatTemplate = ''' @@ -205,14 +187,67 @@ const String baseClassMethodTemplate = ''' String @(name)(@(parameters)); '''; +// DELEGATE CLASS TEMPLATES + +const String delegateClassTemplate = ''' +class _@(class)Delegate extends LocalizationsDelegate<@(class)> { + const _@(class)Delegate(); + + @override + Future<@(class)> load(Locale locale) { + @(loadBody) + } + + @override + bool isSupported(Locale locale) => [@(supportedLanguageCodes)].contains(locale.languageCode); + + @override + bool shouldReload(_@(class)Delegate old) => false; +} + +@(lookupFunction)'''; + +const String loadBodyTemplate = '''return SynchronousFuture<@(class)>(@(lookupName)(locale));'''; + +const String loadBodyDeferredLoadingTemplate = '''return @(lookupName)(locale);'''; + // DELEGATE LOOKUP TEMPLATES +const String lookupFunctionTemplate = ''' +@(class) @(lookupName)(Locale locale) { + @(lookupBody) + assert(false, '@(class).delegate failed to load unsupported locale "\$locale"'); + return null; +}'''; + +const String lookupFunctionDeferredLoadingTemplate = ''' +/// Lazy load the library for web, on other platforms we return the +/// localizations synchronously. +Future<@(class)> _loadLibraryForWeb( + Future Function() loadLibrary, + @(class) Function() localizationClosure, +) { + if (kIsWeb) { + return loadLibrary().then((dynamic _) => localizationClosure()); + } else { + return SynchronousFuture<@(class)>(localizationClosure()); + } +} + +Future<@(class)> @(lookupName)(Locale locale) { + @(lookupBody) + assert(false, '@(class).delegate failed to load unsupported locale "\$locale"'); + return null; +}'''; + const String lookupBodyTemplate = '''@(lookupAllCodesSpecified) @(lookupScriptCodeSpecified) @(lookupCountryCodeSpecified) @(lookupLanguageCodeSpecified)'''; -const String switchClauseTemplate = '''case '@(case)': return @(class)();'''; +const String switchClauseTemplate = '''case '@(case)': return @(localeClass)();'''; + +const String switchClauseDeferredLoadingTemplate = '''case '@(case)': return _loadLibraryForWeb(@(library).loadLibrary, () => @(library).@(localeClass)());'''; const String nestedSwitchTemplate = '''case '@(languageCode)': { switch (locale.@(code)) { diff --git a/dev/tools/test/localization/gen_l10n_test.dart b/dev/tools/test/localization/gen_l10n_test.dart index 6864cd10c09..12be1f19295 100644 --- a/dev/tools/test/localization/gen_l10n_test.dart +++ b/dev/tools/test/localization/gen_l10n_test.dart @@ -380,6 +380,28 @@ void main() { fail('Setting headerFile that does not exist should fail'); }); + test('setting useDefferedLoading to null should fail', () { + _standardFlutterDirectoryL10nSetup(fs); + + LocalizationsGenerator generator; + try { + generator = LocalizationsGenerator(fs); + generator.initialize( + l10nDirectoryPath: defaultArbPathString, + templateArbFileName: defaultTemplateArbFileName, + outputFileString: defaultOutputFileString, + classNameString: defaultClassNameString, + headerString: '/// Sample header', + useDeferredLoading: null, + ); + } on L10nException catch (e) { + expect(e.message, contains('useDeferredLoading argument cannot be null.')); + return; + } + + fail('Setting useDefferedLoading to null should fail'); + }); + group('loadResources', () { test('correctly initializes supportedLocales and supportedLanguageCodes properties', () { _standardFlutterDirectoryL10nSetup(fs); @@ -792,6 +814,67 @@ void main() { expect(englishLocalizationsFile, contains('class AppLocalizationsEn extends AppLocalizations')); }); + test('language imports are sorted when preferredSupportedLocaleString is given', () { + fs.currentDirectory.childDirectory('lib').childDirectory('l10n')..createSync(recursive: true) + ..childFile(defaultTemplateArbFileName).writeAsStringSync(singleMessageArbFileString) + ..childFile('app_zh.arb').writeAsStringSync(singleZhMessageArbFileString) + ..childFile('app_es.arb').writeAsStringSync(singleEsMessageArbFileString); + + const String preferredSupportedLocaleString = '["zh"]'; + final LocalizationsGenerator generator = LocalizationsGenerator(fs); + try { + generator.initialize( + l10nDirectoryPath: defaultArbPathString, + templateArbFileName: defaultTemplateArbFileName, + outputFileString: defaultOutputFileString, + classNameString: defaultClassNameString, + preferredSupportedLocaleString: preferredSupportedLocaleString, + ); + generator.loadResources(); + generator.writeOutputFile(); + } on Exception catch (e) { + fail('Generating output files should not fail: $e'); + } + + final String localizationsFile = fs.file( + path.join('lib', 'l10n', defaultOutputFileString), + ).readAsStringSync(); + expect(localizationsFile, contains( +''' +import '${defaultOutputFileString}_en.dart'; +import '${defaultOutputFileString}_es.dart'; +import '${defaultOutputFileString}_zh.dart'; +''')); + }); + + test('imports are deferred when useDeferredImports are set', () { + fs.currentDirectory.childDirectory('lib').childDirectory('l10n')..createSync(recursive: true) + ..childFile(defaultTemplateArbFileName).writeAsStringSync(singleMessageArbFileString); + + final LocalizationsGenerator generator = LocalizationsGenerator(fs); + try { + generator.initialize( + l10nDirectoryPath: defaultArbPathString, + templateArbFileName: defaultTemplateArbFileName, + outputFileString: defaultOutputFileString, + classNameString: defaultClassNameString, + useDeferredLoading: true, + ); + generator.loadResources(); + generator.writeOutputFile(); + } on Exception catch (e) { + fail('Generating output files should not fail: $e'); + } + + final String localizationsFile = fs.file( + path.join('lib', 'l10n', defaultOutputFileString), + ).readAsStringSync(); + expect(localizationsFile, contains( +''' +import '${defaultOutputFileString}_en.dart' deferred as ${defaultOutputFileString}_en; +''')); + }); + group('DateTime tests', () { test('throws an exception when improperly formatted date is passed in', () { const String singleDateMessageArbFileString = ''' diff --git a/packages/flutter_tools/test/integration.shard/gen_l10n_test.dart b/packages/flutter_tools/test/integration.shard/gen_l10n_test.dart index 949b0ecf56e..017c260842d 100644 --- a/packages/flutter_tools/test/integration.shard/gen_l10n_test.dart +++ b/packages/flutter_tools/test/integration.shard/gen_l10n_test.dart @@ -48,7 +48,7 @@ void main() { } } - test('generated l10n classes produce expected localized strings', () async { + void setUpAndRunGenL10n({List args}) { // Get the intl packages before running gen_l10n. final String flutterBin = globals.platform.isWindows ? 'flutter.bat' : 'flutter'; final String flutterPath = globals.fs.path.join(getFlutterRoot(), 'bin', flutterBin); @@ -58,8 +58,10 @@ void main() { final String genL10nPath = globals.fs.path.join(getFlutterRoot(), 'dev', 'tools', 'localization', 'bin', 'gen_l10n.dart'); final String dartBin = globals.platform.isWindows ? 'dart.exe' : 'dart'; final String dartPath = globals.fs.path.join(getFlutterRoot(), 'bin', 'cache', 'dart-sdk', 'bin', dartBin); - runCommand([dartPath, genL10nPath]); + runCommand([dartPath, genL10nPath, args?.join(' ')]); + } + Future runApp() async { // Run the app defined in GenL10nProject.main and wait for it to // send '#l10n END' to its stdout. final Completer l10nEnd = Completer(); @@ -75,6 +77,10 @@ void main() { await _flutter.run(); await l10nEnd.future; await subscription.cancel(); + return stdout; + } + + void expectOutput(StringBuffer stdout) { expect(stdout.toString(), '#l10n 0 (--- supportedLocales tests ---)\n' '#l10n 1 (supportedLocales[0]: languageCode: en, countryCode: null, scriptCode: null)\n' @@ -133,5 +139,17 @@ void main() { '#l10n 54 (Flutter is "amazing", times 2!)\n' '#l10n END\n' ); + } + + test('generated l10n classes produce expected localized strings', () async { + setUpAndRunGenL10n(); + final StringBuffer stdout = await runApp(); + expectOutput(stdout); + }); + + test('generated l10n classes produce expected localized strings with deferred loading', () async { + setUpAndRunGenL10n(args: ['--use-deferred-loading']); + final StringBuffer stdout = await runApp(); + expectOutput(stdout); }); }