Привет, Хабр! Я — Роза, Flutter-разработчица в Friflex. Уверена, многие из вас знакомы с Dart DevTools и уже использовали его для анализа своих Flutter-приложений. Но пробовали ли вы создавать собственные расширения? Недавно у меня была такая задача, и я хочу поделиться своим опытом.

Что нужно делать

Первым делом создаем новый пакет для нашего расширения. Это можно сделать при помощи команды flutter create --template app --platforms web my_dev_tools_ext. Официального шаблона для DevTools extensions нет, структура добавляется вручную. 

Подключаем пакет devtools_extensions. В нем есть инструменты, чтобы создать собственное расширение: доступ к виртуальной машине, темам, виджетам и другим возможностям DevTools.

Например, вы получите доступ к менеджерам:

  • extensionManager — взаимодействие с DevTools;

  • serviceManager — доступ к VM (если подключена);

  • dtdManager — связь с Dart Tooling Daemon.

Создаем папку devtools в корне пакета, и в ней — папку build. Добавляем файл конфигурации config.yaml.

my_dev_tools_ext/
   extension/
       devtools/
           build/
           config.yaml
   lib/
       src/
 ...

Файл config.yaml:

name: my_dev_tools_ext
issueTracker: https://...
version: 0.0.1
materialIconCodePoint: '0xe0b1'
requiresConnection: false

Описание полей:

  • name: имя пакета, к которому относится расширение DevTools. Значение этого поля будет использоваться для расширения во вкладке DevTools.

  • issueTracker: URL-адрес системы отслеживания ошибок расширения. Когда пользователь нажмет «Сообщить об ошибке» в пользовательском интерфейсе DevTools, он будет перенаправлен на этот URL-адрес.

  • version: версия расширения DevTools. Значение этого поля будет использоваться в заголовке страницы расширения.

  • materialIconCodePoint: этот значок будет использоваться для вкладки расширения на панели вкладок верхнего уровня DevTools.

Необязательные поля:

requiresConnection: требуется ли для запуска этого расширения DevTools подключенное приложение Dart или Flutter. По умолчанию — false.

В статье я сфокусировалась на практическом опыте и не охватила все возможности пакета. Если планируете использовать DevTools extensions в работе, обязательно загляните в документацию — там есть дополнительные API и сценарии, которые могут пригодиться в более сложных кейсах.

Теперь создаем UI. Кроме devtools_extensions, очень полезным будет пакет devtools_app_shared. Он содержит готовые компоненты и утилиты, которые используются в оригинальных DevTools.

Например (для кнопки):

DevToolsButton(
 onPressed: () async {
   await someService.saveData();
   extensionManager.showNotification('Данные успешно сохранены!');
 },
 icon: Icons.save,
 label: 'Сохранить',
)

extensionManager.showNotification покажет уведомление. В симулированной среде это будет лог в консоли.

Подключение и тестирование расширения

Добавим обертку DevToolsExtension, которая инициализирует расширение:

void main() {
 runApp(const LocalizationDevToolsExtension());
}

class LocalizationDevToolsExtension extends StatelessWidget {
 const LocalizationDevToolsExtension({super.key});

 @override
 Widget build(BuildContext context) {
   return const DevToolsExtension(
     child: LocalizationSnapshotterWidget(), // ваш основной виджет
   );
 }
}

Чтобы протестировать расширение, можно воспользоваться симулированной средой. Для этого нужно запустить команду из корня пакета расширения:

flutter run -d chrome --dart-define=use_simulated_environment=true

Если все работает корректно, соберите расширение:

dart run devtools_extensions build_and_copy --source=. --dest=../extension/devtools

После этого в devtools/build/ появится сборка, которая готова к публикации или локальному использованию.


Чтобы проверить, корректно ли настроено расширение и будет ли оно загружаться в DevTools, можно воспользоваться командой validate.

Она проверяет структуру пакета и наличие необходимых файлов.

dart run devtools_extensions validate --package=../my_dev_tools_ext

Это особенно полезно, если расширение не появляется в DevTools.

Как работают расширения DevTools

Все просто: расширение — это обычный Dart-пакет. Вы можете встроить его в другой pub-пакет или создать отдельный. Чтобы расширение появилось в интерфейсе DevTools, его нужно подключить как зависимость в проекте, где DevTools используются.

Какие инструменты можно создавать

С помощью Flutter DevTools extensions можно реализовывать разные типы инструментов:

  • вспомогательные инструменты для существующих пакетов (например, дебаг или визуализацию состояния);

  • отдельные инструменты, распространяемые как самостоятельные пакеты;

  • инструменты, которые взаимодействуют с запущенным приложением через VM Service;

  • инструменты, которые не требуют подключения к приложению (например, анализ конфигурации или данных);

  • инструменты, которые работают с файлами проекта (в связке с IDE).

Какие бывают DevTools extensions

В Flutter DevTools есть два основных типа расширений: standalone extensions и companion extensions.

Standalone extensions

Это расширения, которые разрабатываются как отдельные пакеты и не привязаны к существующему pub-пакету.

Исходный код расширения можно хранить в том же репозитории, что и основной проект, и подключать его как dev_dependency

О создании standalone-расширения я рассказывала выше.

Companion extensions

Это расширения, которые поставляются вместе с существующим pub-пакетом.

Когда пользователь подключает такой пакет к своему приложению, DevTools автоматически обнаруживает расширение и добавляет новую вкладку. 

На практике выбор между standalone и companion зависит от того, насколько расширение связано с вашим пакетом. Если это вспомогательный инструмент для разработки — лучше standalone, если часть пользовательского опыта — companion.

Дальше я расскажу, как создать companion-решение.

Как встроить расширение в существующий pub-пакет

Допустим, у вас уже есть пакет с реализованным функционалом, для которого вы хотите сделать DevTools-расширение. Нет смысла создавать отдельную зависимость только ради расширения. Проще сделать расширение частью этого же пакета.

Например, когда пользователь подключает package:some_package к своему приложению, он автоматически получает доступ к расширению DevTools, встроенному в этот пакет. DevTools при запуске определит наличие расширения и добавит новую вкладку для него.

Как это реализовать? Все довольно просто. В Dart-пакете, который предоставляет расширение DevTools, нужно добавить каталог extension на верхнем уровне структуры:

some_package/ ├── extension/ ├── lib/ └── ...

Структура папки extension будет выглядеть так:

extension/ └── devtools/ ├── build/ └── config.yaml

Вы получите примерно такую структуру:

some_app/
└── packages/
├── some_package/
│ └── extension/
│ └── devtools/
│ ├── build/
│ │ └── ...
│ └── config.yaml
└── some_devtools_extension/
└── lib/

Если функциональность расширения и самого пакета никак не связаны, это может запутать архитектуру. Тогда лучше вынести расширение в отдельный pub-пакет и подключать его как dev_dependency.

Если вы не собираетесь использовать расширение снова и оно должно быть автономным, его можно разместить в том же репозитории, что и основной пакет. Но как отдельный модуль. Это упростит разработку и при подключении через dev_dependency не повлияет на размер конечного пользовательского приложения.

Что такое EvalOnDartLibrary

Это класс из пакета devtools_app_shared, который позволяет выполнять Dart-код прямо из вашего DevTools extension. С его помощью можно управлять состоянием приложения, вызывать методы, получать значения из рантайма.

Для меня это был довольно неожиданный инструмент. Я не сразу поняла, что из расширения DevTools вообще можно настолько глубоко взаимодействовать с приложением.

Как это работает

Чтобы использовать EvalOnDartLibrary, сначала нужно инициализировать его и передать все необходимое для работы с vmService.

Future<void> initEval() async {
 await serviceManager.onServiceAvailable;
 _controllerEval = EvalOnDartLibrary(
   'package:some_package/src/controller.dart', // Путь к библиотеке, где находится нужный код
   serviceManager.service!,                   // Передаем vmService (Убедитесь, что serviceManager.service не равен null (после onServiceAvailable)
   serviceManager: serviceManager,
 );
 evalDisposable = Disposable(); // Обязательно создаем Disposable
}
  • 'package:...' — это путь до библиотеки, в которой находится нужный код;

  • serviceManager.service! — экземпляр VmService;

  • Disposable нужен, чтобы корректно управлять жизненным циклом и не оставлять лишние подписки или вызовы после закрытия DevTools.

Убедитесь, что serviceManager.service не равен null (после onServiceAvailable).

Какие методы есть у EvalOnDartLibrary

  • asyncEval — асинхронное выполнение кода;

  • eval — выполнение выражения;

  • evalInstance — получение экземпляра объекта для дальнейшей работы с его полями и методами;

  • safeEval — более безопасная обертка над eval, которая дополнительно обрабатывает ошибки выполнения.

На практике можно сначала выполнить нужное действие, а затем отдельно получить результат из рантайма. Например, вызвать метод и получить значение.

У eval и asyncEval есть ограничение на размер возвращаемых данных. Поэтому удобнее сначала вызвать метод через asyncEval или eval, а потом получить нужное значение через evalInstance.

Это может выглядеть так:

Future<String?> _getValue() async {
 await _controllerEval.asyncEval(
   'await SomeController.instance.calculateValue()', // Запуск асинхронной функции
   isAlive: evalDisposable,
 );
 final result = await _controllerEval.evalInstance(
   'SomeController.instance.sum.value', // Получение значения после выполнения
   isAlive: evalDisposable,
 ); // Результатом является объект типа Instance
 return result?.valueAsString; // Возвращаем строковое значение
}

Здесь сначала вызывается асинхронный метод calculateValue(), а потом из рантайма читается уже обновленное значение SomeController.instance.sum.value. Еще важно не забывать передавать isAlive: evalDisposable.

Особенности работы EvalOnDartLibrary

При работе с EvalOnDartLibrary стоит учитывать несколько важных моментов.

Код выполняется в контексте текущего isolate, поэтому доступ к объектам и библиотекам ограничен тем, что реально загружено в рантайме.

Из-за этого могут возникать нюансы:

  • код может быть недоступен из-за tree shaking;

  • не всегда можно обратиться к приватным классам и полям;

  • возможны ограничения при использовании deferred imports.

Что такое Instance

Метод evalInstance возвращает Instance — ссылку на результат выполнения кода.

Дальше это значение можно преобразовать, например, в строку через valueAsString. Но важно понимать, что не каждое значение нужно безусловно приводить к строке. В зависимости от результата могут понадобиться дополнительные проверки.

Например:

final result = await _controllerEval.evalInstance(
 'SomeController.instance.sum.value.toString()',
 isAlive: evalDisposable,
);
final value = result?.valueAsString;

Правила работы с EvalOnDartLibrary

На практике это детали, из-за которых все либо быстро начинает работать, либо неожиданно ломается в самый неподходящий момент.

  1. Используйте Disposable для управления жизненным циклом;

  2. Учитывайте ограничение на размер результата;

  3. Передавайте только валидные Dart-выражения;

  4. При изменениях в проекте не забывайте обновлять пути в EvalOnDartLibrary.

Вывод

Для меня это был неплохой опыт с DevTools: я даже не знала, что так можно.

А еще через serviceManager можно получить код приложения через id изолята, но там уже есть свои нюансы, расскажу о них позже. 

А вы уже пробовали писать свои DevTools extensions? Расскажите о своем опыте в комментариях!