DevTools Custom Extension:使用 Dart 插件 API 扩展调试工具功能

扩展Dart DevTools功能:使用Dart插件API构建定制调试工具

在现代软件开发中,调试工具是提升效率、定位问题和理解应用程序行为不可或缺的利器。Dart DevTools作为Flutter和Dart应用程序的官方调试和性能分析工具,提供了丰富的功能集,包括布局检查、性能分析、内存诊断、日志查看等。然而,通用工具往往无法完全满足特定项目或领域中独特的调试需求。这时,Dart DevTools的扩展能力就显得尤为重要。通过利用Dart插件API,开发者可以为DevTools注入定制化的功能,创建与项目逻辑深度融合的专用工具,从而显著提升开发体验和调试效率。

本讲座将深入探讨如何使用Dart插件API来扩展Dart DevTools的功能。我们将从理解DevTools扩展的基本架构开始,逐步深入到开发环境的配置、API的核心概念,并通过一系列详细的代码示例,展示如何构建定制视图、如何与被调试的应用程序进行交互,以及如何实现更高级的调试工具。我们的目标是使您能够掌握开发定制DevTools扩展的技能,为您的Dart和Flutter项目量身定制调试解决方案。

一、DevTools扩展架构与核心概念

在深入编码之前,理解DevTools扩展的基本架构至关重要。DevTools本身是一个基于Web的应用程序,这意味着它的扩展本质上也是Web应用程序。当您构建一个DevTools扩展时,您实际上是创建了一个独立的Web应用,该应用将在DevTools内部的特定沙箱环境中运行,并通过一套定义好的API与DevTools宿主环境以及被调试的Dart/Flutter应用程序进行通信。

1. 扩展类型

从功能表现上,DevTools扩展主要可以归类为:

  • 自定义视图扩展 (Custom View Extensions): 这是最常见的类型,允许您在DevTools界面中添加全新的顶级面板(Tab页)或侧边栏。这些面板可以展示自定义数据、提供定制化的交互界面,或集成外部工具。
  • 自定义工具扩展 (Custom Tool Extensions): 尽管Dart插件API主要侧重于创建独立视图,但“自定义工具”可以理解为一个独立的自定义视图,它提供与现有DevTools功能互补或增强的特定工具集。例如,一个用于自动化测试、模拟数据或特定领域数据分析的面板。直接修改DevTools核心面板的UI通常是不支持的,扩展主要通过添加新的功能区域来提供工具。

2. 核心组件

一个完整的DevTools扩展通常由以下几个核心组件构成:

  • 扩展清单文件 (devtools_extension.json): 这是一个JSON文件,位于扩展项目的根目录。它定义了扩展的元数据,如名称、版本、图标、入口HTML文件等。这是DevTools识别和加载您的扩展的关键。
  • 入口HTML文件 (index.html): 扩展的Web应用程序的起点。它通常会加载您的Dart编译后的JavaScript代码。
  • Dart源文件 (main.dart): 这是扩展的核心逻辑所在。您将在这里使用package:devtools_extensions提供的API来注册扩展、构建UI以及与宿主DevTools和被调试应用程序进行交互。
  • 构建产物: 通过webdev build等工具编译生成的JavaScript文件、HTML文件及其他资源,这些文件最终会被DevTools加载。

3. 安全与通信模型

DevTools扩展运行在一个受限制的沙箱环境中,以确保宿主DevTools的稳定性和安全性。扩展与宿主DevTools之间的通信通常通过一套消息传递机制进行,而package:devtools_extensions则封装了这些底层细节,提供更高级、类型安全的Dart API。

与被调试的Dart/Flutter应用程序的通信则通过Dart VM Service进行。VM Service是Dart VM提供的一个强大的调试和内省API,它允许DevTools(以及您的扩展)查询应用程序状态、调用服务扩展、监听事件等。您的扩展将通过VmServiceWrapper(在devtools_extensions内部管理)来与VM Service交互。

二、开发环境搭建

在开始编写代码之前,我们需要确保开发环境已正确配置。

1. Dart SDK

确保您的系统上安装了最新稳定版的Dart SDK。您可以从Dart官网获取安装指南。

# 检查Dart SDK版本
dart --version

2. Flutter SDK (可选,但推荐)

如果您的目标是为Flutter应用程序开发扩展,那么安装Flutter SDK是必要的。它不仅提供了Flutter框架,还包含了Dart SDK的特定版本,并简化了许多开发工具的集成。

# 检查Flutter SDK版本
flutter --version

3. IDE (集成开发环境)

推荐使用Visual Studio Code或IntelliJ IDEA(Android Studio)。请确保安装了相应的Dart和Flutter插件,它们将提供代码高亮、智能提示、调试支持等功能。

4. webdev 工具

webdev是一个用于构建Dart Web应用的命令行工具,它将Dart代码编译为JavaScript,并处理Web服务器、热重载等。DevTools扩展本质上是Dart Web应用,因此webdev是构建它们的关键工具。

# 全局激活webdev
dart pub global activate webdev

激活后,您可以使用webdev build命令来编译您的扩展。

5. Chrome浏览器

DevTools是Chrome浏览器的一部分(尽管它也支持Edge等Chromium系浏览器)。您将需要Chrome来加载和测试您的扩展。

三、构建一个简单的自定义视图扩展:“Hello World”面板

让我们从创建一个最基本的DevTools自定义视图扩展开始,它将在DevTools中添加一个名为“我的扩展”的新面板,并显示“Hello, DevTools Extension!”。

1. 项目结构

首先,创建一个新的Dart项目。我们推荐使用web模板,因为它与DevTools扩展的性质最为接近。

# 创建项目目录
mkdir my_hello_extension
cd my_hello_extension

# 初始化Dart Web项目
dart create -t web .

清理掉web/index.htmlweb/main.dart中的默认内容,以便我们从头开始。

最终的项目结构将如下:

my_hello_extension/
├── pubspec.yaml
├── web/
│   ├── index.html
│   ├── main.dart
├── devtools_extension.json

2. pubspec.yaml

编辑pubspec.yaml文件,添加devtools_extensions依赖。如果计划使用Flutter UI,还需要添加flutter依赖。

name: my_hello_extension
description: A simple DevTools extension.
version: 1.0.0

environment:
  sdk: '>=3.0.0 <4.0.0' # 根据您的Dart SDK版本调整

dependencies:
  devtools_extensions: ^latest_version # 请替换为当前最新版本
  # 如果您计划使用Flutter UI,请添加:
  # flutter:
  #   sdk: flutter

dev_dependencies:
  build_runner: ^latest_version
  build_web_compilers: ^latest_version
  lints: ^latest_version

运行dart pub get来安装依赖。

dart pub get

3. devtools_extension.json (扩展清单)

在项目根目录创建devtools_extension.json文件,这是DevTools识别您扩展的关键。

{
  "name": "我的Hello扩展",
  "issueTracker": "https://github.com/your-username/my_hello_extension/issues",
  "version": "1.0.0",
  "homepage": "https://github.com/your-username/my_hello_extension",
  "materialIconCodePoint": "0xe8e8",
  "enabledForNonFlutterApps": true,
  "page": "index.html"
}

字段解释:

  • name: 扩展在DevTools中显示的名称。
  • issueTracker: 报告问题的URL。
  • version: 扩展的版本号。
  • homepage: 扩展的主页或代码仓库URL。
  • materialIconCodePoint: Material Design图标的Unicode码点。您可以在Material Icons网站上查找图标并获取其码点。例如,0xe8e8对应extension图标。
  • enabledForNonFlutterApps: 布尔值,指示此扩展是否应该在非Flutter Dart应用程序的DevTools中启用。
  • page: 扩展的入口HTML文件路径,相对于build目录。

4. web/index.html (入口HTML)

创建一个简单的HTML文件,它将加载Dart编译后的JavaScript代码。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My Hello DevTools Extension</title>
    <style>
        body {
            font-family: sans-serif;
            margin: 20px;
            background-color: #f0f0f0;
            color: #333;
        }
        h1 {
            color: #007bff;
        }
    </style>
</head>
<body>
    <h1>Loading DevTools Extension...</h1>
    <!-- Dart compiled JS will be loaded here -->
    <script src="main.dart.js"></script>
</body>
</html>

5. web/main.dart (Dart核心逻辑)

这是扩展的Dart代码。我们将使用devtools_extensions包来注册我们的扩展。

import 'dart:html';
import 'package:devtools_extensions/devtools_extensions.dart';

void main() {
  // 当DevTools准备好加载扩展时,会调用此方法。
  // 我们需要在这里注册我们的扩展。
  // registerExtension() 方法会处理与DevTools宿主的通信。
  extensionManager.registerExtension(
    // 此回调函数在DevTools加载扩展时执行,并提供一个可用于与DevTools通信的API。
    (DevToolsExtensionHost host) {
      // 在这里构建和显示您的UI。
      // 对于简单的HTML,我们可以直接操作DOM。
      final DivElement content = DivElement()
        ..text = 'Hello, DevTools Extension! This is my first custom panel.'
        ..style.fontSize = '24px'
        ..style.fontWeight = 'bold'
        ..style.color = '#28a745'
        ..style.marginTop = '50px'
        ..style.textAlign = 'center';

      document.body?.children.clear(); // 清除加载中的提示
      document.body?.append(content);

      // 可以通过host对象与DevTools宿主进行通信,例如发送消息
      // host.postMessage({'type': 'log', 'message': 'Extension loaded!'});

      print('My Hello DevTools Extension loaded successfully.');
    },
  );
}

使用Flutter UI (可选替代方案):

如果您希望使用Flutter来构建DevTools扩展的UI,web/main.dart会稍有不同:

// 确保pubspec.yaml中已添加flutter sdk依赖
// import 'package:flutter_web_plugins/flutter_web_plugins.dart'; // Old way
import 'package:flutter/material.dart'; // For Material Design widgets
import 'package:devtools_extensions/devtools_extensions.dart';

void main() {
  // initializeApp(); // For older flutter_web_plugins
  // Flutter web entry point
  // runApp(MyApp()); // If you want to use the standard Flutter app setup

  extensionManager.registerExtension(
    (DevToolsExtensionHost host) {
      // Flutter应用的根Widget
      runApp(
        MaterialApp(
          debugShowCheckedModeBanner: false,
          home: Scaffold(
            appBar: AppBar(
              title: const Text('我的Hello扩展'),
            ),
            body: Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: const [
                  Text(
                    'Hello, DevTools Extension!',
                    style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
                  ),
                  SizedBox(height: 10),
                  Text('This panel is built with Flutter!'),
                ],
              ),
            ),
          ),
        ),
      );
      print('My Hello DevTools Extension (Flutter) loaded successfully.');
    },
  );
}

注意: 当使用Flutter构建UI时,devtools_extensions包会处理Flutter Web应用的初始化,您通常只需要在registerExtension的回调中调用runApp即可。

6. 构建扩展

在项目根目录运行webdev build命令:

webdev build

这会在项目根目录生成一个build文件夹,其中包含编译后的JavaScript文件、HTML文件以及其他静态资源。这个build文件夹就是您的DevTools扩展。

7. 加载和测试扩展

  1. 打开Chrome浏览器,并在地址栏输入chrome://extensions
  2. 开启“开发者模式”(通常在右上角)。
  3. 点击“加载已解压的扩展程序”按钮。
  4. 选择您项目根目录下的build文件夹
  5. 如果一切顺利,您会看到您的扩展被加载。

现在,打开一个正在运行的Dart或Flutter应用程序(例如,通过flutter run启动一个Flutter应用)。然后打开其DevTools(通常在Chrome开发者工具的“More tools”菜单中选择“Dart DevTools”)。

在DevTools中,您应该会看到一个新的顶级面板,其名称为“我的Hello扩展”(或您在devtools_extension.json中定义的名称),点击它,您将看到“Hello, DevTools Extension!”的消息。

四、高级自定义视图扩展:与被调试应用程序交互

一个真正的定制工具的价值在于它能与被调试的应用程序进行深度交互。这包括从应用程序获取实时数据、调用应用程序中定义的特定功能(称为服务扩展),以及监听应用程序发出的事件。

我们将创建一个示例:一个DevTools面板,用于显示被调试Flutter应用程序中的一个计数器,并允许从DevTools中增量操作这个计数器。

1. 准备被调试的Flutter应用程序

首先,我们需要一个Flutter应用程序来作为我们的调试目标。创建一个新的Flutter项目:

flutter create my_counter_app
cd my_counter_app

修改lib/main.dart文件:

import 'package:flutter/material.dart';
import 'dart:developer' as developer; // 导入dart:developer

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Counter App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Counter Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  @override
  void initState() {
    super.initState();
    _registerDevToolsExtensions();
  }

  void _registerDevToolsExtensions() {
    // 注册一个服务扩展,用于获取当前计数器的值
    developer.registerExtension(
      'ext.my_counter_app.getCounter',
      (String method, Map<String, String> parameters) async {
        // 返回JSON格式的当前计数器值
        return developer.ServiceExtensionResponse.result(
          '{"count": $_counter}',
        );
      },
    );

    // 注册一个服务扩展,用于增加计数器
    developer.registerExtension(
      'ext.my_counter_app.incrementCounter',
      (String method, Map<String, String> parameters) async {
        setState(() {
          _counter++;
        });
        // 发送一个事件,通知DevTools计数器已更新
        developer.postEvent(
          'ext.my_counter_app.counterChanged',
          {'count': _counter},
        );
        return developer.ServiceExtensionResponse.result(
          '{"success": true, "newCount": $_counter}',
        );
      },
    );

    // 注册一个服务扩展,用于设置计数器
    developer.registerExtension(
      'ext.my_counter_app.setCounter',
      (String method, Map<String, String> parameters) async {
        final int? newCount = int.tryParse(parameters['value'] ?? '');
        if (newCount != null) {
          setState(() {
            _counter = newCount;
          });
          developer.postEvent(
            'ext.my_counter_app.counterChanged',
            {'count': _counter},
          );
          return developer.ServiceExtensionResponse.result(
            '{"success": true, "newCount": $_counter}',
          );
        }
        return developer.ServiceExtensionResponse.error(
          developer.ServiceExtensionResponse.invalidParams,
          'Invalid value parameter.',
        );
      },
    );
  }

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

这个Flutter应用做了几件事:

  • 它有一个简单的计数器。
  • initState中,它注册了三个服务扩展:
    • ext.my_counter_app.getCounter: 返回当前计数器的值。
    • ext.my_counter_app.incrementCounter: 增加计数器,并发送一个名为ext.my_counter_app.counterChanged的VM Service事件。
    • ext.my_counter_app.setCounter: 设置计数器为指定值,并发送counterChanged事件。
  • developer.postEvent用于向DevTools发送自定义事件,这是从应用主动通知DevTools变更的重要机制。

运行此Flutter应用:

flutter run

2. 创建DevTools扩展项目

按照第三节的步骤创建一个新的DevTools扩展项目,例如my_counter_extension

pubspec.yaml

name: my_counter_extension
description: A DevTools extension to interact with a counter app.
version: 1.0.0

environment:
  sdk: '>=3.0.0 <4.0.0'

dependencies:
  devtools_extensions: ^latest_version
  flutter: # 使用Flutter UI
    sdk: flutter
  flutter_web_plugins: # 如果需要,用于更底层的Web初始化
    sdk: flutter
  # 如果需要解析JSON
  json_annotation: ^latest_version

dev_dependencies:
  build_runner: ^latest_version
  build_web_compilers: ^latest_version
  json_serializable: ^latest_version
  lints: ^latest_version

运行dart pub get

devtools_extension.json

{
  "name": "计数器交互",
  "issueTracker": "https://github.com/your-username/my_counter_extension/issues",
  "version": "1.0.0",
  "homepage": "https://github.com/your-username/my_counter_extension",
  "materialIconCodePoint": "0xe85d",
  "enabledForNonFlutterApps": false,
  "page": "index.html"
}

注意: enabledForNonFlutterApps设置为false,因为这个扩展是专门为Flutter计数器应用设计的。

web/index.html
与之前的index.html类似,保持不变。

web/main.dart (DevTools扩展核心逻辑):

import 'package:flutter/material.dart';
import 'package:devtools_extensions/devtools_extensions.dart';
import 'dart:convert'; // 用于JSON解析

void main() {
  extensionManager.registerExtension(
    (DevToolsExtensionHost host) {
      runApp(
        MaterialApp(
          debugShowCheckedModeBanner: false,
          home: CounterExtensionPanel(host: host),
        ),
      );
    },
  );
}

class CounterExtensionPanel extends StatefulWidget {
  final DevToolsExtensionHost host;

  const CounterExtensionPanel({super.key, required this.host});

  @override
  State<CounterExtensionPanel> createState() => _CounterExtensionPanelState();
}

class _CounterExtensionPanelState extends State<CounterExtensionPanel> {
  int _currentCounter = 0;
  String _errorMessage = '';
  final TextEditingController _setTextController = TextEditingController();

  @override
  void initState() {
    super.initState();
    _initExtension();
  }

  void _initExtension() {
    // 获取VM Service Manager,它负责与被调试应用通信
    final serviceManager = extensionManager.serviceManager;

    // 确保服务已连接
    if (serviceManager == null || !serviceManager.connectedApp!.is:'flutter') {
      setState(() {
        _errorMessage = 'No Flutter app connected or service not available.';
      });
      return;
    }

    // 订阅自定义事件流
    serviceManager.service!.onExtensionEvent.listen((event) {
      if (event.extensionKind == 'ext.my_counter_app.counterChanged') {
        _updateCounterFromEvent(event.json);
      }
    });

    // 首次加载时获取当前计数器值
    _fetchCounter();
  }

  Future<void> _fetchCounter() async {
    setState(() {
      _errorMessage = '';
    });
    final service = extensionManager.serviceManager?.service;
    if (service == null) {
      setState(() {
        _errorMessage = 'VM Service not available.';
      });
      return;
    }

    try {
      final response = await service.callServiceExtension(
        'ext.my_counter_app.getCounter',
      );
      final jsonResponse = jsonDecode(response.json!) as Map<String, dynamic>;
      setState(() {
        _currentCounter = jsonResponse['count'] as int;
      });
    } catch (e) {
      setState(() {
        _errorMessage = 'Failed to fetch counter: $e';
      });
      print('Error fetching counter: $e');
    }
  }

  Future<void> _incrementCounter() async {
    setState(() {
      _errorMessage = '';
    });
    final service = extensionManager.serviceManager?.service;
    if (service == null) {
      setState(() {
        _errorMessage = 'VM Service not available.';
      });
      return;
    }

    try {
      // 调用服务扩展来增加计数器
      final response = await service.callServiceExtension(
        'ext.my_counter_app.incrementCounter',
      );
      final jsonResponse = jsonDecode(response.json!) as Map<String, dynamic>;
      if (jsonResponse['success'] == true) {
        setState(() {
          _currentCounter = jsonResponse['newCount'] as int;
        });
      } else {
        setState(() {
          _errorMessage = 'Failed to increment: ${jsonResponse['error']}';
        });
      }
    } catch (e) {
      setState(() {
        _errorMessage = 'Failed to increment counter: $e';
      });
      print('Error incrementing counter: $e');
    }
  }

  Future<void> _setCounter() async {
    setState(() {
      _errorMessage = '';
    });
    final service = extensionManager.serviceManager?.service;
    if (service == null) {
      setState(() {
        _errorMessage = 'VM Service not available.';
      });
      return;
    }

    final String value = _setTextController.text;
    if (value.isEmpty) {
      setState(() {
        _errorMessage = 'Please enter a value.';
      });
      return;
    }

    try {
      final response = await service.callServiceExtension(
        'ext.my_counter_app.setCounter',
        args: {'value': value},
      );
      final jsonResponse = jsonDecode(response.json!) as Map<String, dynamic>;
      if (jsonResponse['success'] == true) {
        setState(() {
          _currentCounter = jsonResponse['newCount'] as int;
          _setTextController.clear();
        });
      } else {
        setState(() {
          _errorMessage = 'Failed to set counter: ${jsonResponse['error']}';
        });
      }
    } catch (e) {
      setState(() {
        _errorMessage = 'Failed to set counter: $e';
      });
      print('Error setting counter: $e');
    }
  }

  void _updateCounterFromEvent(String? eventJson) {
    if (eventJson == null) return;
    try {
      final json = jsonDecode(eventJson) as Map<String, dynamic>;
      setState(() {
        _currentCounter = json['count'] as int;
        _errorMessage = ''; // 清除之前的错误信息
      });
    } catch (e) {
      print('Error parsing counterChanged event: $e');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('计数器交互面板'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              '当前计数器值: $_currentCounter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: _incrementCounter,
              child: const Text('增加计数器'),
            ),
            const SizedBox(height: 20),
            Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _setTextController,
                    keyboardType: TextInputType.number,
                    decoration: const InputDecoration(
                      labelText: '设置新值',
                      border: OutlineInputBorder(),
                    ),
                  ),
                ),
                const SizedBox(width: 10),
                ElevatedButton(
                  onPressed: _setCounter,
                  child: const Text('设置'),
                ),
              ],
            ),
            if (_errorMessage.isNotEmpty) ...[
              const SizedBox(height: 20),
              Text(
                '错误: $_errorMessage',
                style: const TextStyle(color: Colors.red),
              ),
            ],
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: _fetchCounter,
              child: const Text('刷新计数器'),
            ),
          ],
        ),
      ),
    );
  }
}

代码解释:

  • extensionManager.registerExtension: 这是扩展的入口点,其中我们调用runApp来启动Flutter UI。
  • CounterExtensionPanel: 这是一个StatefulWidget,用于管理UI状态和与DevTools的交互逻辑。
  • _initExtension():
    • extensionManager.serviceManager: 这是获取ServiceManager的入口,它提供了与被调试应用程序的VM Service交互的接口。
    • serviceManager.service!.onExtensionEvent.listen(...): 订阅VM Service的扩展事件流。当被调试应用程序通过developer.postEvent发送自定义事件时,我们可以在这里接收到并更新UI。
    • _fetchCounter(): 在初始化时调用,从被调试应用程序获取初始的计数器值。
  • _fetchCounter() / _incrementCounter() / _setCounter():
    • service.callServiceExtension(methodName, args: {...}): 这是调用被调试应用程序中注册的服务扩展的关键方法。methodName是服务扩展的完整名称(例如ext.my_counter_app.getCounter),args是一个可选的参数映射。
    • response.json!: 服务扩展的响应通常是一个JSON字符串,需要使用jsonDecode进行解析。
    • 错误处理:使用try-catch块来捕获VM Service调用可能发生的错误,并更新UI以显示错误消息。
  • _updateCounterFromEvent(): 处理ext.my_counter_app.counterChanged事件,更新UI中的计数器显示。这展示了DevTools扩展如何响应应用程序内部的实时变化。
  • UI (Flutter MaterialApp): 使用Flutter的ScaffoldAppBarTextElevatedButtonTextField等组件构建了一个直观的界面,用于显示计数器、增加计数器和设置计数器。

3. 构建与测试

  1. my_counter_extension项目根目录运行webdev build
  2. 在Chrome中加载my_counter_extension/build目录作为已解压的扩展程序。
  3. 确保您的my_counter_appFlutter应用正在运行。
  4. 打开my_counter_app的DevTools。
  5. 切换到“计数器交互”面板,您应该能看到当前计数器值,并能够通过按钮和文本框进行交互。同时,如果在Flutter应用中点击浮动按钮,DevTools面板中的计数器也会实时更新。

五、构建一个自定义工具扩展:应用日志查看器

虽然Dart DevTools已经提供了“日志”面板,但我们可以创建一个更专业的自定义日志查看器,它能对特定类型的日志消息进行过滤、高亮或结构化显示。这将展示如何监听应用程序的stdout/stderr输出,并进行自定义处理。

1. 准备被调试的Flutter应用程序

我们将继续使用my_counter_app,但对其进行一些修改,使其能发出不同类型的日志消息。

修改my_counter_app/lib/main.dart中的_incrementCounter方法和添加一个发送自定义日志的方法:

// ... 其他代码不变 ...

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  @override
  void initState() {
    super.initState();
    _registerDevToolsExtensions();
  }

  void _registerDevToolsExtensions() {
    // ... 保持之前的服务扩展注册不变 ...

    // 注册一个服务扩展,用于发送自定义日志
    developer.registerExtension(
      'ext.my_counter_app.sendLog',
      (String method, Map<String, String> parameters) async {
        final String? message = parameters['message'];
        final String? type = parameters['type'];
        if (message != null && type != null) {
          // 使用developer.log发送结构化日志,DevTools的日志面板也能看到
          developer.log('[$type] $message', name: 'my_app_logger');
          // 也可以直接print到stdout,但这通常不包含结构化信息
          print('CUSTOM_LOG_TYPE:$type: $message');
          return developer.ServiceExtensionResponse.result(
            '{"success": true}',
          );
        }
        return developer.ServiceExtensionResponse.error(
          developer.ServiceExtensionResponse.invalidParams,
          'Missing message or type parameter.',
        );
      },
    );
  }

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
    // 除了更新计数器,也发送一条日志
    print('Counter incremented to $_counter');
    developer.log('Counter increased', name: 'counter_events', level: developer.Level.INFO.value);
  }

  // 模拟发送不同类型的日志
  void _sendDebugLog() {
    print('DEBUG: Something debug-worthy happened.');
    developer.log('Debug message from app.', name: 'my_app_logger', level: developer.Level.FINE.value);
  }

  void _sendErrorLog() {
    print('ERROR: An error occurred!');
    developer.log('An error occurred.', name: 'my_app_logger', level: developer.Level.SEVERE.value);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
            const SizedBox(height: 20),
            ElevatedButton(
              onPressed: _sendDebugLog,
              child: const Text('发送调试日志'),
            ),
            const SizedBox(height: 10),
            ElevatedButton(
              onPressed: _sendErrorLog,
              child: const Text('发送错误日志'),
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

重新运行my_counter_app

2. 创建DevTools扩展项目

创建一个新的DevTools扩展项目,例如my_logger_extension

pubspec.yaml (与my_counter_extension类似,确保flutterdevtools_extensions依赖)

name: my_logger_extension
description: A custom logger DevTools extension.
version: 1.0.0

environment:
  sdk: '>=3.0.0 <4.0.0'

dependencies:
  devtools_extensions: ^latest_version
  flutter:
    sdk: flutter
  flutter_web_plugins:
    sdk: flutter

dev_dependencies:
  build_runner: ^latest_version
  build_web_compilers: ^latest_version
  lints: ^latest_version

运行dart pub get

devtools_extension.json

{
  "name": "应用日志查看器",
  "issueTracker": "https://github.com/your-username/my_logger_extension/issues",
  "version": "1.0.0",
  "homepage": "https://github.com/your-username/my_logger_extension",
  "materialIconCodePoint": "0xe0b6",
  "enabledForNonFlutterApps": false,
  "page": "index.html"
}

web/index.html
保持不变。

web/main.dart (DevTools扩展核心逻辑):

import 'package:flutter/material.dart';
import 'package:devtools_extensions/devtools_extensions.dart';
import 'dart:convert'; // For JSON parsing

enum LogLevel { info, debug, error, custom }

class LogEntry {
  final DateTime timestamp;
  final LogLevel level;
  final String message;
  final String rawSource;

  LogEntry(this.timestamp, this.level, this.message, this.rawSource);

  Color get color {
    switch (level) {
      case LogLevel.error:
        return Colors.red;
      case LogLevel.debug:
        return Colors.blue;
      case LogLevel.info:
        return Colors.green;
      case LogLevel.custom:
        return Colors.purple;
      default:
        return Colors.black;
    }
  }

  IconData get icon {
    switch (level) {
      case LogLevel.error:
        return Icons.error;
      case LogLevel.debug:
        return Icons.bug_report;
      case LogLevel.info:
        return Icons.info;
      case LogLevel.custom:
        return Icons.code;
    }
  }
}

void main() {
  extensionManager.registerExtension(
    (DevToolsExtensionHost host) {
      runApp(
        MaterialApp(
          debugShowCheckedModeBanner: false,
          home: LoggerExtensionPanel(host: host),
        ),
      );
    },
  );
}

class LoggerExtensionPanel extends StatefulWidget {
  final DevToolsExtensionHost host;

  const LoggerExtensionPanel({super.key, required this.host});

  @override
  State<LoggerExtensionPanel> createState() => _LoggerExtensionPanelState();
}

class _LoggerExtensionPanelState extends State<LoggerExtensionPanel> {
  final List<LogEntry> _logEntries = [];
  String _errorMessage = '';
  final ScrollController _scrollController = ScrollController();
  LogLevel? _selectedFilterLevel;

  @override
  void initState() {
    super.initState();
    _initLoggerExtension();
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  void _initLoggerExtension() {
    final serviceManager = extensionManager.serviceManager;
    if (serviceManager == null || !serviceManager.connectedApp!.is:'flutter') {
      setState(() {
        _errorMessage = 'No Flutter app connected or service not available.';
      });
      return;
    }

    // 订阅stdout流
    serviceManager.service!.onStdoutEvent.listen((event) {
      if (event.bytes != null) {
        final String message = utf8.decode(base64.decode(event.bytes!));
        _parseAndAddLog(message, isStderr: false);
      }
    });

    // 订阅stderr流
    serviceManager.service!.onStderrEvent.listen((event) {
      if (event.bytes != null) {
        final String message = utf8.decode(base64.decode(event.bytes!));
        _parseAndAddLog(message, isStderr: true);
      }
    });

    // 订阅VM Service Log事件(来自developer.log)
    serviceManager.service!.onLoggingEvent.listen((event) {
      // event.logRecord.message.value 是日志消息
      // event.logRecord.level 是日志级别
      // event.logRecord.loggerName 是logger名称
      final String message = event.logRecord.message?.value ?? 'No message';
      final int level = event.logRecord.level ?? 0;
      final String loggerName = event.logRecord.loggerName?.value ?? 'default';

      LogLevel logLevel = LogLevel.info;
      if (level >= developer.Level.SEVERE.value) {
        logLevel = LogLevel.error;
      } else if (level >= developer.Level.INFO.value) {
        logLevel = LogLevel.info;
      } else if (level >= developer.Level.FINE.value) {
        logLevel = LogLevel.debug;
      }

      setState(() {
        _logEntries.add(LogEntry(
          DateTime.now(),
          logLevel,
          '[${loggerName.toUpperCase()}][${level}] $message',
          '${event.json}', // 保留原始JSON用于调试
        ));
      });
      _scrollToBottom();
    });
  }

  void _parseAndAddLog(String rawMessage, {required bool isStderr}) {
    LogLevel level = LogLevel.info;
    String message = rawMessage.trim();

    // 尝试解析自定义前缀
    if (message.startsWith('DEBUG:')) {
      level = LogLevel.debug;
      message = message.substring('DEBUG:'.length).trim();
    } else if (message.startsWith('ERROR:')) {
      level = LogLevel.error;
      message = message.substring('ERROR:'.length).trim();
    } else if (message.startsWith('CUSTOM_LOG_TYPE:')) {
      final parts = message.split(':');
      if (parts.length >= 3) {
        final type = parts[1];
        message = parts.sublist(2).join(':').trim();
        level = LogLevel.custom; // 可以根据type进一步细分
      }
    } else if (isStderr) {
      level = LogLevel.error; // stderr通常视为错误
    }

    setState(() {
      _logEntries.add(LogEntry(DateTime.now(), level, message, rawMessage));
    });
    _scrollToBottom();
  }

  void _scrollToBottom() {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (_scrollController.hasClients) {
        _scrollController.animateTo(
          _scrollController.position.maxScrollExtent,
          duration: const Duration(milliseconds: 300),
          curve: Curves.easeOut,
        );
      }
    });
  }

  List<LogEntry> get _filteredLogEntries {
    if (_selectedFilterLevel == null) {
      return _logEntries;
    }
    return _logEntries.where((entry) => entry.level == _selectedFilterLevel).toList();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('应用日志查看器'),
        actions: [
          DropdownButton<LogLevel?>(
            value: _selectedFilterLevel,
            hint: const Text('过滤级别'),
            items: [
              const DropdownMenuItem<LogLevel?>(
                value: null,
                child: Text('所有级别'),
              ),
              ...LogLevel.values.map((level) => DropdownMenuItem(
                value: level,
                child: Text(level.toString().split('.').last.toUpperCase()),
              )),
            ],
            onChanged: (LogLevel? newValue) {
              setState(() {
                _selectedFilterLevel = newValue;
              });
            },
          ),
          IconButton(
            icon: const Icon(Icons.clear_all),
            tooltip: '清除日志',
            onPressed: () {
              setState(() {
                _logEntries.clear();
                _errorMessage = '';
              });
            },
          ),
        ],
      ),
      body: Padding(
        padding: const EdgeInsets.all(8.0),
        child: Column(
          children: [
            if (_errorMessage.isNotEmpty)
              Text(
                '错误: $_errorMessage',
                style: const TextStyle(color: Colors.red),
              ),
            Expanded(
              child: ListView.builder(
                controller: _scrollController,
                itemCount: _filteredLogEntries.length,
                itemBuilder: (context, index) {
                  final entry = _filteredLogEntries[index];
                  return Card(
                    margin: const EdgeInsets.symmetric(vertical: 4.0),
                    elevation: 1,
                    child: Padding(
                      padding: const EdgeInsets.all(8.0),
                      child: Row(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Icon(entry.icon, color: entry.color, size: 18),
                          const SizedBox(width: 8),
                          Expanded(
                            child: Column(
                              crossAxisAlignment: CrossAxisAlignment.start,
                              children: [
                                Text(
                                  '${entry.timestamp.toIso8601String().substring(11, 23)} [${entry.level.toString().split('.').last.toUpperCase()}]',
                                  style: TextStyle(
                                    fontSize: 10,
                                    color: Colors.grey[600],
                                  ),
                                ),
                                const SizedBox(height: 2),
                                Text(
                                  entry.message,
                                  style: TextStyle(
                                    color: entry.color,
                                    fontWeight: FontWeight.w500,
                                  ),
                                ),
                              ],
                            ),
                          ),
                        ],
                      ),
                    ),
                  );
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

代码解释:

  • LogEntryLogLevel: 定义了日志条目的数据模型,包括时间戳、级别、消息以及显示样式。
  • _initLoggerExtension():
    • serviceManager.service!.onStdoutEvent.listen(...): 监听被调试应用程序的stdout(标准输出)流。应用程序中通过print()输出的内容会出现在这里。
    • serviceManager.service!.onStderrEvent.listen(...): 监听stderr(标准错误)流。
    • serviceManager.service!.onLoggingEvent.listen(...): 监听dart:developerdeveloper.log方法发出的结构化日志事件。这是获取更丰富日志信息(如日志级别、名称)的最佳方式。
    • 重要提示: stdoutstderr事件的event.bytes是Base64编码的字节数组,需要先解码为UTF-8字符串。
  • _parseAndAddLog(): 这是一个自定义解析函数,它检查原始日志消息的文本内容(例如,DEBUG:ERROR:前缀),以确定日志级别,并提取实际的消息内容。这允许我们对非结构化的print输出进行初步分类。
  • ListView.builder: 用于高效地显示大量日志条目。
  • _scrollController: 确保日志列表在有新消息时自动滚动到底部。
  • 过滤和清除功能: AppBar中的DropdownButton允许用户根据日志级别进行过滤,IconButton则提供了清除所有日志的功能。

3. 构建与测试

  1. my_logger_extension项目根目录运行webdev build
  2. 在Chrome中加载my_logger_extension/build目录作为已解压的扩展程序。
  3. 确保您的my_counter_appFlutter应用正在运行。
  4. 打开my_counter_app的DevTools。
  5. 切换到“应用日志查看器”面板。
  6. 在Flutter应用中点击“增加计数器”、“发送调试日志”、“发送错误日志”按钮,观察日志查看器中出现的结构化日志条目。尝试使用过滤功能。

六、高级主题与最佳实践

1. 状态管理

对于更复杂的扩展,简单的setState可能不足以管理状态。您可以考虑集成流行的Flutter状态管理解决方案,如providerriverpodblocgetx。这些库可以帮助您更好地组织代码、实现状态共享和响应式更新。

2. 异步操作与错误处理

与VM Service的所有交互都是异步的。务必使用async/await来处理异步操作,并始终包含try-catch块来优雅地处理网络问题、VM Service错误或数据解析失败等情况。向用户提供明确的错误反馈至关重要。

3. 性能优化

  • UI渲染: 尽量使用ListView.builder等惰性加载组件来显示大量数据。避免在build方法中执行耗时操作。
  • 数据处理: 对于从VM Service接收到的原始数据,进行高效解析和处理。如果数据量巨大,考虑后台线程(Isolates,尽管在Web环境中有限制)或分批处理。
  • 监听器管理:dispose方法中取消订阅所有VM Service事件监听器,防止内存泄漏。

4. 部署与分发

目前,Dart DevTools扩展主要通过“加载已解压的扩展程序”的方式进行本地开发和测试。对于分发,通常是将build目录打包并提供给用户。官方尚未提供类似Chrome Web Store的集中式DevTools扩展商店,但Flutter SDK或特定工具可能会集成推荐的扩展。

5. 与DevTools宿主的更深层通信 (不常见)

虽然devtools_extensions包抽象了大部分与DevTools宿主的通信,但在极少数情况下,您可能需要直接使用window.parent.postMessage来发送或接收消息。但请注意,这种直接通信需要DevTools宿主本身提供相应的API,并且通常不推荐,因为它绕过了devtools_extensions提供的类型安全和高级抽象。

6. 安全考量

  • Content Security Policy (CSP): 您的扩展运行在受DevTools CSP限制的环境中。这意味着您不能随意加载外部脚本、样式或图片。所有资源都应该来自扩展本身或被DevTools CSP允许的源。
  • VM Service权限: 您的扩展通过VM Service与被调试应用程序交互,具有相当大的权限。在设计扩展时,请注意不要引入安全漏洞,例如,不要在未经用户明确同意的情况下执行潜在危险的操作。

七、总结与展望

通过本讲座,我们深入探讨了Dart DevTools扩展的开发。从理解其架构、配置开发环境,到构建“Hello World”面板、实现与应用程序的实时交互,再到创建功能丰富的日志查看器,我们展示了Dart插件API的强大功能。利用package:devtools_extensions和Flutter Web,开发者能够为Dart和Flutter生态系统构建高度定制化的调试工具,显著提高开发效率和调试体验。

DevTools的扩展能力为开发者提供了无限的可能性,以适应各种独特的项目需求。随着Dart和Flutter生态的不断发展,我们可以期待未来DevTools插件API将提供更多强大的功能和更便捷的分发机制,进一步赋能开发者社区。通过积极参与和贡献,我们共同塑造更加高效和智能的开发工具链。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注