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.
This commit is contained in:
Ian Hickson 2016-08-02 16:52:57 -07:00 committed by GitHub
parent 5c2623d977
commit d7fb51a551
9 changed files with 236 additions and 56 deletions

View file

@ -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,
body: new StatefulBuilder(
builder: (BuildContext context, StateSetter setStateRef) {
setState = setStateRef;
return new Column(
children: <Widget>[
new Text('Loading application onto device...',
style: new TextStyle(fontSize: 24.0)),
new CircularProgressIndicator(value: null)
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;
});
}
);
}
}

View file

@ -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
/// name "ext.flutter.name"). The given callback is called when the
/// extension method is called. The callback must return a [Future]

View file

@ -56,13 +56,10 @@ abstract class RendererBinding extends BindingBase implements SchedulerBinding,
return true;
});
assert(() {
registerSignalServiceExtension(
name: 'debugDumpRenderTree',
callback: debugDumpRenderTree
);
return true;
});
assert(() {
// this service extension only works in checked mode

View file

@ -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 {

View file

@ -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

View file

@ -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<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 @@ 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<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.

View file

@ -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<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>{

View file

@ -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,20 +272,21 @@ 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
_stopApp();
@ -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<bool> _updateDevFS() async {
Future<bool> _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<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 @@ class RunAndStayResident {
}
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) {
restartText = ', "r" or F5 to perform a hot reload of the app,';
} else if (device.supportsRestart) {
restartText = ', "r" or F5 to restart the app,';
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() {

View file

@ -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 {