Flutter桌面版Json解析工具设计

2,047 阅读8分钟

引子

前后端数据交互多用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

效果展示

下图为静态图:

image-20220903145230781.png 界面参照了 市面上的 json转化工具经过优化调整改造而成:

  1. 左上角为原始文件输入框
  2. 左下方为json格式化并且高亮显示区域
  3. 右侧输出框为 生成dart文件内容
  4. 中间部分操作按钮

功能架构

以上4个区域,包含的所有功能点一览:

  1. PC风格窗口管理

    • 定制操作窗口的可缩放最大最小尺寸
    • 完全自定义的窗口样式(包括最小化,最大化,关闭按钮的自定义,边框的自定义,头部支持拖动)
    • 鼠标悬停时的提示框
  2. 导入/导出 PC文件

    • 一键读取网络文件
    • 一键读取本地文件
    • 一键拷贝dart文件内容
    • 读取拖拽文件内容
    • 导出dart文件到本地
  3. Json格式化与高亮

    • json语法错误检查
    • 缩进和换行的格式化
    • json部分字段高亮显示
  4. Json转化为 Dart文件

    • json递归遍历生成多个dart类
    • dart类的部分代码高亮显示

相比于网页版的jsonToDart,本工具修复了jsonToDart的语法lint警告,并且支持自定义生成dart函数,其余功能与jsonToDart一样。

下文将分章节讲述功能的实现。

PC风格窗口管理

PC与移动端的操作习惯完全不同,最明显的一点就是窗口管理,移动端通常都是全屏应用不可缩放,而 PC端,多为指定一个最小宽高保证UI正常显示,同时支持缩放到全屏幕,通常右上角还会存在最小化,最大化和关闭按钮的工具栏。

Flutter在PC开发上的生态近期还算完善,关于PC风格界面管理的库,应用比较广泛的是 window_managerbitsdojo_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();
  });
}

定制边框样式

下面代码中的 WindowBorderbitsdojo_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个操作按钮 MinimizeWindowButtonMaximizeWindowButtonCloseWindowButtonbitsdojo_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上特有操作,光标悬停在组件上方时,有时候需要显示一些提示:

image-20220903153258169.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;
  }
}
​

以上代码关注几个要素:

  1. InkWell

    onHover函数可以响应鼠标悬停以及离开的事件,但是要特别注意一个坑,使用 onHover 之后,onTab函数必需不为空,否则 onHover也无效。原因不明。

  2. Overlay

    Overlay是Flutter中的浮层组件,支持窗口多级分层。悬浮组件用Overlay刚刚好。

  3. 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读取文件内容

image-20220903155756545.png

一键读取本地文件

引入 file_picker: ^4.4.0,使用方法pickFiles选择本地文件:

FilePickerResult? value = await FilePicker.platform.pickFiles();

image-20220903155813798.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语法错误检查

image-20220903161415099.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(同样支持风格)

展现的效果如下:

image-20220906144850052.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:

image-20220906145538549.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上即可。

效果如下:

image-20220906151950966.png

完整代码请关注文末,代码可运行。

总结

写完一套工具下来,最大的感受就是,用Flutter写出来的PC应用,严格来说不是传统意义上的PC应用,而是 移动应用在PC终端上展示。

原因如下:

  1. PC端很常见的多窗口模式,就像某IM的PC端:登录的小窗,接上 主界面大窗,单独私聊的小窗。

image-20220906152526028.png

目前Flutter没有找到这种效果的官方支持。

2. PC上还有把应用隐藏到右下角小图标的操作,也没有找到官方支持。

image-20220906152725419.png

  1. 在登录时,我们通常会用PC的键盘回车,来替代鼠标点击登录按钮,Flutter官方也尚不支持。

Flutter目前已知能够支持的PC应用的特性包括但不限于:

  1. 鼠标放置的效果:hover ,当鼠标光标放置在组件上时,需要显示 浮动组件。

image-20220906153117036.png

  1. 本地文件选择

image-20220906153136187.png 3. PC端的安装过程。通常PC上的软件有两种方式可以安装,一个是绿色免安装包,拷贝进来直接就能用,一个是安装包,双击解压,安装到磁盘指定目录。

image-20220330150350046.png

上面是官网说明,确实是支持,不过目前本人还未尝试过。

参考代码

完整的参考代码在 github.com/18598925736…

有关Flutter PC端开发的问题欢迎留言讨论。