Skip to content

Conversation

@dnfield
Copy link
Contributor

@dnfield dnfield commented Jan 29, 2020

Description

Shakes out unused icon fonts from Flutter applications. This reduces the Gallery by 100kb.

  • Wires in the font subetting logic to the assets target, defaulted to off, only available in release or profile mode
  • Updates all commands to take flag.
  • Defaults flag to false
  • Stubs out implementation for other platforms that aren't yet supported.

Not 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.

  • I read the Contributor Guide and followed the process outlined there for submitting PRs.
  • I signed the CLA.
  • I read and followed the Flutter Style Guide, including Features we expect every widget to implement.
  • I read the Tree Hygiene wiki page, which explains my responsibilities.
  • I updated/added relevant documentation (doc comments with ///).
  • All existing and new tests are passing.
  • The analyzer (flutter analyze --flutter-repo) does not report any problems on my PR.
  • I am willing to follow-up on review comments in a timely manner.

Breaking Change

Did any tests fail when you ran them? Please read Handling breaking changes.

  • No, no existing tests failed, so this is not a breaking change.

@fluttergithubbot fluttergithubbot added tool Affects the "flutter" command-line tool. See also t: labels. work in progress; do not review labels Jan 29, 2020
@dnfield dnfield added the a: size Reducing IPA/APK/JS sizes label Jan 29, 2020
// 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';
Copy link
Contributor

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');
Copy link
Contributor

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(' ')}...');
Copy link
Contributor

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 {
Copy link
Contributor

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

Copy link
Member

@zanderso zanderso left a 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) {

Copy link
Member

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;
Copy link
Member

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(
Copy link
Member

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) {
Copy link
Member

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,
);

Copy link
Contributor Author

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.

Copy link
Contributor Author

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);
Copy link
Member

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;
Copy link
Member

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;
Copy link
Member

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>;
Copy link
Member

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);
Copy link
Member

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';
Copy link
Member

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?

Copy link
Contributor Author

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)),
Copy link

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.

Copy link
Contributor Author

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',
Copy link

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.

@dnfield
Copy link
Contributor Author

dnfield commented Jan 30, 2020

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 {

Copy link
Member

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) {
Copy link
Member

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.

Copy link
Contributor Author

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?

Copy link
Member

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

Copy link
Contributor Author

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}.');
Copy link
Member

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.

Copy link
Contributor Author

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;
}


Copy link
Member

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));
Copy link
Member

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)) {
Copy link
Member

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.');
Copy link
Member

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}');
Copy link
Member

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.');
Copy link
Member

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.');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto

@dnfield dnfield marked this pull request as ready for review January 31, 2020 06:12
if (extraGenSnapshotOptions != null) {
args "--ExtraGenSnapshotOptions=${extraGenSnapshotOptions}"
}
if (treeShakeIcons == true) {
Copy link
Contributor

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

Copy link
Contributor Author

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,
Copy link
Contributor

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

Copy link
Contributor Author

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".');
Copy link
Contributor

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

Copy link
Contributor Author

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) {
Copy link
Contributor

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

Copy link
Contributor Author

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?

Copy link
Member

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.

Copy link
Contributor Author

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(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use Environment.test

Copy link
Contributor Author

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?

Copy link
Contributor

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

Copy link
Contributor Author

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)
Copy link
Contributor

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

Copy link
Contributor Author

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.

Copy link
Contributor

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

Copy link
Contributor Author

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 {
Copy link
Member

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?

Copy link
Contributor Author

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.

Copy link
Member

@zanderso zanderso left a 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

Copy link
Contributor

@jonahwilliams jonahwilliams left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@fluttergithubbot
Copy link
Contributor

This pull request is not suitable for automatic merging in its current state.

  • The status or check suite hostonly_devicelab_tests-2-macos has failed. Please fix the issues identified (or deflake) before re-applying this label.
  • The status or check suite build_tests-macos has failed. Please fix the issues identified (or deflake) before re-applying this label.

@dnfield
Copy link
Contributor Author

dnfield commented Feb 5, 2020

Cirrus says the build is not currently red - status isn't up to date. landing.

@dnfield dnfield merged commit 4b8efad into flutter:master Feb 5, 2020
@dnfield dnfield deleted the font_subset branch February 5, 2020 04:34
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Aug 1, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

a: size Reducing IPA/APK/JS sizes c: contributor-productivity Team-specific productivity, code health, technical debt. framework flutter/packages/flutter repository. See also f: labels. tool Affects the "flutter" command-line tool. See also t: labels.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Consume font-subset in flutter_tools and treeshake icon fonts Tree shake material icons

6 participants