Reland "Add feature flags to the framework" (#171545)

This relands https://github.com/flutter/flutter/pull/168437. The google3
fixes were landed in: https://github.com/flutter/flutter/pull/171547,
https://critique.corp.google.com/cl/781275353,
https://github.com/flutter/flutter/pull/171933.

This PR is split into two commits:

1. d6253794e8982348c5c21cb63e8f6bf785664be6, code from
https://github.com/flutter/flutter/pull/168437 without any changes
2. f35d29e4af630d2d4fdb0cda8686b6ff9f77227a, updates the PR to omit
obvious types.

Original PR description:

## Motivation

We'd like to let users opt-in to experimental features so that they can
give early feedback while we iterate on the feature. For example:

Example feature flags:

1. Android sensitive content:
https://github.com/flutter/flutter/pull/158473. When enabled, Flutter
will tell Android when the view contains sensitive content like a
password.
3. Desktop multi-window. When enabled, Flutter will use child windows to
allow things like a context menu to "escape" outside of the current
window.

### Use case

Users will be able to turn on features by:

* **Option 1**: Run `flutter config --enable-my-feature`. This enables
the feature for all projects on the machine
* **Option 2**: Add `enable-my-feature: true` in their `pubspec.yaml`,
under the `flutter` section. This would enable the for a single project
on the machine.

Turning on a feature affects _both_ development-time (`flutter run`) and
deployment-time (`flutter build x`). For example, I can `flutter build
windows` to create an `.exe` with multi-window features enabled.

## How this works

This adds a new
[`runtimeId`](https://github.com/flutter/flutter/pull/168437/files#diff-0ded384225f19a4c34d43c7c11f7cb084ff3db947cfa82d8d52fc94c112bb2a7R243-R247)
property to the tool's `Feature` class. If a feature is on and has a
`runtimeId`, its `runtimeId` will be [stamped into the Dart application
as a Dart
define](https://github.com/flutter/flutter/pull/168437/files#diff-bd662448bdc2e6f50e47cd3b20b22b41a828561bce65cb4d54ea4f5011cc604eR293-R327).
The framework uses this Dart define to [determine which features are
enabled](https://github.com/flutter/flutter/pull/168437/files#diff-c8dbd5cd3103bc5be53c4ac5be8bdb9bf73e10cd5d8e4ac34e737fd1f8602d45).

### Multi-window example

https://github.com/flutter/flutter/pull/168697 shows how this new
feature flag system can be used to add a multi-window feature flag:

1. It adds a new [multi-window
feature](https://github.com/flutter/flutter/pull/168697/files#diff-0ded384225f19a4c34d43c7c11f7cb084ff3db947cfa82d8d52fc94c112bb2a7R189-R198)
to the Flutter tool. This can be turned on using `flutter config
--enable-multi-window` or by putting `enable-multi-window: true` in an
app's .pubspec, under the `flutter` section.
2. It adds a new
[`isMultiWindowEnabled`](https://github.com/flutter/flutter/pull/168697/files#diff-c8dbd5cd3103bc5be53c4ac5be8bdb9bf73e10cd5d8e4ac34e737fd1f8602d45R7-R11)
property to the framework.
4. The Material library can use this new property to determine whether
it should create a new window.
[Example](https://github.com/flutter/flutter/pull/168697/files#diff-2cbc1634ed6b61d61dfa090e7bfbbb7c60b74c8abc3a28df6f79eee691fd1b73).

## Limitations

### Tool and framework only

For now, these feature flags are available only to the Flutter tool and
Flutter framework. The flags are not automatically available to the
embedder or the engine.

For example, embedders need to configure their surfaces differently if
Impeller is enabled. This configuration must happen before the Dart
isolate is launched. As a result, the framework's feature flags is not a
viable solution for this scenario for now. For these kinds of scenarios,
we should continue to use platform-specific configuration like the
`AndroidManifest.xml` or `Info.plist` files.

This is a fixable limitation, we just need to invest in this plumbing :)

### Tree shaking

Feature flags are not designed to help tree shaking. For example, you
cannot conditionally import Dart code depending on the enabled feature
flags. Code that is feature flagged off will still be imported into
user's apps.
This commit is contained in:
Loïc Sharma 2025-07-14 17:02:16 -07:00 committed by GitHub
parent 50d5f022c0
commit 6474b04e6d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 297 additions and 1 deletions

View File

@ -1183,6 +1183,23 @@ targets:
["devicelab", "hostonly", "linux"]
task_name: linux_desktop_impeller
- name: Linux linux_feature_flags_test
recipe: devicelab/devicelab_drone
timeout: 60
bringup: true
presubmit: false
properties:
xvfb: "1"
dependencies: >-
[
{"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"},
{"dependency": "cmake", "version": "build_id:8787856497187628321"},
{"dependency": "ninja", "version": "version:1.9.0"}
]
tags: >
["devicelab", "hostonly", "linux"]
task_name: linux_feature_flags_test
- name: Linux_android_emu android_display_cutout
recipe: devicelab/devicelab_drone
timeout: 60

View File

@ -303,11 +303,12 @@
/dev/devicelab/bin/tasks/web_benchmarks_ddc_hot_reload.dart @yjbanov @flutter/web
/dev/devicelab/bin/tasks/web_benchmarks_skwasm.dart @eyebrowsoffire @flutter/web
/dev/devicelab/bin/tasks/web_benchmarks_skwasm_st.dart @eyebrowsoffire @flutter/web
/dev/devicelab/bin/tasks/windows_desktop_impeller.dart @jonahwilliams @flutter/engine
/dev/devicelab/bin/tasks/windows_home_scroll_perf__timeline_summary.dart @jonahwilliams @flutter/engine
/dev/devicelab/bin/tasks/windows_startup_test.dart @loic-sharma @flutter/desktop
/dev/devicelab/bin/tasks/windows_desktop_impeller.dart @jonahwilliams @flutter/engine
/dev/devicelab/bin/tasks/mac_desktop_impeller.dart @jonahwilliams @flutter/engine
/dev/devicelab/bin/tasks/linux_desktop_impeller.dart @jonahwilliams @flutter/engine
/dev/devicelab/bin/tasks/linux_feature_flags_test.dart @loic-sharma @flutter/desktop
/dev/devicelab/bin/tasks/android_display_cutout.dart @reidbaker @flutter/android
## Host only framework tests

View File

@ -0,0 +1,13 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter_devicelab/framework/devices.dart';
import 'package:flutter_devicelab/framework/framework.dart';
import 'package:flutter_devicelab/tasks/integration_tests.dart';
/// Verify that feature flags work on Linux.
Future<void> main() async {
deviceOperatingSystem = DeviceOperatingSystem.linux;
await task(featureFlagsTask());
}

View File

@ -188,6 +188,16 @@ TaskFunction dartDefinesTask() {
).call;
}
TaskFunction featureFlagsTask() {
return DriverTest(
'${flutterDirectory.path}/dev/integration_tests/ui',
'lib/feature_flags.dart',
// TODO(loic-sharma): Turn on a framework feature flag once one exists.
// https://github.com/flutter/flutter/issues/167668
environment: const <String, String>{},
).call;
}
TaskFunction createEndToEndIntegrationTest() {
return IntegrationTest(
'${flutterDirectory.path}/dev/integration_tests/ui',

View File

@ -0,0 +1,22 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// ignore: implementation_imports
import 'package:flutter/src/foundation/_features.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_driver/driver_extension.dart';
/// This application displays the framework's enabled feature flags as a string.
void main() {
enableFlutterDriverExtension();
runApp(
Center(
child: Text(
// ignore: invalid_use_of_internal_member
'Feature flags: "$debugEnabledFeatureFlags"',
textDirection: TextDirection.ltr,
),
),
);
}

View File

@ -0,0 +1,24 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter_driver/flutter_driver.dart';
import 'package:test/test.dart' hide TypeMatcher, isInstanceOf;
void main() {
FlutterDriver? driver;
setUpAll(() async {
driver = await FlutterDriver.connect();
});
tearDownAll(() async {
await driver?.close();
});
test('Can run with feature flags enabled', () async {
// TODO(loic-sharma): Turn on a framework feature flag once one exists.
// https://github.com/flutter/flutter/issues/167668
await driver?.waitFor(find.text('Feature flags: "{}"'));
}, timeout: Timeout.none);
}

View File

@ -20,6 +20,7 @@ Table of Contents
- [Allowing flags to be enabled](#allowing-flags-to-be-enabled)
- [Enabling a flag by default](#enabling-a-flag-by-default)
- [Removing a flag](#removing-a-flag)
- [Precedence](#precedence)
- [Using a flag to drive behavior](#using-a-flag-to-drive-behavior)
- [Tool](#tool)
- [Framework](#framework)
@ -238,6 +239,43 @@ integration tests as well.
[^1]: Some flags might have a longer or indefinite lifespan, but this is rare.
### Precedence
Users have several options to configure flags. Assuming the following feature:
```dart
const Feature unicornEmojis = Feature(
name: 'add unicorn emojis in lots of fun places',
configSetting: 'enable-unicorn-emojis',
environmentOverride: 'FLUTTER_ENABLE_UNICORN_EMOJIS',
);
```
Flutter uses the following precendence order:
1. The app's `pubspec.yaml` file:
```yaml
flutter:
config:
enable-unicorn-emojis: true
```
2. The tool's global configuration:
```sh
flutter config --enable-unicorn-emojis
```
3. Environment variables:
```sh
FLUTTER_ENABLE_UNICORN_EMOJIS=true flutter some-command
```
If none of these are set, Flutter falls back to the feature's
default value for the current release channel.
## Using a flag to drive behavior
Once you have a flag, you can use it to conditionally enable something or
@ -305,6 +343,10 @@ final class SensitiveContent extends StatelessWidget {
Note that feature flag usage in the framework runtime is very new, and is likely
to evolve over time.
Feature flags are not designed to help tree shaking. For example, you
cannot conditionally import Dart code depending on the enabled feature flags.
Tree shaking might not remove code that is feature flagged off.
### Tests
#### Integration tests

View File

@ -0,0 +1,13 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:meta/meta.dart';
/// The feature flags this app was built with.
///
/// Do not use this API. Flutter can and will make breaking changes to this API.
@internal
Set<String> debugEnabledFeatureFlags = <String>{
...const String.fromEnvironment('FLUTTER_ENABLED_FEATURE_FLAGS').split(','),
};

View File

@ -1023,6 +1023,12 @@ const kFlavor = 'Flavor';
/// by the `appFlavor` service.
const kAppFlavor = 'FLUTTER_APP_FLAVOR';
/// Environment variable of the enabled feature flags to be set in the
/// dartDefines.
///
/// This tells the framework which features are on.
const kEnabledFeatureFlags = 'FLUTTER_ENABLED_FEATURE_FLAGS';
/// The Xcode configuration used to build the project.
const kXcodeConfiguration = 'Configuration';

View File

@ -217,6 +217,7 @@ class Feature {
required this.name,
this.environmentOverride,
this.configSetting,
this.runtimeId,
this.extraHelpText,
this.master = const FeatureChannelSetting(),
this.beta = const FeatureChannelSetting(),
@ -228,6 +229,7 @@ class Feature {
required this.name,
this.environmentOverride,
this.configSetting,
this.runtimeId,
this.extraHelpText,
}) : master = const FeatureChannelSetting(available: true, enabledByDefault: true),
beta = const FeatureChannelSetting(available: true, enabledByDefault: true),
@ -259,6 +261,12 @@ class Feature {
/// If not provided, defaults to `null` meaning there is no config setting.
final String? configSetting;
/// The unique identifier for this feature at runtime.
///
/// If not `null`, the Flutter framework's enabled feature flags will
/// contain this value if this feature is enabled.
final String? runtimeId;
/// Additional text to add to the end of the help message.
///
/// If not provided, defaults to `null` meaning there is no additional text.

View File

@ -1450,6 +1450,7 @@ abstract class FlutterCommand extends Command<void> {
dartDefines.add('$kAppFlavor=$flavor');
}
_addFlutterVersionToDartDefines(globals.flutterVersion, dartDefines);
_addFeatureFlagsToDartDefines(dartDefines);
return BuildInfo(
buildMode,
@ -1511,6 +1512,27 @@ abstract class FlutterCommand extends Command<void> {
]);
}
void _addFeatureFlagsToDartDefines(List<String> dartDefines) {
if (dartDefines.any((String define) => define.startsWith(kEnabledFeatureFlags))) {
throwToolExit(
'$kEnabledFeatureFlags is used by the framework and cannot be '
'set using --${FlutterOptions.kDartDefinesOption} or --${FlutterOptions.kDartDefineFromFileOption}.\n'
'\n'
'Use the "flutter config" command to enable feature flags.',
);
}
final String enabledFeatureFlags = featureFlags.allFeatures
.where((Feature feature) => featureFlags.isEnabled(feature))
.where((Feature feature) => feature.runtimeId != null)
.map((Feature feature) => feature.runtimeId!)
.join(',');
if (enabledFeatureFlags.isNotEmpty) {
dartDefines.add('$kEnabledFeatureFlags=$enabledFeatureFlags');
}
}
void setupApplicationPackages() {
applicationPackages ??= ApplicationPackageFactory.instance;
}

View File

@ -139,6 +139,23 @@ void main() {
expect(featureNames, unorderedEquals(testFeatureNames));
});
testUsingContext('Feature runtime IDs are valid', () {
// Verify features' runtime IDs can be encoded into a Dart define.
final runtimeIdPattern = RegExp(r'^[a-zA-Z_]+$');
assert(runtimeIdPattern.hasMatch('multi_window'));
assert(!runtimeIdPattern.hasMatch('multi-window'));
final Iterable<String> runtimeIds = featureFlags.allFeatures
.map((Feature feature) => feature.runtimeId)
.nonNulls;
expect(
runtimeIds,
everyElement(matches(runtimeIdPattern)),
reason: 'Feature runtime ID must contain only alphabetical or underscore characters',
);
});
});
group('Linux Destkop', () {

View File

@ -20,6 +20,7 @@ import 'package:flutter_tools/src/build_info.dart';
import 'package:flutter_tools/src/cache.dart';
import 'package:flutter_tools/src/commands/run.dart' show RunCommand;
import 'package:flutter_tools/src/device.dart';
import 'package:flutter_tools/src/features.dart';
import 'package:flutter_tools/src/globals.dart' as globals;
import 'package:flutter_tools/src/pre_run_validator.dart';
import 'package:flutter_tools/src/runner/flutter_command.dart';
@ -1537,6 +1538,87 @@ flutter:
},
);
});
group('feature flags', () {
testUsingContext(
'tool exits when FLUTTER_ENABLED_FEATURE_FLAGS is set in --dart-define or --dart-define-from-file',
() async {
final CommandRunner<void> runner = createTestCommandRunner(
_TestRunCommandThatOnlyValidates(),
);
expect(
runner.run(<String>[
'run',
'--dart-define=FLUTTER_ENABLED_FEATURE_FLAGS=AlreadySet',
'--no-pub',
'--no-hot',
]),
throwsToolExit(
message: '''
FLUTTER_ENABLED_FEATURE_FLAGS is used by the framework and cannot be set using --dart-define or --dart-define-from-file.
Use the "flutter config" command to enable feature flags.''',
),
);
expect(
runner.run(<String>[
'run',
'--dart-define-from-file=config.json',
'--no-pub',
'--no-hot',
]),
throwsToolExit(
message: '''
FLUTTER_ENABLED_FEATURE_FLAGS is used by the framework and cannot be set using --dart-define or --dart-define-from-file.
Use the "flutter config" command to enable feature flags.''',
),
);
},
overrides: <Type, Generator>{
DeviceManager: () =>
FakeDeviceManager()..attachedDevices = <Device>[FakeDevice('name', 'id')],
Platform: () => FakePlatform(),
Cache: () => Cache.test(processManager: FakeProcessManager.any()),
FileSystem: () {
final fileSystem = MemoryFileSystem.test();
fileSystem
..file('lib/main.dart').createSync(recursive: true)
..file('pubspec.yaml').createSync()
..file('.packages').createSync();
fileSystem.file('config.json')
..createSync()
..writeAsStringSync('{"FLUTTER_ENABLED_FEATURE_FLAGS": "AlreadySet"}');
return fileSystem;
},
ProcessManager: () => FakeProcessManager.any(),
FlutterVersion: () => FakeFlutterVersion(),
},
);
testUsingContext(
'FLUTTER_ENABLED_FEATURE_FLAGS is set in dartDefines',
() async {
final flutterCommand = DummyFlutterCommand(packagesPath: 'foo');
final BuildInfo buildInfo = await flutterCommand.getBuildInfo(
forcedBuildMode: BuildMode.debug,
);
expect(buildInfo.dartDefines, contains('FLUTTER_ENABLED_FEATURE_FLAGS=buzz_feature'));
},
overrides: <Type, Generator>{
ProcessManager: () => FakeProcessManager.any(),
FeatureFlags: () => const FakeFeatureFlags(
allFeatures: <FakeFeature>[
FakeFeature(name: 'Foo', enabled: true),
FakeFeature(name: 'Bar', runtimeId: 'bar_feature', enabled: false),
FakeFeature(name: 'Buzz', runtimeId: 'buzz_feature', enabled: true),
],
),
},
);
});
});
}
@ -1658,3 +1740,22 @@ class _TestRunCommandThatOnlyValidates extends RunCommand {
@override
bool get shouldRunPub => false;
}
class FakeFeature extends Feature {
const FakeFeature({required super.name, super.runtimeId, required this.enabled});
final bool enabled;
}
class FakeFeatureFlags implements FeatureFlags {
const FakeFeatureFlags({required this.allFeatures});
@override
final List<FakeFeature> allFeatures;
@override
bool isEnabled(Feature feature) => (feature as FakeFeature).enabled;
@override
Object? noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}