feat(assets): add platform-specific asset filtering in pubspec.yaml (#176393)

## Description

This PR introduces platform-specific asset support in `pubspec.yaml`.

Currently, Flutter does not allow specifying which platforms an asset
should be included for.
This results in all declared assets being bundled for every target
platform, even if some are irrelevant (e.g. desktop-only or mobile-only
images).

### What this PR changes
- Adds a new optional `platforms` field under each asset in
`pubspec.yaml`.
- The field accepts a list of strings (platform identifiers, e.g.
`["android", "ios", "web", "windows", "macos", "linux"]`).
- Assets with a `platforms` restriction are only included in the bundle
when building for a matching platform.
- Invalid values (non-strings or unknown platform names) log an error.

### Example
```yaml
flutter:
  assets:
    - path: assets/logo.png
   
    - path: assets/web_worker.js
      platforms: [web]

    - path: assets/desktop_icon.png
      platforms: [windows, linux, macos]
```

#### Before
All assets (`logo.png`, `web_worker.js`, `desktop_icon.png`) are bundled
into **every build**, regardless of platform.

#### After
- `logo.png` is included on all platforms.
- `web_worker.js` is included only on web builds.
- `desktop_icon.png` is included only on desktop builds.

### Why this is useful
This significantly improves bundle size, prevents unused resources from
being shipped, and gives developers better control over asset
management.

## Issues
Fixes #65065

## Reviewer note
Would a design document be helpful for this change, or is the current
explanation sufficient?

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.


<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
This commit is contained in:
Alex Frei 2025-11-11 19:50:05 +01:00 committed by GitHub
parent ef29db350f
commit 5239c8db48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 515 additions and 45 deletions

View File

@ -195,7 +195,7 @@ abstract class AssetBundle {
String manifestPath = defaultManifestPath,
required String packageConfigPath,
bool deferredComponentsEnabled = false,
TargetPlatform? targetPlatform,
required TargetPlatform targetPlatform,
String? flavor,
bool includeAssetsFromDevDependencies = false,
});
@ -326,7 +326,7 @@ class ManifestAssetBundle implements AssetBundle {
FlutterProject? flutterProject,
required String packageConfigPath,
bool deferredComponentsEnabled = false,
TargetPlatform? targetPlatform,
required TargetPlatform targetPlatform,
String? flavor,
bool includeAssetsFromDevDependencies = false,
}) async {
@ -402,6 +402,7 @@ class ManifestAssetBundle implements AssetBundle {
assetBasePath,
wildcardDirectories,
flutterProject.directory,
targetPlatform: targetPlatform,
flavor: flavor,
);
if (!_splitDeferredAssets || !deferredComponentsEnabled) {
@ -716,7 +717,7 @@ class ManifestAssetBundle implements AssetBundle {
}
}
void _setLicenseIfChanged(String combinedLicenses, TargetPlatform? targetPlatform) {
void _setLicenseIfChanged(String combinedLicenses, TargetPlatform targetPlatform) {
// On the web, don't compress the NOTICES file since the client doesn't have
// dart:io to decompress it. So use the standard _setIfChanged to check if
// the strings still match.
@ -838,6 +839,7 @@ class ManifestAssetBundle implements AssetBundle {
String assetBasePath,
List<Uri> wildcardDirectories,
Directory projectDirectory, {
required TargetPlatform targetPlatform,
String? flavor,
}) {
final List<DeferredComponent>? components = flutterManifest.deferredComponents;
@ -859,6 +861,7 @@ class ManifestAssetBundle implements AssetBundle {
componentAssets,
assetsEntry.uri,
flavors: assetsEntry.flavors,
platforms: assetsEntry.platforms,
transformers: assetsEntry.transformers,
);
} else {
@ -870,13 +873,15 @@ class ManifestAssetBundle implements AssetBundle {
componentAssets,
assetsEntry.uri,
flavors: assetsEntry.flavors,
platforms: assetsEntry.platforms,
transformers: assetsEntry.transformers,
);
}
}
componentAssets.removeWhere(
(_Asset asset, List<_Asset> variants) => !asset.matchesFlavor(flavor),
(_Asset asset, List<_Asset> variants) =>
!asset.matchesFlavor(flavor) || !asset.matchesPlatform(targetPlatform),
);
deferredComponentsAssetVariants[component.name] = componentAssets;
}
@ -1018,7 +1023,7 @@ class ManifestAssetBundle implements AssetBundle {
FlutterManifest flutterManifest,
List<Uri> wildcardDirectories,
String assetBase,
TargetPlatform? targetPlatform, {
TargetPlatform targetPlatform, {
String? packageName,
Package? attributedPackage,
required String? flavor,
@ -1039,6 +1044,7 @@ class ManifestAssetBundle implements AssetBundle {
packageName: packageName,
attributedPackage: attributedPackage,
flavors: assetsEntry.flavors,
platforms: assetsEntry.platforms,
transformers: assetsEntry.transformers,
);
} else {
@ -1052,6 +1058,7 @@ class ManifestAssetBundle implements AssetBundle {
packageName: packageName,
attributedPackage: attributedPackage,
flavors: assetsEntry.flavors,
platforms: assetsEntry.platforms,
transformers: assetsEntry.transformers,
);
}
@ -1066,6 +1073,15 @@ class ManifestAssetBundle implements AssetBundle {
);
return true;
}
if (!asset.matchesPlatform(targetPlatform)) {
_logger.printTrace(
'Skipping assets entry "${asset.entryUri.path}" since '
'its configured platform(s) did not match the target platform.\n'
'Configured platforms: ${asset.platforms.join(', ')}\n'
'Target platform: ${targetPlatform.osName}\n',
);
return true;
}
return false;
});
@ -1101,6 +1117,7 @@ class ManifestAssetBundle implements AssetBundle {
attributedPackage: attributedPackage,
assetKind: AssetKind.shader,
flavors: <String>{},
platforms: <String>{},
transformers: <AssetTransformerEntry>[],
);
}
@ -1116,6 +1133,7 @@ class ManifestAssetBundle implements AssetBundle {
attributedPackage,
assetKind: AssetKind.font,
flavors: <String>{},
platforms: <String>{},
transformers: <AssetTransformerEntry>[],
);
final File baseAssetFile = baseAsset.lookupAssetFile(_fileSystem);
@ -1142,6 +1160,7 @@ class ManifestAssetBundle implements AssetBundle {
String? packageName,
Package? attributedPackage,
required Set<String> flavors,
required Set<String> platforms,
required List<AssetTransformerEntry> transformers,
}) {
final String directoryPath;
@ -1174,6 +1193,7 @@ class ManifestAssetBundle implements AssetBundle {
attributedPackage: attributedPackage,
originUri: assetUri,
flavors: flavors,
platforms: platforms,
transformers: transformers,
);
}
@ -1191,6 +1211,7 @@ class ManifestAssetBundle implements AssetBundle {
Package? attributedPackage,
AssetKind assetKind = AssetKind.regular,
required Set<String> flavors,
required Set<String> platforms,
required List<AssetTransformerEntry> transformers,
}) {
final _Asset asset = _resolveAsset(
@ -1202,6 +1223,7 @@ class ManifestAssetBundle implements AssetBundle {
assetKind: assetKind,
originUri: originUri,
flavors: flavors,
platforms: platforms,
transformers: transformers,
);
@ -1225,6 +1247,7 @@ class ManifestAssetBundle implements AssetBundle {
package: attributedPackage,
kind: assetKind,
flavors: flavors,
platforms: platforms,
transformers: transformers,
),
);
@ -1335,6 +1358,7 @@ class ManifestAssetBundle implements AssetBundle {
Uri? originUri,
AssetKind assetKind = AssetKind.regular,
required Set<String> flavors,
required Set<String> platforms,
required List<AssetTransformerEntry> transformers,
}) {
_ensureAssetPathIsValid(assetsBaseDir: assetsBaseDir, assetUri: assetUri);
@ -1351,6 +1375,7 @@ class ManifestAssetBundle implements AssetBundle {
assetKind: assetKind,
originUri: originUri,
flavors: flavors,
platforms: platforms,
transformers: transformers,
);
if (packageAsset != null) {
@ -1370,6 +1395,7 @@ class ManifestAssetBundle implements AssetBundle {
originUri: originUri,
kind: assetKind,
flavors: flavors,
platforms: platforms,
transformers: transformers,
);
}
@ -1381,6 +1407,7 @@ class ManifestAssetBundle implements AssetBundle {
AssetKind assetKind = AssetKind.regular,
Uri? originUri,
Set<String>? flavors,
Set<String>? platforms,
List<AssetTransformerEntry>? transformers,
}) {
assert(assetUri.pathSegments.first == 'packages');
@ -1397,6 +1424,7 @@ class ManifestAssetBundle implements AssetBundle {
kind: assetKind,
originUri: originUri,
flavors: flavors,
platforms: platforms,
transformers: transformers,
);
}
@ -1420,9 +1448,11 @@ class _Asset {
required this.package,
this.kind = AssetKind.regular,
Set<String>? flavors,
Set<String>? platforms,
List<AssetTransformerEntry>? transformers,
}) : originUri = originUri ?? entryUri,
flavors = flavors ?? const <String>{},
platforms = platforms ?? const <String>{},
transformers = transformers ?? const <AssetTransformerEntry>[];
final String baseDir;
@ -1444,6 +1474,8 @@ class _Asset {
final Set<String> flavors;
final Set<String> platforms;
final List<AssetTransformerEntry> transformers;
File lookupAssetFile(FileSystem fileSystem) {
@ -1472,11 +1504,20 @@ class _Asset {
return flavors.contains(flavor);
}
bool matchesPlatform(TargetPlatform targetPlatform) {
if (platforms.isEmpty || targetPlatform == TargetPlatform.tester) {
return true;
}
return platforms.contains(targetPlatform.osName);
}
bool hasEquivalentFlavorsWith(_Asset other) {
final Set<String> assetFlavors = flavors.toSet();
final Set<String> otherFlavors = other.flavors.toSet();
return assetFlavors.length == otherFlavors.length &&
assetFlavors.every((String e) => otherFlavors.contains(e));
return setEquals(flavors, other.flavors);
}
bool hasEquivalentPlatformsWith(_Asset other) {
return setEquals(platforms, other.platforms);
}
@override
@ -1495,11 +1536,13 @@ class _Asset {
other.relativeUri == relativeUri &&
other.entryUri == entryUri &&
other.kind == kind &&
hasEquivalentFlavorsWith(other);
hasEquivalentFlavorsWith(other) &&
hasEquivalentPlatformsWith(other);
}
@override
int get hashCode => Object.hashAll(<Object>[baseDir, relativeUri, entryUri, kind, ...flavors]);
int get hashCode =>
Object.hashAll(<Object>[baseDir, relativeUri, entryUri, kind, ...flavors, ...platforms]);
}
// Given an assets directory like this:

View File

@ -108,6 +108,9 @@ class DeferredComponent {
if (asset.flavors.isNotEmpty) {
out.write(' (flavors: ${asset.flavors.join(', ')})');
}
if (asset.platforms.isNotEmpty) {
out.write(' (platforms: ${asset.platforms.join(', ')})');
}
}
return out.toString();
}

View File

@ -269,7 +269,10 @@ class CopyAssets extends Target {
List<String> get depfiles => const <String>['flutter_assets.d'];
@override
Future<void> build(Environment environment) async {
Future<void> build(
Environment environment, {
TargetPlatform targetPlatform = TargetPlatform.android,
}) async {
final String? buildModeEnvironment = environment.defines[kBuildMode];
if (buildModeEnvironment == null) {
throw MissingDefineException(kBuildMode, name);
@ -282,7 +285,7 @@ class CopyAssets extends Target {
environment,
output,
dartHookResult: dartHookResult,
targetPlatform: TargetPlatform.android,
targetPlatform: targetPlatform,
buildMode: buildMode,
flavor: environment.defines[kFlavor],
additionalContent: <String, DevFSContent>{

View File

@ -108,7 +108,7 @@ Future<AssetBundle?> buildAssets({
required String manifestPath,
String? assetDirPath,
required String packageConfigPath,
TargetPlatform? targetPlatform,
required TargetPlatform targetPlatform,
String? flavor,
}) async {
assetDirPath ??= getAssetBuildDirectory();

View File

@ -769,6 +769,7 @@ class TestCommand extends FlutterCommand with DeviceBasedDevelopmentArtifacts {
packageConfigPath: packageConfigPath,
flavor: flavor,
includeAssetsFromDevDependencies: true,
targetPlatform: TargetPlatform.tester,
);
if (build != 0) {
throwToolExit('Error: Failed to build asset bundle');

View File

@ -788,20 +788,23 @@ class AssetsEntry {
const AssetsEntry({
required this.uri,
this.flavors = const <String>{},
this.platforms = const <String>{},
this.transformers = const <AssetTransformerEntry>[],
});
final Uri uri;
final Set<String> flavors;
final Set<String> platforms;
final List<AssetTransformerEntry> transformers;
Object? get descriptor {
if (transformers.isEmpty && flavors.isEmpty) {
if (transformers.isEmpty && flavors.isEmpty && platforms.isEmpty) {
return uri.toString();
}
return <String, Object?>{
_pathKey: uri.toString(),
if (flavors.isNotEmpty) _flavorKey: flavors.toList(),
if (platforms.isNotEmpty) _platformsKey: platforms.toList(),
if (transformers.isNotEmpty)
_transformersKey: transformers.map((AssetTransformerEntry e) => e.descriptor).toList(),
};
@ -809,6 +812,7 @@ class AssetsEntry {
static const _pathKey = 'path';
static const _flavorKey = 'flavors';
static const _platformsKey = 'platforms';
static const _transformersKey = 'transformers';
static AssetsEntry? parseFromYaml(Object? yaml) {
@ -856,11 +860,15 @@ class AssetsEntry {
final (List<String>? flavors, List<String> flavorsErrors) = _parseFlavorsSection(
yaml[_flavorKey],
);
final (List<String>? platforms, List<String> platformsErrors) = _parsePlatformsSection(
yaml[_platformsKey],
);
final (List<AssetTransformerEntry>? transformers, List<String> transformersErrors) =
_parseTransformersSection(yaml[_transformersKey]);
final errors = <String>[
...flavorsErrors.map((String e) => 'In $_flavorKey section of asset "$path": $e'),
...platformsErrors.map((String e) => 'In $_platformsKey section of asset "$path": $e'),
...transformersErrors.map(
(String e) => 'In $_transformersKey section of asset "$path": $e',
),
@ -873,6 +881,7 @@ class AssetsEntry {
AssetsEntry(
uri: Uri(pathSegments: path.split('/')),
flavors: Set<String>.from(flavors ?? <String>[]),
platforms: Set<String>.from(platforms ?? <String>[]),
transformers: transformers ?? <AssetTransformerEntry>[],
),
null,
@ -894,6 +903,41 @@ class AssetsEntry {
return _parseList<String>(yaml, _flavorKey, 'String');
}
/// Parses and validates the "platforms" section of an asset entry in pubspec.yaml.
///
/// Returns a tuple containing the parsed platforms list and any validation errors.
/// If errors are encountered, the platforms list will be null and errors will be non-empty.
static (List<String>? platforms, List<String> errors) _parsePlatformsSection(Object? yaml) {
if (yaml == null) {
return (null, <String>[]);
}
final (List<String>? platforms, List<String> errors) = _parseList<String>(
yaml,
_platformsKey,
'String',
);
if (errors.isNotEmpty) {
return (null, errors);
}
if (platforms != null) {
final Set<String> invalidPlatforms = platforms.toSet().difference(_kValidPluginPlatforms);
if (invalidPlatforms.isNotEmpty) {
return (
null,
<String>[
'Invalid platform(s): "${invalidPlatforms.join(", ")}". Supported platforms are: "${_kValidPluginPlatforms.join(", ")}".',
],
);
}
}
return (platforms, errors);
}
static (List<AssetTransformerEntry>?, List<String> errors) _parseTransformersSection(
Object? yaml,
) {
@ -934,18 +978,22 @@ class AssetsEntry {
return false;
}
return uri == other.uri && setEquals(flavors, other.flavors);
return uri == other.uri &&
setEquals(flavors, other.flavors) &&
setEquals(platforms, other.platforms);
}
@override
int get hashCode => Object.hashAll(<Object?>[
uri.hashCode,
Object.hashAllUnordered(flavors),
Object.hashAllUnordered(platforms),
Object.hashAll(transformers),
]);
@override
String toString() => 'AssetsEntry(uri: $uri, flavors: $flavors, transformers: $transformers)';
String toString() =>
'AssetsEntry(uri: $uri, flavors: $flavors, platforms: $platforms, transformers: $transformers)';
}
/// Represents an entry in the "transformers" section of an asset.

View File

@ -499,6 +499,7 @@ class HotRunner extends ResidentRunner {
),
packageConfigPath: debuggingOptions.buildInfo.packageConfigPath,
flavor: debuggingOptions.buildInfo.flavor,
targetPlatform: targetPlatform,
);
if (result != 0) {
return UpdateFSReport();

View File

@ -76,6 +76,7 @@ class PreviewPubspecBuilder {
return AssetsEntry(
uri: transformAssetUri(asset.uri),
flavors: asset.flavors,
platforms: asset.platforms,
transformers: asset.transformers,
);
}

View File

@ -8,6 +8,7 @@ import 'package:flutter_tools/src/base/file_system.dart';
import 'package:flutter_tools/src/base/logger.dart';
import 'package:flutter_tools/src/base/platform.dart';
import 'package:flutter_tools/src/base/user_messages.dart';
import 'package:flutter_tools/src/build_info.dart';
import 'package:flutter_tools/src/cache.dart';
import 'package:flutter_tools/src/project.dart';
@ -37,6 +38,7 @@ void main() {
packageConfigPath: '.dart_tool/package_config.json',
flutterProject: FlutterProject.fromDirectoryTest(fileSystem.currentDirectory),
flavor: flavor,
targetPlatform: TargetPlatform.tester,
);
return bundle;
}

View File

@ -9,6 +9,7 @@ import 'package:file/memory.dart';
import 'package:flutter_tools/src/asset.dart';
import 'package:flutter_tools/src/base/file_system.dart';
import 'package:flutter_tools/src/build_info.dart';
import 'package:flutter_tools/src/globals.dart' as globals;
@ -62,7 +63,10 @@ $fontsSection
String expectedAssetManifest,
) async {
final AssetBundle bundle = AssetBundleFactory.instance.createBundle();
await bundle.build(packageConfigPath: '.dart_tool/package_config.json');
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
targetPlatform: TargetPlatform.tester,
);
for (final packageName in packages) {
for (final packageFont in packageFonts) {
@ -114,7 +118,10 @@ $fontsSection
writePubspecFile('p/p/pubspec.yaml', 'test_package');
final AssetBundle bundle = AssetBundleFactory.instance.createBundle();
await bundle.build(packageConfigPath: '.dart_tool/package_config.json');
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
targetPlatform: TargetPlatform.tester,
);
expect(
bundle.entries.keys,
unorderedEquals(<String>['AssetManifest.bin', 'FontManifest.json', 'NOTICES.Z']),

View File

@ -10,6 +10,7 @@ import 'package:file/memory.dart';
import 'package:flutter_tools/src/asset.dart';
import 'package:flutter_tools/src/base/file_system.dart';
import 'package:flutter_tools/src/build_info.dart';
import 'package:flutter_tools/src/globals.dart' as globals;
import 'package:standard_message_codec/standard_message_codec.dart';
@ -83,7 +84,11 @@ $assetsSection
String? flavor,
}) async {
final AssetBundle bundle = AssetBundleFactory.instance.createBundle();
await bundle.build(packageConfigPath: '.dart_tool/package_config.json', flavor: flavor);
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
flavor: flavor,
targetPlatform: TargetPlatform.tester,
);
for (final packageName in packages) {
for (final asset in assets) {
@ -138,7 +143,10 @@ $assetsSection
writePubspecFile('p/p/pubspec.yaml', 'test_package');
final AssetBundle bundle = AssetBundleFactory.instance.createBundle();
await bundle.build(packageConfigPath: '.dart_tool/package_config.json');
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
targetPlatform: TargetPlatform.tester,
);
expect(
bundle.entries.keys,
unorderedEquals(<String>['NOTICES.Z', 'AssetManifest.bin', 'FontManifest.json']),
@ -166,7 +174,10 @@ $assetsSection
writeAssets('p/p/', assets);
final AssetBundle bundle = AssetBundleFactory.instance.createBundle();
await bundle.build(packageConfigPath: '.dart_tool/package_config.json');
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
targetPlatform: TargetPlatform.tester,
);
expect(
bundle.entries.keys,
unorderedEquals(<String>['NOTICES.Z', 'AssetManifest.bin', 'FontManifest.json']),
@ -665,7 +676,10 @@ $assetsSection
writeAssets('p/p/', assetsOnDisk);
final AssetBundle bundle = AssetBundleFactory.instance.createBundle();
await bundle.build(packageConfigPath: '.dart_tool/package_config.json');
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
targetPlatform: TargetPlatform.tester,
);
expect(
bundle.entries['AssetManifest.bin'],
@ -754,7 +768,10 @@ $assetsSection
writePubspecFile('p/p/pubspec.yaml', 'test_package', assets: assetOnManifest);
final AssetBundle bundle = AssetBundleFactory.instance.createBundle();
await bundle.build(packageConfigPath: '.dart_tool/package_config.json');
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
targetPlatform: TargetPlatform.tester,
);
},
overrides: <Type, Generator>{
FileSystem: () => testFileSystem,

View File

@ -44,7 +44,13 @@ void main() {
'nonempty',
() async {
final AssetBundle ab = AssetBundleFactory.instance.createBundle();
expect(await ab.build(packageConfigPath: '.dart_tool/package_config.json'), 0);
expect(
await ab.build(
packageConfigPath: '.dart_tool/package_config.json',
targetPlatform: TargetPlatform.tester,
),
0,
);
expect(ab.entries.length, greaterThan(0));
},
overrides: <Type, Generator>{
@ -62,7 +68,10 @@ void main() {
..writeAsStringSync('');
final AssetBundle bundle = AssetBundleFactory.instance.createBundle();
await bundle.build(packageConfigPath: '.dart_tool/package_config.json');
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
targetPlatform: TargetPlatform.tester,
);
expect(bundle.entries.keys, unorderedEquals(<String>['AssetManifest.bin']));
const expectedBinAssetManifest = <Object, Object>{};
expect(
@ -109,7 +118,10 @@ flutter:
}
final AssetBundle bundle = AssetBundleFactory.instance.createBundle();
await bundle.build(packageConfigPath: '.dart_tool/package_config.json');
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
targetPlatform: TargetPlatform.tester,
);
expect(
bundle.entries.keys,
@ -149,7 +161,10 @@ flutter:
- assets/foo/
''');
final AssetBundle bundle = AssetBundleFactory.instance.createBundle();
await bundle.build(packageConfigPath: '.dart_tool/package_config.json');
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
targetPlatform: TargetPlatform.tester,
);
expect(
bundle.entries.keys,
unorderedEquals(<String>[
@ -165,7 +180,10 @@ flutter:
..setLastModifiedSync(packageFile.lastModifiedSync().add(const Duration(hours: 1)));
expect(bundle.needsBuild(), true);
await bundle.build(packageConfigPath: '.dart_tool/package_config.json');
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
targetPlatform: TargetPlatform.tester,
);
expect(
bundle.entries.keys,
unorderedEquals(<String>[
@ -203,7 +221,10 @@ flutter:
mainLibName: 'my_app',
);
final AssetBundle bundle = AssetBundleFactory.instance.createBundle();
await bundle.build(packageConfigPath: '.dart_tool/package_config.json');
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
targetPlatform: TargetPlatform.tester,
);
expect(
bundle.entries.keys,
unorderedEquals(<String>[
@ -231,7 +252,10 @@ name: my_app''')
// asset manifest and not updated. This is due to the devfs not
// supporting file deletion.
expect(bundle.needsBuild(), true);
await bundle.build(packageConfigPath: '.dart_tool/package_config.json');
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
targetPlatform: TargetPlatform.tester,
);
expect(
bundle.entries.keys,
unorderedEquals(<String>[
@ -269,7 +293,10 @@ flutter:
''');
writePackageConfigFiles(directory: globals.fs.currentDirectory, mainLibName: 'my_app');
final AssetBundle bundle = AssetBundleFactory.instance.createBundle();
await bundle.build(packageConfigPath: '.dart_tool/package_config.json');
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
targetPlatform: TargetPlatform.tester,
);
expect(
bundle.entries.keys,
unorderedEquals(<String>[
@ -323,6 +350,7 @@ flutter:
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
deferredComponentsEnabled: true,
targetPlatform: TargetPlatform.tester,
);
expect(
bundle.entries.keys,
@ -371,7 +399,10 @@ flutter:
- assets/wild/
''');
final AssetBundle bundle = AssetBundleFactory.instance.createBundle();
await bundle.build(packageConfigPath: '.dart_tool/package_config.json');
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
targetPlatform: TargetPlatform.tester,
);
expect(
bundle.entries.keys,
unorderedEquals(<String>[
@ -431,6 +462,7 @@ flutter:
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
deferredComponentsEnabled: true,
targetPlatform: TargetPlatform.tester,
);
expect(
bundle.entries.keys,
@ -454,6 +486,7 @@ flutter:
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
deferredComponentsEnabled: true,
targetPlatform: TargetPlatform.tester,
);
expect(
@ -507,6 +540,7 @@ flutter:
() => bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
flutterProject: FlutterProject.fromDirectoryTest(fileSystem.currentDirectory),
targetPlatform: TargetPlatform.tester,
),
throwsToolExit(
message:
@ -546,6 +580,7 @@ flutter:
() => bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
flutterProject: FlutterProject.fromDirectoryTest(fileSystem.currentDirectory),
targetPlatform: TargetPlatform.tester,
),
throwsToolExit(
message:
@ -590,6 +625,7 @@ flutter:
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
flutterProject: FlutterProject.fromDirectoryTest(fileSystem.currentDirectory),
targetPlatform: TargetPlatform.tester,
);
expect(bundle.entries['my-asset.txt']!.content.isModified, isTrue);
@ -597,6 +633,7 @@ flutter:
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
flutterProject: FlutterProject.fromDirectoryTest(fileSystem.currentDirectory),
targetPlatform: TargetPlatform.tester,
);
expect(bundle.entries['my-asset.txt']!.content.isModified, isFalse);
@ -614,6 +651,7 @@ flutter:
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
flutterProject: FlutterProject.fromDirectoryTest(fileSystem.currentDirectory),
targetPlatform: TargetPlatform.tester,
);
expect(bundle.entries['my-asset.txt']!.content.isModified, isTrue);
@ -762,12 +800,18 @@ assets:
- assets/foo/bar.txt
''');
final AssetBundle bundle = AssetBundleFactory.instance.createBundle();
await bundle.build(packageConfigPath: '.dart_tool/package_config.json');
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
targetPlatform: TargetPlatform.tester,
);
final AssetBundleEntry? fontManifest = bundle.entries['FontManifest.json'];
final AssetBundleEntry? license = bundle.entries['NOTICES'];
await bundle.build(packageConfigPath: '.dart_tool/package_config.json');
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
targetPlatform: TargetPlatform.tester,
);
expect(fontManifest, bundle.entries['FontManifest.json']);
expect(license, bundle.entries['NOTICES']);
@ -795,7 +839,13 @@ flutter:
''');
final AssetBundle bundle = AssetBundleFactory.instance.createBundle();
expect(await bundle.build(packageConfigPath: '.dart_tool/package_config.json'), 0);
expect(
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
targetPlatform: TargetPlatform.tester,
),
0,
);
expect(
bundle.additionalDependencies.single.path,
contains('DOES_NOT_EXIST_RERUN_FOR_WILDCARD'),
@ -824,7 +874,13 @@ flutter:
''');
final AssetBundle bundle = AssetBundleFactory.instance.createBundle();
expect(await bundle.build(packageConfigPath: '.dart_tool/package_config.json'), 0);
expect(
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
targetPlatform: TargetPlatform.tester,
),
0,
);
expect(bundle.additionalDependencies, isEmpty);
},
overrides: <Type, Generator>{
@ -871,7 +927,13 @@ flutter:
''');
final AssetBundle bundle = AssetBundleFactory.instance.createBundle();
expect(await bundle.build(packageConfigPath: '.dart_tool/package_config.json'), 0);
expect(
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
targetPlatform: TargetPlatform.tester,
),
0,
);
await writeBundle(
output,
@ -1097,7 +1159,13 @@ flutter:
final AssetBundle bundle = AssetBundleFactory.instance.createBundle();
globals.fs.file('foo/bar/fizz.txt').createSync(recursive: true);
expect(await bundle.build(packageConfigPath: '.dart_tool/package_config.json'), 0);
expect(
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
targetPlatform: TargetPlatform.tester,
),
0,
);
expect(bundle.additionalDependencies, isEmpty);
},
overrides: <Type, Generator>{
@ -1135,7 +1203,10 @@ flutter:
final AssetBundle bundle = AssetBundleFactory.instance.createBundle();
globals.fs.file('foo/bar/fizz.txt').createSync(recursive: true);
await bundle.build(packageConfigPath: '.dart_tool/package_config.json');
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
targetPlatform: TargetPlatform.tester,
);
expect(
bundle.entries.keys,
@ -1188,7 +1259,13 @@ flutter:
''');
final AssetBundle bundle = AssetBundleFactory.instance.createBundle();
expect(await bundle.build(packageConfigPath: '.dart_tool/package_config.json'), 1);
expect(
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
targetPlatform: TargetPlatform.tester,
),
1,
);
expect(testLogger.errorText, contains('This asset was included from package foo'));
},
overrides: <Type, Generator>{
@ -1217,7 +1294,13 @@ flutter:
''');
final AssetBundle bundle = AssetBundleFactory.instance.createBundle();
expect(await bundle.build(packageConfigPath: '.dart_tool/package_config.json'), 1);
expect(
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
targetPlatform: TargetPlatform.tester,
),
1,
);
expect(testLogger.errorText, isNot(contains('This asset was included from')));
},
overrides: <Type, Generator>{
@ -1256,7 +1339,13 @@ flutter:
''');
final AssetBundle bundle = AssetBundleFactory.instance.createBundle();
expect(await bundle.build(packageConfigPath: '.dart_tool/package_config.json'), 0);
expect(
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
targetPlatform: TargetPlatform.tester,
),
0,
);
expect((bundle.entries['FontManifest.json']!.content as DevFSStringContent).string, '[]');
expect(testLogger.errorText, contains('package:foo has `uses-material-design: true` set'));
},
@ -1292,7 +1381,13 @@ flutter:
final AssetBundle bundle = AssetBundleFactory.instance.createBundle();
expect(await bundle.build(packageConfigPath: '.dart_tool/package_config.json'), 0);
expect(
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
targetPlatform: TargetPlatform.tester,
),
0,
);
expect(
bundle.entries.keys,
unorderedEquals(<String>[
@ -1336,7 +1431,13 @@ flutter:
globals.fs.file('assets/zebra.jpg').createSync();
final AssetBundle bundle = AssetBundleFactory.instance.createBundle();
expect(await bundle.build(packageConfigPath: '.dart_tool/package_config.json'), 0);
expect(
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
targetPlatform: TargetPlatform.tester,
),
0,
);
expect((bundle.entries['FontManifest.json']!.content as DevFSStringContent).string, '[]');
},
overrides: <Type, Generator>{
@ -1375,6 +1476,7 @@ flutter:
() => bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
flutterProject: FlutterProject.fromDirectoryTest(fileSystem.currentDirectory),
targetPlatform: TargetPlatform.tester,
),
throwsToolExit(
message:

View File

@ -12,6 +12,7 @@ import 'package:flutter_tools/src/base/file_system.dart';
import 'package:flutter_tools/src/base/logger.dart';
import 'package:flutter_tools/src/base/platform.dart';
import 'package:flutter_tools/src/base/user_messages.dart';
import 'package:flutter_tools/src/build_info.dart';
import 'package:flutter_tools/src/cache.dart';
import 'package:flutter_tools/src/project.dart';
@ -86,6 +87,7 @@ ${assets.map((String entry) => ' - $entry').join('\n')}
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
flutterProject: FlutterProject.fromDirectoryTest(fs.currentDirectory),
targetPlatform: TargetPlatform.tester,
);
final Map<Object?, Object?> smcBinManifest = await extractAssetManifestSmcBinFromBundle(
@ -133,6 +135,7 @@ ${assets.map((String entry) => ' - $entry').join('\n')}
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
flutterProject: FlutterProject.fromDirectoryTest(fs.currentDirectory),
targetPlatform: TargetPlatform.tester,
);
final Map<Object?, Object?> smcBinManifest = await extractAssetManifestSmcBinFromBundle(
@ -176,6 +179,7 @@ ${assets.map((String entry) => ' - $entry').join('\n')}
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
flutterProject: FlutterProject.fromDirectoryTest(fs.currentDirectory),
targetPlatform: TargetPlatform.tester,
);
final Map<Object?, Object?> smcBinManifest = await extractAssetManifestSmcBinFromBundle(
@ -215,6 +219,7 @@ ${assets.map((String entry) => ' - $entry').join('\n')}
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
flutterProject: FlutterProject.fromDirectoryTest(fs.currentDirectory),
targetPlatform: TargetPlatform.tester,
);
final expectedManifest = <String, List<Map<String, Object>>>{
@ -282,6 +287,7 @@ flutter:
await bundle.build(
packageConfigPath: '.dart_tool/package_config.json',
flutterProject: FlutterProject.fromDirectoryTest(fs.currentDirectory),
targetPlatform: TargetPlatform.tester,
);
final expectedAssetManifest = <String, List<Map<String, Object>>>{

View File

@ -96,6 +96,7 @@ dependencies:
packageConfigPath: packageConfigPath,
manifestPath: manifestPath,
flutterProject: FlutterProject.fromDirectoryTest(fileSystem.directory('main')),
targetPlatform: TargetPlatform.tester,
);
expect(assetBundle.entries, contains('FontManifest.json'));
@ -254,6 +255,7 @@ flutter:
packageConfigPath: packageConfigPath,
manifestPath: manifestPath,
flutterProject: FlutterProject.fromDirectoryTest(fileSystem.directory('main')),
targetPlatform: TargetPlatform.tester,
);
expect(assetBundle.entries, contains('FontManifest.json'));
@ -298,6 +300,7 @@ flutter:
manifestPath: manifestPath, // file doesn't exist
packageConfigPath: packageConfigPath,
flutterProject: FlutterProject.fromDirectoryTest(fileSystem.file(manifestPath).parent),
targetPlatform: TargetPlatform.tester,
);
expect(assetBundle.wasBuiltOnce(), true);
@ -397,6 +400,7 @@ flutter:
final int result = await assetBundle.build(
packageConfigPath: '.dart_tool/package_config.json',
flutterProject: FlutterProject.fromDirectoryTest(fileSystem.currentDirectory),
targetPlatform: TargetPlatform.tester,
);
expect(result, isNot(0));
expect(
@ -426,6 +430,7 @@ flutter:
final int result = await assetBundle.build(
packageConfigPath: '.dart_tool/package_config.json',
flutterProject: FlutterProject.fromDirectoryTest(fileSystem.currentDirectory),
targetPlatform: TargetPlatform.tester,
);
expect(result, isNot(0));
expect(

View File

@ -461,6 +461,161 @@ flutter:
},
);
group('platform-specific assets', () {
/// All supported platforms that should be validated.
const kValidPluginPlatforms = <String>{'android', 'ios', 'web', 'windows', 'linux', 'macos'};
TargetPlatform targetFor(String platform) =>
TargetPlatform.values.firstWhere((p) => p.osName == platform);
/// Writes a `pubspec.yaml` with an asset, optionally restricted to
/// certain [platforms], then runs the build for [targetPlatform] and
/// returns whether the asset was bundled.
///
/// This helper reflects how Flutter decides which assets to include
/// depending on the `platforms:` key in `pubspec.yaml`.
Future<bool> setupAndBuildPlatformAsset(String platform, TargetPlatform targetPlatform) async {
final filePath = 'assets/test-$platform.txt';
final pubspec = platform.isEmpty
? '''
name: example
flutter:
assets:
- path: $filePath
'''
: '''
name: example
flutter:
assets:
- path: $filePath
platforms:
- $platform
''';
fileSystem.file('pubspec.yaml')
..createSync()
..writeAsStringSync(pubspec);
writePackageConfigFiles(directory: globals.fs.currentDirectory, mainLibName: 'example');
fileSystem.file(filePath).createSync(recursive: true);
await const CopyAssets().build(environment, targetPlatform: targetPlatform);
final File file = fileSystem.file('${environment.buildDir.path}/flutter_assets/$filePath');
return file.existsSync();
}
group('includes assets only for matching platform', () {
for (final platform in kValidPluginPlatforms) {
testUsingContext(
platform,
() async {
final TargetPlatform targetPlatform = targetFor(platform);
final bool didInclude = await setupAndBuildPlatformAsset(platform, targetPlatform);
expect(didInclude, isTrue, reason: 'Expected asset for $platform to be included');
},
overrides: <Type, Generator>{
FileSystem: () => fileSystem,
ProcessManager: () => FakeProcessManager.any(),
},
);
}
});
group('skips assets for non-matching platform', () {
for (final platform in kValidPluginPlatforms) {
testUsingContext(
platform,
() async {
final TargetPlatform targetPlatform = platform == 'android'
? TargetPlatform.ios
: TargetPlatform.android;
final bool didInclude = await setupAndBuildPlatformAsset(platform, targetPlatform);
expect(
didInclude,
isFalse,
reason: 'Expected asset for $platform to be skipped when target is $targetPlatform',
);
},
overrides: <Type, Generator>{
FileSystem: () => fileSystem,
ProcessManager: () => FakeProcessManager.any(),
},
);
}
});
group('includes assets for all platforms when no restriction is set', () {
for (final platform in kValidPluginPlatforms) {
testUsingContext(
platform,
() async {
final TargetPlatform targetPlatform = targetFor(platform);
final bool didInclude = await setupAndBuildPlatformAsset('', targetPlatform);
expect(
didInclude,
isTrue,
reason:
'Expected asset to be included for all platforms when no platforms are specified (platform: $platform)',
);
},
overrides: <Type, Generator>{
FileSystem: () => fileSystem,
ProcessManager: () => FakeProcessManager.any(),
},
);
}
});
group('includes assets only for declared multiple platforms', () {
for (final platform in kValidPluginPlatforms) {
testUsingContext(
platform,
() async {
const filePath = 'assets/test-multi.txt';
final targetPlatforms = <String>['android', 'ios'];
fileSystem.file('pubspec.yaml')
..createSync()
..writeAsStringSync('''
name: example
flutter:
assets:
- path: $filePath
platforms: [${targetPlatforms.join(',')}]
''');
writePackageConfigFiles(directory: globals.fs.currentDirectory, mainLibName: 'example');
fileSystem.file(filePath).createSync(recursive: true);
final TargetPlatform targetPlatform = targetFor(platform);
await const CopyAssets().build(environment, targetPlatform: targetPlatform);
final File bundledFile = fileSystem.file(
'${environment.buildDir.path}/flutter_assets/$filePath',
);
final bool exists = bundledFile.existsSync();
if (targetPlatforms.contains(platform)) {
expect(exists, isTrue, reason: 'Expected asset to be included for $platform');
} else {
expect(exists, isFalse, reason: 'Expected asset to be skipped for $platform');
}
},
overrides: <Type, Generator>{
FileSystem: () => fileSystem,
ProcessManager: () => FakeProcessManager.any(),
},
);
}
});
});
testUsingContext(
'Uses processors~/2 to transform assets',
() async {

View File

@ -1516,6 +1516,74 @@ flutter:
expect(logger.errorText, 'Expected "default-flavor" to be a string, but got 3 (int).\n');
});
testWithoutContext('FlutterManifest parses asset with platforms', () async {
const manifest = '''
name: test
dependencies:
flutter:
sdk: flutter
flutter:
assets:
- path: assets/test.png
platforms:
- web
- android
''';
final FlutterManifest flutterManifest = FlutterManifest.createFromString(
manifest,
logger: logger,
)!;
expect(flutterManifest.assets, hasLength(1));
final AssetsEntry entry = flutterManifest.assets.single;
expect(entry.uri.path, 'assets/test.png');
expect(entry.platforms, containsAll(<String>['web', 'android']));
});
testWithoutContext(
'FlutterManifest fails when platforms contains invalid platform name',
() async {
const manifest = '''
name: test
flutter:
assets:
- path: assets/test.png
platforms:
- toasterOS
- windows
''';
final FlutterManifest? flutterManifest = FlutterManifest.createFromString(
manifest,
logger: logger,
);
expect(flutterManifest, isNull);
expect(logger.errorText, contains('Invalid platform'));
},
);
testWithoutContext('FlutterManifest supports empty platforms list', () async {
const manifest = '''
name: test
flutter:
assets:
- path: assets/test.png
platforms: []
''';
final FlutterManifest flutterManifest = FlutterManifest.createFromString(
manifest,
logger: logger,
)!;
expect(flutterManifest.assets, hasLength(1));
final AssetsEntry entry = flutterManifest.assets.single;
expect(entry.uri.path, 'assets/test.png');
expect(entry.platforms, isEmpty);
});
testWithoutContext('FlutterManifest.copyWith generates a valid manifest', () async {
const manifest = '''
name: test
@ -1536,6 +1604,7 @@ flutter:
AssetsEntry(
uri: Uri(path: 'foo'),
flavors: const <String>{'flavor'},
platforms: const <String>{'web', 'android', 'ios'},
transformers: const <AssetTransformerEntry>[
AssetTransformerEntry(package: 'package:foo', args: <String>['arg']),
],
@ -1555,6 +1624,7 @@ flutter:
AssetsEntry(
uri: Uri(path: 'deferredComponentUri'),
flavors: const <String>{'deferredComponentFlavor'},
platforms: const <String>{'macos'},
transformers: const <AssetTransformerEntry>[
AssetTransformerEntry(
package: 'package:deferredComponent',
@ -1578,6 +1648,10 @@ flutter:
- path: foo
flavors:
- flavor
platforms:
- web
- android
- ios
transformers:
- package: package:foo
args:
@ -1598,6 +1672,8 @@ flutter:
- path: deferredComponentUri
flavors:
- deferredComponentFlavor
platforms:
- macos
transformers:
- package: package:deferredComponent
args: