Flutter 糖果群主跑了!?

20,208

前言

如果有一天,我跑路了,肯定是因为更新不动这 2 个组件。

三方官方
ExtendedTextText
ExtendedTextFieldTextField

起源

支持图文混排和自定义溢出效果的富文本

截屏2023-05-16 10.32.15.png

思绪被一下子拉回了四年前,当时的 Text 不支持图文混合富文本(那时候还没有 WidgetSpan ),不支持自定义文本溢出的效果(滑鸡,现在都还没有支持),不支持选择。有需求就有动力,很快就做出了第一版的 ExtendedText

image.png

支持图文混合的输入框

在搞定了文本之后,根据相同的原理,又增加输入框的图文混合功能。

image.png

支持选择的富文本

再根据输入框的原理,给富文本增加了选择功能。

DM_20230516105856_001.webp

自定义输入框选择菜单和选择器

当时还没有 TextSelectionControls,没法自定义菜单和选择器。

image.png

自定义文本溢出位置

Add TextOverflow "ellipsisStart" , "ellipsisMiddle" and "ellipsisEnd" · Issue #45336 · flutter/flutter (github.com)

增加指定文本溢出(省略号)的位置

image.png

老架构

功能对比

Flutter 目前稳定版本 3.10.0 以及老架构下面的功能对比:

功能ExtendedTextText说明
富文本图文混合使用 WidgetSpan
选择ExtendedText 使用和输入框一套的逻辑;Text 使用最新的 SelectionArea
自定义文本溢出效果xExtendedText 支持文本溢出为自定义的 Widget,并且支持溢出的位置(前,中,后)。
复制真实文本xExtendedText 支持复制出文本的真实值,Text 只能复制出 WidgetSpan 的占位值 (\uFFFC)

------------------分割线-----------------------

功能ExtendedTextFieldTextField说明
富文本图文混合xExtendedTextField 支持图文混合,由 SpecialTextSpanBuilder 生成。 TextFieldTextEditingControllerbuildTextSpan 生成,但是 Selection 会出现问题。
选择 WidgetSpan支持选择 WidgetSpan
复制真实文本xExtendedTextField 支持复制出文本的真实值,TextField 只能复制出 WidgetSpan 的占位值 (\uFFFC)

extended_text_library

├─ lib
│  ├─ extended_text_library.dart
│  └─ src
│     ├─ background_text_span.dart
│     ├─ extended_text_typedef.dart
│     ├─ extended_text_utils.dart
│     ├─ extended_widget_span.dart
│     ├─ extension.dart
│     ├─ image_span.dart
│     ├─ painting_image_span.dart
│     ├─ special_inline_span_base.dart
│     ├─ special_text_span.dart
│     ├─ special_text_span_builder.dart
│     └─ text_painter_helper.dart
│     ├─ render_object
│     │  ├─ extended_text_render_box.dart
│     │  └─ extended_text_selection_render_object.dart
│     ├─ selection
│     │  ├─ extended_text_selection.dart
│     │  ├─ extended_text_selection_overlay.dart
│     │  ├─ painter.dart
│     │  ├─ scribble_focusable.dart
│     │  └─ typedef.dart
└─ pubspec.yaml
  • special_inline_span_base.dart, 主要定义了 actualTextdeleteAlltextRange

其中 actualText 表示文本的真实内容,比如 [1] 代表表情图片,表情图片是展示在前端的,[1] 为后端真实内容。

deleteAll 代表是否把这个特殊的文本当作一个整体,比如输入框里面删除表情图片,会删除掉 [1] 而不是 ];或者比如真实文本$FlutterCandies$,展示内容 FlutterCandies, 在选择的时候,是否是直接选中整体(true),还是说能选中 FlutterCandies 的一部分(false)。

textRange 代表在真实内容中的具体位置。

  • render_object 文件夹下面 extended_text_render_box.dart 主要支持图文混合, extended_text_selection_render_object.dart主要是绘制 Selection 相关的代码。

ExtendedTextExtendedTextField 的最终绘制代码都继承了这 2 个文件,让它们都能支持图文混合以及 Selection

  • selection 文件夹下面是对 Selection 支持的共享代码。

extended_text

├─ lib
│  ├─ extended_text.dart
│  └─ src
│     ├─ extended_render_paragraph.dart
│     ├─ extended_rich_text.dart
│     ├─ extended_text.dart
│     ├─ extended_text_typedef.dart
│     ├─ selection
│     │  ├─ extended_text_selection.dart
│     │  └─ extended_text_selection_pointer_handler.dart
│     ├─ text_overflow_render_mixin.dart
│     └─ text_overflow_widget.dart
├─ pubspec.yaml

对应类与文件:

三方位置官方位置
ExtendedTextextended_text.dartTexttext.dart
ExtendedRichTextextended_rich_text.dartRichTextbasic.dart
ExtendedRenderParagraphextended_render_paragraph.dartRenderParagraphparagraph.dart
ExtendedTextSelectionextended_text_selection.dartEditableTexteditable_text.dart

extended_text_field

├─ lib
│  ├─ extended_text_field.dart
│  └─ src
│     ├─ extended_editable_text.dart
│     ├─ extended_render_editable.dart
│     ├─ extended_text_field.dart
│     └─ keyboard
│        ├─ binding.dart
│        └─ focus_node.dart
└─ pubspec.yaml

对应类与文件:

三方位置官方位置
ExtendedTextFieldextended_text_field.dartTextFieldtext.dart
ExtendedEditableTextextended_editable_text.dartEditableTexteditable_text.dart
ExtendedRenderEditableextended_render_editable.dartRenderEditableeditable.dart

优点缺点

优点

extended_text_library 共同处理了图文混合和 Selection 的逻辑,一套代码,2 个库能共享。在项目前期,确实减少不少工作量。

缺点

随着官方的版本迭代,TextTextField 的代码不断更新,特别是 SelectionArea 的出现,很难做到在旧的架构下面再继续同步官方的版本迭代。

Please design as public interface/Mixing instead of passing RenderEditable everywhere · Issue #84086 · flutter/flutter (github.com)

RenderEditableEditableTextState 被到处传的情况,现在依然还是这样。对于三方开发者来说,我们只能不断的复制更多的源码到自己库里面。

新架构

1684766242175.gif

其实很早就有重构计划,我一直在等待,TextTextField 稳定,但是现在依然还是看到有大量的废弃的 api 在代码里面,不知道什么时候才能删掉,稳定下来。

但是也是到了不得不的进行重构的时候了,重构先要保证之前的功能,然后是以后能够更容易的维护。

重构之后,ExtendedText 会跟随 Text 代码迭代,ExtendedTextField 会跟随 TextField 代码迭代,绘制部分的代码不再共用。

extended_text

├─ lib
│  ├─ extended_text.dart
│  └─ src
│     ├─ extended
│     │  ├─ rendering
│     │  │  └─ paragraph.dart
│     │  ├─ selection_mixin.dart
│     │  ├─ text_overflow_mixin.dart
│     │  └─ widgets
│     │     ├─ rich_text.dart
│     │     ├─ text.dart
│     │     └─ text_overflow_widget.dart
│     └─ official
│        ├─ rendering
│        │  └─ paragraph.dart
│        └─ widgets
│           ├─ rich_text.dart
│           └─ text.dart
├─ pubspec.yaml

  • official 文件夹中为从源码中复制出来的代码,要求里面的文件尽量不要进行改动,方便后面维护的时候直接跟原文件进行对比 merge

  • extended 文件夹中相同位置,相同名字的文件,是我们的扩展实现。text_overflow_mixin.dart 为自定义文本溢出的实现;selection_mixin.dart 处理 Selection的实现,比如复制真实文本,deleteAll 的处理。

后面,只需要针对 official 的文件跟官方最新的代码进行对比,将对应的代码 merge 过来即可。

extended_text_field


├─ lib
│  ├─ extended_text_field.dart
│  └─ src
│     ├─ extended
│     │  ├─ cupertino
│     │  │  └─ spell_check_suggestions_toolbar.dart
│     │  ├─ material
│     │  │  └─ spell_check_suggestions_toolbar.dart
│     │  │  └─ selectable_text.dart
│     │  ├─ rendering
│     │  │  └─ editable.dart
│     │  └─ widgets
│     │     ├─ editable_text.dart
│     │     ├─ spell_check.dart
│     │     ├─ text_field.dart
│     │     └─ text_selection.dart
│     ├─ keyboard
│     │  ├─ binding.dart
│     │  └─ focus_node.dart
│     └─ official
│        ├─ material
│        │  └─ selectable_text.dart
│        ├─ rendering
│        │  └─ editable.dart
│        └─ widgets
│           ├─ editable_text.dart
│           ├─ spell_check.dart
│           ├─ text_field.dart
│           └─ text_selection.dart
└─ pubspec.yaml

  • official 文件夹中为从源码中复制出来的代码,要求里面的文件尽量不要进行改动,方便后面维护的时候直接跟原文件进行对比 merge 。由于 RenderEditableEditableTextState 被到处传的情况,我们需要复制更多的源码到自己的项目中。

  • extended 文件夹中相同位置,相同名字的文件,是我们的扩展实现。

后面,只需要针对 official 的文件跟官方最新的代码进行对比,将对应的代码 merge 过来即可。

extended_text_library

├─ lib
│  ├─ extended_text_library.dart
│  └─ src
│     ├─ background_text_span.dart
│     ├─ extended_text_utils.dart
│     ├─ extended_text_typedef.dart
│     ├─ extended_widget_span.dart
│     ├─ image_span.dart
│     ├─ painting_image_span.dart
│     ├─ special_inline_span_base.dart
│     ├─ special_text_span.dart
│     ├─ special_text_span_builder.dart
│     └─ text_painter_helper.dart
└─ pubspec.yaml

共用的绘制代码已移除,增加了 extended_text_utils.dart 处理 Selection 的相关相同处理逻辑。

功能对比

重构之后,新架构支持了 Flutter ``3.10.0 版本的全部功能,代码也更加清楚,针对官方的扩展的功能也能一目了然。

基于 Flutter 目前稳定版本 3.10.0 新架构下面的功能对比:

功能ExtendedTextText说明
富文本图文混合使用 WidgetSpan
选择使用 SelectionArea
自定义文本溢出效果xExtendedText 支持文本溢出为自定义的 Widget,并且支持溢出的位置(前,中,后)。
复制真实文本xExtendedText 支持复制出文本的真实值,Text 只能复制出 WidgetSpan 的占位值 (\uFFFC)

------------------分割线-----------------------

功能ExtendedTextFieldTextField说明
富文本图文混合xExtendedTextField 支持图文混合,由 SpecialTextSpanBuilder 生成。 TextFieldTextEditingControllerbuildTextSpan 生成,但是 Selection 会出现问题。
选择 WidgetSpan支持选择 WidgetSpan
复制真实文本xExtendedTextField 支持复制出文本的真实值,TextField 只能复制出 WidgetSpan 的占位值 (\uFFFC)

注意

菜单(ContextMenu)和光标选择器(Handles)

现在这2个东西是分开实现了。

ExtendedText

对于 ExtendedText 来说,菜单(ContextMenu)由 SelectionArea.contextMenuBuilder 实现,而光标选择器(Handles) 还是由 SelectionArea.selectionControls 实现。

  • SelectionArea.contextMenuBuilder 例子
class CommonSelectionArea extends StatelessWidget {
  const CommonSelectionArea({
    super.key,
    required this.child,
    this.joinZeroWidthSpace = false,
  });
  final Widget child;
  final bool joinZeroWidthSpace;

  @override
  Widget build(BuildContext context) {
    SelectedContent? _selectedContent;
    return SelectionArea(
      selectionControls: MyTextSelectionControls(),
      contextMenuBuilder:
          (BuildContext context, SelectableRegionState selectableRegionState) {
        return AdaptiveTextSelectionToolbar.buttonItems(
          buttonItems: <ContextMenuButtonItem>[
            ContextMenuButtonItem(
              onPressed: () {
                // TODO(zmtzawqlp):  how to get Selectable
                // and  _clearSelection is not public
                // https://github.com/flutter/flutter/issues/126980

                //  onCopy: () {
                //   _copy();

                //   // In Android copy should clear the selection.
                //   switch (defaultTargetPlatform) {
                //     case TargetPlatform.android:
                //     case TargetPlatform.fuchsia:
                //       _clearSelection();
                //     case TargetPlatform.iOS:
                //       hideToolbar(false);
                //     case TargetPlatform.linux:
                //     case TargetPlatform.macOS:
                //     case TargetPlatform.windows:
                //       hideToolbar();
                //   }
                // },

                // if (_selectedContent != null) {
                //   String content = _selectedContent!.plainText;
                //   if (joinZeroWidthSpace) {
                //     content = content.replaceAll(zeroWidthSpace, '');
                //   }

                //   Clipboard.setData(ClipboardData(text: content));
                //   selectableRegionState.hideToolbar(true);
                //   selectableRegionState._clearSelection();
                // }

                selectableRegionState
                    .copySelection(SelectionChangedCause.toolbar);

                // remove zeroWidthSpace
                if (joinZeroWidthSpace) {
                  Clipboard.getData('text/plain').then((ClipboardData? value) {
                    if (value != null) {
                      // remove zeroWidthSpace
                      final String? plainText =
                          value.text?.replaceAll(ExtendedTextLibraryUtils.zeroWidthSpace, '');
                      if (plainText != null) {
                        Clipboard.setData(ClipboardData(text: plainText));
                      }
                    }
                  });
                }
              },
              type: ContextMenuButtonType.copy,
            ),
            ContextMenuButtonItem(
              onPressed: () {
                selectableRegionState.selectAll(SelectionChangedCause.toolbar);
              },
              type: ContextMenuButtonType.selectAll,
            ),
            ContextMenuButtonItem(
              onPressed: () {
                launchUrl(Uri.parse(
                    'mailto:zmtzawqlp@live.com?subject=extended_text_share&body=${_selectedContent?.plainText}'));
                selectableRegionState.hideToolbar();
              },
              type: ContextMenuButtonType.custom,
              label: 'like',
            ),
          ],
          anchors: selectableRegionState.contextMenuAnchors,
        );
        // return AdaptiveTextSelectionToolbar.selectableRegion(
        //   selectableRegionState: selectableRegionState,
        // );
      },
      onSelectionChanged: (SelectedContent? value) {
        print(value?.plainText);
        _selectedContent = value;
      },
      child: child,
    );
  }
}

  • SelectionArea.selectionControls 例子

这里要特别注意,必须 with TextSelectionHandleControls,不然源码还是会走 TextSelectionControls.buildToolbar 去创建菜单(ContextMenu)。

截屏2023-05-17 11.37.43.png

class MyTextSelectionControls extends TextSelectionControls
    with TextSelectionHandleControls {
  MyTextSelectionControls({this.joinZeroWidthSpace = false});
  final bool joinZeroWidthSpace;

  /// Returns the size of the Material handle.
  @override
  Size getHandleSize(double textLineHeight) =>
      const Size(_kHandleSize, _kHandleSize);

  /// Builder for material-style text selection handles.
  @override
  Widget buildHandle(
      BuildContext context, TextSelectionHandleType type, double textLineHeight,
      [VoidCallback? onTap, double? startGlyphHeight, double? endGlyphHeight]) {
    final Widget handle = SizedBox(
      width: _kHandleSize,
      height: _kHandleSize,
      child: Image.asset(
        'assets/40.png',
      ),
    );

    // [handle] is a circle, with a rectangle in the top left quadrant of that
    // circle (an onion pointing to 10:30). We rotate [handle] to point
    // straight up or up-right depending on the handle type.
    switch (type) {
      case TextSelectionHandleType.left: // points up-right
        return Transform.rotate(
          angle: math.pi / 4.0,
          child: handle,
        );
      case TextSelectionHandleType.right: // points up-left
        return Transform.rotate(
          angle: -math.pi / 4.0,
          child: handle,
        );
      case TextSelectionHandleType.collapsed: // points up
        return handle;
    }
  }

  /// Gets anchor for material-style text selection handles.
  ///
  /// See [TextSelectionControls.getHandleAnchor].
  @override
  Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight,
      [double? startGlyphHeight, double? endGlyphHeight]) {
    switch (type) {
      case TextSelectionHandleType.left:
        return const Offset(_kHandleSize, 0);
      case TextSelectionHandleType.right:
        return Offset.zero;
      default:
        return const Offset(_kHandleSize / 2, -4);
    }
  }
}
ExtendedTextField

对于 ExtendedTextField 来说,菜单(ContextMenu)由 ExtendedTextField.extendedContextMenuBuilder 实现,而光标选择器(Handles) 还是由 ExtendedTextField.selectionControls 实现。

  • ExtendedTextField.extendedContextMenuBuilder 例子
  static Widget defaultContextMenuBuilder(
      BuildContext context, ExtendedEditableTextState editableTextState) {
    return AdaptiveTextSelectionToolbar.buttonItems(
      buttonItems: <ContextMenuButtonItem>[
        ...editableTextState.contextMenuButtonItems,
        ContextMenuButtonItem(
          onPressed: () {
            launchUrl(
              Uri.parse(
                'mailto:zmtzawqlp@live.com?subject=extended_text_share&body=${editableTextState.textEditingValue.text}',
              ),
            );
            editableTextState.hideToolbar(true);
            editableTextState.textEditingValue
                .copyWith(selection: const TextSelection.collapsed(offset: 0));
          },
          type: ContextMenuButtonType.custom,
          label: 'like',
        ),
      ],
      anchors: editableTextState.contextMenuAnchors,
    );
    // return AdaptiveTextSelectionToolbar.editableText(
    //   editableTextState: editableTextState,
    // );
  }
  • ExtendedTextField.selectionControls 例子

这里要特别注意,必须 with TextSelectionHandleControls,不然源码还是会走 TextSelectionControls.buildToolbar 去创建菜单(ContextMenu)。

截屏2023-05-17 11.43.57.png

const double _kHandleSize = 22.0;

/// Android Material styled text selection controls.
class MyTextSelectionControls extends TextSelectionControls
    with TextSelectionHandleControls {

  /// Returns the size of the Material handle.
  @override
  Size getHandleSize(double textLineHeight) =>
      const Size(_kHandleSize, _kHandleSize);

  /// Builder for material-style text selection handles.
  @override
  Widget buildHandle(
      BuildContext context, TextSelectionHandleType type, double textLineHeight,
      [VoidCallback? onTap, double? startGlyphHeight, double? endGlyphHeight]) {
    final Widget handle = SizedBox(
      width: _kHandleSize,
      height: _kHandleSize,
      child: Image.asset(
        'assets/40.png',
      ),
    );

    // [handle] is a circle, with a rectangle in the top left quadrant of that
    // circle (an onion pointing to 10:30). We rotate [handle] to point
    // straight up or up-right depending on the handle type.
    switch (type) {
      case TextSelectionHandleType.left: // points up-right
        return Transform.rotate(
          angle: math.pi / 4.0,
          child: handle,
        );
      case TextSelectionHandleType.right: // points up-left
        return Transform.rotate(
          angle: -math.pi / 4.0,
          child: handle,
        );
      case TextSelectionHandleType.collapsed: // points up
        return handle;
    }
  }

  /// Gets anchor for material-style text selection handles.
  ///
  /// See [TextSelectionControls.getHandleAnchor].
  @override
  Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight,
      [double? startGlyphHeight, double? endGlyphHeight]) {
    switch (type) {
      case TextSelectionHandleType.left:
        return const Offset(_kHandleSize, 0);
      case TextSelectionHandleType.right:
        return Offset.zero;
      default:
        return const Offset(_kHandleSize / 2, -4);
    }
  }

  @override
  bool canSelectAll(TextSelectionDelegate delegate) {
    // Android allows SelectAll when selection is not collapsed, unless
    // everything has already been selected.
    final TextEditingValue value = delegate.textEditingValue;
    return delegate.selectAllEnabled &&
        value.text.isNotEmpty &&
        !(value.selection.start == 0 &&
            value.selection.end == value.text.length);
  }
}

已知问题

deleteAll

deleteAll 这个参数源于,展示在页面上面的文字/图形,并不是它们的真实文本内容。当它等于 false 的时候,我们很难将屏幕上面选中的内容,跟实际真实想要的内容有所关联起来。这个问题一直都存在,暂时没有想到合理的解决方案。

  • 对于 ExtendedText 你可以通过重载SpecialInlineSpanBasegetSelectedContent 的方法,去重载被选择的内容。默认 deleteAlltrue 的时候返回的是 actualText,否则返回的是屏幕上显示的内容 showText
  /// showText is the text on screen
  String getSelectedContent(String showText) {
    if (deleteAll) {
      return actualText;
    }
    return showText;
  }

例子:

class DollarText extends SpecialText {
  DollarText(TextStyle? textStyle, SpecialTextGestureTapCallback? onTap,
      {this.start})
      : super(flag, flag, textStyle, onTap: onTap);
  static const String flag = '\$';
  final int? start;
  @override
  InlineSpan finishText() {
    final String text = getContent();

    return _SpecialTextSpan(
        text: text,
        actualText: toString(),
        start: start!,
        deleteAll: false,
        style: textStyle?.copyWith(color: Colors.orange, fontSize: 16),
        mouseCursor: SystemMouseCursors.text,
        recognizer: TapGestureRecognizer()
          ..onTap = () {
            if (onTap != null) {
              onTap!(toString());
            }
          });
  }
}

class _SpecialTextSpan extends SpecialTextSpan {
  _SpecialTextSpan({
    super.style,
    required super.text,
    super.actualText,
    super.start = 0,
    super.deleteAll = true,
    super.recognizer,
    super.children,
    super.semanticsLabel,
    super.mouseCursor,
    super.onEnter,
    super.onExit,
  });

  @override
  String getSelectedContent(String showText) {
    return '${DollarText.flag}$showText${DollarText.flag}';
  }
}

效果如下:

截屏2023-05-16 16.23.33.png

  • 对于 ExtendedTextField 来说,这个属性等于 false 的时候就更难处理了。因为输入框有编辑功能,用户可能删除,粘贴文本。所以在 ExtendedTextField 上面,不建议使用 deleteAllflase 的特殊文本。

后续可能考虑移除 deleteAll 属性,特殊文本默认就是当作一个整体。

SelectionArea 的问题

选择算法的问题

这是官方的 SelectionArea 的问题, SelectionArea is not working well · Issue #126817 · flutter/flutter (github.com),源于 SelectionArea 要支持跨文本可以选择。

截屏2023-05-16 16.37.51.png

多个文本,会根据 0xFFFC 占位符(PlaceholderSpan)进行分割并且根据自身的位置,确定前后顺序。但在,可能因为文本的字体大小,导致计算出错。比如下面 FlutterCandies 设置了更小的字体,结果被计算到了 if you want to ... 文本的后面。

    SelectionArea(
      child: Text.rich(TextSpan(children: <InlineSpan>[
        TextSpan(
          text:
              '[love]Extended text help you to build rich text quickly. any special text you will have with extended text. '
              'It\'s my pleasure to invite you to join  ',
        ),
        WidgetSpan(child: SizedBox.shrink()),
        TextSpan(
          text: 'FlutterCandies',
          style: TextStyle(fontSize: 10),
        ),
        WidgetSpan(child: SizedBox.shrink()),
        TextSpan(
          text: 'if you want to improve flutter .[love]'
              'if you meet any problem, please let me know @zmtzawqlp .[sun_glasses]',
        ),
      ])),
    )

1684226607282.gif

临时解决方案:

  • 调整字体,避免这种场景,狗头, 坐等修复。

截屏2023-05-20 13.38.09.png

p4issue5000 多个,狗头。

截屏2023-05-20 13.39.17.png 后续继续关注,实际上应该要有一个 parent 的概念才好区分。

  • 使用 ExtendedSelectableText,它和输入框是一套逻辑,缺点是没法指定文本溢出效果。
  • 实在是不想改源码了,而且复制了的话,官方的 Text 就没法加入了。但是也不是不可以。

DM_20230520134130_001.webp

选中一个词时候的异常

_SelectableFragment _handleSelectWord assert failed · Issue #127076 · flutter/flutter (github.com)

这个问题我在 extended_text 做了简单的修复。具体最终方案还是看看官方回复再说。

Fix _handleSelectWord error · fluttercandies/extended_text@e5e7623 (github.com)

结语

有一说一,糖果的组件,一般都能在大版本更新之后,及时地更新适配。三方开发者,不是全职做这些东西,不可能把全部的精力放在这个上面。Fluttermasterbeta 分支,代码更新很快,而且还会有回滚的情况,想让开发者提前去适配 masterbeta 分支是太不现实的。

哦,对了,东西弄出来了,老板们记得打钱哈!

IMG_20230529_111811.jpg

Flutter,爱糖果,欢迎加入Flutter Candies,一起生产可爱的Flutter小糖果QQ群:181398081

最最后放上 Flutter Candies 全家桶,真香。