Very recently, the Flutter devtool got extended to include a screen that
integrates with provider to allow you to inspect and edit the state of your applications.
Let’s explain how you can do the same yourself.
Getting started: Setting up the project
First, let us setup everything.
Interestingly, the Flutter devtool is itself implemented using Flutter. So our
new screen will be but another Widget. No need to learn a new technology to get started.
It is worth noting that, at the moment, the devtool does not have
a plugin mechanism (but there is a design document
for it). That means that we will have to contribute directly to the devtool repository.
So rather than a new project, we will start by forking the devtool:
- Head to the devtool repository: https://github.com/flutter/devtools
- Click on the fork button:
- Clone the created project:
git clone https://github.com/my-user-name/devtools/
- Run
flutter pub get
in the different packages.
The project structure
The devtool is a fairly big project. But for writing new screens, we only care about two things:
- _packages/devtoolsapp
This is the source code of the Flutter application that powers the devtool. - _packages/devtooltesting
This package is where we will write some of our integration tests. More about that later.
Starting the project in debug mode
The devtool is pre-setup with everything necessary to debug the application using VS Code.
To start the devtool, press F5
, and the IDE will start devtools_app
, at which point you should see:
At this stage, the devtool asks us to connect it to a Flutter application.
To do so, start any other Flutter project separately.
When starting the project, Flutter should output something similar to:
Launching lib/main.dart on sdk gphone x86 arm in debug mode...
lib/main.dart:1
✓ Built build/app/outputs/flutter-apk/app-debug.apk.
Connecting to VM Service at ws://127.0.0.1:65477/XBceR2wE4rI=/ws
We will need to copy and paste that ws://127.0.0.1:12345/whatever=/ws
in the devtool page:
Click connect
and you should now see:
Nice job! We can now start writing our plugin.
Adding a new screen
Let’s add a new screen.
To create a screen, we first need to define a subclass of Screen
.
Often, we will want a screen that is visible only if a specific package in the
inspected application exists.
In this case, our Screen
subclass will look like:
class ExampleConditionalScreen extends Screen {
const ExampleConditionalScreen()
: super.conditional(
id: id,
// The name of the package that needs to be
// included in the inspected application
requiresLibrary: 'package:my_package/',
title: 'Example',
icon: Icons.palette,
);
static const id = 'example';
@override
Widget build(BuildContext context) {
// TODO create our UI as you would normally do in Flutter
}
}
From there, we need to insert our screen in the list of screens.
For that, head to the file packages/devtools_app/src/app.dart
.
You should see a variable named defaultScreens
which contains the list of all screens.
Simply add our new class in the list like so:
List<DevToolsScreen> get defaultScreens {
return <DevToolsScreen>[
// other screens
DevToolsScreen<void>(
const ExampleConditionalScreen(),
createController: () {}
),
];
}
Hot-restart the devtool, and you should now see your new screen in the list of screens.
At this stage, you should be able to write the UI code for your plugin as you would usually
do in Flutter.
Interacting with the inspected application
Adding a new screen to the devtool is great, but this isn’t very useful if we
cannot interact with the debugged application.
Fortunately, using the package vm_service, it is possible for our new screen
to both read and edit variables from the inspected application.
The devtool comes with vm_service pre-installed and setup for us. That is concretized by:
- The global variable
serviceManager
, fromdevtools_app/src/globals.dart
.
It contains numerous information useful to connect with the inspected application. - The class
EvalOnDartLibrary
, fromdevtools_app/src/eval_on_dart_library.dart
.
This class allows us to execute dart code dynamically.
Example: Reading a global variable from the inspected application.
Assume that the inspected application defined a global variable like so:
// my_app/lib/main.dart
int counter = 0;
We can then use EvalOnDartLibrary
to read this variable like so:
Future<int> getCounter() async {
final evalOnDartLibrary = EvalOnDartLibrary(
// the dart library we want to inspect, here the file
// that declared the global variable
['package:my_app/main.dart'],
serviceManager.service,
);
// We evaluate the expression 'counter', here just reading a variable.
// This returns an object that allows us to read the expression result.
InstanceRef counterRef = await evalOnDartLibrary.safeEval('counter', isAlive: null);
// The value received is a string, so we parse it
int counter = int.tryParse(counterRef.valueAsString);
return counter;
}
Note:
You are not limited to inspecting variables. Any valid dart expression is accepted:
InstanceRef result = await evalOnDartLibrary.safeEval(
'sum(42, 1) * 2',
isAlive: null,
);
You aren’t limited to receiving strings either. InstanceRef
exposes numerous
properties for reading more complex objects
Defining a “Binding” in the package we interact with.
As you may have noticed, using EvalOnDartLibrary
requires knowing the dart file
we want to inspect.
If you are interacting with a package (like trying to inspect the state of providers),
one solution to this is to add debug utilities in the package that the application imports.
For example, provider defines an internal ProviderBinding
class that
contains a list of all the providers created by the inspected application:
// provider/lib/src/devtools.dart
class ProviderBinding {
static List<Provider> getProviders() {...}
}
This allows us to use EvalOnDartLibrary
like so:
final evalOnDartLibrary = EvalOnDartLibrary(
['package:provider/src/devtools.dart'],
serviceManager.service,
);
final providersRef = await evalOnDartLibrary.safeEval(
'ProviderBinding.getProviders()',
isAlive: null,
);
final providers = await evalOnDartLibrary.getInstance(providersRef, null);
// List of InstanceRef that points to the providers in the application
print(providers.elements);
Evaluating expressions from other variables
A common use-case for evaluations is to try and evaluate something from a variable
obtained by a previous evaluation.
For example, we may first get a variable in our application. And then
we would like to mutate a property of that variable.
To do so, we can reuse a previously obtained InstanceRef
, combined with thescope
parameter of evaluations:
// Obtains a list defined in the inspected application
var someListRef = await evalOnDartLibrary.safeEval('getList()', isAlive: null);
await evalOnDartLibrary.safeEval(
'someList.add(42)',
isAlive: null,
scope: {
// this defines a variable `someList`
// that will be available in our expression
'someList': someListRef.id,
},
);
Avoiding expression results from being garbage collected
By default, results of evaluation queries are not kept in memory.
That can be problematic sometimes, as it can make further evaluations fail because
we are trying to manipulate an object that is no longer in memory.
Sadly vm_service doesn’t include a way to preserve objects in memory by default.
Fortunately, Flutter comes with some utilities that allow us to work around the issue: WidgetInspectorService
.
Assuming that we first perform an evaluation that creates a variable we want to keep in memory:
var someListRef = await evalOnDartLibrary.safeEval('[1, 2, 3]', isAlive: null);
We can use WidgetInspectorService
by making a separate evaluation to tell Flutter
to keep this variable in memory:
final materialEval = EvalOnDartLibrary(
// this time we need to import Flutter
['package:flutter/material.dart'],
serviceManager.service,
);
final someListIdRef = await materialEval.safeEval(
'WidgetInspectorService.instance.toId(someList, "some-unique-key"))',
isAlive: null,
scope: { 'someList': someListRef.id }
);
Then, when we later want to re-access our variable, we can do:
Instance someListRef = await materialEval.safeEval(
'WidgetInspectorService.instance.toObject(someListId, "some-unique-key"))',
isAlive: null,
scope: { 'someListId': someListIdRef.id },
);
And finally, when we no longer need the variable, we can allow it to be
garbage collected with:
await materialEval.safeEval(
'WidgetInspectorService.instance.disposeId(someListId, "some-unique-key"))',
isAlive: null,
scope: { 'someListId': someListIdRef.id },
);
Receiving events from the inspected application
In some cases, you may want your devtool to react to events from the inspected application.
For example, the Provider devtool wants to listen to when providers are
added/removed/updated, so that the devtool can refresh to show the changes.
For this, the inspected application can use dart:developer
‘s postEvent
to emit events that the devtool can then listen to.
In the case of Provider, it uses this function inside the initState
of a provider to do:
@override
void initState() {
super.initState();
postEvent('provider:provider_list_changed', { });
}
Then the devtool plugin subscribes to this event with:
serviceManager.service.onExtensionEvent.where((event) {
return event.extensionKind == 'provider:provider_list_changed';
).listen((_) {
// TODO: refresh the list of providers.
});
Supporting hot-restart on the inspected application
One thing to keep in mind is that the inspected application may be hot-restarted at any time.
The devtool need to handle those cases to avoid situations where the UI shows outdated content.
One way to support hot-restarts is to listen to changes on the inspected isolate:
serviceManager.isolateManager.onSelectedIsolateChanged.listen((_) {
// TODO refresh our devtool
});
Inspected application change
It is possible that the inspected application will change over time, without
the devtool being restarted.
In this case, serviceManager.service
will be replaced with a new instance.
But this means that the devtool needs to recompute everything that depended onserviceManager.service
, including re-creating instances of EvalOnDartLibrary
.
You can listen to changes of serviceManager.service
with:
serviceManager.onConnectionAvailable.listen((newService) {
// serviceManager.service changed and the new value is `newService`.
});
Aborting pending evaluations when no longer needed
For performance reasons, we may want to cancel pending evaluations.
That can be done using the isAlive
parameter that was mentioned in the
previous snippets, combined with the Disposable
class.
First, we need to create an instance of that Disposable
class:
final isAlive = Disposable();
Then, when making evaluations, we can pass this variable:
evalOnDartLibrary.safeEval('expression', isAlive: isAlive);
Then, if we want to cancel the expression, we can do:
isAlive.dispose();
That will automatically abort all pending requests associated with this isAlive
variable.
Writing e2e tests
Last but not least, we will want to write tests.
Since the devtool is implemented using Flutter, you can write unit and widget
tests as usual.
But unit/widget tests will not be able to test anything that uses serviceManager
,
since there is no inspected application during tests.
Writing tests for logic that interact with the inspected application could be tricky
as we need to interact with both the devtool, and the inspected application at the same time.
Luckily, the devtool comes with everything you need for this.
That is where _devtooltesting becomes useful.
This package defines testing applications, later used by integration tests.
You can open packages/devtools_testing/fixtures
to see the list of built-in
testing applications and potentially add your own there.
For our example, our tests will use the provider_app
, an application that uses provider.
Now let’s add an e2e test. Adding an e2e test is a two-step process.
We first need to declare a Dart file in packages/devtools_testing/my_test.dart
.
This file should export a function (not a main
) that defines our tests:
Future<void> runMyTests(FlutterTestEnvironment env) async {
test('my test', () {
// TODO
});
}
Then, we need to tell _devtoolsapp to run this test.
For this, we need to add a dart file in packages/devtools_app/my_test.dart
:
@TestOn('vm')
import 'package:devtools_testing/my_test.dart';
import 'package:devtools_testing/support/flutter_test_driver.dart'
show FlutterRunConfiguration;
import 'package:devtools_testing/support/flutter_test_environment.dart';
import 'package:flutter_test/flutter_test.dart';
void main() async {
final FlutterTestEnvironment env = FlutterTestEnvironment(
const FlutterRunConfiguration(withDebugger: true),
// pass the application that we want our test to interact with
testAppDirectory: 'fixtures/provider_app',
);
await runMyTest(env);
}
We can then execute our test with:
cd packages/devtools_app
flutter test
Note: You will not need an emulator for this to work
That’s it!
Our test is then able to interact with vm_service/EvalOnDartLibrary
.
We can then update our runMyTests
function to test the full flow:
Future<void> runMyTests(FlutterTestEnvironment env) async {
test('my test', () {
final eval = EvalOnDartLibrary(
['package:provider_app/main.dart'],
env.service,
);
await eval.safeEval('counter = 2');
});
}
Conclusion
That’s it! You should have all the keys in your hand to be able to extend the Flutter devtool.
Thanks for reading~
And while you’re here, I would like to give a shout out to the Flutter team and especially Jacob.
The journey of implementing the Provider devtool would have been a lot harder if not for their help.
If you are facing issues too, I am sure they would be glad to help.
Have fun!