设计概要
app的开发上线,需要产研测三方共同努力。而 成熟的产品线需要完整的基础设施链路作为支撑,让问题的发现变得简单。Flutter应用内调试组件,是 基于Flutter悬浮窗的调试工具,目的是在脱离编译器的前提下,提供应用内关键信息的查看的功能。
功能拆解
-
设备信息
使用 device_info 插件 提供的性能指标;CPU信息基于 system_info 插件,直接从Dart层读取系统基础信息。
-
日志读取
通过重定向
foundation
包的debugPrint
函数,实现对日志输出函数的插装处理,并记录日志输出时间等额外信息,通过对外统一的面板提供筛选,导出等功能。 -
网络请求监控
通过 dio 库自带的拦截器机制,添加若干个 interceptor的实现,抓取所有网络请求的 域名,路径,请求头,请求体,响应头,响应体等数据。
-
自定义插件
基础组件提供的功能主要是以上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;
函数对象。 插装分为三步走:
- 暂存原始函数对象
- 在原函数中加入自己的逻辑
- 并且仍然执行老的逻辑
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…
后期做的时候可以在这里寻找思路。