🔍

【Flutter】Riverpod を監視する DevTool を作ってみる

に公開

初めに

今回は Riverpod の Provider の動作を監視するための DevTool を作成していきます。
そもそも Riverpod の Devtool に関しては以下のような Issue で議論がされています。

https://github.com/rrousselGit/riverpod/issues/798

https://github.com/rrousselGit/riverpod/issues/1033

https://github.com/rrousselGit/riverpod/issues/1039

例えば、こちらでは provider パッケージの場合と同様に、Riverpod でも「DevTool のタブでそれぞれの Provider の状態を確認できるような実装を行うこと」を目的として議論がされています。

ただ、今回は筆者が DevTool の使い方や開発の仕方を学ぶために独自で実装してみています。

記事の対象者

  • Flutter 開発者
  • DevTool の開発方法を簡単に知りたい方

目的

今回は DevTool の開発方法を学ぶことに主眼を置いています。
最終的には、以下の動画のように DevTool で Riverpod の動作を観察できるようにしたいと思います。

https://youtu.be/WkmdCPxoYPk

DevTool の実装に際しては以下の記事を参考にさせていただきました 🙇‍♂️

https://zenn.dev/koki0728/articles/6e3114c2d6614b

なお、今回実装する内容は以下で公開しているので、適宜ご参照いただければと思います。

https://github.com/Koichi5/riverpod-devtool-extension

全体の把握

実装に取り掛かる前に以下の図でこの DevTool の全容をざっと把握しておきたいと思います。

上記の図では以下のようなデータの流れになっています。

  1. DevTool 側から Provider のデータ要求
  2. App 側の ProviderObserver で各 Provider の監視
  3. Provider の監視結果を DevTool 側へ渡す
  4. DevTool 側で監視結果を表示
  5. 1 ~ 4 を繰り返し

実装の方針

今回の DevTool の実装は、大きく分けて 2 つの部分から構成されています。

  1. アプリケーション側(以下 app)の実装
  2. DevTool 拡張機能(以下 devtool)の実装

今回はまず app 側の実装を行い、次に devtool 側の実装を行います。

app 側の実装

まずは app の実装を行います。
app の実装は以下の手順で進めていきます。

  1. app のプロジェクト作成
  2. config.yaml ファイル作成
  3. devtools_options.yaml の作成
  4. models の作成
  5. providers の作成
  6. AppProviderObserver の作成
  7. DevToolsExtContainer の作成
  8. サンプルの作成

1. app のプロジェクト作成

まずは app プロジェクトを作成します。
既存のプロジェクトに DevTool を追加する場合はこのステップは不要です。

今回は riverpod_devtools_extension というルートディレクトリの中に packages というディレクトリを作成し、その中に app プロジェクトを作成しています。
ディレクトリ構造は以下のようになっています。

riverpod_devtools_extension/
└── packages/
    └── app/            # アプリケーション本体      
        └── lib/
            └── main.dart

2. config.yaml ファイル作成

次に app の中に config.yaml ファイルを作成していきます。
config.yaml ファイルでは DevTool を app 側で認識して実行できるようにするための設定を行います。

packages/app/extension/devtools ディレクトリに config.yaml ファイルを作成します。
コードは以下の通りです。

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 の作成は完了です。

3. devtools_options.yaml の作成

次に app ディレクトリのルートに devtools_options.yaml の作成を行います。
devtools_options.yaml を設定しなければ、以下の画像のように DevTool の読み込みができなくなるため、設定が必要です。

コードは以下の通りです。

packages/app/devtools_options.yaml
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

4. models の作成

次に、 app 側で必要な model の定義をしていきます。
models では以下の3つを定義していきます。

  • EventType : Provider のイベントの種類(追加、変更、破棄)
  • ProviderInfo : Provider の情報(名前、タイプ等)
  • ProviderState : Provider の状態をまとめたもの

コードはそれぞれ以下の通りです。
なお、今回の実装では freezed を用いてクラスの実装をしているため、 build runner を実行する必要があります。

packages/app/lib/models/event_type.dart
enum EventType {
  added,
  updated,
  disposed,
}
packages/app/lib/models/provider_info.dart
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);
}
packages/app/lib/models/provider_state.dart
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

5. providers の作成

次に providers の作成を行います。
今回は、app 側のそれぞれの Provider の変化を監視、保持するための Provider を作成します。(ややこしいですが...)

コードは以下の通りです。

packages/app/lib/providers/provider_state_observer.dart
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 かどうかを判定して、追加されていない場合のみ providershistory を更新するようにしています。

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 が破棄された時の処理を記述しています。

これで ProviderStateObserverProviderstate では以下の二つの状態を持つことができるようになります。

  • 現時点で有効な 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

6. AppProviderObserver の作成

次に 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);
        }
      });
    }
  }
}

コード自体は長いですが以下の点を押さえておくことで読みやすくなります。

  • 実装している AppProviderObserverProviderObserver を継承している
  • ProviderObserver はそれぞれの Provider の以下のイベントを監視している
    • 追加された時
    • エラーが発生した時
    • 更新された時
    • 破棄された時

これらの点を踏まえた上で、 Provider が追加された時の処理である didAddProvider をみていきます。
渡されてきた Provider がすでに登録されているかどうかを判定することで重複を回避し、登録されていない時のみ providerStateObserverProvideraddProvider メソッドを実行することで 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

7. DevToolsExtContainer の作成

DevTool に関連する app 側の実装はこれで最後になります。
DevToolsExtContainer を作成していきます。
DevToolsExtContainer の目的は、app 側のそれぞれの Provider の状態を DevTool 側に伝える窓口となることです。

最初にコードを提示します。

packages/app/lib/devtools_ext.dart
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 側に伝えることができるようになります。

packages/app/lib/main.dart
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 を組み込む場合は必要ありませんが、以下にサンプルを作成しています。
https://github.com/Koichi5/riverpod-devtool-extension/tree/main/packages/app/lib/samples/weather

Open Meteo という天気が取得できるAPIにリクエストを送り、データを表示する過程で Riverpod を使用し、どの Provider が生成、変更、破棄されるかを観察できます。

devtool 側の実装

次に DevTool 側の実装を進めていきます。
DevTool 側の実装は以下の手順で進めていきます。

  1. プロジェクト作成
  2. API作成
  3. 画面作成
  4. 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 ファイルを作成します。
コードは以下の通りです。

packages/devtools_ext/lib/api/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
packages/devtools_ext/lib/screens/my_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
packages/devtools_ext/lib/screens/components/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
packages/devtools_ext/lib/screens/components/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 で囲む必要があります。

packages/devtools_ext/lib/main.dart
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 が動作するようになるかと思います。

https://youtu.be/WkmdCPxoYPk

まとめ

最後まで読んでいただいてありがとうございました。

今回は DevTool を自身で作成する方法についてまとめました。
誤っている点やもっと良い書き方があればご指摘いただければ幸いです。

参考

https://github.com/rrousselGit/riverpod/issues/1033

https://zenn.dev/koki0728/articles/6e3114c2d6614b

Discussion