引子
前后端数据交互多用Json,比较好用的 json解析工具或者框架,比如:web版本的 jsonToDart,IDE版本的 jsonToDartBeanAction,基本都能满足常规需求,但是在某些特殊的项目场景之下,比如,我们自建了一套dart版的网络集成框架,其中需要一个 单独的 静态decode函数,用于将map直接转成 对象。如果仍然用 jsonToDart那么每一次生成的json都需要手动创建decode方法,比较麻烦。
Flutter在桌面端的落地,让移动开发者定制自己的JSON工具成为可能,支持所有的PC端,包括前端开发常用的windows,mac。
主要内容为:从0开始构建一个PC下的FlutterJSON解析工具的全过程,将JSON转化工具 必需的功能,开发中遇到的问题,对应的解决方案,以及 Flutter的PC端生态现状 通过图文展示出来。
本案例在windows平台下进行试验,使用flutterSDK版本为 3.0.1 。
效果展示
下图为静态图:
![](https://img.haomeiwen.com/i27208369/1188dfbce3b5751f.png)
界面参照了 市面上的 json转化工具经过优化调整改造而成:
- 左上角为原始文件输入框
- 左下方为json格式化并且高亮显示区域
- 右侧输出框为 生成dart文件内容
- 中间部分操作按钮
功能架构
以上4个区域,包含的所有功能点一览:
-
PC风格窗口管理
- 定制操作窗口的可缩放最大最小尺寸
- 完全自定义的窗口样式(包括最小化,最大化,关闭按钮的自定义,边框的自定义,头部支持拖动)
- 鼠标悬停时的提示框
-
导入/导出 PC文件
- 一键读取网络文件
- 一键读取本地文件
- 一键拷贝dart文件内容
- 读取拖拽文件内容
- 导出dart文件到本地
-
Json格式化与高亮
- json语法错误检查
- 缩进和换行的格式化
- json部分字段高亮显示
-
Json转化为 Dart文件
- json递归遍历生成多个dart类
- dart类的部分代码高亮显示
相比于网页版的jsonToDart,本工具修复了jsonToDart的语法lint警告,并且支持自定义生成dart函数,其余功能与jsonToDart一样。
下文将分章节讲述功能的实现。
PC风格窗口管理
PC与移动端的操作习惯完全不同,最明显的一点就是窗口管理,移动端通常都是全屏应用不可缩放,而 PC端,多为指定一个最小宽高保证UI正常显示,同时支持缩放到全屏幕,通常右上角还会存在最小化,最大化和关闭按钮的工具栏。
Flutter在PC开发上的生态近期还算完善,关于PC风格界面管理的库,应用比较广泛的是 window_manager 和 bitsdojo_window,两者不相上下,对比了一下使用难度,发现 后者不仅支持 窗口拖拽,而且工具栏还支持完全自定义,通用性相对较好,而前者没有发现相关资料,于是本案例选择了后者。
使用方式如下:
引入依赖库
bitsdojo_window: ^0.1.1
特别注意
使用 bitsdojo_window 之后必须修改 widows目录下的 main.cpp文件 ,引入一个头文件以及一行代码, 否则自定义窗口会失效。
#include <bitsdojo_window_windows/bitsdojo_window_plugin.h>
auto bdw = bitsdojo_window_configure(BDW_CUSTOM_FRAME | BDW_HIDE_ON_STARTUP);
定制窗口尺寸
void main() {
runApp(const MyApp());
doWhenWindowReady(() {
const initialSize = Size(1220, 600);// 设定初始值
appWindow.minSize = initialSize; // 缩放时不能小于这个值
appWindow.size = initialSize;// 打开应用时的默认大小
appWindow.alignment = Alignment.center;// 窗口对齐方式
appWindow.show();
});
}
定制边框样式
下面代码中的 WindowBorder 是 bitsdojo_window库提供的边框。仅支持 边框的颜色和厚度。本来我预想是否可以支持到边框的形状圆角,尝试了一番发现并不支持,即使我修改 源代码也无法做到。猜测可能是PC生态中禁止了这一行为。
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return OKToast(
child: MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Flutter Demo',
theme: ThemeData(primarySwatch: Colors.blueGrey),
home: Scaffold(
body: WindowBorder(color: Colors.blueGrey, width: 2, child: Column(children: const [WindowTopBox(), MainPage()])),
),
));
}
}
定制最小化,最大化,关闭的操作栏
以下代码关注两个点:第一是 MoveWindow,flutter打出pc包时,会默认带上对应系统自带的窗口头部,包括标题以及3个操作按钮,并支持窗口在非全屏时的拖动。而 bitsdojo_window首先是禁用了 系统默认的头部,然后提供了 MoveWindow 提供拖动效果。
第二,是 3个操作按钮 MinimizeWindowButton, MaximizeWindowButton,CloseWindowButton 由 bitsdojo_window 提供默认样式,如果不喜欢 原来的样式,还可以 自己做一个组件,并且使用 appWindow.appWindow.minimize()
的操作函数进行完全化的自定义。
Color _mainColor = Colors.blueGrey;
/// 顶部操作按钮
class WindowTopBox extends StatelessWidget {
const WindowTopBox({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
Widget current;
current = WindowTitleBarBox(
child: Row(children: [
Expanded(
child: Container(
color: _mainColor,
child: MoveWindow(
child: Row(children: const [
SizedBox(width: 20),
Text(
'Json解析工具',
style: TextStyle(fontWeight: FontWeight.w700, color: Colors.white),
)
])))),
_WindowButtons()
]));
return current;
}
}
class _WindowButtons extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Row(children: [
MinimizeWindowButton(),
MaximizeWindowButton(),
CloseWindowButton(),
]);
}
}
鼠标悬停时的提示框
PC上特有操作,光标悬停在组件上方时,有时候需要显示一些提示:
![](https://img.haomeiwen.com/i27208369/bbb45dc9846eb46a.png)
我们需要自定义 鼠标悬停时显示浮层,鼠标离开时 浮层消失的Widget。参考代码如下:
import 'package:flutter/material.dart';
/// 鼠标放置上去会显示提示框的组件,底层显示的子组件和 弹窗显示的组件都必传
/// 提示框的位置会跟随组件位置而变化
class HoverEventWidget extends StatefulWidget {
final Widget showChild; // 原组件
final Widget floatWidget; // 浮层组件
final bool showDown; // 是否显示在原组件的下方
const HoverEventWidget({Key? key, required this.showChild, required this.floatWidget, required this.showDown}) : super(key: key);
@override
State<StatefulWidget> createState() {
return HoverEventWidgetState();
}
}
class HoverEventWidgetState extends State<HoverEventWidget> {
bool showTipBool = false; // true 窗口已弹出,false窗口未弹出
OverlayEntry? overlay;
final GlobalKey _keyGreen = GlobalKey();
@override
Widget build(BuildContext context) {
return InkWell(
key: _keyGreen,
hoverColor: Colors.white,
highlightColor: Colors.white,
splashColor: Colors.white,
onHover: (bool value) {
if (value == true) {
showTipWidget(context);
} else {
dismissDialog();
}
},
onTap: () {},
child: widget.showChild,
);
}
void dismissDialog() {
overlay?.remove();
showTipBool = false;
}
/// 让这个方法支持多次调用,如果已经显示了,再次调用显示,则不与反应
void showTipWidget(BuildContext context) {
if (showTipBool) {
return;
}
showTipBool = true;
final RenderBox box = _keyGreen.currentContext?.findRenderObject() as RenderBox;
Offset offset = box.localToGlobal(Offset.zero);
OverlayEntry overlay = OverlayEntry(builder: (_) {
return Positioned(
left: offset.dx,
top: widget.showDown ? offset.dy + 30 : offset.dy - box.size.height - 30,
child: widget.floatWidget,
);
});
Overlay.of(context)?.insert(overlay);
this.overlay = overlay;
}
}
以上代码关注几个要素:
-
InkWell
onHover函数可以响应鼠标悬停以及离开的事件,但是要特别注意一个坑,使用 onHover 之后,onTab函数必需不为空,否则 onHover也无效。原因不明。
-
Overlay
Overlay是Flutter中的浮层组件,支持窗口多级分层。悬浮组件用Overlay刚刚好。
-
GlobalKey
显示悬浮组件时存在一个位置问题,我们往往想悬浮层显示在组件的附近,比如上方和下方,但是前提是我们必须要能够获得组件的位置和大小。当我们用 一个 GlobalKey 标记了一个widget之后,就能采用
final RenderBox box = _keyGreen.currentContext?.findRenderObject() as RenderBox;
获取组件在运行时的宽高(
box.size.height
) 位置 (Offset offset = box.localToGlobal(Offset.zero);
)。
导入/导出 PC文件
一键读取网络文件
引入 dio: ^4.0.6
, 弹窗要求输入 网络文件地址,使用 dio读取文件内容
![](https://img.haomeiwen.com/i27208369/29a9cb97d73bc3f6.png)
一键读取本地文件
引入 file_picker: ^4.4.0
,使用方法pickFiles选择本地文件:
FilePickerResult? value = await FilePicker.platform.pickFiles();
![](https://img.haomeiwen.com/i27208369/e6a7383947f1f53c.png)
一键拷贝dart文件内容
Flutter自带的 Clipboard 可以直接管理剪切板,无需引入其他依赖库。
Clipboard.setData(ClipboardData(text: widget.textContent));
读取拖拽文件内容
引入 desktop_drop: ^0.3.3
使用 DropTarget 组件包裹原来的主布局,并且实现几个关键函数即可。
@override
Widget build(BuildContext context) {
return Expanded(
child: DropTarget(
onDragDone: (detail) { // 拖拽完成
setState(() {
_list.clear();
// 只能接收一个文件的拖拽
if (detail.files.length > 1) {
showToast('只能同时解析一个文件');
} else {
_list.addAll(detail.files);
readDraggedFile(_list[0]);
}
});
},
onDragEntered: (detail) { // 拖拽进入
setState(() {
_dragging = true;
});
},
onDragExited: (detail) { // 拖拽离开
setState(() {
_dragging = false;
});
},
child: Container(// 主布局
color: Colors.grey.shade200,
padding: const EdgeInsets.all(10.5),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
buildLeft(),
buildMid(),
buildRight(),
]),
),
),
);
}
导出dart文件到本地
先使用上面的FilePicker选择本地文件夹,然后用File直接写入文件即可。
ElevatedButton(
onPressed: () async {
String? path = await FilePicker.platform.getDirectoryPath();// 选择目录
if (path == null) {
return;
}
File f = File('$path/${widget.clzName}.dart');
f.writeAsString(widget.textContent);
showToast('生成桌面文件成功 ${f.path}');
},
child: const Text('导出文件到'))
小结
PC上的文件操作我们完全无需关心实现方式,可见在这方面Flutter的生态还是比较完善的。但是MAC上可能涉及到 文件权限,有另外的编码成本。
Json格式化与高亮
json语法错误检查
![](https://img.haomeiwen.com/i27208369/38e60868ac759f28.png)
当发生json的语法性错误时,我们需要将错误展示给用户。实现的方式比想象中简单,其实我们只需要尝试对 json进行 jsonDecode即可。
我们提供一个 String的扩展函数:
extension JSONHelper on String? {
String get jsJSON {
if (this == null) return 'ERROR: 内容为空';
try {
jsonDecode(this ?? '');
} catch (e) {
return 'ERROR: $e';
}
return '';
}
}
其中,使用 jsonDecode进行解析,它解析的结果可能是一个Map对象,或者一个List对象,如果解析中出现异常,通过try catch可以捕获,我们直接将异常抛出即可。
缩进和换行的格式化
通过上面的语法检查之后,json要经过格式化才能更方便阅读。
参考代码如下:
这是一个递归函数,三个入参的意义分别为:
-
object
将要转化的对象,可能的类型包括: String,num,bool,Map以及List。其中比较复杂的场景,为 map和list相互嵌套的情况。比如,list的元素类型就是Map,或者Map中某个属性值为 List类型。这时候就会涉及到递归调用,经过多次缩进来分化层级。
简单类型,则只需要将String,num,或者bool的值,填补在key后面即可。
最后,如果后端给的某个key对应的value是null,同样我们也用null作为兜底。
-
deep
当前层级,每一次递归,层级+1,文字缩进也多一层
-
isObject
第一个参数object是否来自属性值(最外层的对象首行不需要缩进,而内层对象则需要缩进)。
String convert(dynamic object, int deep, {bool isObject = false}) {
var buffer = StringBuffer();
var nextDeep = deep + 1;
if (object is String) {
//为字符串时,需要添加双引号并返回当前内容
buffer.write(""$object"");
return buffer.toString();
}
if (object is num || object is bool) {
//为数字或者布尔值时,返回当前内容
buffer.write(object);
return buffer.toString();
}
if (object is Map) {
var list = object.keys.toList();
if (isObject) {
buffer.write(space1());
}
buffer.write("{");
if (list.isEmpty) {
//当map为空,直接返回‘}’
buffer.write("}");
} else {
buffer.write("\n");
for (int i = 0; i < list.length; i++) {
buffer.write("${getDeepSpace(nextDeep)}"${list[i]}":");
buffer.write(convert(object[list[i]], nextDeep, isObject: true));
if (i < list.length - 1) {
buffer.write(",");
buffer.write("\n");
}
}
buffer.write("\n");
buffer.write("${getDeepSpace(deep)}}");
}
return buffer.toString();
}
if (object is List) {
if (isObject) {
buffer.write(space1());
}
buffer.write("[");
if (object.isEmpty) {
//当list为空,直接返回‘]’
buffer.write("]");
} else {
buffer.write("\n");
for (int i = 0; i < object.length; i++) {
buffer.write(getDeepSpace(nextDeep));
buffer.write(convert(object[i], nextDeep));
if (i < object.length - 1) {
buffer.write(",");
buffer.write("\n");
}
}
buffer.write("\n");
buffer.write("${getDeepSpace(deep)}]");
}
return buffer.toString();
}
//如果对象为空,则返回null字符串
buffer.write("null");
return buffer.toString();
}
json部分字段高亮显示
json除了要格式化之外,为了清晰地看到字段地层级结构,最好是能够用颜色区分key和value,以及不同类型的value使用不同的颜色。
在dart中,textSpan这个概念可以支持 同一个 文本对象的各个部分拥有不同的风格。它的使用方式大概如下:
Text.rich(TextSpan(text: '自身的文案内容和风格',style: TextStyle(color:Colors.lime),children: [
TextSpan(text: '子span',style: TextStyle(color:Colors.red))
])),
主要属性为:
text,style : 自身的文案内容和风格。
children: 子span(同样支持风格)
展现的效果如下:
![](https://img.haomeiwen.com/i27208369/dc2849f077629a4f.png)
子span会跟随在自身内容之后。所以如果我们需要拼接的话,就只需要将 要拼接的内容放在children中。
可以使用flutter支持的 运算符重载的 特性,让 拼接上写法大大简化。
extension TextSpanHelper on TextSpan {
TextSpan operator +(TextSpan textSpan) {
return TextSpan(children: [this, textSpan]);
}
}
完整的参考代码如下:
同样是递归函数,递归仅发生在object类型为map和list的时候。所有入参和上一小节一样。
TextSpan getFormattedJsonSpan(dynamic object, int deep, {bool isObject = false}) {
TextSpan box = const TextSpan();
var nextDeep = deep + 1; // 每次递归,层级都会+1
if (object is Map) {
if (object.isEmpty) {
box += TextSpan(text: ' { }', style: getTextStyleByColor(color: Colors.black));
return box;
}
if (isObject) {
box += TextSpan(text: space1());
}
box += TextSpan(text: '{\n', style: getTextStyleByColor(color: Colors.black));
List list = object.keys.toList();
for (int i = 0; i < list.length; i++) {
var k = list[i];
var v = object[k];
box += TextSpan(text: getDeepSpace(nextDeep));
box += TextSpan(text: '"$k"', style: getTextStyleByColor(color: Colors.lightGreen));
box += TextSpan(text: ':', style: getTextStyleByColor(color: Colors.black));
box += getFormattedJsonSpan(v, nextDeep, isObject: true);
if (i < list.length - 1) {
box += TextSpan(text: ',\n', style: getTextStyleByColor(color: Colors.black));
}
}
if (isObject) {
box += TextSpan(text: '\n${getDeepSpace(nextDeep - 1)}}', style: getTextStyleByColor(color: Colors.black));
} else {
box += TextSpan(text: '\n}', style: getTextStyleByColor(color: Colors.black));
}
return box;
}
if (object is List) {
if (object.isEmpty) {
box += TextSpan(text: ' [ ]', style: getTextStyleByColor(color: Colors.black));
return box;
}
if (isObject) {
box += TextSpan(text: space1());
}
box += TextSpan(text: '[\n', style: getTextStyleByColor(color: Colors.black));
for (int i = 0; i < object.length; i++) {
box += TextSpan(text: getDeepSpace(nextDeep));
box += getFormattedJsonSpan(object[i], nextDeep, isObject: true);
if (i < object.length - 1) {
box += TextSpan(text: ',\n', style: getTextStyleByColor(color: Colors.black));
}
}
if (isObject) {
box += TextSpan(text: '\n${getDeepSpace(nextDeep - 1)}}', style: getTextStyleByColor(color: Colors.black));
} else {
box += const TextSpan(text: '\n}', style: TextStyle(color: Colors.black));
}
return box;
}
if (object is String) {
//为字符串时,需要添加双引号并返回当前内容
box += TextSpan(text: ' "$object"', style: getTextStyleByColor(color: Colors.blue));
return box;
}
// num下就只有int和double
if (object is num) {
box += TextSpan(text: ' $object', style: getTextStyleByColor(color: Colors.redAccent));
return box;
}
if (object is bool) {
box += TextSpan(text: ' $object', style: getTextStyleByColor(color: Colors.lightGreen));
return box;
}
// num下就只有int和double
box += TextSpan(text: 'null', style: getTextStyleByColor(color: Colors.cyan));
return box;
}
经过这个函数的处理,我们就得到了高亮之后的json:
![](https://img.haomeiwen.com/i27208369/42c9c1cf25a3dd7f.png)
Json转化为 Dart文件
一个用于业务开发的entity类,通常包含如下部分,
- 成员变量区
- 构造函数区
- FromJson函数区
- toJson函数区
- 自定义函数区
上面4个是通用的,而自定义函数区是在使用方有特别要求时,可以按要求加入特殊的代码进去。此时我需要一个decode函数用于第三方网络框架去使用。所以 这里的自定义函数就是decode。
生成的dart文件大概用作两类,第一:通过文本的方式拷贝,或者导出到PC本地,第二,现场阅读。前者必须是 字符串的形式导出,而后者,为了阅读的方便清晰,同样需要通过textSpan的高亮效果将重要的环节醒目处理。
json递归遍历生成多个dart类
核心函数的脉络如下:
static String trans(Map map, {required String className}) {
StringBuffer sb = StringBuffer();
try {
// 类头
sb.writeln('class $className {\n');
// 成员属性区
FieldParserResult fieldParserResult = _fieldArea(map);
sb.writeln(fieldParserResult.fields);
// 构造函数区
sb.writeln(_constructorFunctionArea(map, className));
// fromJson函数
sb.writeln(_fromJsonFunctionArea(map,className));
// toJson函数区
sb.writeln(_toJsonFunctionArea(map));
// decode函数
sb.writeln(_decodeFunctionArea(map,className));
sb.writeln('}\n\n');
// 生成相关实体类
for (var e in fieldParserResult.clzs) {
sb.writeln(e);
}
} catch (e) {
rethrow;
}
return sb.toString();
}
json类的生成效果参考了比较权威的 jsonToDart网站,但是它生成的类有一些语法警告,顺手修复了一些警告之后,完成了这一步的转化工作。
必须注意的是,json转dart,要考虑生成多级 实体类的情况,如果一个key对应的value是复杂类型Map时, 或者 value是List,并且list的泛型是 Map时,即 如下两种情况 :
{
"m": {
"a":1
},
"m2": [
{
"a":1
}
]
}
所以一个完整的jsonToDart函数,一定是一个递归函数,递归的过程,发生在 解析 类属性的过程中,即上方的 _fieldArea 函数。
dart类的部分代码高亮显示
思路同上一届类似,只不过把String替换成 TextSpan。
static TextSpan trans(Map map, {required String className, required bool needDecodeFunction}) {
TextSpan ts = const TextSpan();
try {
// 类头
ts += const TextSpan(text: 'class ');
ts += TextSpan(text: className, style: classNameStyle);
ts += const TextSpan(text: ' {\n');
// 成员属性区
FieldParserTextSpanResult fieldParserResult = _fieldArea(map, needDecodeFunction);
ts += fieldParserResult.fields;
// 构造函数区
ts += _constructorFunctionArea(map, className);
// fromJson函数
ts += _fromJsonFunctionArea(map, className);
// toJson函数区
ts += _toJsonFunctionArea(map);
if (needDecodeFunction) {
// decode函数
ts += _decodeFunctionArea(map, className);
}
ts += const TextSpan(text: '\n}\n\n');
// 生成相关实体类
for (var e in fieldParserResult.clzs) {
ts += e;
}
} catch (e) {
rethrow;
}
return ts;
}
最终生成的 TextSpan对象通过 Text.rich填充到UI上即可。
效果如下:
![](https://img.haomeiwen.com/i27208369/6295e5a34e90df09.png)
完整代码请关注文末,代码可运行。
总结
写完一套工具下来,最大的感受就是,用Flutter写出来的PC应用,严格来说不是传统意义上的PC应用,而是 移动应用在PC终端上展示。
原因如下:
- PC端很常见的多窗口模式,就像某IM的PC端:登录的小窗,接上 主界面大窗,单独私聊的小窗。
![](https://img.haomeiwen.com/i27208369/595d8e962c20f070.png)
目前Flutter没有找到这种效果的官方支持。
2. PC上还有把应用隐藏到右下角小图标的操作,也没有找到官方支持。
![](https://img.haomeiwen.com/i27208369/87d8f43cd5c4caee.png)
- 在登录时,我们通常会用PC的键盘回车,来替代鼠标点击登录按钮,Flutter官方也尚不支持。
Flutter目前已知能够支持的PC应用的特性包括但不限于:
- 鼠标放置的效果:hover ,当鼠标光标放置在组件上时,需要显示 浮动组件。
![](https://img.haomeiwen.com/i27208369/5ae3b3814338563b.png)
- 本地文件选择
![](https://img.haomeiwen.com/i27208369/b50b037c1224263c.png)
3. PC端的安装过程。通常PC上的软件有两种方式可以安装,一个是绿色免安装包,拷贝进来直接就能用,一个是安装包,双击解压,安装到磁盘指定目录。
![](https://img.haomeiwen.com/i27208369/6e0da6764650a467.png)
上面是官网说明,确实是支持,不过目前本人还未尝试过。
参考代码
完整的参考代码在 github.com/18598925736…
有关Flutter PC端开发的问题欢迎留言讨论。
作者:拳布离手
链接:https://juejin.cn/post/7140178485275787272
网友评论