【Flutter】Riverpod を監視する DevTool を作ってみる
初めに
今回は Riverpod の Provider の動作を監視するための DevTool を作成していきます。
そもそも Riverpod の Devtool に関しては以下のような Issue で議論がされています。
例えば、こちらでは provider パッケージの場合と同様に、Riverpod でも「DevTool のタブでそれぞれの Provider の状態を確認できるような実装を行うこと」を目的として議論がされています。
ただ、今回は筆者が DevTool の使い方や開発の仕方を学ぶために独自で実装してみています。
記事の対象者
- Flutter 開発者
- DevTool の開発方法を簡単に知りたい方
目的
今回は DevTool の開発方法を学ぶことに主眼を置いています。
最終的には、以下の動画のように DevTool で Riverpod の動作を観察できるようにしたいと思います。
DevTool の実装に際しては以下の記事を参考にさせていただきました 🙇♂️
なお、今回実装する内容は以下で公開しているので、適宜ご参照いただければと思います。
全体の把握
実装に取り掛かる前に以下の図でこの DevTool の全容をざっと把握しておきたいと思います。
上記の図では以下のようなデータの流れになっています。
- DevTool 側から Provider のデータ要求
- App 側の ProviderObserver で各 Provider の監視
- Provider の監視結果を DevTool 側へ渡す
- DevTool 側で監視結果を表示
- 1 ~ 4 を繰り返し
実装の方針
今回の DevTool の実装は、大きく分けて 2 つの部分から構成されています。
- アプリケーション側(以下 app)の実装
- DevTool 拡張機能(以下 devtool)の実装
今回はまず app 側の実装を行い、次に devtool 側の実装を行います。
app 側の実装
まずは app の実装を行います。
app の実装は以下の手順で進めていきます。
-
app
のプロジェクト作成 -
config.yaml
ファイル作成 -
devtools_options.yaml
の作成 -
models
の作成 -
providers
の作成 -
AppProviderObserver
の作成 -
DevToolsExtContainer
の作成 - サンプルの作成
app
のプロジェクト作成
1. まずは app
プロジェクトを作成します。
既存のプロジェクトに DevTool を追加する場合はこのステップは不要です。
今回は riverpod_devtools_extension
というルートディレクトリの中に packages
というディレクトリを作成し、その中に app
プロジェクトを作成しています。
ディレクトリ構造は以下のようになっています。
riverpod_devtools_extension/
└── packages/
└── app/ # アプリケーション本体
└── lib/
└── main.dart
config.yaml
ファイル作成
2. 次に app の中に config.yaml
ファイルを作成していきます。
config.yaml
ファイルでは DevTool を app 側で認識して実行できるようにするための設定を行います。
packages/app/extension/devtools
ディレクトリに config.yaml
ファイルを作成します。
コードは以下の通りです。
name: riverpod_monitor
issueTracker: 'https://github.com/Koichi5/riverpod-devtool-extension/issues'
version: 0.0.1
materialIconCodePoint: '0xe3fb'
requiresConnection: true
それぞれのパラメータは以下のような指定ができます。
-
name
: DevTool の名前。タブで表示される名前 -
issueTracker
: DevTool 右上の「Report an issue」で開くURL -
version
: DevTool のバージョン -
materialIconCodePoint
: DevTool のタブで表示されるアイコン -
requiresConnection
: Flutter アプリとの連絡が必要かどうか
name
に関しては、自分の手元では UpperCamelCase で指定することができませんでした。
materialIconCodePoint
に関しては、Flutter のレポジトリの material/icons.dart から選択します
requiresConnection
は、今回はアプリ側の Riverpod の状態を取得したいので、 true
にします。
現在のディレクトリ構造は以下のようになっています。
riverpod_devtools_extension/
└── packages/
└── app/ # アプリケーション本体
├── lib/
│ └── main.dart
└── extension/
└── devtools/
└── config.yaml # New
これで config.yaml
の作成は完了です。
devtools_options.yaml
の作成
3. 次に app
ディレクトリのルートに devtools_options.yaml
の作成を行います。
devtools_options.yaml
を設定しなければ、以下の画像のように DevTool の読み込みができなくなるため、設定が必要です。
コードは以下の通りです。
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
- riverpod_monitor: true
extensions
に DevTool の名前を指定して、 true
を渡すことでツールを有効化することができます。
ディレクトリ構造は以下のようになっています。
riverpod_devtools_extension/
└── packages/
└── app/ # アプリケーション本体
├── lib/
│ └── main.dart
├── extension/
│ └── devtools/
│ └── config.yaml
└── devtools_options.yaml # New
models
の作成
4. 次に、 app 側で必要な model
の定義をしていきます。
models
では以下の3つを定義していきます。
-
EventType
: Provider のイベントの種類(追加、変更、破棄) -
ProviderInfo
: Provider の情報(名前、タイプ等) -
ProviderState
: Provider の状態をまとめたもの
コードはそれぞれ以下の通りです。
なお、今回の実装では freezed を用いてクラスの実装をしているため、 build runner を実行する必要があります。
enum EventType {
added,
updated,
disposed,
}
import 'package:freezed_annotation/freezed_annotation.dart';
part 'provider_info.freezed.dart';
part 'provider_info.g.dart';
class ProviderInfo with _$ProviderInfo {
const factory ProviderInfo({
required String type,
String? name,
required DateTime timestamp,
required String eventType,
}) = _ProviderInfo;
factory ProviderInfo.fromJson(Map<String, dynamic> json) =>
_$ProviderInfoFromJson(json);
}
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:app/models/provider_info.dart';
part 'provider_state.freezed.dart';
part 'provider_state.g.dart';
class ProviderState with _$ProviderState {
const factory ProviderState({
([]) List<ProviderInfo> providers,
([]) List<ProviderInfo> history,
}) = _ProviderState;
factory ProviderState.fromJson(Map<String, dynamic> json) =>
_$ProviderStateFromJson(json);
}
今回定義した ProviderInfo
のプロパティ以外でも取得できる Provider のデータはあるかと思うので、必要に応じて追加していただければと思います。
ディレクトリ構造は以下のようになっています。
riverpod_devtools_extension/
└── packages/
└── app/ # アプリケーション本体
├── lib/
│ ├── models/
│ │ ├── event_type.dart # New
│ │ ├── provider_info.dart # New
│ │ └── provider_state.dart # New
│ └── main.dart
├── extension/
│ └── devtools/
│ └── config.yaml
└── devtools_options.yaml
providers
の作成
5. 次に providers
の作成を行います。
今回は、app 側のそれぞれの Provider の変化を監視、保持するための Provider を作成します。(ややこしいですが...)
コードは以下の通りです。
import 'package:app/models/event_type.dart';
import 'package:app/models/provider_state.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../models/provider_info.dart';
part 'provider_state_observer.g.dart';
(keepAlive: true)
class ProviderStateObserver extends _$ProviderStateObserver {
ProviderState build() {
return const ProviderState(providers: [], history: []);
}
void addProvider(ProviderBase<Object?> provider) {
final existingProviderIndex = state.providers.indexWhere(
(info) =>
info.name == provider.name &&
info.type == provider.runtimeType.toString(),
);
final info = ProviderInfo(
type: provider.runtimeType.toString(),
name: provider.name,
timestamp: DateTime.now(),
eventType: EventType.added.name,
);
final updatedHistory = [...state.history, info];
if (existingProviderIndex == -1) {
state = state.copyWith(
providers: [...state.providers, info],
history: updatedHistory,
);
} else {
state = state.copyWith(history: updatedHistory);
}
}
void updateProvider(ProviderBase<Object?> provider) {
final info = ProviderInfo(
type: provider.runtimeType.toString(),
name: provider.name,
timestamp: DateTime.now(),
eventType: EventType.updated.name,
);
final updatedProviders =
state.providers
.where(
(info) =>
info.name != provider.name ||
info.type != provider.runtimeType.toString(),
)
.toList();
updatedProviders.add(info);
state = state.copyWith(
providers: updatedProviders,
history: [...state.history, info],
);
}
void disposeProvider(ProviderBase<Object?> provider) {
final info = ProviderInfo(
type: provider.runtimeType.toString(),
name: provider.name,
timestamp: DateTime.now(),
eventType: EventType.disposed.name,
);
state = state.copyWith(
providers: state.providers
.where((info) => info.name != provider.name)
.toList(),
history: [...state.history, info],
);
}
}
それぞれ詳しくみていきます。
以下の部分では、keepAlive: true
にすることで autoDispose されないようにします。
また、build
メソッドで ProviderState
を保持するようにしています。これで、state
を更新することで ProviderState
が更新され、それぞれの Provider の状態を保持することができます。
(keepAlive: true)
class ProviderStateObserver extends _$ProviderStateObserver {
ProviderState build() {
return const ProviderState(providers: [], history: []);
}
以下では、Provider が追加された場合の処理を実装しています。
複数回実行される可能性があるため、existingProviderIndex
ですでに追加されているProvider かどうかを判定して、追加されていない場合のみ providers
と history
を更新するようにしています。
void addProvider(ProviderBase<Object?> provider) {
final existingProviderIndex = state.providers.indexWhere(
(info) =>
info.name == provider.name &&
info.type == provider.runtimeType.toString(),
);
final info = ProviderInfo(
type: provider.runtimeType.toString(),
name: provider.name,
timestamp: DateTime.now(),
eventType: EventType.added.name,
);
final updatedHistory = [...state.history, info];
if (existingProviderIndex == -1) {
state = state.copyWith(
providers: [...state.providers, info],
history: updatedHistory,
);
} else {
state = state.copyWith(history: updatedHistory);
}
}
上記の addProvider
と同様で、 updateProvider
は Provider が更新された時、 disposeProvider
は Provider が破棄された時の処理を記述しています。
これで ProviderStateObserverProvider
の state
では以下の二つの状態を持つことができるようになります。
- 現時点で有効な Provider
- それぞれの Provider の追加、変更、破棄の履歴
ディレクトリ構造は以下のようになっています。
riverpod_devtools_extension/
└── packages/
└── app/ # アプリケーション本体
├── lib/
│ ├── models/
│ │ ├── event_type.dart
│ │ ├── provider_info.dart
│ │ └── provider_state.dart
│ ├── providers/
│ │ └── provider_state_observer.dart # New
│ └── main.dart
├── extension/
│ └── devtools/
│ └── config.yaml
└── devtools_options.yaml
AppProviderObserver
の作成
6. 次に AppProviderObserver
を作成していきます。
AppProviderObserver
はそれぞれの Provider のイベントを検知して、先ほど作成した ProviderStateObserverProvider
にデータを渡す役割を果たします。
コードは以下の通りです。
import 'package:app/providers/provider_state_observer.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class AppProviderObserver extends ProviderObserver {
final Set<String> _processedProviders = {};
void didAddProvider(
ProviderBase<Object?> provider,
Object? value,
ProviderContainer container,
) {
super.didAddProvider(provider, value, container);
if (provider == providerStateObserverProvider) {
return;
}
final providerKey = '${provider.name}_${provider.runtimeType}';
if (_processedProviders.contains(providerKey)) {
return;
}
_processedProviders.add(providerKey);
debugPrint('Provider added: ${provider.runtimeType}');
Future.microtask(() {
try {
if (container.exists(providerStateObserverProvider)) {
container
.read(providerStateObserverProvider.notifier)
.addProvider(provider);
}
} catch (e) {
debugPrint('Error adding provider to observer: $e');
}
});
}
void providerDidFail(
ProviderBase<Object?> provider,
Object error,
StackTrace stackTrace,
ProviderContainer container,
) {
if (provider != providerStateObserverProvider) {
debugPrint('Provider failed: ${provider.runtimeType}');
Future.microtask(() {
if (container.exists(providerStateObserverProvider)) {
container
.read(providerStateObserverProvider.notifier)
.disposeProvider(provider);
}
});
}
}
void didUpdateProvider(
ProviderBase<Object?> provider,
Object? previousValue,
Object? newValue,
ProviderContainer container,
) {
if (provider != providerStateObserverProvider) {
debugPrint('Provider updated: ${provider.runtimeType}');
Future.microtask(() {
if (container.exists(providerStateObserverProvider)) {
container
.read(providerStateObserverProvider.notifier)
.updateProvider(provider);
}
});
}
}
void didDisposeProvider(
ProviderBase<Object?> provider,
ProviderContainer container,
) {
debugPrint('Provider disposed: ${provider.runtimeType}');
if (container.exists(providerStateObserverProvider)) {
Future.microtask(() {
if (container.exists(providerStateObserverProvider)) {
container
.read(providerStateObserverProvider.notifier)
.disposeProvider(provider);
}
});
}
}
}
コード自体は長いですが以下の点を押さえておくことで読みやすくなります。
- 実装している
AppProviderObserver
はProviderObserver
を継承している -
ProviderObserver
はそれぞれの Provider の以下のイベントを監視している- 追加された時
- エラーが発生した時
- 更新された時
- 破棄された時
これらの点を踏まえた上で、 Provider が追加された時の処理である didAddProvider
をみていきます。
渡されてきた Provider がすでに登録されているかどうかを判定することで重複を回避し、登録されていない時のみ providerStateObserverProvider
の addProvider
メソッドを実行することで Provider を追加しています。
void didAddProvider(
ProviderBase<Object?> provider,
Object? value,
ProviderContainer container,
) {
super.didAddProvider(provider, value, container);
if (provider == providerStateObserverProvider) {
return;
}
final providerKey = '${provider.name}_${provider.runtimeType}';
if (_processedProviders.contains(providerKey)) {
return;
}
_processedProviders.add(providerKey);
debugPrint('Provider added: ${provider.runtimeType}');
Future.microtask(() {
try {
if (container.exists(providerStateObserverProvider)) {
container
.read(providerStateObserverProvider.notifier)
.addProvider(provider);
}
} catch (e) {
debugPrint('Error adding provider to observer: $e');
}
});
}
上記の didAddProvider
メソッドと同様で、 ProviderObserver
に用意されている各メソッドを override して実装しています。
ProviderObserver
はログを取得したり、DevTool を作成するために Riverpod に用意されている抽象クラスで、 ProviderContainer
の変更を監視しています。
ただ、 ProviderObserver
を継承した AppProviderObserver
を作成しただけでは、それぞれの Provider の監視はできません。main.dart
で以下のように AppProviderObserver
を割り当てる必要があります。
import 'package:app/app_provider_observer.dart';
import 'package:app/samples/sample_page.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
void main() {
runApp(
ProviderScope(
observers: [AppProviderObserver()], // ここで追加
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Riverpod DevTool',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
),
home: SamplePage(),
);
}
}
これで、app 側の Provider の変化を検知して、それを providerStateObserverProvider
に伝えて保持しておくという流れができました。
ディレクトリ構造は以下のようになっています。
riverpod_devtools_extension/
└── packages/
└── app/ # アプリケーション本体
├── lib/
│ ├── models/
│ │ ├── event_type.dart
│ │ ├── provider_info.dart
│ │ └── provider_state.dart
│ ├── providers/
│ │ └── provider_state_observer.dart
│ ├── app_provider_observer.dart # New
│ └── main.dart
├── extension/
│ └── devtools/
│ └── config.yaml
└── devtools_options.yaml
DevToolsExtContainer
の作成
7. DevTool に関連する app 側の実装はこれで最後になります。
DevToolsExtContainer
を作成していきます。
DevToolsExtContainer
の目的は、app 側のそれぞれの Provider の状態を DevTool 側に伝える窓口となることです。
最初にコードを提示します。
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'dart:async';
import 'package:app/providers/provider_state_observer.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class DevToolsExtKey {
static const fetchProviders = 'ext.fetchProviders';
static const fetchProviderHistory = 'ext.fetchProviderHistory';
static const subscribeProviderEvents = 'ext.subscribeProviderEvents';
static const unsubscribeProviderEvents = 'ext.unsubscribeProviderEvents';
}
class DevToolsExtContainer extends ConsumerStatefulWidget {
const DevToolsExtContainer({required this.child, super.key});
final Widget child;
ConsumerState<DevToolsExtContainer> createState() =>
_DevToolsExtContainerState();
}
class _DevToolsExtContainerState extends ConsumerState<DevToolsExtContainer> {
bool _isSubscribed = false;
Timer? _debounceTimer;
void initState() {
super.initState();
_registerExtensions();
}
void dispose() {
_debounceTimer?.cancel();
super.dispose();
}
void _registerExtensions() {
if ((!kIsWeb && Platform.environment.containsKey('FLUTTER_TEST')) ||
kReleaseMode) {
return;
}
registerExtension(DevToolsExtKey.fetchProviders, (_, __) async {
try {
final state = ref.read(providerStateObserverProvider);
final providers =
state.providers
.where((info) => info.name != null && info.name!.isNotEmpty)
.map(
(info) => {
'type': info.type.toString(),
'name': info.name ?? 'Unnamed Provider',
'timestamp': info.timestamp.toIso8601String(),
'eventType': info.eventType,
},
)
.toList();
final sortedHistory = [...state.history];
sortedHistory.sort((a, b) => b.timestamp.compareTo(a.timestamp));
final history =
sortedHistory
.map(
(info) => {
'type': info.type.toString(),
'name': info.name ?? 'Unnamed Provider',
'timestamp': info.timestamp.toIso8601String(),
'eventType': info.eventType,
},
)
.take(100)
.toList();
final response = {
DevToolsExtKey.fetchProviders: providers,
DevToolsExtKey.fetchProviderHistory: history,
};
debugPrint('Sending provider data: $response');
return ServiceExtensionResponse.result(jsonEncode(response));
} catch (e, stackTrace) {
debugPrint('Error in fetchProviders: $e');
debugPrint('Stack trace: $stackTrace');
return ServiceExtensionResponse.error(
500,
'Failed to fetch providers: $e',
);
}
});
registerExtension(DevToolsExtKey.subscribeProviderEvents, (_, __) async {
if (!_isSubscribed) {
_isSubscribed = true;
ref.listen(providerStateObserverProvider, (_, state) {
if (state.history.isNotEmpty) {
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 100), () {
final lastEvent = state.history.last;
final eventData = {
'type': lastEvent.type.toString(),
'name': lastEvent.name ?? 'Unnamed Provider',
'timestamp': lastEvent.timestamp.toIso8601String(),
'eventType': lastEvent.eventType,
};
debugPrint('Posting provider event: $eventData');
postEvent('provider.event', eventData);
});
}
});
}
return ServiceExtensionResponse.result(jsonEncode({'success': true}));
});
registerExtension(DevToolsExtKey.unsubscribeProviderEvents, (_, __) async {
_isSubscribed = false;
_debounceTimer?.cancel();
return ServiceExtensionResponse.result(jsonEncode({'success': true}));
});
}
Widget build(BuildContext context) {
return widget.child;
}
}
それぞれ詳しくみていきます。
以下では、DevTool 側とのやり取りをする際のキーを指定しています。
これらの値をもとに、app と DevTool は「どの処理が呼ばれたか」を判定し、その値に応じた処理を行います。
class DevToolsExtKey {
static const fetchProviders = 'ext.fetchProviders';
static const fetchProviderHistory = 'ext.fetchProviderHistory';
static const subscribeProviderEvents = 'ext.subscribeProviderEvents';
static const unsubscribeProviderEvents = 'ext.unsubscribeProviderEvents';
}
以下では、DevTool から要求があった際に実行するメソッドの登録を行なっています。
第一引数には DevToolsExtKey.fetchProviders
を指定しています。DevTool からこのキーの処理の要求が来た際には、第二引数に指定されているメソッドを実行します。
ここでは、fetchProviders
の要求が来たら providerStateObserverProvider
から以下の二つのデータを抽出して ServiceExtensionResponse.result
として渡しています。
- 現在有効な Provider のリスト
- それぞれの Provider の変更履歴
registerExtension(DevToolsExtKey.fetchProviders, (_, __) async {
try {
final state = ref.read(providerStateObserverProvider);
final providers = // provider のリスト
final history = // provider の変化の履歴
final response = {
DevToolsExtKey.fetchProviders: providers,
DevToolsExtKey.fetchProviderHistory: history,
};
debugPrint('Sending provider data: $response');
return ServiceExtensionResponse.result(jsonEncode(response));
}
上記の fetchProviders
以外のメソッドも同様で、DevTool からの要求に対してどのようなメソッドを実行するかを登録しています。
これで DevTool とのやり取りを行う DevToolsExtContainer
の実装は完了です。
最後に app 側の main.dart
で以下のように home
に登録されている Widget をラップすることで、そのツリーの下の Provider の変化を DevTool 側に伝えることができるようになります。
import 'package:app/app_provider_observer.dart';
import 'package:app/devtools_ext.dart';
import 'package:app/samples/sample_page.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
void main() {
runApp(
ProviderScope(observers: [AppProviderObserver()], child: const MyApp()),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Riverpod DevTool',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
),
home: const DevToolsExtContainer(child: SamplePage()), // ここでラップ
);
}
}
8. サンプルの作成
既存のアプリにこの DevTool を組み込む場合は必要ありませんが、以下にサンプルを作成しています。
Open Meteo という天気が取得できるAPIにリクエストを送り、データを表示する過程で Riverpod を使用し、どの Provider が生成、変更、破棄されるかを観察できます。
devtool 側の実装
次に DevTool 側の実装を進めていきます。
DevTool 側の実装は以下の手順で進めていきます。
- プロジェクト作成
- API作成
- 画面作成
- main.dart の編集
1. プロジェクト作成
まずは DevTool 側のプロジェクトを作成していきます。
packages
ディレクトリの直下(app
ディレクトリと同じ階層)で以下のコマンドを実行します。
flutter create --template app --platforms web devtools_ext
DevTool は Web で実行することを想定しているため、上記のコマンドで Web のプロジェクトを作成しています。
ディレクトリ構造は以下のようになります。
riverpod_devtools_extension/
└── packages/
├── app/ # アプリケーション本体
│ ├── lib/
│ │ ├── models/
│ │ │ ├── event_type.dart
│ │ │ ├── provider_info.dart
│ │ │ └── provider_state.dart
│ │ ├── providers/
│ │ │ └── provider_state_observer.dart
│ │ ├── app_provider_observer.dart
│ │ └── main.dart
│ ├── extension/
│ │ └── devtools/
│ │ └── config.yaml
│ └── devtools_options.yaml
└── devtools_ext/ # DevTool
├── lib/ # New
├── test/ # New
└── web/ # New
2. API作成
次に app 側と DevTool 側を繋ぐ API の作成を行います。
devtools_ext/lib
ディレクトリ配下に app_connection.dart
ファイルを作成します。
コードは以下の通りです。
import 'dart:async';
import 'package:devtools_extensions/devtools_extensions.dart';
import 'package:flutter/material.dart';
class DevToolsExtKey {
static const fetchProviders = 'ext.fetchProviders';
static const fetchProviderHistory = 'ext.fetchProviderHistory';
static const subscribeProviderEvents = 'ext.subscribeProviderEvents';
static const unsubscribeProviderEvents = 'ext.unsubscribeProviderEvents';
}
class AppConnection {
static Timer? _pollingTimer;
static bool _isSubscribed = false;
static Future<Map<String, dynamic>?> fetchProviders() async {
try {
final result = await serviceManager.callServiceExtensionOnMainIsolate(
DevToolsExtKey.fetchProviders,
);
if (result.json == null) {
debugPrint('Received null response from service extension');
return null;
}
final providers =
result.json?[DevToolsExtKey.fetchProviders] as List<dynamic>?;
final history =
result.json?[DevToolsExtKey.fetchProviderHistory] as List<dynamic>?;
if (providers == null || history == null) {
debugPrint('Invalid response structure: ${result.json}');
return null;
}
return {
DevToolsExtKey.fetchProviders: providers,
DevToolsExtKey.fetchProviderHistory: history,
};
} catch (e, stackTrace) {
debugPrint('Error fetching providers: $e');
debugPrint('Stack trace: $stackTrace');
return null;
}
}
static Future<void> startPollingProviders({
required void Function(Map<String, dynamic>?) onData,
Duration interval = const Duration(seconds: 1),
}) async {
_pollingTimer?.cancel();
try {
final initialData = await fetchProviders();
onData(initialData);
_pollingTimer = Timer.periodic(interval, (_) async {
final data = await fetchProviders();
if (data != null) {
onData(data);
}
});
if (!_isSubscribed) {
await serviceManager.callServiceExtensionOnMainIsolate(
DevToolsExtKey.subscribeProviderEvents,
);
_isSubscribed = true;
}
} catch (e, stackTrace) {
debugPrint('Error in startPollingProviders: $e');
debugPrint('Stack trace: $stackTrace');
}
}
static Future<void> stopPollingProviders() async {
_pollingTimer?.cancel();
_pollingTimer = null;
if (_isSubscribed) {
try {
await serviceManager.callServiceExtensionOnMainIsolate(
DevToolsExtKey.unsubscribeProviderEvents,
);
_isSubscribed = false;
} catch (e) {
debugPrint('Error unsubscribing from provider events: $e');
}
}
}
}
それぞれ詳しくみていきます。
以下では、 app 側と全く同じようにキーを指定しています。
このキーの値をもとにして app 側とのやり取りを行います。
class DevToolsExtKey {
static const fetchProviders = 'ext.fetchProviders';
static const fetchProviderHistory = 'ext.fetchProviderHistory';
static const subscribeProviderEvents = 'ext.subscribeProviderEvents';
static const unsubscribeProviderEvents = 'ext.unsubscribeProviderEvents';
}
以下では、 fetchProviders
メソッドを作成して、app 側から Provider の一覧を取得する処理を実装しています。 serviceManager.callServiceExtensionOnMainIsolate
メソッドでは、DevTool 側で定義したメソッドを呼び出すことができます。
ここで呼び出されるメソッドは、 app の方で registerExtension
で定義したメソッドです。それを DevToolsExtKey
で判別して呼び出すようになっています。
static Future<Map<String, dynamic>?> fetchProviders() async {
try {
final result = await serviceManager.callServiceExtensionOnMainIsolate(
DevToolsExtKey.fetchProviders,
);
if (result.json == null) {
// result がない場合
}
final providers = result.json?[DevToolsExtKey.fetchProviders] as List<dynamic>?;
final history = result.json?[DevToolsExtKey.fetchProviderHistory] as List<dynamic>?;
if (providers == null || history == null) {
// provider や history がない場合
}
return {
DevToolsExtKey.fetchProviders: providers,
DevToolsExtKey.fetchProviderHistory: history,
};
} catch (e, stackTrace) {
// エラーハンドリング
}
}
他のメソッドに関しても同様で、 callServiceExtensionOnMainIsolate
メソッドに DevToolsExtKey
を渡すことで app 側のメソッドの判別と呼び出しを行なっています。
3. 画面作成
次に DevTool 側の画面作成を行います。
基本的には、先ほど定義した API を用いて、Provider の取得を行い表示を行なっています。
この辺りは自由にカスタマイズできる部分かと思います。
home_page.dart
import 'package:devtools_ext/api/app_connection.dart';
import 'package:devtools_ext/main.dart';
import 'package:devtools_ext/screens/components/provider_card.dart';
import 'package:devtools_ext/screens/components/timeline_item.dart';
import 'package:flutter/material.dart';
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
Map<String, dynamic>? _providerData;
bool _isLoading = true;
String? _error;
int _selectedTabIndex = 0;
String _searchQuery = '';
void initState() {
super.initState();
_startPolling();
}
void dispose() {
AppConnection.stopPollingProviders();
super.dispose();
}
void _startPolling() {
AppConnection.startPollingProviders(
onData: (data) {
if (data == null) {
setState(() {
_error =
'Failed to fetch provider data. Please check if the app is running and connected.';
_isLoading = false;
});
return;
}
if (!data.containsKey(DevToolsExtKey.fetchProviders) ||
!data.containsKey(DevToolsExtKey.fetchProviderHistory)) {
setState(() {
_error = 'Invalid data structure received from the app.';
_isLoading = false;
});
return;
}
setState(() {
_providerData = data;
_isLoading = false;
_error = null;
});
},
);
}
void _refresh() {
setState(() {
_isLoading = true;
_error = null;
});
_startPolling();
}
Widget build(BuildContext context) {
final tabs = [
const Tab(icon: Icon(Icons.view_list), text: 'Active Providers'),
const Tab(icon: Icon(Icons.history), text: 'Timeline'),
];
return DefaultTabController(
length: tabs.length,
child: Scaffold(
appBar: AppBar(
title: const Text('Riverpod Monitor'),
bottom: TabBar(
tabs: tabs,
onTap: (index) => setState(() => _selectedTabIndex = index),
indicatorColor: Theme.of(context).colorScheme.primary,
),
actions: [
if (_error != null)
IconButton(icon: const Icon(Icons.refresh), onPressed: _refresh),
],
),
body:
_isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? _buildErrorView(_error!, _refresh)
: Column(
children: [
if (_selectedTabIndex == 1)
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
decoration: InputDecoration(
hintText:
'Search by provider name or event type ...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
),
contentPadding: const EdgeInsets.symmetric(
vertical: 12.0,
),
isDense: true,
),
onChanged:
(value) => setState(() => _searchQuery = value),
),
),
Expanded(
child: TabBarView(
children: [
_buildActiveProvidersView(
_providerData?[DevToolsExtKey.fetchProviders] ?? [],
),
_buildTimelineView(
_providerData?[DevToolsExtKey
.fetchProviderHistory] ??
[],
_searchQuery,
),
],
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: _refresh,
tooltip: 'Refresh',
child: const Icon(Icons.refresh),
),
),
);
}
Widget _buildErrorView(String errorMessage, VoidCallback onRetry) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Error: $errorMessage',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.red),
),
const SizedBox(height: 16),
ElevatedButton(onPressed: onRetry, child: const Text('Retry')),
],
),
);
}
Widget _buildActiveProvidersView(List<dynamic> providers) {
if (providers.isEmpty) {
return const Center(child: Text('No active providers found'));
}
return ListView.separated(
itemCount: providers.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
final provider = providers[index];
return ProviderCard(provider: provider);
},
);
}
Widget _buildTimelineView(List<dynamic> history, String searchQuery) {
if (history.isEmpty) {
return const Center(child: Text('No provider history found'));
}
final sortedHistory = List<dynamic>.from(history)..sort(
(a, b) => DateTime.parse(
b['timestamp'].toString(),
).compareTo(DateTime.parse(a['timestamp'].toString())),
);
final filteredHistory =
searchQuery.isEmpty
? sortedHistory
: sortedHistory.where((event) {
final name = event['name']?.toString().toLowerCase() ?? '';
final type = event['type']?.toString().toLowerCase() ?? '';
final eventType =
event['eventType']?.toString().toLowerCase() ?? '';
final query = searchQuery.toLowerCase();
return name.contains(query) ||
type.contains(query) ||
eventType.contains(query);
}).toList();
if (filteredHistory.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.search_off, size: 48, color: Colors.grey),
const SizedBox(height: 16),
Text('No providers found matching search criteria: "$searchQuery"'),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: filteredHistory.length,
itemBuilder: (context, index) {
final event = filteredHistory[index];
final timestamp = DateTime.parse(event['timestamp'].toString());
final relativeTime = formatRelativeTime(timestamp);
return TimelineItem(
provider: event,
relativeTime: relativeTime,
isFirst: index == 0,
isLast: index == filteredHistory.length - 1,
);
},
);
}
}
provider_card.dart
import 'package:devtools_ext/main.dart';
import 'package:flutter/material.dart';
class ProviderCard extends StatelessWidget {
final dynamic provider;
const ProviderCard({super.key, required this.provider});
Widget build(BuildContext context) {
final timestamp = DateTime.parse(provider['timestamp'].toString());
final relativeTime = formatRelativeTime(timestamp);
return ExpansionTile(
title: Text(
provider['name']?.toString() ?? 'Unnamed Provider',
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text('Updated: $relativeTime'),
expandedAlignment: Alignment.centerLeft,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Type: ${provider['type']}'),
const SizedBox(height: 8),
Text('Event: ${provider['eventType']}'),
const SizedBox(height: 8),
Text('Timestamp: ${timestamp.toLocal()}'),
],
),
),
],
);
}
}
timeline_item.dart
import 'package:flutter/material.dart';
class TimelineItem extends StatelessWidget {
final dynamic provider;
final String relativeTime;
final bool isFirst;
final bool isLast;
const TimelineItem({
super.key,
required this.provider,
required this.relativeTime,
this.isFirst = false,
this.isLast = false,
});
Widget build(BuildContext context) {
final eventType = provider['eventType']?.toString() ?? 'unknown';
final colorScheme = Theme.of(context).colorScheme;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 60,
child: Column(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: _getEventColor(eventType),
shape: BoxShape.circle,
),
child: Center(
child: Icon(
_getEventIcon(eventType),
color: Colors.white,
size: 16,
),
),
),
if (!isLast)
Expanded(
child: VerticalDivider(
color: colorScheme.outlineVariant,
thickness: 2,
),
),
],
),
),
Expanded(
child: Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(
alpha: 0.5,
),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
provider['name']?.toString() ?? 'Unnamed Provider',
style: TextStyle(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
Text(
relativeTime,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 8),
Text(
'Event: $eventType',
style: TextStyle(
color: _getEventColor(eventType),
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text('Type: ${provider['type']}'),
],
),
),
),
],
),
);
}
Color _getEventColor(String eventType) {
switch (eventType) {
case 'added':
return Colors.green;
case 'updated':
return Colors.blue;
case 'disposed':
return Colors.red;
case 'invalidated':
return Colors.orange;
default:
return Colors.grey;
}
}
IconData _getEventIcon(String eventType) {
switch (eventType) {
case 'added':
return Icons.add_circle_outline;
case 'updated':
return Icons.update;
case 'disposed':
return Icons.delete_outline;
case 'invalidated':
return Icons.refresh;
default:
return Icons.device_unknown;
}
}
}
4. main.dart の編集
次に main.dart
の編集を行います。
コードは以下の通りです。
DevTool として実装するためには、 DevTool 側のアプリのルートを DevToolsExtension
で囲む必要があります。
import 'package:devtools_ext/screens/my_home_page.dart';
import 'package:devtools_extensions/devtools_extensions.dart';
import 'package:flutter/material.dart';
void main() {
runApp(DevToolsExtension(child: const MyApp()));
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(),
);
}
}
String formatRelativeTime(DateTime timestamp) {
final now = DateTime.now();
final difference = now.difference(timestamp);
if (difference.inSeconds < 60) {
final roundedSeconds = (difference.inSeconds / 10).floor() * 10;
if (roundedSeconds == 0) {
return 'Just now';
}
return '$roundedSeconds sec ago';
} else if (difference.inMinutes < 60) {
return '${difference.inMinutes} min ago';
} else {
return 'Over 1 hour ago';
}
}
これで実装は完了です。
最後に DevTool 側のルートディレクトリ(筆者の手元では devtools_ext
ディレクトリ)で以下のコマンドを実行します。
dart run devtools_extensions build_and_copy --source=. --dest=../app/extension/devtools
上記コマンドの --dest
で指定するパスは app 側の extension/devtools
ディレクトリに設定します。
問題なく実行できた場合は packages/app/extension/devtools/
ディレクトリに build ディレクトリが追加されます。
この build ファイルをもとに DevTool がビルドされます。
この状態で app ディレクトリの既存アプリを実行して、DevTools の「Open in browser」を押して Web の DevTool を開くと「riverpod_monitor」のタブが追加されている状態になっているかと思います。
これで以下の動画のように DevTool が動作するようになるかと思います。
まとめ
最後まで読んでいただいてありがとうございました。
今回は DevTool を自身で作成する方法についてまとめました。
誤っている点やもっと良い書き方があればご指摘いただければ幸いです。
参考
Discussion