Unverified Commit 81aa2710 authored by Jonah Williams's avatar Jonah Williams Committed by GitHub

[flutter_tool] add a vmservice API for hot ui requests (#45649)

parent 523ac7b6
......@@ -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<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',
......
......@@ -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<String, dynamic> result;
result = await binding.testExtension('fastReassemble', <String, String>{'class': 'Foo'});
expect(result, containsPair('Success', 'true'));
}, skip: isBrowser);
}
......@@ -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.
......
......@@ -392,6 +392,7 @@ typedef _RunOrAttach = Future<void> 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<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 @@ class AppInstance {
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();
......
......@@ -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');
......
......@@ -453,6 +453,7 @@ class DevFS {
String projectRootPath,
@required String pathToReload,
@required List<Uri> invalidatedFiles,
bool skipAssets = false,
}) async {
assert(trackWidgetCreation != null);
assert(generator != null);
......@@ -463,7 +464,7 @@ class DevFS {
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.
......
......@@ -160,6 +160,7 @@ class FlutterDevice {
ReloadSources reloadSources,
Restart restart,
CompileExpression compileExpression,
ReloadMethod reloadMethod,
}) {
final Completer<void> completer = Completer<void>();
StreamSubscription<void> 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<OperationResult> 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();
......
......@@ -145,6 +145,57 @@ class HotRunner extends ResidentRunner {
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 @@ class HotRunner extends ResidentRunner {
reloadSources: _reloadSourcesService,
restart: _restartService,
compileExpression: _compileExpressionService,
reloadMethod: reloadMethod,
);
} catch (error) {
printError('Error connecting to the service protocol: $error');
......
......@@ -60,6 +60,11 @@ typedef CompileExpression = Future<String> Function(
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 @@ typedef VMServiceConnector = Future<VMService> 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 <String, String>{'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<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 @@ class Isolate extends ServiceObjectOwner {
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
......
......@@ -280,6 +280,7 @@ class WebDevFS implements DevFS {
String projectRootPath,
String pathToReload,
List<Uri> invalidatedFiles,
bool skipAssets = false,
}) async {
assert(trackWidgetCreation != null);
assert(generator != null);
......
......@@ -747,6 +747,7 @@ VMServiceConnector getFakeVmServiceFactory({
ReloadSources reloadSources,
Restart restart,
CompileExpression compileExpression,
ReloadMethod reloadMethod,
CompressionOptions compression,
Device device,
}) async {
......
......@@ -183,6 +183,7 @@ class TestFlutterDevice extends FlutterDevice {
ReloadSources reloadSources,
Restart restart,
CompileExpression compileExpression,
ReloadMethod reloadMethod,
}) async {
throw exception;
}
......
......@@ -397,6 +397,7 @@ class TestFlutterDevice extends FlutterDevice {
ReloadSources reloadSources,
Restart restart,
CompileExpression compileExpression,
ReloadMethod reloadMethod,
}) async {
throw exception;
}
......
......@@ -664,6 +664,7 @@ void main() {
ReloadSources reloadSources,
Restart restart,
CompileExpression compileExpression,
ReloadMethod reloadMethod,
io.CompressionOptions compression,
Device device,
}) async => mockVMService,
......
......@@ -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<String> registeredServices =
mockPeer.sentNotifications['registerService']
......@@ -270,8 +270,8 @@ void main() {
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 @@ void main() {
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'));
});
......
......@@ -48,6 +48,23 @@ void main() {
}
});
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();
......
......@@ -538,6 +538,29 @@ class FlutterRunTestDriver extends FlutterTestDriver {
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');
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment