[flutter_tool] add a vmservice API for hot ui requests (#45649)
diff --git a/packages/flutter/lib/src/widgets/binding.dart b/packages/flutter/lib/src/widgets/binding.dart
index 8a72e40..4965249 100644
--- a/packages/flutter/lib/src/widgets/binding.dart
+++ b/packages/flutter/lib/src/widgets/binding.dart
@@ -326,6 +326,27 @@
},
);
+ // Register the ability to quickly mark elements as dirty.
+ // The performance of this method may be improved with additional
+ // information from https://github.com/flutter/flutter/issues/46195.
+ registerServiceExtension(
+ name: 'fastReassemble',
+ callback: (Map<String, Object> params) async {
+ final String className = params['class'];
+ void markElementsDirty(Element element) {
+ if (element == null) {
+ return;
+ }
+ if (element.widget?.runtimeType?.toString()?.startsWith(className) ?? false) {
+ element.markNeedsBuild();
+ }
+ element.visitChildElements(markElementsDirty);
+ }
+ markElementsDirty(renderViewElement);
+ return <String, String>{'Success': 'true'};
+ },
+ );
+
// Expose the ability to send Widget rebuilds as [Timeline] events.
registerBoolServiceExtension(
name: 'profileWidgetBuilds',
diff --git a/packages/flutter/test/foundation/service_extensions_test.dart b/packages/flutter/test/foundation/service_extensions_test.dart
index 942e64d..3477cbd 100644
--- a/packages/flutter/test/foundation/service_extensions_test.dart
+++ b/packages/flutter/test/foundation/service_extensions_test.dart
@@ -170,7 +170,7 @@
const int disabledExtensions = kIsWeb ? 3 : 0;
// If you add a service extension... TEST IT! :-)
// ...then increment this number.
- expect(binding.extensions.length, 27 + widgetInspectorExtensionCount - disabledExtensions);
+ expect(binding.extensions.length, 28 + widgetInspectorExtensionCount - disabledExtensions);
expect(console, isEmpty);
debugPrint = debugPrintThrottled;
@@ -692,4 +692,11 @@
expect(trace, contains('package:test_api/test_api.dart,::,test\n'));
expect(trace, contains('service_extensions_test.dart,::,main\n'));
}, skip: isBrowser);
+
+ test('Service extensions - fastReassemble', () async {
+ Map<String, dynamic> result;
+ result = await binding.testExtension('fastReassemble', <String, String>{'class': 'Foo'});
+
+ expect(result, containsPair('Success', 'true'));
+ }, skip: isBrowser);
}
diff --git a/packages/flutter_tools/doc/daemon.md b/packages/flutter_tools/doc/daemon.md
index 0e63815..b8383e1 100644
--- a/packages/flutter_tools/doc/daemon.md
+++ b/packages/flutter_tools/doc/daemon.md
@@ -107,6 +107,15 @@
- `reason`: optional; the reason for the full restart (eg. `save`, `manual`) for reporting purposes
- `pause`: optional; when doing a hot restart the isolate should enter a paused mode
+#### app.reloadMethod
+
+Performs a limited hot restart which does not sync assets and only marks elements as dirty, instead of reassembling the full application. A `code` of `0` indicates success, and non-zero indicates a failure.
+
+- `appId`: the id of a previously started app; this is required.
+- `library`: the absolute file URI of the library to be updated; this is required.
+- `class`: the name of the StatelessWidget that was updated, or the StatefulWidget
+corresponding to the updated State class; this is required.
+
#### app.callServiceExtension
The `callServiceExtension()` allows clients to make arbitrary calls to service protocol extensions. It returns a `Map` - the result returned by the service protocol method.
diff --git a/packages/flutter_tools/lib/src/commands/daemon.dart b/packages/flutter_tools/lib/src/commands/daemon.dart
index 282b666..5c1b9db 100644
--- a/packages/flutter_tools/lib/src/commands/daemon.dart
+++ b/packages/flutter_tools/lib/src/commands/daemon.dart
@@ -392,6 +392,7 @@
class AppDomain extends Domain {
AppDomain(Daemon daemon) : super(daemon, 'app') {
registerHandler('restart', restart);
+ registerHandler('reloadMethod', reloadMethod);
registerHandler('callServiceExtension', callServiceExtension);
registerHandler('stop', stop);
registerHandler('detach', detach);
@@ -584,6 +585,28 @@
});
}
+ Future<OperationResult> reloadMethod(Map<String, dynamic> args) async {
+ final String appId = _getStringArg(args, 'appId', required: true);
+ final String classId = _getStringArg(args, 'class', required: true);
+ final String libraryId = _getStringArg(args, 'library', required: true);
+
+ final AppInstance app = _getApp(appId);
+ if (app == null) {
+ throw "app '$appId' not found";
+ }
+
+ if (_inProgressHotReload != null) {
+ throw 'hot restart already in progress';
+ }
+
+ _inProgressHotReload = app._runInZone<OperationResult>(this, () {
+ return app.reloadMethod(classId: classId, libraryId: libraryId);
+ });
+ return _inProgressHotReload.whenComplete(() {
+ _inProgressHotReload = null;
+ });
+ }
+
/// Returns an error, or the service extension result (a map with two fixed
/// keys, `type` and `method`). The result may have one or more additional keys,
/// depending on the specific service extension end-point. For example:
@@ -926,6 +949,10 @@
return runner.restart(fullRestart: fullRestart, pauseAfterRestart: pauseAfterRestart, reason: reason);
}
+ Future<OperationResult> reloadMethod({ String classId, String libraryId }) {
+ return runner.reloadMethod(classId: classId, libraryId: libraryId);
+ }
+
Future<void> stop() => runner.exit();
Future<void> detach() => runner.detach();
diff --git a/packages/flutter_tools/lib/src/compile.dart b/packages/flutter_tools/lib/src/compile.dart
index cd89240..3e5cf3c 100644
--- a/packages/flutter_tools/lib/src/compile.dart
+++ b/packages/flutter_tools/lib/src/compile.dart
@@ -613,6 +613,7 @@
printTrace('<- recompile $mainUri$inputKey');
for (Uri fileUri in request.invalidatedFiles) {
_server.stdin.writeln(_mapFileUri(fileUri.toString(), packageUriMapper));
+ printTrace('${_mapFileUri(fileUri.toString(), packageUriMapper)}');
}
_server.stdin.writeln(inputKey);
printTrace('<- $inputKey');
diff --git a/packages/flutter_tools/lib/src/devfs.dart b/packages/flutter_tools/lib/src/devfs.dart
index 1c076d4..1e329e8 100644
--- a/packages/flutter_tools/lib/src/devfs.dart
+++ b/packages/flutter_tools/lib/src/devfs.dart
@@ -453,6 +453,7 @@
String projectRootPath,
@required String pathToReload,
@required List<Uri> invalidatedFiles,
+ bool skipAssets = false,
}) async {
assert(trackWidgetCreation != null);
assert(generator != null);
@@ -463,7 +464,7 @@
final Map<Uri, DevFSContent> dirtyEntries = <Uri, DevFSContent>{};
int syncedBytes = 0;
- if (bundle != null) {
+ if (bundle != null && !skipAssets) {
printTrace('Scanning asset files');
// We write the assets into the AssetBundle working dir so that they
// are in the same location in DevFS and the iOS simulator.
diff --git a/packages/flutter_tools/lib/src/resident_runner.dart b/packages/flutter_tools/lib/src/resident_runner.dart
index 1fec7d2..0402b83 100644
--- a/packages/flutter_tools/lib/src/resident_runner.dart
+++ b/packages/flutter_tools/lib/src/resident_runner.dart
@@ -160,6 +160,7 @@
ReloadSources reloadSources,
Restart restart,
CompileExpression compileExpression,
+ ReloadMethod reloadMethod,
}) {
final Completer<void> completer = Completer<void>();
StreamSubscription<void> subscription;
@@ -177,6 +178,7 @@
reloadSources: reloadSources,
restart: restart,
compileExpression: compileExpression,
+ reloadMethod: reloadMethod,
device: device,
);
} on Exception catch (exception) {
@@ -718,6 +720,22 @@
throw '${fullRestart ? 'Restart' : 'Reload'} is not supported in $mode mode';
}
+ /// The resident runner API for interaction with the reloadMethod vmservice
+ /// request.
+ ///
+ /// This API should only be called for UI only-changes spanning a single
+ /// library/Widget.
+ ///
+ /// The value [classId] should be the identifier of the StatelessWidget that
+ /// was invalidated, or the StatefulWidget for the corresponding State class
+ /// that was invalidated. This must be provided.
+ ///
+ /// The value [libraryId] should be the absolute file URI for the containing
+ /// library of the widget that was invalidated. This must be provided.
+ Future<OperationResult> reloadMethod({ String classId, String libraryId }) {
+ throw UnsupportedError('Method is not supported.');
+ }
+
@protected
void writeVmserviceFile() {
if (debuggingOptions.vmserviceOutFile != null) {
@@ -896,6 +914,7 @@
ReloadSources reloadSources,
Restart restart,
CompileExpression compileExpression,
+ ReloadMethod reloadMethod,
}) async {
if (!debuggingOptions.debuggingEnabled) {
throw 'The service protocol is not enabled.';
@@ -909,6 +928,7 @@
reloadSources: reloadSources,
restart: restart,
compileExpression: compileExpression,
+ reloadMethod: reloadMethod,
);
await device.getVMs();
await device.refreshViews();
diff --git a/packages/flutter_tools/lib/src/run_hot.dart b/packages/flutter_tools/lib/src/run_hot.dart
index 4acfd37..9629942 100644
--- a/packages/flutter_tools/lib/src/run_hot.dart
+++ b/packages/flutter_tools/lib/src/run_hot.dart
@@ -145,6 +145,57 @@
throw 'Failed to compile $expression';
}
+ @override
+ Future<OperationResult> reloadMethod({String libraryId, String classId}) async {
+ final Stopwatch stopwatch = Stopwatch()..start();
+ final UpdateFSReport results = UpdateFSReport(success: true);
+ final List<Uri> invalidated = <Uri>[Uri.parse(libraryId)];
+ for (FlutterDevice device in flutterDevices) {
+ results.incorporateResults(await device.updateDevFS(
+ mainPath: mainPath,
+ target: target,
+ bundle: assetBundle,
+ firstBuildTime: firstBuildTime,
+ bundleFirstUpload: false,
+ bundleDirty: false,
+ fullRestart: false,
+ projectRootPath: projectRootPath,
+ pathToReload: getReloadPath(fullRestart: false),
+ invalidatedFiles: invalidated,
+ dillOutputPath: dillOutputPath,
+ ));
+ }
+ if (!results.success) {
+ return OperationResult(1, 'Failed to compile');
+ }
+ try {
+ final String entryPath = fs.path.relative(
+ getReloadPath(fullRestart: false),
+ from: projectRootPath,
+ );
+ for (FlutterDevice device in flutterDevices) {
+ final List<Future<Map<String, dynamic>>> reportFutures = device.reloadSources(
+ entryPath, pause: false,
+ );
+ final List<Map<String, dynamic>> reports = await Future.wait(reportFutures);
+ final Map<String, dynamic> firstReport = reports.first;
+ await device.updateReloadStatus(validateReloadReport(firstReport, printErrors: false));
+ }
+ } catch (error) {
+ return OperationResult(1, error.toString());
+ }
+
+ for (FlutterDevice device in flutterDevices) {
+ for (FlutterView view in device.views) {
+ await view.uiIsolate.flutterFastReassemble(classId);
+ }
+ }
+
+ printStatus('reloadMethod took ${stopwatch.elapsedMilliseconds}');
+ flutterUsage.sendTiming('hot', 'ui', stopwatch.elapsed);
+ return OperationResult.ok;
+ }
+
// Returns the exit code of the flutter tool process, like [run].
@override
Future<int> attach({
@@ -157,6 +208,7 @@
reloadSources: _reloadSourcesService,
restart: _restartService,
compileExpression: _compileExpressionService,
+ reloadMethod: reloadMethod,
);
} catch (error) {
printError('Error connecting to the service protocol: $error');
diff --git a/packages/flutter_tools/lib/src/vmservice.dart b/packages/flutter_tools/lib/src/vmservice.dart
index 8dca5ea..928cf2a 100644
--- a/packages/flutter_tools/lib/src/vmservice.dart
+++ b/packages/flutter_tools/lib/src/vmservice.dart
@@ -60,6 +60,11 @@
bool isStatic,
);
+typedef ReloadMethod = Future<void> Function({
+ String classId,
+ String libraryId,
+});
+
Future<StreamChannel<String>> _defaultOpenChannel(Uri uri, {io.CompressionOptions compression = io.CompressionOptions.compressionDefault}) async {
Duration delay = const Duration(milliseconds: 100);
int attempts = 0;
@@ -102,6 +107,7 @@
ReloadSources reloadSources,
Restart restart,
CompileExpression compileExpression,
+ ReloadMethod reloadMethod,
io.CompressionOptions compression,
Device device,
});
@@ -117,6 +123,7 @@
Restart restart,
CompileExpression compileExpression,
Device device,
+ ReloadMethod reloadMethod,
) {
_vm = VM._empty(this);
_peer.listen().catchError(_connectionError.completeError);
@@ -150,15 +157,21 @@
'alias': 'Flutter Tools',
});
+ }
+
+ if (reloadMethod != null) {
// Register a special method for hot UI. while this is implemented
// currently in the same way as hot reload, it leaves the tool free
// to change to a more efficient implementation in the future.
+ //
+ // `library` should be the file URI of the updated code.
+ // `class` should be the name of the Widget subclass to be marked dirty. For example,
+ // if the build method of a StatelessWidget is updated, this is the name of class.
+ // If the build method of a StatefulWidget is updated, then this is the name
+ // of the Widget class that created the State object.
_peer.registerMethod('reloadMethod', (rpc.Parameters params) async {
- final String isolateId = params['isolateId'].value as String;
final String libraryId = params['library'].value as String;
final String classId = params['class'].value as String;
- final String methodId = params['method'].value as String;
- final String methodBody = params['methodBody'].value as String;
if (libraryId.isEmpty) {
throw rpc.RpcException.invalidParams('Invalid \'libraryId\': $libraryId');
@@ -166,17 +179,14 @@
if (classId.isEmpty) {
throw rpc.RpcException.invalidParams('Invalid \'classId\': $classId');
}
- if (methodId.isEmpty) {
- throw rpc.RpcException.invalidParams('Invalid \'methodId\': $methodId');
- }
- if (methodBody.isEmpty) {
- throw rpc.RpcException.invalidParams('Invalid \'methodBody\': $methodBody');
- }
printTrace('reloadMethod not yet supported, falling back to hot reload');
try {
- await reloadSources(isolateId);
+ await reloadMethod(
+ libraryId: libraryId,
+ classId: classId,
+ );
return <String, String>{'type': 'Success'};
} on rpc.RpcException {
rethrow;
@@ -298,6 +308,7 @@
ReloadSources reloadSources,
Restart restart,
CompileExpression compileExpression,
+ ReloadMethod reloadMethod,
io.CompressionOptions compression = io.CompressionOptions.compressionDefault,
Device device,
}) async {
@@ -308,6 +319,7 @@
compileExpression: compileExpression,
compression: compression,
device: device,
+ reloadMethod: reloadMethod,
);
}
@@ -316,13 +328,23 @@
ReloadSources reloadSources,
Restart restart,
CompileExpression compileExpression,
+ ReloadMethod reloadMethod,
io.CompressionOptions compression = io.CompressionOptions.compressionDefault,
Device device,
}) async {
final Uri wsUri = httpUri.replace(scheme: 'ws', path: fs.path.join(httpUri.path, 'ws'));
final StreamChannel<String> channel = await _openChannel(wsUri, compression: compression);
final rpc.Peer peer = rpc.Peer.withoutJson(jsonDocument.bind(channel), onUnhandledError: _unhandledError);
- final VMService service = VMService(peer, httpUri, wsUri, reloadSources, restart, compileExpression, device);
+ final VMService service = VMService(
+ peer,
+ httpUri,
+ wsUri,
+ reloadSources,
+ restart,
+ compileExpression,
+ device,
+ reloadMethod,
+ );
// This call is to ensure we are able to establish a connection instead of
// keeping on trucking and failing farther down the process.
await service._sendRequest('getVersion', const <String, dynamic>{});
@@ -1336,6 +1358,12 @@
return invokeFlutterExtensionRpcRaw('ext.flutter.reassemble');
}
+ Future<Map<String, dynamic>> flutterFastReassemble(String classId) {
+ return invokeFlutterExtensionRpcRaw('ext.flutter.fastReassemble', params: <String, Object>{
+ 'class': classId,
+ });
+ }
+
Future<bool> flutterAlreadyPaintedFirstUsefulFrame() async {
final Map<String, dynamic> result = await invokeFlutterExtensionRpcRaw('ext.flutter.didSendFirstFrameRasterizedEvent');
// result might be null when the service extension is not initialized
diff --git a/packages/flutter_tools/lib/src/web/devfs_web.dart b/packages/flutter_tools/lib/src/web/devfs_web.dart
index c9fbe8b..6a5db70 100644
--- a/packages/flutter_tools/lib/src/web/devfs_web.dart
+++ b/packages/flutter_tools/lib/src/web/devfs_web.dart
@@ -280,6 +280,7 @@
String projectRootPath,
String pathToReload,
List<Uri> invalidatedFiles,
+ bool skipAssets = false,
}) async {
assert(trackWidgetCreation != null);
assert(generator != null);
diff --git a/packages/flutter_tools/test/commands.shard/hermetic/attach_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/attach_test.dart
index c9cec04..ccb7249 100644
--- a/packages/flutter_tools/test/commands.shard/hermetic/attach_test.dart
+++ b/packages/flutter_tools/test/commands.shard/hermetic/attach_test.dart
@@ -747,6 +747,7 @@
ReloadSources reloadSources,
Restart restart,
CompileExpression compileExpression,
+ ReloadMethod reloadMethod,
CompressionOptions compression,
Device device,
}) async {
diff --git a/packages/flutter_tools/test/general.shard/cold_test.dart b/packages/flutter_tools/test/general.shard/cold_test.dart
index 3321445..28df639 100644
--- a/packages/flutter_tools/test/general.shard/cold_test.dart
+++ b/packages/flutter_tools/test/general.shard/cold_test.dart
@@ -183,6 +183,7 @@
ReloadSources reloadSources,
Restart restart,
CompileExpression compileExpression,
+ ReloadMethod reloadMethod,
}) async {
throw exception;
}
diff --git a/packages/flutter_tools/test/general.shard/hot_test.dart b/packages/flutter_tools/test/general.shard/hot_test.dart
index 5f00902..47ac6c1 100644
--- a/packages/flutter_tools/test/general.shard/hot_test.dart
+++ b/packages/flutter_tools/test/general.shard/hot_test.dart
@@ -397,6 +397,7 @@
ReloadSources reloadSources,
Restart restart,
CompileExpression compileExpression,
+ ReloadMethod reloadMethod,
}) async {
throw exception;
}
diff --git a/packages/flutter_tools/test/general.shard/resident_runner_test.dart b/packages/flutter_tools/test/general.shard/resident_runner_test.dart
index 94bfb05..39b55c6 100644
--- a/packages/flutter_tools/test/general.shard/resident_runner_test.dart
+++ b/packages/flutter_tools/test/general.shard/resident_runner_test.dart
@@ -664,6 +664,7 @@
ReloadSources reloadSources,
Restart restart,
CompileExpression compileExpression,
+ ReloadMethod reloadMethod,
io.CompressionOptions compression,
Device device,
}) async => mockVMService,
diff --git a/packages/flutter_tools/test/general.shard/vmservice_test.dart b/packages/flutter_tools/test/general.shard/vmservice_test.dart
index 0b56587..138ac83 100644
--- a/packages/flutter_tools/test/general.shard/vmservice_test.dart
+++ b/packages/flutter_tools/test/general.shard/vmservice_test.dart
@@ -198,7 +198,7 @@
bool done = false;
final MockPeer mockPeer = MockPeer();
expect(mockPeer.returnedFromSendRequest, 0);
- final VMService vmService = VMService(mockPeer, null, null, null, null, null, null);
+ final VMService vmService = VMService(mockPeer, null, null, null, null, null, null, null);
expect(mockPeer.sentNotifications, contains('registerService'));
final List<String> registeredServices =
mockPeer.sentNotifications['registerService']
@@ -270,8 +270,8 @@
testUsingContext('registers hot UI method', () {
FakeAsync().run((FakeAsync time) {
final MockPeer mockPeer = MockPeer();
- Future<void> reloadSources(String isolateId, { bool pause, bool force}) async {}
- VMService(mockPeer, null, null, reloadSources, null, null, null);
+ Future<void> reloadMethod({ String classId, String libraryId }) async {}
+ VMService(mockPeer, null, null, null, null, null, null, reloadMethod);
expect(mockPeer.registeredMethods, contains('reloadMethod'));
});
@@ -285,7 +285,7 @@
final MockDevice mockDevice = MockDevice();
final MockPeer mockPeer = MockPeer();
Future<void> reloadSources(String isolateId, { bool pause, bool force}) async {}
- VMService(mockPeer, null, null, reloadSources, null, null, mockDevice);
+ VMService(mockPeer, null, null, reloadSources, null, null, mockDevice, null);
expect(mockPeer.registeredMethods, contains('flutterMemoryInfo'));
});
diff --git a/packages/flutter_tools/test/integration.shard/hot_reload_test.dart b/packages/flutter_tools/test/integration.shard/hot_reload_test.dart
index e7cf361..a08b67d 100644
--- a/packages/flutter_tools/test/integration.shard/hot_reload_test.dart
+++ b/packages/flutter_tools/test/integration.shard/hot_reload_test.dart
@@ -48,6 +48,23 @@
}
});
+ test('reloadMethod triggers hot reload behavior', () async {
+ await _flutter.run();
+ _project.uncommentHotReloadPrint();
+ final StringBuffer stdout = StringBuffer();
+ final StreamSubscription<String> subscription = _flutter.stdout.listen(stdout.writeln);
+ try {
+ final String libraryId = _project.buildBreakpointUri.toString();
+ await _flutter.reloadMethod(libraryId: libraryId, classId: 'MyApp');
+ // reloadMethod does not wait for the next frame, to allow scheduling a new
+ // update while the previous update was pending.
+ await Future<void>.delayed(const Duration(seconds: 1));
+ expect(stdout.toString(), contains('(((((RELOAD WORKED)))))'));
+ } finally {
+ await subscription.cancel();
+ }
+ });
+
test('hot restart works without error', () async {
await _flutter.run();
await _flutter.hotRestart();
diff --git a/packages/flutter_tools/test/integration.shard/test_driver.dart b/packages/flutter_tools/test/integration.shard/test_driver.dart
index 6f1c7bc..4279b7a4 100644
--- a/packages/flutter_tools/test/integration.shard/test_driver.dart
+++ b/packages/flutter_tools/test/integration.shard/test_driver.dart
@@ -538,6 +538,29 @@
Future<void> hotRestart({ bool pause = false }) => _restart(fullRestart: true, pause: pause);
Future<void> hotReload() => _restart(fullRestart: false);
+ Future<void> scheduleFrame() async {
+ if (_currentRunningAppId == null) {
+ throw Exception('App has not started yet');
+ }
+ await _sendRequest(
+ 'app.callServiceExtension',
+ <String, dynamic>{'appId': _currentRunningAppId, 'methodName': 'ext.ui.window.scheduleFrame'},
+ );
+ }
+
+ Future<void> reloadMethod({ String libraryId, String classId }) async {
+ if (_currentRunningAppId == null) {
+ throw Exception('App has not started yet');
+ }
+ final dynamic reloadMethodResponse = await _sendRequest(
+ 'app.reloadMethod',
+ <String, dynamic>{'appId': _currentRunningAppId, 'class': classId, 'library': libraryId},
+ );
+ if (reloadMethodResponse == null || reloadMethodResponse['code'] != 0) {
+ _throwErrorResponse('reloadMethodResponse request failed');
+ }
+ }
+
Future<void> _restart({ bool fullRestart = false, bool pause = false }) async {
if (_currentRunningAppId == null) {
throw Exception('App has not started yet');