Flutter开发调试组件设计概要

435 阅读9分钟

设计概要

app的开发上线,需要产研测三方共同努力。而 成熟的产品线需要完整的基础设施链路作为支撑,让问题的发现变得简单。Flutter应用内调试组件,是 基于Flutter悬浮窗的调试工具,目的是在脱离编译器的前提下,提供应用内关键信息的查看的功能。

功能拆解

  1. 设备信息

    使用 device_info 插件 提供的性能指标;CPU信息基于 system_info 插件,直接从Dart层读取系统基础信息。

  2. 日志读取

    通过重定向 foundation 包的 debugPrint函数,实现对日志输出函数的插装处理,并记录日志输出时间等额外信息,通过对外统一的面板提供筛选,导出等功能。

  3. 网络请求监控

    通过 dio 库自带的拦截器机制,添加若干个 interceptor的实现,抓取所有网络请求的 域名,路径,请求头,请求体,响应头,响应体等数据。

  4. 自定义插件

    基础组件提供的功能主要是以上3种,但是具体到某个业务方的app,可能还需要提供 诸如。

    • 网络环境切换
    • 网络代理配置
    • 打开任意H5页面
    • App内部文件浏览
    • Crash堆栈查看

    等功能,我们必须允许业务方app自己定义自己的特有插件。

具体细节

可拖拽悬浮窗

市面上大概有两种悬浮窗,一个是 全局的 system_alert_window,即使本app退到了后台,悬浮窗依然存在,一个是本app的 flutter_floating,只会跟随FlutterActivity而存在,app退到后台,悬浮窗不见。

经过推敲,这两种都过于重量级,目前用不着这么花哨的效果,很多东西引入会加大包的体积。所以决定手写。

按步骤来解决问题:

全局悬浮组件

Flutter提供了 Overlay+OverlayEntry的组合,允许添加自定义widget到屏幕的任意位置。

支持可拖拽

Flutter 自带的 Draggable 组件可以实现拖动效果。

边界回弹

如果在拖动到了边缘位置,可在 onDraggableCanceled 之后,计算组件的宽高和屏幕宽高,判断是否有超出边界,如果有超出,则进行回弹,使之回到界内。值得注意的是,回弹的过程中,加上效果,体验更佳。Flutter的动画原理基于插值器,先设计好初始值,动画运行时间,以及数值计算规律(指的是线性或者非线性),然后将运行中的数值赋予全局变量并setState让UI发生变化:

  void startAnimation(double startX, double startY, double endX, double endY) {
    controller = AnimationController(
      duration: const Duration(milliseconds: 200),
      vsync: this,
    );
​
    animation = Tween<Offset>(begin: Offset(startX, startY), end: Offset(endX, endY)).animate(controller)
      ..addListener(() {
        debugPrint('动画执行中 ${animation.value.dx}- ${animation.value.dy}');
        setState(() {
          _dx = animation.value.dx;
          _dy = animation.value.dy;
        });
      })
      ..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          debugPrint('动画执行完成');
        }
      });
    controller.forward();
  }

完整参考代码如下:

import 'package:flutter/material.dart';
​
class DraggableWidget extends StatefulWidget {
  final Offset offset;
  final VoidCallback onTap;
  final String text;
​
  DraggableWidget({required this.text, required this.offset, required this.onTap});
​
  @override
  _DraggableWidgetState createState() {
    return _DraggableWidgetState();
  }
}
​
class _DraggableWidgetState extends State<DraggableWidget> with TickerProviderStateMixin {
  // 防止创建太多Offset对象产生性能问题,所以,直接使用double值
  late double _dx;
  late double _dy;
​
  final Color _widgetColor = Colors.green;
​
  late Animation<Offset> animation;
  late AnimationController controller;
​
  // 滑块宽度
  final double _btnWidth = 90;
​
  // 滑块高度
  final double _btnHeight = 30;
​
  // 设置边界安全距离
  final double _safePadding = 10;
​
  @override
  void initState() {
    super.initState();
    _dx = widget.offset.dx;
    _dy = widget.offset.dy;
  }
​
  void startAnimation(double startX, double startY, double endX, double endY) {
    controller = AnimationController(
      duration: const Duration(milliseconds: 200),
      vsync: this,
    );
​
    animation = Tween<Offset>(begin: Offset(startX, startY), end: Offset(endX, endY)).animate(controller)
      ..addListener(() {
        debugPrint('动画执行中 ${animation.value.dx}- ${animation.value.dy}');
        setState(() {
          _dx = animation.value.dx;
          _dy = animation.value.dy;
        });
      })
      ..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          debugPrint('动画执行完成');
        }
      });
    controller.forward();
  }
​
  @override
  void dispose() {
    super.dispose();
    controller.dispose();
  }
​
  @override
  Widget build(BuildContext context) {
    return Positioned(
      left: _dx,
      top: _dy,
      child: Draggable(
        data: _widgetColor,
        child: _buildButton(),
        feedback: _buildButton(isFeedback: true),
        childWhenDragging: const SizedBox(),
        onDraggableCanceled: (Velocity velocity, Offset offset) {
          // 计算是否超出边界,如果超出边界,则必须让悬浮窗返回在界限之内,手动去计算XY
          // 如果右侧超过边界
​
          bool ifXOut;
          double correctX = 0;
          if (offset.dx + _btnWidth > MediaQuery.of(context).size.width) {
            debugPrint('右侧超过边界');
            ifXOut = true;
            correctX = MediaQuery.of(context).size.width - _btnWidth - _safePadding;
          } else if (offset.dx < 0) {
            debugPrint('左侧超过边界');
            ifXOut = true;
            correctX = _safePadding;
          } else {
            ifXOut = false;
            correctX = offset.dx;
            debugPrint('横向在边界内');
          }
​
          bool ifYOut;
          double correctY = 0;
          // 如果上边超过边界, 上下两边还加上上边的状态栏和下边的导航栏,只能在规定范围内拖动
          if (offset.dy + _btnHeight > MediaQuery.of(context).size.height - MediaQuery.of(context).padding.bottom) {
            debugPrint('下边超过边界');
            ifYOut = true;
            correctY = MediaQuery.of(context).size.height - MediaQuery.of(context).padding.bottom - _btnHeight - _safePadding;
          } else if (offset.dy < MediaQuery.of(context).padding.top) {
            debugPrint('上边超过边界');
            ifYOut = true;
            correctY = MediaQuery.of(context).padding.top + _safePadding;
          } else {
            ifYOut = false;
            debugPrint('纵向在边界内');
            correctY = offset.dy;
          }
​
          if (ifXOut || ifYOut) {
            startAnimation(offset.dx, offset.dy, correctX, correctY);
          } else {
            _dx = correctX;
            _dy = correctY;
          }
        },
      ),
    );
  }
​
  Widget _buildButton({bool isFeedback = false}) {
    return GestureDetector(
      onTap: widget.onTap,
      child: Container(
        width: _btnWidth,
        height: _btnHeight,
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(6),
          boxShadow: [
            BoxShadow(
                color: _widgetColor,
                offset: const Offset(1.0, 1.0), //阴影xy轴偏移量
                blurRadius: 5.0, //阴影模糊程度
                spreadRadius: 1.0 //阴影扩散程度
                )
          ],
          color: isFeedback ? _widgetColor.withOpacity(0.5) : _widgetColor,
        ),
        alignment: Alignment.center,
        child: Text(
          widget.text,
          style: TextStyle(
              fontSize: 13,
              fontWeight: FontWeight.normal,
              decoration: TextDecoration.none,
              color: isFeedback ? Colors.white.withOpacity(0.5) : Colors.white),
        ),
      ),
    );
  }
}
​
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hank/draggable_widget.dart';
​
class MyConsole {
  static bool showing = false;
​
  static show(BuildContext context) {
    if (showing) return;
    showing = true;
    var overlayState = Overlay.of(context);
    OverlayEntry overlayEntry;
    overlayEntry = OverlayEntry(builder: (context) {
      return DraggableWidget(
          text: "内部调试", offset: Offset(MediaQuery.of(context).size.width * 0.7, MediaQuery.of(context).size.height * 0.8), onTap: () {});
    });
    overlayState?.insert(overlayEntry);
  }
}
​

使用时则很简单:MyConsole.show(context);

这样就得到了一个能够在屏幕范围内来回拖动,并且带边界弹性回弹的全局悬浮窗了。

底部弹窗显示调试的主布局

打开底部弹出式主布局

显示主布局时小滑块消失,关闭主布局时小悬浮窗 重新显示。

使用flutter自带的用 Visiability 包裹悬浮窗, ValueNotify 来控制滑块是否可见.

ValueNotifier ifShowNotify = ValueNotifier<bool>(true);
​
  @override
  Widget build(BuildContext context) {
    return Visibility(
      visible: ifShowNotify.value,
      ...

支持TAB切换的主布局

主布局是一个独立的Widget,使用 TabBar 做头部,TabBarView做模块主体,TabController 进行两者的联动。这样就能实现一个安卓上类似ViewPager的效果。并且为了提高流畅度减轻内存开销,使用了 KeepAliveWrapper 来对已经初始化的子页面进行缓存,以免在滑动时重复创建。并且 TabBarView 与内部的可滑动组件(比如:SingleChildScrollView) 可能存在滑动冲突, 需要解决,最简单的方法就是禁用 TabBarView的滑动手势,禁用的方式如下:

TabBarView(
    physics: const NeverScrollableScrollPhysics(),// 禁用tab的滑动效果
    controller: _tabController,
    children: tabs.map<Widget>((e) {
      return KeepAliveWrapper(child: dispatchPage(e));
    }).toList()))

核心日志模块

日志的获取,使用插装的形式,从flutterSDK的底层获取日志信息,在运行时内存中临时缓存,并实时刷新在日志模块中。

参考了头条的Flutter_UME 调试插件的源码,发现他们插装的方式如下:

  static DebugPrintCallback? _originalDebugPrint;
​
  static redirectDebugPrint() {
    if (_originalDebugPrint != null) return;
    _originalDebugPrint = debugPrint;// 1. 暂存原始函数对象
    debugPrint = (String? message, {int? wrapWidth}) {
      ConsoleManager.streamController!.sink.add(message);//2. 在原函数中加入自己的逻辑,将打印出的日志用自定义的方式暂存起来
      if (_originalDebugPrint != null) {
        _originalDebugPrint!(message, wrapWidth: wrapWidth);// 3. 并且仍然执行老的逻辑
      }
    };
  }

而它插装的对象则是 foundation/ print.dart 内部的 DebugPrintCallback debugPrint = debugPrintThrottled; 函数对象。 插装分为三步走:

  1. 暂存原始函数对象
  2. 在原函数中加入自己的逻辑
  3. 并且仍然执行老的逻辑

dart的插装比起java的插装难度小了很多,java插装基于反射,动态代理等机制,在dart里面,只要是一个全局的函数对象,就能去进行hook,插入自己的逻辑

日志应该是存储在全局变量中,如果用 ChangeNotifier ,在窗口消失以后就不再能通知到UI,所以必须换一个思路,参照Flutter_Ume的做法,使用 StreamController进行多次监听,并在每次打开日志时都可以刷新到最新日志。

import 'dart:async';
import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:tuple/tuple.dart';
​
/// 日志的最大行数,超过行数的优先删掉早期进入的日志
const int maxLine = 100;
​
​
/// 这是一个全局的日志临时存储仓库
class ConsoleManager {
  /// 用队列承载日志数据
  static final Queue<Tuple2<DateTime, String>> _logData = Queue();
​
  // ignore: close_sinks
  /// stream的控制器
  static StreamController? _logStreamController;
​
  /// 开放给外界的队列数据
  static Queue<Tuple2<DateTime, String>> get logData => _logData; //
​
  /// 控制器也可以给外界控制
  static StreamController? get streamController => _getLogStreamController();
​
  /// 对debugPrint函数进行重新定义
  static DebugPrintCallback? _originalDebugPrint;
​
  /// 初始化控制器,保证只有一份
  static StreamController? _getLogStreamController() {
    if (_logStreamController == null) {
      _logStreamController = StreamController.broadcast();
      var transformer = StreamTransformer<dynamic, Tuple2<DateTime, String>>.fromHandlers(handleData: (str, sink) {
        final now = DateTime.now();
        if (str is String) {
          sink.add(Tuple2(now, str));
        } else {
          sink.add(Tuple2(now, str.toString()));
        }
      });
​
      _logStreamController!.stream.transform(transformer).listen((value) {
        if (_logData.length == maxLine) {
          _logData.removeLast();
        }
        _logData.addFirst(value);
      });
    }
    return _logStreamController;
  }
​
  /// 重写debugPrint函数,加入逻辑,写日志到临时内存中,不进行持久化
  static redirectDebugPrint() {
    if (_originalDebugPrint != null) return;
    _originalDebugPrint = debugPrint;
    debugPrint = (String? message, {int? wrapWidth}) {
      ConsoleManager.streamController!.sink.add(message);
      if (_originalDebugPrint != null) {
        _originalDebugPrint!(message, wrapWidth: wrapWidth);
      }
    };
  }
​
  /// 清空日志队列
  static clearLog() {
    logData.clear();
    _logStreamController!.add('UME CONSOLE == ClearLog');
  }
​
  /// 取消debugPrint的重定向,避免多余的内存消耗
  @visibleForTesting
  static clearRedirect() {
    debugPrint = _originalDebugPrint!;
    _originalDebugPrint = null;
  }
}

这一模块的两个重点,第一,如何无侵入式获取 debugPrint 的日志信息,第二,如何把全局的日志数据刷新到 日志模块并随时同步,前者用到了 dart插装,做法和js如出一辙。后者用到了 StreamController 进行日志对象的同步更新。

至于其他骚操作,比如,清空日志,停止日志的滚动,日志的过滤,那就见仁见智了。不做具体示范。

核心设备信息模块

引入 device_info 插件 提供的性能指标;CPU信息基于 system_info 插件,直接从Dart层读取系统基础信息。读出基础数据之后堆叠在ListView中。

import 'dart:io';
​
import 'package:flutter/material.dart';
import 'package:device_info/device_info.dart';
import 'package:system_info/system_info.dart';
import 'package:flutter/services.dart';
​
class DeviceInfoPage extends StatefulWidget {
  const DeviceInfoPage({Key? key}) : super(key: key);
​
  @override
  State<StatefulWidget> createState() {
    return _MyDeviceInfoPageState();
  }
}
​
class _MyDeviceInfoPageState extends State<DeviceInfoPage> {
  static final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
  Map<String, dynamic> _deviceData = <String, dynamic>{};
  final int MEGABYTE = 1024 * 1024;
​
  @override
  void initState() {
    super.initState();
    initPlatformState();
  }
​
  Future<void> initPlatformState() async {
    Map<String, dynamic> deviceData = <String, dynamic>{};
​
    try {
      if (Platform.isAndroid) {
        deviceData = _readAndroidBuildData(await deviceInfoPlugin.androidInfo);
      } else if (Platform.isIOS) {
        deviceData = _readIosDeviceInfo(await deviceInfoPlugin.iosInfo);
      }
    } on PlatformException {
      deviceData = <String, dynamic>{'Error:': 'Failed to get platform version.'};
    }
    deviceData['Kernel architecture'] = SysInfo.kernelArchitecture;
​
    deviceData['Kernel bitness'] = SysInfo.kernelBitness;
​
    deviceData['Kernel name             '] = SysInfo.kernelName;
    deviceData['Kernel version          '] = SysInfo.kernelVersion;
    deviceData['Operating system name   '] = SysInfo.operatingSystemName;
    deviceData['Operating system version'] = SysInfo.operatingSystemVersion;
    deviceData['User directory          '] = SysInfo.userDirectory;
    deviceData['User id                 '] = SysInfo.userId;
    deviceData['User name               '] = SysInfo.userName;
    deviceData['User space bitness      '] = SysInfo.userSpaceBitness;
    final processors = SysInfo.processors;
    deviceData['Number of processors    '] = processors.length;
​
    for (var processor in processors) {
      deviceData['  Architecture          '] = '${processor.architecture}';
      deviceData['  Name                  '] = processor.name;
      deviceData['  Socket                '] = '${processor.socket}';
      deviceData['  Vendor                '] = processor.vendor;
    }
​
    deviceData['Free physical memory    '] = '${SysInfo.getFreePhysicalMemory() ~/ MEGABYTE} MB';
    deviceData['Total virtual memory    '] = '${SysInfo.getTotalVirtualMemory() ~/ MEGABYTE} MB';
    deviceData['Free virtual memory     '] = '${SysInfo.getFreeVirtualMemory() ~/ MEGABYTE} MB';
    deviceData['Virtual memory size     '] = '${SysInfo.getVirtualMemorySize() ~/ MEGABYTE} MB';
​
    if (!mounted) return;
​
    setState(() {
      _deviceData = deviceData;
    });
  }
​
  Map<String, dynamic> _readAndroidBuildData(AndroidDeviceInfo build) {
    return <String, dynamic>{
      'version.securityPatch': build.version.securityPatch,
      'version.sdkInt': build.version.sdkInt,
      'version.release': build.version.release,
      'version.previewSdkInt': build.version.previewSdkInt,
      'version.incremental': build.version.incremental,
      'version.codename': build.version.codename,
      'version.baseOS': build.version.baseOS,
      'board': build.board,
      'bootloader': build.bootloader,
      'brand': build.brand,
      'device': build.device,
      'display': build.display,
      'fingerprint': build.fingerprint,
      'hardware': build.hardware,
      'host': build.host,
      'id': build.id,
      'manufacturer': build.manufacturer,
      'model': build.model,
      'product': build.product,
      'supported32BitAbis': build.supported32BitAbis,
      'supported64BitAbis': build.supported64BitAbis,
      'supportedAbis': build.supportedAbis,
      'tags': build.tags,
      'type': build.type,
      'isPhysicalDevice': build.isPhysicalDevice,
      'androidId': build.androidId,
      'systemFeatures': build.systemFeatures
    };
  }
​
  Map<String, dynamic> _readIosDeviceInfo(IosDeviceInfo data) {
    return <String, dynamic>{
      'name': data.name,
      'systemName': data.systemName,
      'systemVersion': data.systemVersion,
      'model': data.model,
      'localizedModel': data.localizedModel,
      'identifierForVendor': data.identifierForVendor,
      'isPhysicalDevice': data.isPhysicalDevice,
      'utsname.sysname:': data.utsname.sysname,
      'utsname.nodename:': data.utsname.nodename,
      'utsname.release:': data.utsname.release,
      'utsname.version:': data.utsname.version,
      'utsname.machine:': data.utsname.machine
    };
  }
​
  @override
  Widget build(BuildContext context) {
    return Container(
        padding: const EdgeInsets.fromLTRB(0, 0, 10, 10),
        color: const Color(0xffcccccc),
        child: ListView(
          children: _deviceData.keys.map((String key) {
            return Row(children: <Widget>[
              Container(padding: const EdgeInsets.fromLTRB(10, 10, 10, 0), child: Text(key, style: const TextStyle(fontWeight: FontWeight.bold))),
              Expanded(
                  child: Container(
                      padding: const EdgeInsets.fromLTRB(0.0, 10.0, 0.0, 0.0),
                      child: Text('${_deviceData[key]}', maxLines: 10, overflow: TextOverflow.ellipsis)))
            ]);
          }).toList(),
        ),
        alignment: Alignment.topLeft);
  }
}

核心网络监控模块

官方的网络库:Dio 开放了一个拦截器机制interceptors,可以添加多个网络拦截器, dio.interceptors.add(XXXDioInterceptor());

XXXDioInterceptor 的实现如下:

class XXXDioInterceptor extends Interceptor {
    
    /// 重写请求发出之前的逻辑
  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    options.extra[DIO_EXTRA_START_TIME] = _timestamp;// 传入一个时间值,以计算本次请求的时长
    handler.next(options);
  }
​
    /// 重写收到响应之后的逻辑
  @override
  void onResponse(
    Response<dynamic> response,
    ResponseInterceptorHandler handler,
  ) {
    response.requestOptions.extra[DIO_EXTRA_END_TIME] = _timestamp;// 记录结束的时间
    InspectorInstance.httpContainer.addRequest(response);// 将本次请求加入 全局请求列表
    handler.next(response);
  }
​
    /// 重写请求报错时的逻辑
  @override
  void onError(DioError err, ErrorInterceptorHandler handler) {
    // Create an empty response with the [RequestOptions] for delivery.
    err.response ??= Response<dynamic>(requestOptions: err.requestOptions);
    err.response!.requestOptions.extra[DIO_EXTRA_END_TIME] = _timestamp;
    InspectorInstance.httpContainer.addRequest(err.response!);// 请求报错也要加入 全局请求列表
    handler.next(err);
  }
}

上面重写了 网络请求 的 发出之后,响应来到之后,以及请求报错之后的逻辑。

在请求发出之后,记录一个时间点,响应来到之时再记录一个时间点,来计算时间差,即为本次请求消耗的时间。并且响应到来以及请求报错之后,拿到响应,或者错误,加入到全局的请求列表中。

InspectorInstance.httpContainer.addRequest(response);// 保存本次的网络请求
InspectorInstance.httpContainer.addRequest(err.response!);// 请求报错也要加入 全局请求列表

InspectorInstance.httpContainer是全局的保存网络请求的核心类,

class InspectorInstance {
  const InspectorInstance._();
​
  static final HttpContainer httpContainer = HttpContainer();// 保证单例 (好家伙,居然还有这种写法)
}
class HttpContainer extends ChangeNotifier {
  /// Store all responses.
  List<Response<dynamic>> get requests => _requests;
  final List<Response<dynamic>> _requests = <Response<dynamic>>[];// 用list保存所有的response
​
  /// Paging fields.
  int get page => _page;
  int _page = 1;
  final int _perPage = 10;
​
  /// Return requests according to the paging.
  List<Response<dynamic>> get pagedRequests {
    return _requests.sublist(0, math.min(page * _perPage, _requests.length));
  }
​
  bool get _hasNextPage => _page * _perPage < _requests.length;
​
  void addRequest(Response<dynamic> response) {
    _requests.insert(0, response);
    notifyListeners();
  }
​
  void loadNextPage() {
    if (!_hasNextPage) {
      return;
    }
    _page++;
    notifyListeners();
  }
​
  void resetPaging() {
    _page = 1;
    notifyListeners();
  }
​
  void clearRequests() {
    _requests.clear();
    _page = 1;
    notifyListeners();
  }
​
  @override
  void dispose() {
    _requests.clear();
    super.dispose();
  }
}
​

Flutter_ume的源代码给我们演示了 除了上面日志模块用到StreamController之外的另外一个 绑定全局list的方法,用上述方式保证list所在的对象唯一,然后在UI层 添加监听:

InspectorInstance.httpContainer.addListener(_listener);//给ChangeNotifier添加监听器
​
  void _listener() {
      setState(() {});// 刷新状态
  }

然后把 唯一一个list对象当作UI层所需的数据源:

Widget _itemList(BuildContext context) {
    final List<Response<dynamic>> requests = InspectorInstance.httpContainer.pagedRequests;// 数据源
    ...
    return Container(...);
}

总结

上面这些已经足够复刻一份Flutter内部调试工具了。头条给的最多提供思路,整个搬过来还是有点水土不服,Flutter_ume的博文地址是:blog.csdn.net/flutterdevs…

后期做的时候可以在这里寻找思路。