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.
diff --git a/packages/flutter/bin/loader/loader_app.dart b/packages/flutter/bin/loader/loader_app.dart
index 5afac13..ad2ec4e 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: <Widget>[
- 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: <Widget>[
+ 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<Widget> children = <Widget>[];
+ 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: <Widget>[ 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 79df9cc..bb9b3a8 100644
--- a/packages/flutter/lib/src/foundation/binding.dart
+++ b/packages/flutter/lib/src/foundation/binding.dart
@@ -203,6 +203,34 @@
);
}
+ /// 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<String> getter,
+ @required ValueSetter<String> setter
+ }) {
+ assert(name != null);
+ assert(getter != null);
+ assert(setter != null);
+ registerServiceExtension(
+ name: name,
+ callback: (Map<String, String> parameters) async {
+ if (parameters.containsKey('value'))
+ setter(parameters['value']);
+ return <String, dynamic>{ '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 2b41a40..b20184d 100644
--- a/packages/flutter/lib/src/rendering/binding.dart
+++ b/packages/flutter/lib/src/rendering/binding.dart
@@ -56,13 +56,10 @@
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 42bb2d9..4e16873 100644
--- a/packages/flutter_tools/lib/src/base/logger.dart
+++ b/packages/flutter_tools/lib/src/base/logger.dart
@@ -13,6 +13,7 @@
bool quiet = false;
+ bool get supportsColor => terminal.supportsColor;
set supportsColor(bool value) {
terminal.supportsColor = value;
}
@@ -76,7 +77,7 @@
_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 f4b5175..cd14ae2 100644
--- a/packages/flutter_tools/lib/src/commands/run.dart
+++ b/packages/flutter_tools/lib/src/commands/run.dart
@@ -63,7 +63,7 @@
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 c46fac9..2d4e4f0 100644
--- a/packages/flutter_tools/lib/src/devfs.dart
+++ b/packages/flutter_tools/lib/src/devfs.dart
@@ -13,6 +13,8 @@
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 @@
return await _operations.destroy(fsName);
}
- Future<dynamic> update([AssetBundle bundle = null]) async {
+ Future<dynamic> update({ DevFSProgressReporter progressReporter, AssetBundle bundle }) async {
_bytes = 0;
// Mark all entries as not seen.
_entries.forEach((String path, DevFSEntry entry) {
@@ -203,9 +205,7 @@
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 @@
// 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<dynamic> 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 c04c9b9..b7d3337 100644
--- a/packages/flutter_tools/lib/src/observatory.dart
+++ b/packages/flutter_tools/lib/src/observatory.dart
@@ -9,6 +9,8 @@
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 @@
}).then((dynamic result) => new Response(result));
}
+ // Loader page extension methods.
+
+ Future<Response> flutterLoaderShowMessage(String isolateId, String message) {
+ return peer.sendRequest('ext.flutter.loaderShowMessage', <String, dynamic>{
+ 'isolateId': isolateId,
+ 'value': message
+ }).then(
+ (dynamic result) => new Response(result),
+ onError: (dynamic exception) { printTrace('ext.flutter.loaderShowMessage: $exception'); }
+ );
+ }
+
+ Future<Response> flutterLoaderSetProgress(String isolateId, double progress) {
+ return peer.sendRequest('ext.flutter.loaderSetProgress', <String, dynamic>{
+ 'isolateId': isolateId,
+ 'loaderSetProgress': progress
+ }).then(
+ (dynamic result) => new Response(result),
+ onError: (dynamic exception) { printTrace('ext.flutter.loaderSetProgress: $exception'); }
+ );
+ }
+
+ Future<Response> flutterLoaderSetProgressMax(String isolateId, double max) {
+ return peer.sendRequest('ext.flutter.loaderSetProgressMax', <String, dynamic>{
+ '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<Response> flutterReassemble(String isolateId) {
return peer.sendRequest('ext.flutter.reassemble', <String, dynamic>{
diff --git a/packages/flutter_tools/lib/src/run.dart b/packages/flutter_tools/lib/src/run.dart
index 7eac9b7..1ee4eab 100644
--- a/packages/flutter_tools/lib/src/run.dart
+++ b/packages/flutter_tools/lib/src/run.dart
@@ -219,15 +219,6 @@
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 @@
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 @@
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 @@
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<bool> _updateDevFS() async {
+ Future<bool> _updateDevFS({ DevFSProgressReporter progressReporter }) async {
if (_devFS == null) {
Directory directory = Directory.current;
_devFSProjectRootPath = directory.path;
@@ -358,7 +378,7 @@
}
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 @@
Future<bool> _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 @@
}
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<dynamic> _stopLogger() {
diff --git a/packages/flutter_tools/test/devfs_test.dart b/packages/flutter_tools/test/devfs_test.dart
index 67a8a1c..0f313e2 100644
--- a/packages/flutter_tools/test/devfs_test.dart
+++ b/packages/flutter_tools/test/devfs_test.dart
@@ -58,17 +58,17 @@
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 {