diff --git a/packages/flutter/lib/src/widgets/binding.dart b/packages/flutter/lib/src/widgets/binding.dart index 8a72e40f9b..4965249ed5 100644 --- a/packages/flutter/lib/src/widgets/binding.dart +++ b/packages/flutter/lib/src/widgets/binding.dart @@ -326,6 +326,27 @@ mixin WidgetsBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureB }, ); + // 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 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 {'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 942e64dbfa..3477cbd429 100644 --- a/packages/flutter/test/foundation/service_extensions_test.dart +++ b/packages/flutter/test/foundation/service_extensions_test.dart @@ -170,7 +170,7 @@ void main() { 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 @@ void main() { 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 result; + result = await binding.testExtension('fastReassemble', {'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 0e63815c7d..b8383e172e 100644 --- a/packages/flutter_tools/doc/daemon.md +++ b/packages/flutter_tools/doc/daemon.md @@ -107,6 +107,15 @@ The `restart()` restarts the given application. It returns a Map of `{ int code, - `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 282b666f14..5c1b9db3ed 100644 --- a/packages/flutter_tools/lib/src/commands/daemon.dart +++ b/packages/flutter_tools/lib/src/commands/daemon.dart @@ -392,6 +392,7 @@ typedef _RunOrAttach = Future Function({ 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 @@ class AppDomain extends Domain { }); } + Future reloadMethod(Map 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(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 @@ class AppInstance { return runner.restart(fullRestart: fullRestart, pauseAfterRestart: pauseAfterRestart, reason: reason); } + Future reloadMethod({ String classId, String libraryId }) { + return runner.reloadMethod(classId: classId, libraryId: libraryId); + } + Future stop() => runner.exit(); Future detach() => runner.detach(); diff --git a/packages/flutter_tools/lib/src/compile.dart b/packages/flutter_tools/lib/src/compile.dart index cd8924015c..3e5cf3c4cf 100644 --- a/packages/flutter_tools/lib/src/compile.dart +++ b/packages/flutter_tools/lib/src/compile.dart @@ -613,6 +613,7 @@ class DefaultResidentCompiler implements ResidentCompiler { 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 1c076d4587..1e329e82f1 100644 --- a/packages/flutter_tools/lib/src/devfs.dart +++ b/packages/flutter_tools/lib/src/devfs.dart @@ -453,6 +453,7 @@ class DevFS { String projectRootPath, @required String pathToReload, @required List invalidatedFiles, + bool skipAssets = false, }) async { assert(trackWidgetCreation != null); assert(generator != null); @@ -463,7 +464,7 @@ class DevFS { final Map dirtyEntries = {}; 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 1fec7d2a51..0402b83f0f 100644 --- a/packages/flutter_tools/lib/src/resident_runner.dart +++ b/packages/flutter_tools/lib/src/resident_runner.dart @@ -160,6 +160,7 @@ class FlutterDevice { ReloadSources reloadSources, Restart restart, CompileExpression compileExpression, + ReloadMethod reloadMethod, }) { final Completer completer = Completer(); StreamSubscription subscription; @@ -177,6 +178,7 @@ class FlutterDevice { reloadSources: reloadSources, restart: restart, compileExpression: compileExpression, + reloadMethod: reloadMethod, device: device, ); } on Exception catch (exception) { @@ -718,6 +720,22 @@ abstract class ResidentRunner { 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 reloadMethod({ String classId, String libraryId }) { + throw UnsupportedError('Method is not supported.'); + } + @protected void writeVmserviceFile() { if (debuggingOptions.vmserviceOutFile != null) { @@ -896,6 +914,7 @@ abstract class ResidentRunner { ReloadSources reloadSources, Restart restart, CompileExpression compileExpression, + ReloadMethod reloadMethod, }) async { if (!debuggingOptions.debuggingEnabled) { throw 'The service protocol is not enabled.'; @@ -909,6 +928,7 @@ abstract class ResidentRunner { 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 4acfd3790f..9629942893 100644 --- a/packages/flutter_tools/lib/src/run_hot.dart +++ b/packages/flutter_tools/lib/src/run_hot.dart @@ -145,6 +145,57 @@ class HotRunner extends ResidentRunner { throw 'Failed to compile $expression'; } + @override + Future reloadMethod({String libraryId, String classId}) async { + final Stopwatch stopwatch = Stopwatch()..start(); + final UpdateFSReport results = UpdateFSReport(success: true); + final List invalidated = [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>> reportFutures = device.reloadSources( + entryPath, pause: false, + ); + final List> reports = await Future.wait(reportFutures); + final Map 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 attach({ @@ -157,6 +208,7 @@ class HotRunner extends ResidentRunner { 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 8dca5ea3e7..928cf2a097 100644 --- a/packages/flutter_tools/lib/src/vmservice.dart +++ b/packages/flutter_tools/lib/src/vmservice.dart @@ -60,6 +60,11 @@ typedef CompileExpression = Future Function( bool isStatic, ); +typedef ReloadMethod = Future Function({ + String classId, + String libraryId, +}); + Future> _defaultOpenChannel(Uri uri, {io.CompressionOptions compression = io.CompressionOptions.compressionDefault}) async { Duration delay = const Duration(milliseconds: 100); int attempts = 0; @@ -102,6 +107,7 @@ typedef VMServiceConnector = Future Function(Uri httpUri, { ReloadSources reloadSources, Restart restart, CompileExpression compileExpression, + ReloadMethod reloadMethod, io.CompressionOptions compression, Device device, }); @@ -117,6 +123,7 @@ class VMService { Restart restart, CompileExpression compileExpression, Device device, + ReloadMethod reloadMethod, ) { _vm = VM._empty(this); _peer.listen().catchError(_connectionError.completeError); @@ -150,15 +157,21 @@ class VMService { '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 @@ class VMService { 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 {'type': 'Success'}; } on rpc.RpcException { rethrow; @@ -298,6 +308,7 @@ class VMService { ReloadSources reloadSources, Restart restart, CompileExpression compileExpression, + ReloadMethod reloadMethod, io.CompressionOptions compression = io.CompressionOptions.compressionDefault, Device device, }) async { @@ -308,6 +319,7 @@ class VMService { compileExpression: compileExpression, compression: compression, device: device, + reloadMethod: reloadMethod, ); } @@ -316,13 +328,23 @@ class VMService { 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 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 {}); @@ -1336,6 +1358,12 @@ class Isolate extends ServiceObjectOwner { return invokeFlutterExtensionRpcRaw('ext.flutter.reassemble'); } + Future> flutterFastReassemble(String classId) { + return invokeFlutterExtensionRpcRaw('ext.flutter.fastReassemble', params: { + 'class': classId, + }); + } + Future flutterAlreadyPaintedFirstUsefulFrame() async { final Map 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 c9fbe8b102..6a5db70708 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 @@ class WebDevFS implements DevFS { String projectRootPath, String pathToReload, List 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 c9cec048de..ccb72494b6 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 @@ VMServiceConnector getFakeVmServiceFactory({ 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 33214450de..28df639e2c 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 @@ class TestFlutterDevice extends FlutterDevice { 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 5f00902aa1..47ac6c1a70 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 @@ class TestFlutterDevice extends FlutterDevice { 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 94bfb05ea0..39b55c69e5 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 @@ void main() { 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 0b565877e8..138ac83c76 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 @@ void main() { 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 registeredServices = mockPeer.sentNotifications['registerService'] @@ -270,8 +270,8 @@ void main() { testUsingContext('registers hot UI method', () { FakeAsync().run((FakeAsync time) { final MockPeer mockPeer = MockPeer(); - Future reloadSources(String isolateId, { bool pause, bool force}) async {} - VMService(mockPeer, null, null, reloadSources, null, null, null); + Future reloadMethod({ String classId, String libraryId }) async {} + VMService(mockPeer, null, null, null, null, null, null, reloadMethod); expect(mockPeer.registeredMethods, contains('reloadMethod')); }); @@ -285,7 +285,7 @@ void main() { final MockDevice mockDevice = MockDevice(); final MockPeer mockPeer = MockPeer(); Future 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 e7cf36156e..a08b67d000 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 @@ void main() { } }); + test('reloadMethod triggers hot reload behavior', () async { + await _flutter.run(); + _project.uncommentHotReloadPrint(); + final StringBuffer stdout = StringBuffer(); + final StreamSubscription 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.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 6f1c7bc2a7..4279b7a452 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 @@ class FlutterRunTestDriver extends FlutterTestDriver { Future hotRestart({ bool pause = false }) => _restart(fullRestart: true, pause: pause); Future hotReload() => _restart(fullRestart: false); + Future scheduleFrame() async { + if (_currentRunningAppId == null) { + throw Exception('App has not started yet'); + } + await _sendRequest( + 'app.callServiceExtension', + {'appId': _currentRunningAppId, 'methodName': 'ext.ui.window.scheduleFrame'}, + ); + } + + Future reloadMethod({ String libraryId, String classId }) async { + if (_currentRunningAppId == null) { + throw Exception('App has not started yet'); + } + final dynamic reloadMethodResponse = await _sendRequest( + 'app.reloadMethod', + {'appId': _currentRunningAppId, 'class': classId, 'library': libraryId}, + ); + if (reloadMethodResponse == null || reloadMethodResponse['code'] != 0) { + _throwErrorResponse('reloadMethodResponse request failed'); + } + } + Future _restart({ bool fullRestart = false, bool pause = false }) async { if (_currentRunningAppId == null) { throw Exception('App has not started yet');