Commit d7fb51a5 authored by Ian Hickson's avatar Ian Hickson Committed by GitHub

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.
parent 5c2623d9
import 'dart:async';
import 'package:flutter/material.dart'; 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() { void main() {
runApp(new MaterialApp( new LoaderBinding();
title: 'Flutter Initial Load', runApp(
new MaterialApp(
title: 'Flutter Debug Loader',
debugShowCheckedModeBanner: false,
home: new Scaffold( home: new Scaffold(
body: new Column( body: new StatefulBuilder(
mainAxisAlignment: MainAxisAlignment.center, builder: (BuildContext context, StateSetter setStateRef) {
setState = setStateRef;
return new Column(
children: <Widget>[ children: <Widget>[
new Text('Loading application onto device...', new Flexible(
style: new TextStyle(fontSize: 24.0)), child: new Container() // TODO(ianh): replace this with our logo in a Center box
new CircularProgressIndicator(value: null) ),
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
...@@ -203,6 +203,34 @@ abstract class BindingBase { ...@@ -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<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 /// Registers a service extension method with the given name (full
/// name "ext.flutter.name"). The given callback is called when the /// name "ext.flutter.name"). The given callback is called when the
/// extension method is called. The callback must return a [Future] /// extension method is called. The callback must return a [Future]
......
...@@ -56,13 +56,10 @@ abstract class RendererBinding extends BindingBase implements SchedulerBinding, ...@@ -56,13 +56,10 @@ abstract class RendererBinding extends BindingBase implements SchedulerBinding,
return true; return true;
}); });
assert(() {
registerSignalServiceExtension( registerSignalServiceExtension(
name: 'debugDumpRenderTree', name: 'debugDumpRenderTree',
callback: debugDumpRenderTree callback: debugDumpRenderTree
); );
return true;
});
assert(() { assert(() {
// this service extension only works in checked mode // this service extension only works in checked mode
......
...@@ -13,6 +13,7 @@ abstract class Logger { ...@@ -13,6 +13,7 @@ abstract class Logger {
bool quiet = false; bool quiet = false;
bool get supportsColor => terminal.supportsColor;
set supportsColor(bool value) { set supportsColor(bool value) {
terminal.supportsColor = value; terminal.supportsColor = value;
} }
...@@ -76,7 +77,7 @@ class StdoutLogger extends Logger { ...@@ -76,7 +77,7 @@ class StdoutLogger extends Logger {
_status?.cancel(); _status?.cancel();
_status = null; _status = null;
if (terminal.supportsColor) { if (supportsColor) {
_status = new _AnsiStatus(message); _status = new _AnsiStatus(message);
return _status; return _status;
} else { } else {
......
...@@ -63,7 +63,7 @@ class RunCommand extends RunCommandBase { ...@@ -63,7 +63,7 @@ class RunCommand extends RunCommandBase {
argParser.addFlag('hot', argParser.addFlag('hot',
negatable: false, negatable: false,
defaultsTo: 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 // Hidden option to enable a benchmarking mode. This will run the given
// application, measure the startup time and the app restart time, write the // application, measure the startup time and the app restart time, write the
......
...@@ -13,6 +13,8 @@ import 'asset.dart'; ...@@ -13,6 +13,8 @@ import 'asset.dart';
import 'globals.dart'; import 'globals.dart';
import 'observatory.dart'; import 'observatory.dart';
typedef void DevFSProgressReporter(int progress, int max);
// A file that has been added to a DevFS. // A file that has been added to a DevFS.
class DevFSEntry { class DevFSEntry {
DevFSEntry(this.devicePath, this.file) DevFSEntry(this.devicePath, this.file)
...@@ -178,7 +180,7 @@ class DevFS { ...@@ -178,7 +180,7 @@ class DevFS {
return await _operations.destroy(fsName); return await _operations.destroy(fsName);
} }
Future<dynamic> update([AssetBundle bundle = null]) async { Future<dynamic> update({ DevFSProgressReporter progressReporter, AssetBundle bundle }) async {
_bytes = 0; _bytes = 0;
// Mark all entries as not seen. // Mark all entries as not seen.
_entries.forEach((String path, DevFSEntry entry) { _entries.forEach((String path, DevFSEntry entry) {
...@@ -203,9 +205,7 @@ class DevFS { ...@@ -203,9 +205,7 @@ class DevFS {
if (_syncDirectory(directory, if (_syncDirectory(directory,
directoryName: 'packages/$packageName', directoryName: 'packages/$packageName',
recursive: true)) { recursive: true)) {
if (sb == null) { sb ??= new StringBuffer();
sb = new StringBuffer();
}
sb.writeln('$packageName:packages/$packageName'); sb.writeln('$packageName:packages/$packageName');
} }
} }
...@@ -233,11 +233,20 @@ class DevFS { ...@@ -233,11 +233,20 @@ class DevFS {
// Send the assets. // Send the assets.
printTrace('DevFS: Waiting for sync of ${_pendingWrites.length} files ' printTrace('DevFS: Waiting for sync of ${_pendingWrites.length} files '
'to finish'); 'to finish');
await Future.wait(_pendingWrites);
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(); _pendingWrites.clear();
if (sb != null) {
if (sb != null)
await _operations.writeSource(fsName, '.packages', sb.toString()); await _operations.writeSource(fsName, '.packages', sb.toString());
}
printTrace('DevFS: Sync finished'); printTrace('DevFS: Sync finished');
// NB: You must call flush after a printTrace if you want to be printed // NB: You must call flush after a printTrace if you want to be printed
// immediately. // immediately.
......
...@@ -9,6 +9,8 @@ import 'dart:io'; ...@@ -9,6 +9,8 @@ import 'dart:io';
import 'package:json_rpc_2/json_rpc_2.dart' as rpc; import 'package:json_rpc_2/json_rpc_2.dart' as rpc;
import 'package:web_socket_channel/io.dart'; import 'package:web_socket_channel/io.dart';
import 'globals.dart';
// TODO(johnmccutchan): Rename this class to ServiceProtocol or VmService. // TODO(johnmccutchan): Rename this class to ServiceProtocol or VmService.
class Observatory { class Observatory {
Observatory._(this.peer, this.port) { Observatory._(this.peer, this.port) {
...@@ -204,6 +206,38 @@ class Observatory { ...@@ -204,6 +206,38 @@ class Observatory {
}).then((dynamic result) => new Response(result)); }).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. /// Causes the application to pick up any changed code.
Future<Response> flutterReassemble(String isolateId) { Future<Response> flutterReassemble(String isolateId) {
return peer.sendRequest('ext.flutter.reassemble', <String, dynamic>{ return peer.sendRequest('ext.flutter.reassemble', <String, dynamic>{
......
...@@ -219,22 +219,30 @@ class RunAndStayResident { ...@@ -219,22 +219,30 @@ class RunAndStayResident {
if (debuggingOptions.debuggingEnabled) { if (debuggingOptions.debuggingEnabled) {
observatory = await Observatory.connect(_result.observatoryPort); observatory = await Observatory.connect(_result.observatoryPort);
printTrace('Connected to observatory port: ${_result.observatoryPort}.'); printTrace('Connected to observatory port: ${_result.observatoryPort}.');
observatory.populateIsolateInfo();
observatory.onExtensionEvent.listen((Event event) {
printTrace(event.toString());
});
observatory.onIsolateEvent.listen((Event event) {
printTrace(event.toString());
});
if (hotMode && device.needsDevFS) { if (hotMode && device.needsDevFS) {
bool result = await _updateDevFS(); _loaderShowMessage('Connecting...', progress: 0);
bool result = await _updateDevFS(
progressReporter: (int progress, int max) {
_loaderShowMessage('Syncing files to device...', progress: progress, max: max);
}
);
if (!result) { if (!result) {
_loaderShowMessage('Failed.');
printError('Could not perform initial file synchronization.'); printError('Could not perform initial file synchronization.');
return 3; return 3;
} }
printStatus('Running ${getDisplayPath(_mainPath)} on ${device.name}...'); printStatus('Running ${getDisplayPath(_mainPath)} on ${device.name}...');
_loaderShowMessage('Launching...');
await _launchFromDevFS(_package, _mainPath); await _launchFromDevFS(_package, _mainPath);
} }
observatory.populateIsolateInfo();
observatory.onExtensionEvent.listen((Event event) {
printTrace(event.toString());
});
observatory.onIsolateEvent.listen((Event event) {
printTrace(event.toString());
});
if (benchmark) if (benchmark)
await observatory.waitFirstIsolate; await observatory.waitFirstIsolate;
...@@ -264,20 +272,21 @@ class RunAndStayResident { ...@@ -264,20 +272,21 @@ class RunAndStayResident {
terminal.singleCharMode = true; terminal.singleCharMode = true;
terminal.onCharInput.listen((String code) { terminal.onCharInput.listen((String code) {
String lower = code.toLowerCase(); printStatus(''); // the key the user tapped might be on this line
final String lower = code.toLowerCase();
if (lower == 'h' || code == AnsiTerminal.KEY_F1) { if (lower == 'h' || lower == '?' || code == AnsiTerminal.KEY_F1) {
// F1, help // F1, help
_printHelp(); _printHelp();
} else if (lower == 'r' || code == AnsiTerminal.KEY_F5) { } else if (lower == 'r' || code == AnsiTerminal.KEY_F5) {
if (hotMode) { // F5, restart
if (hotMode && code == 'r') {
// lower-case 'r'
_reloadSources(); _reloadSources();
} else { } else {
if (device.supportsRestart) { // upper-case 'r', or hot restart disabled
// F5, restart if (device.supportsRestart)
restart(); restart();
} }
}
} else if (lower == 'q' || code == AnsiTerminal.KEY_F10) { } else if (lower == 'q' || code == AnsiTerminal.KEY_F10) {
// F10, exit // F10, exit
_stopApp(); _stopApp();
...@@ -335,9 +344,20 @@ class RunAndStayResident { ...@@ -335,9 +344,20 @@ class RunAndStayResident {
observatory.flutterDebugDumpRenderTree(observatory.firstIsolateId); 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; DevFS _devFS;
String _devFSProjectRootPath; String _devFSProjectRootPath;
Future<bool> _updateDevFS() async { Future<bool> _updateDevFS({ DevFSProgressReporter progressReporter }) async {
if (_devFS == null) { if (_devFS == null) {
Directory directory = Directory.current; Directory directory = Directory.current;
_devFSProjectRootPath = directory.path; _devFSProjectRootPath = directory.path;
...@@ -358,7 +378,7 @@ class RunAndStayResident { ...@@ -358,7 +378,7 @@ class RunAndStayResident {
} }
Status devFSStatus = logger.startProgress('Syncing files on device...'); Status devFSStatus = logger.startProgress('Syncing files on device...');
await _devFS.update(); await _devFS.update(progressReporter: progressReporter);
devFSStatus.stop(showElapsedTime: true); devFSStatus.stop(showElapsedTime: true);
printStatus('Synced ${getSizeAsMB(_devFS.bytes)} MB'); printStatus('Synced ${getSizeAsMB(_devFS.bytes)} MB');
return true; return true;
...@@ -387,9 +407,8 @@ class RunAndStayResident { ...@@ -387,9 +407,8 @@ class RunAndStayResident {
Future<bool> _reloadSources() async { Future<bool> _reloadSources() async {
if (observatory.firstIsolateId == null) if (observatory.firstIsolateId == null)
throw 'Application isolate not found'; throw 'Application isolate not found';
if (_devFS != null) { if (_devFS != null)
await _updateDevFS(); await _updateDevFS();
}
Status reloadStatus = logger.startProgress('Performing hot reload'); Status reloadStatus = logger.startProgress('Performing hot reload');
try { try {
await observatory.reloadSources(observatory.firstIsolateId); await observatory.reloadSources(observatory.firstIsolateId);
...@@ -413,14 +432,21 @@ class RunAndStayResident { ...@@ -413,14 +432,21 @@ class RunAndStayResident {
} }
void _printHelp() { void _printHelp() {
String restartText = ''; 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) { if (hotMode) {
restartText = ', "r" or F5 to perform a hot reload of the app,'; cold = ', and "R" to cold restart the app';
} else if (device.supportsRestart) { } else {
restartText = ', "r" or F5 to restart the app,'; 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.'); if (hot != '' || cold != '')
printStatus('Type "w" to print the widget hierarchy of the app, and "t" for the render tree.'); 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() { Future<dynamic> _stopLogger() {
......
...@@ -58,17 +58,17 @@ void main() { ...@@ -58,17 +58,17 @@ void main() {
expect(devFSOperations.contains('deleteFile test bar/foo.txt'), isTrue); expect(devFSOperations.contains('deleteFile test bar/foo.txt'), isTrue);
}); });
testUsingContext('add file in an asset bundle', () async { 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); expect(devFSOperations.contains('writeFile test build/flx/a.txt'), isTrue);
}); });
testUsingContext('add a file to the asset bundle', () async { testUsingContext('add a file to the asset bundle', () async {
assetBundle.entries.add(new AssetBundleEntry.fromString('b.txt', '')); 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); expect(devFSOperations.contains('writeFile test build/flx/b.txt'), isTrue);
}); });
testUsingContext('delete a file from the asset bundle', () async { testUsingContext('delete a file from the asset bundle', () async {
assetBundle.entries.clear(); assetBundle.entries.clear();
await devFS.update(assetBundle); await devFS.update(bundle: assetBundle);
expect(devFSOperations.contains('deleteFile test build/flx/b.txt'), isTrue); expect(devFSOperations.contains('deleteFile test build/flx/b.txt'), isTrue);
}); });
testUsingContext('delete dev file system', () async { testUsingContext('delete dev file system', () async {
......
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