From d7fb51a551d43f2f93cf5e4436adb60efb1daa35 Mon Sep 17 00:00:00 2001 From: Ian Hickson Date: Tue, 2 Aug 2016 16:52:57 -0700 Subject: [PATCH] Hot reload UI polish (#5193) * General improvoments to the loader app: * Show a message after 8 seconds if no connection comes in. * Show a progress bar as files are being uploaded. * Hide the spinner just before launching the application. * General improvements to the "flutter run" UI: * Add "?" key as a silent alias for "h". * Make the help text bold so it doesn't get mixed with the logs. * Make "R" do a cold restart when hot reload is enabled. * Supporting features and bug fixes: * Add support for string service extensions. * Other bug fixes: * Expose debugDumpRenderTree() outside debug mode. * Logger.supportsColor was missing a getter. * Mention in the usage docs that --hot requires --resident. * Trivial style fixes. --- packages/flutter/bin/loader/loader_app.dart | 103 ++++++++++++++++-- .../flutter/lib/src/foundation/binding.dart | 28 +++++ .../flutter/lib/src/rendering/binding.dart | 11 +- .../flutter_tools/lib/src/base/logger.dart | 3 +- .../flutter_tools/lib/src/commands/run.dart | 2 +- packages/flutter_tools/lib/src/devfs.dart | 25 +++-- .../flutter_tools/lib/src/observatory.dart | 34 ++++++ packages/flutter_tools/lib/src/run.dart | 80 +++++++++----- packages/flutter_tools/test/devfs_test.dart | 6 +- 9 files changed, 236 insertions(+), 56 deletions(-) diff --git a/packages/flutter/bin/loader/loader_app.dart b/packages/flutter/bin/loader/loader_app.dart index 5afac1309f..ad2ec4e096 100644 --- a/packages/flutter/bin/loader/loader_app.dart +++ b/packages/flutter/bin/loader/loader_app.dart @@ -1,19 +1,104 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +String message = 'Flutter Debug Loader'; +String explanation = 'Please stand by...'; +double progress = 0.0; +double progressMax = 0.0; +StateSetter setState = (VoidCallback fn) => fn(); +Timer connectionTimeout; + void main() { - runApp(new MaterialApp( - title: 'Flutter Initial Load', + new LoaderBinding(); + runApp( + new MaterialApp( + title: 'Flutter Debug Loader', + debugShowCheckedModeBanner: false, home: new Scaffold( - body: new Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - new Text('Loading application onto device...', - style: new TextStyle(fontSize: 24.0)), - new CircularProgressIndicator(value: null) - ] + body: new StatefulBuilder( + builder: (BuildContext context, StateSetter setStateRef) { + setState = setStateRef; + return new Column( + children: [ + new Flexible( + child: new Container() // TODO(ianh): replace this with our logo in a Center box + ), + new Flexible( + child: new Builder( + builder: (BuildContext context) { + List children = []; + children.add(new Text( + message, + style: new TextStyle(fontSize: 24.0), + textAlign: TextAlign.center + )); + if (progressMax >= 0.0) { + children.add(new SizedBox(height: 18.0)); + children.add(new Center(child: new CircularProgressIndicator(value: progressMax > 0 ? progress / progressMax : null))); + } + return new Block(children: children); + } + ) + ), + new Flexible( + child: new Block( + padding: new EdgeInsets.symmetric(horizontal: 16.0), + children: [ new Text(explanation, textAlign: TextAlign.center) ] + ) + ), + ] + ); + } ) ) ) ); + connectionTimeout = new Timer(const Duration(seconds: 8), () { + setState(() { + explanation = + 'This is a hot-reload-enabled debug-mode Flutter application. ' + 'To launch this application, please use the "flutter run" command. ' + 'To be able to launch a Flutter application in debug mode from the ' + 'device, please use "flutter run --no-hot". To install a release ' + 'mode build of this application on your device, use "flutter install".'; + progressMax = -1.0; + }); + }); } +class LoaderBinding extends WidgetsFlutterBinding { + @override + void initServiceExtensions() { + super.initServiceExtensions(); + registerStringServiceExtension( + name: 'loaderShowMessage', + getter: () => message, + setter: (String value) { + connectionTimeout?.cancel(); + connectionTimeout = null; + setState(() { + message = value; + }); + } + ); + registerNumericServiceExtension( + name: 'loaderSetProgress', + getter: () => progress, + setter: (double value) { + setState(() { + progress = value; + }); + } + ); + registerNumericServiceExtension( + name: 'loaderSetProgressMax', + getter: () => progressMax, + setter: (double value) { + setState(() { + progressMax = value; + }); + } + ); + } +} \ No newline at end of file diff --git a/packages/flutter/lib/src/foundation/binding.dart b/packages/flutter/lib/src/foundation/binding.dart index 79df9ccdbc..bb9b3a8466 100644 --- a/packages/flutter/lib/src/foundation/binding.dart +++ b/packages/flutter/lib/src/foundation/binding.dart @@ -203,6 +203,34 @@ abstract class BindingBase { ); } + /// Registers a service extension method with the given name (full name + /// "ext.flutter.name"), which optionally takes a single argument with the + /// name "value". If the argument is omitted, the value is to be read, + /// otherwise it is to be set. Returns the current value. + /// + /// Calls the `getter` callback to obtain the value when + /// responding to the service extension method being called. + /// + /// Calls the `setter` callback with the new value when the + /// service extension method is called with a new value. + void registerStringServiceExtension({ + @required String name, + @required ValueGetter getter, + @required ValueSetter setter + }) { + assert(name != null); + assert(getter != null); + assert(setter != null); + registerServiceExtension( + name: name, + callback: (Map parameters) async { + if (parameters.containsKey('value')) + setter(parameters['value']); + return { 'value': getter() }; + } + ); + } + /// Registers a service extension method with the given name (full /// name "ext.flutter.name"). The given callback is called when the /// extension method is called. The callback must return a [Future] diff --git a/packages/flutter/lib/src/rendering/binding.dart b/packages/flutter/lib/src/rendering/binding.dart index 2b41a40080..b20184dea7 100644 --- a/packages/flutter/lib/src/rendering/binding.dart +++ b/packages/flutter/lib/src/rendering/binding.dart @@ -56,13 +56,10 @@ abstract class RendererBinding extends BindingBase implements SchedulerBinding, return true; }); - assert(() { - registerSignalServiceExtension( - name: 'debugDumpRenderTree', - callback: debugDumpRenderTree - ); - return true; - }); + registerSignalServiceExtension( + name: 'debugDumpRenderTree', + callback: debugDumpRenderTree + ); assert(() { // this service extension only works in checked mode diff --git a/packages/flutter_tools/lib/src/base/logger.dart b/packages/flutter_tools/lib/src/base/logger.dart index 42bb2d9280..4e16873a0b 100644 --- a/packages/flutter_tools/lib/src/base/logger.dart +++ b/packages/flutter_tools/lib/src/base/logger.dart @@ -13,6 +13,7 @@ abstract class Logger { bool quiet = false; + bool get supportsColor => terminal.supportsColor; set supportsColor(bool value) { terminal.supportsColor = value; } @@ -76,7 +77,7 @@ class StdoutLogger extends Logger { _status?.cancel(); _status = null; - if (terminal.supportsColor) { + if (supportsColor) { _status = new _AnsiStatus(message); return _status; } else { diff --git a/packages/flutter_tools/lib/src/commands/run.dart b/packages/flutter_tools/lib/src/commands/run.dart index f4b5175166..cd14ae2ed9 100644 --- a/packages/flutter_tools/lib/src/commands/run.dart +++ b/packages/flutter_tools/lib/src/commands/run.dart @@ -63,7 +63,7 @@ class RunCommand extends RunCommandBase { argParser.addFlag('hot', negatable: false, defaultsTo: false, - help: 'Run with support for hot reloading.'); + help: 'Run with support for hot reloading. Requires resident.'); // Hidden option to enable a benchmarking mode. This will run the given // application, measure the startup time and the app restart time, write the diff --git a/packages/flutter_tools/lib/src/devfs.dart b/packages/flutter_tools/lib/src/devfs.dart index c46fac9f16..2d4e4f0d39 100644 --- a/packages/flutter_tools/lib/src/devfs.dart +++ b/packages/flutter_tools/lib/src/devfs.dart @@ -13,6 +13,8 @@ import 'asset.dart'; import 'globals.dart'; import 'observatory.dart'; +typedef void DevFSProgressReporter(int progress, int max); + // A file that has been added to a DevFS. class DevFSEntry { DevFSEntry(this.devicePath, this.file) @@ -178,7 +180,7 @@ class DevFS { return await _operations.destroy(fsName); } - Future update([AssetBundle bundle = null]) async { + Future update({ DevFSProgressReporter progressReporter, AssetBundle bundle }) async { _bytes = 0; // Mark all entries as not seen. _entries.forEach((String path, DevFSEntry entry) { @@ -203,9 +205,7 @@ class DevFS { if (_syncDirectory(directory, directoryName: 'packages/$packageName', recursive: true)) { - if (sb == null) { - sb = new StringBuffer(); - } + sb ??= new StringBuffer(); sb.writeln('$packageName:packages/$packageName'); } } @@ -233,11 +233,20 @@ class DevFS { // Send the assets. printTrace('DevFS: Waiting for sync of ${_pendingWrites.length} files ' 'to finish'); - await Future.wait(_pendingWrites); - _pendingWrites.clear(); - if (sb != null) { - await _operations.writeSource(fsName, '.packages', sb.toString()); + + if (progressReporter != null) { + final int max = _pendingWrites.length; + int complete = 0; + _pendingWrites.forEach((Future f) => f.then((dynamic v) { + complete += 1; + progressReporter(complete, max); + })); } + await Future.wait(_pendingWrites, eagerError: true); + _pendingWrites.clear(); + + if (sb != null) + await _operations.writeSource(fsName, '.packages', sb.toString()); printTrace('DevFS: Sync finished'); // NB: You must call flush after a printTrace if you want to be printed // immediately. diff --git a/packages/flutter_tools/lib/src/observatory.dart b/packages/flutter_tools/lib/src/observatory.dart index c04c9b97f1..b7d3337dca 100644 --- a/packages/flutter_tools/lib/src/observatory.dart +++ b/packages/flutter_tools/lib/src/observatory.dart @@ -9,6 +9,8 @@ import 'dart:io'; import 'package:json_rpc_2/json_rpc_2.dart' as rpc; import 'package:web_socket_channel/io.dart'; +import 'globals.dart'; + // TODO(johnmccutchan): Rename this class to ServiceProtocol or VmService. class Observatory { Observatory._(this.peer, this.port) { @@ -204,6 +206,38 @@ class Observatory { }).then((dynamic result) => new Response(result)); } + // Loader page extension methods. + + Future flutterLoaderShowMessage(String isolateId, String message) { + return peer.sendRequest('ext.flutter.loaderShowMessage', { + 'isolateId': isolateId, + 'value': message + }).then( + (dynamic result) => new Response(result), + onError: (dynamic exception) { printTrace('ext.flutter.loaderShowMessage: $exception'); } + ); + } + + Future flutterLoaderSetProgress(String isolateId, double progress) { + return peer.sendRequest('ext.flutter.loaderSetProgress', { + 'isolateId': isolateId, + 'loaderSetProgress': progress + }).then( + (dynamic result) => new Response(result), + onError: (dynamic exception) { printTrace('ext.flutter.loaderSetProgress: $exception'); } + ); + } + + Future flutterLoaderSetProgressMax(String isolateId, double max) { + return peer.sendRequest('ext.flutter.loaderSetProgressMax', { + 'isolateId': isolateId, + 'loaderSetProgressMax': max + }).then( + (dynamic result) => new Response(result), + onError: (dynamic exception) { printTrace('ext.flutter.loaderSetProgressMax: $exception'); } + ); + } + /// Causes the application to pick up any changed code. Future flutterReassemble(String isolateId) { return peer.sendRequest('ext.flutter.reassemble', { diff --git a/packages/flutter_tools/lib/src/run.dart b/packages/flutter_tools/lib/src/run.dart index 7eac9b7bf3..1ee4eaba45 100644 --- a/packages/flutter_tools/lib/src/run.dart +++ b/packages/flutter_tools/lib/src/run.dart @@ -219,15 +219,6 @@ class RunAndStayResident { if (debuggingOptions.debuggingEnabled) { observatory = await Observatory.connect(_result.observatoryPort); printTrace('Connected to observatory port: ${_result.observatoryPort}.'); - if (hotMode && device.needsDevFS) { - bool result = await _updateDevFS(); - if (!result) { - printError('Could not perform initial file synchronization.'); - return 3; - } - printStatus('Running ${getDisplayPath(_mainPath)} on ${device.name}...'); - await _launchFromDevFS(_package, _mainPath); - } observatory.populateIsolateInfo(); observatory.onExtensionEvent.listen((Event event) { printTrace(event.toString()); @@ -236,6 +227,23 @@ class RunAndStayResident { printTrace(event.toString()); }); + if (hotMode && device.needsDevFS) { + _loaderShowMessage('Connecting...', progress: 0); + bool result = await _updateDevFS( + progressReporter: (int progress, int max) { + _loaderShowMessage('Syncing files to device...', progress: progress, max: max); + } + ); + if (!result) { + _loaderShowMessage('Failed.'); + printError('Could not perform initial file synchronization.'); + return 3; + } + printStatus('Running ${getDisplayPath(_mainPath)} on ${device.name}...'); + _loaderShowMessage('Launching...'); + await _launchFromDevFS(_package, _mainPath); + } + if (benchmark) await observatory.waitFirstIsolate; @@ -264,19 +272,20 @@ class RunAndStayResident { terminal.singleCharMode = true; terminal.onCharInput.listen((String code) { - String lower = code.toLowerCase(); - - if (lower == 'h' || code == AnsiTerminal.KEY_F1) { + printStatus(''); // the key the user tapped might be on this line + final String lower = code.toLowerCase(); + if (lower == 'h' || lower == '?' || code == AnsiTerminal.KEY_F1) { // F1, help _printHelp(); } else if (lower == 'r' || code == AnsiTerminal.KEY_F5) { - if (hotMode) { + // F5, restart + if (hotMode && code == 'r') { + // lower-case 'r' _reloadSources(); } else { - if (device.supportsRestart) { - // F5, restart + // upper-case 'r', or hot restart disabled + if (device.supportsRestart) restart(); - } } } else if (lower == 'q' || code == AnsiTerminal.KEY_F10) { // F10, exit @@ -335,9 +344,20 @@ class RunAndStayResident { observatory.flutterDebugDumpRenderTree(observatory.firstIsolateId); } + void _loaderShowMessage(String message, { int progress, int max }) { + observatory.flutterLoaderShowMessage(observatory.firstIsolateId, message); + if (progress != null) { + observatory.flutterLoaderSetProgress(observatory.firstIsolateId, progress.toDouble()); + observatory.flutterLoaderSetProgressMax(observatory.firstIsolateId, max?.toDouble() ?? 0.0); + } else { + observatory.flutterLoaderSetProgress(observatory.firstIsolateId, 0.0); + observatory.flutterLoaderSetProgressMax(observatory.firstIsolateId, -1.0); + } + } + DevFS _devFS; String _devFSProjectRootPath; - Future _updateDevFS() async { + Future _updateDevFS({ DevFSProgressReporter progressReporter }) async { if (_devFS == null) { Directory directory = Directory.current; _devFSProjectRootPath = directory.path; @@ -358,7 +378,7 @@ class RunAndStayResident { } Status devFSStatus = logger.startProgress('Syncing files on device...'); - await _devFS.update(); + await _devFS.update(progressReporter: progressReporter); devFSStatus.stop(showElapsedTime: true); printStatus('Synced ${getSizeAsMB(_devFS.bytes)} MB'); return true; @@ -387,9 +407,8 @@ class RunAndStayResident { Future _reloadSources() async { if (observatory.firstIsolateId == null) throw 'Application isolate not found'; - if (_devFS != null) { + if (_devFS != null) await _updateDevFS(); - } Status reloadStatus = logger.startProgress('Performing hot reload'); try { await observatory.reloadSources(observatory.firstIsolateId); @@ -413,14 +432,21 @@ class RunAndStayResident { } void _printHelp() { - String restartText = ''; - if (hotMode) { - restartText = ', "r" or F5 to perform a hot reload of the app,'; - } else if (device.supportsRestart) { - restartText = ', "r" or F5 to restart the app,'; + printStatus('Type "h" or F1 for this help message. Type "q", F10, or ctrl-c to quit.', emphasis: true); + String hot = ''; + String cold = ''; + if (hotMode) + hot = 'Type "r" or F5 to perform a hot reload of the app'; + if (device.supportsRestart) { + if (hotMode) { + cold = ', and "R" to cold restart the app'; + } else { + cold = 'Type "r" or F5 to restart the app'; + } } - printStatus('Type "h" or F1 for help$restartText and "q", F10, or ctrl-c to quit.'); - printStatus('Type "w" to print the widget hierarchy of the app, and "t" for the render tree.'); + if (hot != '' || cold != '') + printStatus('$hot$cold.', emphasis: true); + printStatus('Type "w" to print the widget hierarchy of the app, and "t" for the render tree.', emphasis: true); } Future _stopLogger() { diff --git a/packages/flutter_tools/test/devfs_test.dart b/packages/flutter_tools/test/devfs_test.dart index 67a8a1ced3..0f313e2b1d 100644 --- a/packages/flutter_tools/test/devfs_test.dart +++ b/packages/flutter_tools/test/devfs_test.dart @@ -58,17 +58,17 @@ void main() { expect(devFSOperations.contains('deleteFile test bar/foo.txt'), isTrue); }); testUsingContext('add file in an asset bundle', () async { - await devFS.update(assetBundle); + await devFS.update(bundle: assetBundle); expect(devFSOperations.contains('writeFile test build/flx/a.txt'), isTrue); }); testUsingContext('add a file to the asset bundle', () async { assetBundle.entries.add(new AssetBundleEntry.fromString('b.txt', '')); - await devFS.update(assetBundle); + await devFS.update(bundle: assetBundle); expect(devFSOperations.contains('writeFile test build/flx/b.txt'), isTrue); }); testUsingContext('delete a file from the asset bundle', () async { assetBundle.entries.clear(); - await devFS.update(assetBundle); + await devFS.update(bundle: assetBundle); expect(devFSOperations.contains('deleteFile test build/flx/b.txt'), isTrue); }); testUsingContext('delete dev file system', () async {