美文网首页Flutter
Flutter桌面版Json解析工具设计

Flutter桌面版Json解析工具设计

作者: super可乐 | 来源:发表于2022-09-07 10:54 被阅读0次

    引子

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

    效果展示

    下图为静态图:

    界面参照了 市面上的 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上特有操作,光标悬停在组件上方时,有时候需要显示一些提示:

    我们需要自定义 鼠标悬停时显示浮层,鼠标离开时 浮层消失的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读取文件内容

    一键读取本地文件

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

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

    一键拷贝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语法错误检查

    当发生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(同样支持风格)

    展现的效果如下:

    子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:

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

    效果如下:

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

    总结

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

    原因如下:

    1. PC端很常见的多窗口模式,就像某IM的PC端:登录的小窗,接上 主界面大窗,单独私聊的小窗。
    目前Flutter没有找到这种效果的官方支持。
    

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

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

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

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

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

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

    参考代码

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

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

    作者:拳布离手
    链接:https://juejin.cn/post/7140178485275787272

    相关文章

      网友评论

        本文标题:Flutter桌面版Json解析工具设计

        本文链接:https://www.haomeiwen.com/subject/fkhgnrtx.html