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