mirror of
https://github.com/flutter/flutter.git
synced 2026-01-09 07:51:35 +08:00
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:
parent
50d5f022c0
commit
6474b04e6d
17
.ci.yaml
17
.ci.yaml
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
13
dev/devicelab/bin/tasks/linux_feature_flags_test.dart
Normal file
13
dev/devicelab/bin/tasks/linux_feature_flags_test.dart
Normal 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());
|
||||
}
|
||||
@ -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',
|
||||
|
||||
22
dev/integration_tests/ui/lib/feature_flags.dart
Normal file
22
dev/integration_tests/ui/lib/feature_flags.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
24
dev/integration_tests/ui/test_driver/feature_flags_test.dart
Normal file
24
dev/integration_tests/ui/test_driver/feature_flags_test.dart
Normal 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);
|
||||
}
|
||||
@ -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
|
||||
|
||||
13
packages/flutter/lib/src/foundation/_features.dart
Normal file
13
packages/flutter/lib/src/foundation/_features.dart
Normal 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(','),
|
||||
};
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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', () {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user