[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');