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'; |
| codePoints: iconData[entry.key], | ||
| ); | ||
| } | ||
| globals.printTrace('Found iconData: $result'); |
There was a problem hiding this comment.
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.
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.
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.
+1 to creating (a) class(es), probably in a new file, for the font subsetting logic.
| } | ||
| print(fontSubset); | ||
| if (fontSubset == true) { | ||
|
|
| 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.
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.
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.
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.
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.
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.
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.
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; |
| 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.
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); |
| // 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.
import '../build_system/targets/assets.dart';
but it looks like it's unused?
There was a problem hiding this comment.
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.
nit: fontSubset default could be false.
There was a problem hiding this comment.
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.
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 { | ||
|
|
| /// | ||
| /// 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.
assert(fontManifest != null) in the initializer list to match the comment.
There was a problem hiding this comment.
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.
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.
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.
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.
Adding some guidance. This should be a user error.
| return result; | ||
| } | ||
|
|
||
|
|
| return false; | ||
| } | ||
|
|
||
| final File fontSubset = globals.fs.file(globals.artifacts.getArtifactPath(Artifact.fontSubset)); |
There was a problem hiding this comment.
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.
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.
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.
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.
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.'); |
| if (extraGenSnapshotOptions != null) { | ||
| args "--ExtraGenSnapshotOptions=${extraGenSnapshotOptions}" | ||
| } | ||
| if (treeShakeIcons == true) { |
There was a problem hiding this comment.
nit: the ExtraGenSnapshotOptions is a bit weird, so place the -d above it so it aligned within the other define options
| DevFSStringContent fontManifest, { | ||
| @required ProcessManager processManager, | ||
| @required Logger logger, | ||
| @required FileSystem fs, |
There was a problem hiding this comment.
nit: name this fileSystem and not fs to avoid confusion with the global
| _artifacts.getArtifactPath(Artifact.fontSubset), | ||
| ); | ||
| if (!fontSubset.existsSync()) { | ||
| throwToolExit('The font-subset utility is missing. Run "flutter doctor".'); |
There was a problem hiding this comment.
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.
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.
nit: catch the specific error/exception types you expect. As written this will catch NoSuchMethodError or TypeError
There was a problem hiding this comment.
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.
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.
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.
Environment.test wants a test directory, which I don't have, right?
There was a problem hiding this comment.
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.
Ah, I get it now, done.
| 'xcrun', | ||
| 'xcodebuild', | ||
| '-configuration', configuration, | ||
| if (buildInfo.treeShakeIcons) |
There was a problem hiding this comment.
Add this with the reset of the environment variables in buildCommands
There was a problem hiding this comment.
Did you have somewhere specific in mind? They seem sprinked throughout the rest of this function.
There was a problem hiding this comment.
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.
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.
This seems like a good property to have. Why does the test need to be removed?
There was a problem hiding this comment.
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.
lgtm if all of @jonahwilliams' comments are addressed
|
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.