-
Notifications
You must be signed in to change notification settings - Fork 29.8k
Font subset in the tool #49737
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Font subset in the tool #49737
Conversation
| // Use of this source code is governed by a BSD-style license that can be | ||
| // found in the LICENSE file. | ||
|
|
||
| import 'package:flutter_tools/src/build_system/targets/assets.dart'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: import
| codePoints: iconData[entry.key], | ||
| ); | ||
| } | ||
| globals.printTrace('Found iconData: $result'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This might be a lot of data to see in devicelab logs
| outputPath, | ||
| inputPath, | ||
| ]; | ||
| globals.printTrace('Starting font-subset: ${cmd.join(' ')}...'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would printTrace less here since the command runs in a pool with concurrency they'll be stacked up in ways that don't make sense
| } | ||
|
|
||
| /// Returns a map of [FontSubsetData] keyed by relative path. | ||
| Future<Map<String, FontSubsetData>> _getIconData(Environment environment, DevFSStringContent fontManifest) async { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we collect the various private methods here into a single class? Once this is done, it would be better if injected values like the filesystem, artifacts, or logger came from the class instead. For example, see https://github.com/flutter/flutter/pull/48444/files
zanderso
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1 to creating (a) class(es), probably in a new file, for the font subsetting logic.
| } | ||
| print(fontSubset); | ||
| if (fontSubset == true) { | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: extra newline
| case Artifact.fontSubset: | ||
| case Artifact.constFinder: | ||
| return _cache.getArtifactDirectory('font-subset').childFile(_artifactToFileName(artifact, platform, mode)).path; | ||
| return _cache.getArtifactDirectory('engine').childDirectory(getNameForTargetPlatform(platform)).childFile(_artifactToFileName(artifact, platform, mode)).path; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: long line:
return _cache.getArtifactDirectory('engine')
.childDirectory(getNameForTargetPlatform(platform))
.childFile(_artifactToFileName(artifact, platform, mode))
.path;| final List<File> outputs = <File>[]; | ||
|
|
||
| final Map<String, FontSubsetData> iconData = useFontSubset | ||
| ? await _getIconData( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: 2 space indent on a continued line.
| inputs.add(globals.fs.file(content.file.path)); | ||
| await (content.file as File).copy(file.path); | ||
| final FontSubsetData fontSubsetData = iconData[entry.key]; | ||
| if (fontSubsetData != null) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is getting a bit too nested. Maybe:
if (content is! DevFSFileContent || content.file is! File) {
await file.writeAsBytes(await entry.value.contentAsBytes());
return;
}
inputs.add(globals.fs.file(content.file.path));
final FontSubsetData fontSubsetData = iconData[entry.key];
if (fontSubsetData == null) {
await (content.file as File).copy(file.path);
return;
}
assert(useFontSubset);
await _subsetFont(
environment: environment,
inputPath: content.file.path,
outputPath: file.path,
fontSubsetData: fontSubsetData,
);There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This fails analysis - analyzer isn't smart enough to see that we've said content is! ... || and then know that otherwise it is.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Refactored to a class and made it a bit easier to use - now subsetFont returns a bool to tell you whether it actually did the thing, and that eliminates a level of nesting and a few lines of code in this file.
| final Process fontSubsetProcess = await globals.processManager.start(cmd); | ||
| final String codePoints = fontSubsetData.codePoints.join(' '); | ||
| globals.printTrace('Started, writing $codePoints...'); | ||
| fontSubsetProcess.stdin.writeln(codePoints); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Writing to the subprocess' stdin can throw an exception if the subprocess exits early for some reason. I'd suggest wrapping these three lines in a try {} catch {}, and letting the exit code test handle that case.
| final Map<String, String> result = <String, String>{}; | ||
| final List<Map<String, dynamic>> fontList = _getList(json.decode(fontManifestData)); | ||
| for (final Map<String, dynamic> map in fontList) { | ||
| final String familyKey = map['family'] as String; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need to validate that this is a string?
| if (fonts.length != 1) { | ||
| throwToolExit('This tool cannot process icon fonts with multiple fonts in a single family.'); | ||
| } | ||
| result[familyKey] = fonts.first['asset'] as String; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ditto
| if (constFinderProcessResult.exitCode != 0) { | ||
| throwToolExit('ConstFinder failure: ${constFinderProcessResult.stderr}'); | ||
| } | ||
| final Map<String, dynamic> constFinderMap = json.decode(constFinderProcessResult.stdout as String) as Map<String, dynamic>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd recommend an explicit check here that the json matches the schema your expecting.
| Map<String, String> _parseDefines(List<String> values) { | ||
| final Map<String, String> results = <String, String>{}; | ||
| for (final String chunk in values) { | ||
| print(chunk); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
leftover debug print?
| // Use of this source code is governed by a BSD-style license that can be | ||
| // found in the LICENSE file. | ||
|
|
||
| import 'package:flutter_tools/src/build_system/targets/assets.dart'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
import '../build_system/targets/assets.dart';
but it looks like it's unused?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I started web and it won't quite work yet - reverted this part.
| test('assemble release', () { | ||
| expect( | ||
| getAssembleTaskFor(const BuildInfo(BuildMode.release, null)), | ||
| getAssembleTaskFor(const BuildInfo(BuildMode.release, null, fontSubset: false)), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: fontSubset default could be false.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't want to miss any invocations of it anywhere. I think there's also a slight preference for requiring parameters to be set in the tool so that we avoid surprise defaults
| } | ||
|
|
||
| void addFontSubsetFlag() { | ||
| argParser.addFlag('font-subset', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maybe tree-shake-fonts?
I'm not sure if folks would be able to determinate what this flag does from the current name and help text.
|
I think I captured all the review, will look to add tests soon. |
| /// | ||
| /// Returns a [Depfile] containing all assets used in the build. | ||
| Future<Depfile> copyAssets(Environment environment, Directory outputDirectory) async { | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: extra newline
| /// | ||
| /// The constructor will validate the environment and print a warning if | ||
| /// font subsetting has been requested in a debug build mode. | ||
| FontSubset(this._environment, DevFSStringContent fontManifest) : assert(_environment != null) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
assert(fontManifest != null) in the initializer list to match the comment.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
One thing I'm worried about here is that this ctor can actually launcha process. This is probably surprising to callers.
I considered putting that logic into the first call of subsetFont (e.g. if the _iconData is null, run that logic.
WDYT?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh yeah, I missed that. The analyzer should be warning you about the dropped Future at the _getIconData() call site in the constructor. It should also be warning that _getIconData() is returning a Map but is declared to return Future<void>.
Putting the _getIconData() call in the first call to subsetFont sounds better to me
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah - I was going to have to rework it to set a completer somewhere. Super weird, and probably never good to have a ctor start a process.
| final Map<String, String> fonts = await _parseFontJson(fontManifest.string, familyKeys); | ||
|
|
||
| if (fonts.length != iconData.length) { | ||
| throwToolExit('Expected to find fonts for ${iconData.keys}, but found ${fonts.keys}.'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we throw a ToolExit we should be giving the user some guidance about how to fix the problem. If this isn't a problem the user can fix, and is instead a bug, then you can just throw an exception. Maybe a custom exception type like FontSubsetException so that it will be more visible in crash logging.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Adding some guidance. This should be a user error.
| return result; | ||
| } | ||
|
|
||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: extra newline
| return false; | ||
| } | ||
|
|
||
| final File fontSubset = globals.fs.file(globals.artifacts.getArtifactPath(Artifact.fontSubset)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Check fontSubset.existsSync(). If it doesn't exist you can throwToolExit() with guidance to run the doctor.
| throwToolExit('FontManifest.json invalid: expected the family value to be a string, got: ${map['family']}.'); | ||
| } | ||
| final String familyKey = map['family'] as String; | ||
| if (families.contains(familyKey)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Flip the sense, and unindent the bigger part.
if (!families.contains(familyKey)) {
continue;
}
...| } | ||
| final dynamic jsonDecode = json.decode(constFinderProcessResult.stdout as String); | ||
| if (jsonDecode is! Map<String, dynamic>) { | ||
| throwToolExit('Invalid ConstFinder output: expected a top level JavaScript object, got $jsonDecode.'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This one seems like an internal error that the user can't do anything about, so probably an exception is better than a ToolExit.
| final ProcessResult constFinderProcessResult = await globals.processManager.run(cmd); | ||
|
|
||
| if (constFinderProcessResult.exitCode != 0) { | ||
| throwToolExit('ConstFinder failure: ${constFinderProcessResult.stderr}'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A ToolExit is fine here if the stderr will tell the user how to fix the problem.
| const _ConstFinderResult(this.result); | ||
|
|
||
| final Map<String, dynamic> result; | ||
| List<Map<String, dynamic>> get constantInstances => _getList(result['constantInstances'], 'Invalid ConstFinder output: Expected "constInstances" to be a list of objects.'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cache the results of json parsing to avoid recomputing every time the getter is touched.
| final Map<String, List<int>> result = <String, List<int>>{}; | ||
| for (final Map<String, dynamic> iconDataMap in consts.constantInstances) { | ||
| if (iconDataMap['fontPackage'] is! String || iconDataMap['fontFamily'] is! String || iconDataMap['codePoint'] is! int) { | ||
| throwToolExit('Invalid ConstFinder result. Expected "fontPackage" to be a String, "fontFamily" to be a String, and "codePoint" to be an int, got: $iconDataMap.'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ditto
| if (extraGenSnapshotOptions != null) { | ||
| args "--ExtraGenSnapshotOptions=${extraGenSnapshotOptions}" | ||
| } | ||
| if (treeShakeIcons == true) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: the ExtraGenSnapshotOptions is a bit weird, so place the -d above it so it aligned within the other define options
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
| DevFSStringContent fontManifest, { | ||
| @required ProcessManager processManager, | ||
| @required Logger logger, | ||
| @required FileSystem fs, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: name this fileSystem and not fs to avoid confusion with the global
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
| _artifacts.getArtifactPath(Artifact.fontSubset), | ||
| ); | ||
| if (!fontSubset.existsSync()) { | ||
| throwToolExit('The font-subset utility is missing. Run "flutter doctor".'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This would indicate a programming error in the flutter tool. I think we should just let this one crash if we hit it so we can see it crash logging instead of covering it with a tool exit
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was thinking a user could mess this up on us but I guess we should re-download if they do. Will change.
| fontSubsetProcess.stdin.writeln(codePoints); | ||
| await fontSubsetProcess.stdin.flush(); | ||
| await fontSubsetProcess.stdin.close(); | ||
| } catch (e) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: catch the specific error/exception types you expect. As written this will catch NoSuchMethodError or TypeError
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's not clear to me which exceptions these methods can throw. The idea is that exitCode will handle it. @zanderso is there some exception or set of exceptions to do? OSError and some others?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Catching NoSuchMethodError or TypeError is bad because those are programming errors. There are several errors that could happen writing to stdin. They'll all be derived from Exception, which doesn't include the Error types like TypeError etc.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok. I have a block for on Exception and a block for on OSError that I think shoudl cover us.
| }); | ||
|
|
||
| Environment _createEnvironment(Map<String, String> defines) { | ||
| return Environment( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use Environment.test
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Environment.test wants a test directory, which I don't have, right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It allows you to set a single directory that everything is based on, if the directories themselves don't matter much
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, I get it now, done.
| 'xcrun', | ||
| 'xcodebuild', | ||
| '-configuration', configuration, | ||
| if (buildInfo.treeShakeIcons) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add this with the reset of the environment variables in buildCommands
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did you have somewhere specific in mind? They seem sprinked throughout the rest of this function.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
in buildCommands, those get written to an xcconfig so builds through the xcode ui reference the variables correctly. You're adding it to the invocation which only takes effect when running through the flutter tool
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Moved this to the xcodeproject file where it writes to the generated xcconfig
| expect(globals.fs.file(globals.fs.path.join(environment.buildDir.path, 'flutter_assets', 'assets/wildcard/%23bar.png')).existsSync(), true); | ||
| })); | ||
|
|
||
| test('Does not leave stale files in build directory', () => testbed.run(() async { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems like a good property to have. Why does the test need to be removed?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test requires the whole build system to work. With assets now having new dependencies, it's much more complicated to make it work. @jonahwilliams says its covered elsewhere anyway, but that he'll rework this specific case into a build system check - #49914 was filed to track that.
zanderso
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
lgtm if all of @jonahwilliams' comments are addressed
jonahwilliams
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM
|
This pull request is not suitable for automatic merging in its current state.
|
|
Cirrus says the build is not currently red - status isn't up to date. landing. |
Description
Shakes out unused icon fonts from Flutter applications. This reduces the Gallery by 100kb.
assetstarget, defaulted to off, only available in release or profile modeNot available in debug mode because incremental kernels in there would make this really tricky, slow down hot restart, and you generally don't care about font file size for local debugging.
Needs tests.
/cc @willlarche
/cc @chingjun - we will want to wire up similar logic for Google builds
Related Issues
Fixes #43644
Fixes #16311
Addresses two platforms for #49730
Tests
I added the following tests:
TODO
Checklist
Before you create this PR confirm that it meets all requirements listed below by checking the relevant checkboxes (
[x]). This will ensure a smooth and quick review process.///).flutter analyze --flutter-repo) does not report any problems on my PR.Breaking Change
Did any tests fail when you ran them? Please read Handling breaking changes.