Add option for deferred loading to gen_l10n (#53824)
diff --git a/dev/tools/localization/bin/gen_l10n.dart b/dev/tools/localization/bin/gen_l10n.dart
index 81163be..a23580d 100644
--- a/dev/tools/localization/bin/gen_l10n.dart
+++ b/dev/tools/localization/bin/gen_l10n.dart
@@ -81,6 +81,24 @@
'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 @@
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 @@
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 dac09d9..8baf0cb 100644
--- a/dev/tools/localization/gen_l10n.dart
+++ b/dev/tools/localization/gen_l10n.dart
@@ -206,7 +206,10 @@
.replaceAll('@(name)', message.resourceId);
}
-String _generateLookupByAllCodes(AppResourceBundleCollection allBundles, String className) {
+String _generateLookupByAllCodes(
+ AppResourceBundleCollection allBundles,
+ String Function(LocaleInfo) generateSwitchClauseTemplate,
+) {
final Iterable<LocaleInfo> localesWithAllCodes = allBundles.locales.where((LocaleInfo locale) {
return locale.scriptCode != null && locale.countryCode != null;
});
@@ -216,9 +219,8 @@
}
final Iterable<String> switchClauses = localesWithAllCodes.map<String>((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 _generateLookupByScriptCode(AppResourceBundleCollection allBundles, String className) {
+String _generateLookupByScriptCode(
+ AppResourceBundleCollection allBundles,
+ String Function(LocaleInfo) generateSwitchClauseTemplate,
+) {
final Iterable<String> switchClauses = allBundles.languages.map((String language) {
final Iterable<LocaleInfo> locales = allBundles.localesForLanguage(language);
final Iterable<LocaleInfo> localesWithScriptCodes = locales.where((LocaleInfo locale) {
@@ -240,11 +245,9 @@
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 _generateLookupByCountryCode(AppResourceBundleCollection allBundles, String className) {
+String _generateLookupByCountryCode(
+ AppResourceBundleCollection allBundles,
+ String Function(LocaleInfo) generateSwitchClauseTemplate,
+) {
final Iterable<String> switchClauses = allBundles.languages.map((String language) {
final Iterable<LocaleInfo> locales = allBundles.localesForLanguage(language);
final Iterable<LocaleInfo> localesWithCountryCodes = locales.where((LocaleInfo locale) {
@@ -271,11 +277,9 @@
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 @@
.replaceAll('@(switchClauses)', switchClauses.join('\n '));
}
-String _generateLookupByLanguageCode(AppResourceBundleCollection allBundles, String className) {
+String _generateLookupByLanguageCode(
+ AppResourceBundleCollection allBundles,
+ String Function(LocaleInfo) generateSwitchClauseTemplate,
+) {
final Iterable<String> switchClauses = allBundles.languages.map((String language) {
final Iterable<LocaleInfo> locales = allBundles.localesForLanguage(language);
final Iterable<LocaleInfo> localesWithLanguageCode = locales.where((LocaleInfo locale) {
@@ -299,9 +306,8 @@
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 @@
.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<String> 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 @@
final Map<LocaleInfo, List<String>> _unimplementedMessages = <LocaleInfo, List<String>>{};
+ /// 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 @@
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 @@
}
}
+ 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 @@
}
}
- final Iterable<String> localeImports = supportedLocales
+ final List<String> 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 @@
.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 434e67c..90cd6d8 100644
--- a/dev/tools/localization/gen_l10n_templates.dart
+++ b/dev/tools/localization/gen_l10n_templates.dart
@@ -6,6 +6,7 @@
@(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 @@
@(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) => <String>[@(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 @@
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) => <String>[@(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<dynamic> 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 6864cd1..12be1f1 100644
--- a/dev/tools/test/localization/gen_l10n_test.dart
+++ b/dev/tools/test/localization/gen_l10n_test.dart
@@ -380,6 +380,28 @@
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 @@
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 949b0ec..017c260 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 @@
}
}
- test('generated l10n classes produce expected localized strings', () async {
+ void setUpAndRunGenL10n({List<String> 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 @@
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(<String>[dartPath, genL10nPath]);
+ runCommand(<String>[dartPath, genL10nPath, args?.join(' ')]);
+ }
+ Future<StringBuffer> runApp() async {
// Run the app defined in GenL10nProject.main and wait for it to
// send '#l10n END' to its stdout.
final Completer<void> l10nEnd = Completer<void>();
@@ -75,6 +77,10 @@
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 @@
'#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: <String>['--use-deferred-loading']);
+ final StringBuffer stdout = await runApp();
+ expectOutput(stdout);
});
}