theme: cyanosis
highlight: androidstudio
前言
我在 Flutter 重识 NestedScrollView (juejin.cn) 中留下 增大点击范围
的挑战,时间已经过了一个星期,不知道大家思考的怎么样了?今天说了一下对于 增大点击范围
我个人的的思路。
调试源码
首先我们先顺一顺,Flutter
中手势相关事件这些东西是从何而来的。
事件从何而来
- 首先找到我们经常使用的一个组件
Listener
,注册一个事件,打一个断点。
return Listener(
onPointerDown: (PointerDownEvent value) {
showToast('$text:onTap${i++}',
duration: const Duration(milliseconds: 500));
},
child: mockButtonUI(text),
);
我们可以看到整个 call stack
信息,我们反推回去。
-
Listener
暴露一些原始的指针事件的回调, 最终处理的类RenderPointerListener
。
const Listener({
Key? key,
this.onPointerDown,
this.onPointerMove,
this.onPointerUp,
this.onPointerHover,
this.onPointerCancel,
this.onPointerSignal,
this.behavior = HitTestBehavior.deferToChild,
Widget? child,
})
...省略部分代码
@override
RenderPointerListener createRenderObject(BuildContext context) {
return RenderPointerListener(
onPointerDown: onPointerDown,
onPointerMove: onPointerMove,
onPointerUp: onPointerUp,
onPointerHover: onPointerHover,
onPointerCancel: onPointerCancel,
onPointerSignal: onPointerSignal,
behavior: behavior,
);
}
- RenderPointerListener.handleEvent 方法中触发了
onPointerDown
回调
class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior {
/// Creates a render object that forwards pointer events to callbacks.
///
/// The [behavior] argument defaults to [HitTestBehavior.deferToChild].
RenderPointerListener({
this.onPointerDown,
this.onPointerMove,
this.onPointerUp,
this.onPointerHover,
this.onPointerCancel,
this.onPointerSignal,
HitTestBehavior behavior = HitTestBehavior.deferToChild,
RenderBox? child,
}) : super(behavior: behavior, child: child);
// 省略一些代码
...
@override
Size computeSizeForNoChild(BoxConstraints constraints) {
return constraints.biggest;
}
@override
void handleEvent(PointerEvent event, HitTestEntry entry) {
assert(debugHandleEvent(event, entry));
if (event is PointerDownEvent)
return onPointerDown?.call(event);
if (event is PointerMoveEvent)
return onPointerMove?.call(event);
if (event is PointerUpEvent)
return onPointerUp?.call(event);
if (event is PointerHoverEvent)
return onPointerHover?.call(event);
if (event is PointerCancelEvent)
return onPointerCancel?.call(event);
if (event is PointerSignalEvent)
return onPointerSignal?.call(event);
}
}
-
GestureBinding.dispatchEvent
方法中的对hitTestResult
分发事件
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
...省略一部分代码
for (final HitTestEntry entry in hitTestResult.path) {
try {
entry.target.handleEvent(event.transformed(entry.transform), entry);
}
-
RendererBinding.dispatchEvent
中调用super.dispatchEvent(event, hitTestResult)
@override // from GestureBinding
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
if (hitTestResult != null ||
event is PointerAddedEvent ||
event is PointerRemovedEvent) {
assert(event.position != null);
_mouseTracker!.updateWithEvent(event, () => hitTestResult ?? renderView.hitTestMouseTrackers(event.position));
}
super.dispatchEvent(event, hitTestResult);
}
- 再次回到中
GestureBinding
通过一些内部方法,最终_handlePointerDataPacket
来注册的原生回调的地方。
graph TD
GestureBinding._handlePointerEventImmediately --> GestureBinding.handlePointerEvent --> GestureBinding._flushPointerEventQueue --> GestureBinding._handlePointerDataPacket
mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, HitTestTarget {
@override
void initInstances() {
super.initInstances();
_instance = this;
window.onPointerDataPacket = _handlePointerDataPacket;
}
- window 里面的回调实际上是
PlatformDispatcher.onPointerDataPacket
,并且在_dispatchPointerDataPacket
调用,把引擎传递过来的数据转换成PointerDataPacket
// Called from the engine, via hooks.dart
void _dispatchPointerDataPacket(ByteData packet) {
if (onPointerDataPacket != null) {
_invoke1(
onPointerDataPacket,
_onPointerDataPacketZone,
_unpackPointerDataPacket(packet),
);
}
}
-
PointerDataPacket
是 通过_unpackPointerDataPacket
方法把引擎传递的数据转换成为下面的数据结构。
/// 从原生传递过来的原始指针的一些信息
/// A sequence of reports about the state of pointers.
class PointerDataPacket {
/// Creates a packet of pointer data reports.
const PointerDataPacket({ this.data = const [] }) : assert(data != null);
/// Data about the individual pointers in this packet.
///
/// This list might contain multiple pieces of data about the same pointer.
final List data;
}
/// 原始指针包含的一些信息
/// Information about the state of a pointer.
class PointerData {
/// Creates an object that represents the state of a pointer.
const PointerData({
this.embedderId = 0,
this.timeStamp = Duration.zero,
this.change = PointerChange.cancel,
this.kind = PointerDeviceKind.touch,
this.signalKind,
this.device = 0,
this.pointerIdentifier = 0,
this.physicalX = 0.0,
this.physicalY = 0.0,
this.physicalDeltaX = 0.0,
this.physicalDeltaY = 0.0,
this.buttons = 0,
this.obscured = false,
this.synthesized = false,
this.pressure = 0.0,
this.pressureMin = 0.0,
this.pressureMax = 0.0,
this.distance = 0.0,
this.distanceMax = 0.0,
this.size = 0.0,
this.radiusMajor = 0.0,
this.radiusMinor = 0.0,
this.radiusMin = 0.0,
this.radiusMax = 0.0,
this.orientation = 0.0,
this.tilt = 0.0,
this.platformData = 0,
this.scrollDeltaX = 0.0,
this.scrollDeltaY = 0.0,
});
- 最后来到 hooks.dart
https://github.com/flutter/flutter/blob/stable/bin/cache/pkg/sky_engine/lib/ui/hooks.dart
@pragma('vm:entry-point')
// ignore: unused_element
void _dispatchPointerDataPacket(ByteData packet) {
PlatformDispatcher.instance._dispatchPointerDataPacket(packet);
}
- 这就是我们整个获取事件的一个流程
HitTest
从上面的流程,我们能知道点击事件是从哪里来的,那么 Flutter 又是怎么知道我是点击的哪个位置呢?还记得我在前面有留提示,GestureBinding.dispatchEvent
方法中的对 hitTestResult
分发事件,那我们看看 hitTestResult
又是从何而来的呢?
找到 https://github.com/flutter/flutter/blob/stable/packages/flutter/lib/src/gestures/binding.dart
中 GestureBinding.dispatchEvent
方法。可以看到 hitTestResult
是作为参数传递进来的,那我们再向上找。
@override // from HitTestDispatcher
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
assert(!locked);
// No hit test information implies that this is a [PointerHoverEvent],
// [PointerAddedEvent], or [PointerRemovedEvent]. These events are specially
// routed here; other events will be routed through the `handleEvent` below.
if (hitTestResult == null) {
assert(event is PointerAddedEvent || event is PointerRemovedEvent);
try {
pointerRouter.route(event);
} catch (exception, stack) {
FlutterError.reportError(FlutterErrorDetailsForPointerEventDispatcher(
exception: exception,
stack: stack,
library: 'gesture library',
context: ErrorDescription('while dispatching a non-hit-tested pointer event'),
event: event,
hitTestEntry: null,
informationCollector: () sync* {
yield DiagnosticsProperty('Event', event, style: DiagnosticsTreeStyle.errorProperty);
},
));
}
return;
}
for (final HitTestEntry entry in hitTestResult.path) {
try {
entry.target.handleEvent(event.transformed(entry.transform), entry);
} catch (exception, stack) {
FlutterError.reportError(FlutterErrorDetailsForPointerEventDispatcher(
exception: exception,
stack: stack,
library: 'gesture library',
context: ErrorDescription('while dispatching a pointer event'),
event: event,
hitTestEntry: entry,
informationCollector: () sync* {
yield DiagnosticsProperty('Event', event, style: DiagnosticsTreeStyle.errorProperty);
yield DiagnosticsProperty('Target', entry.target, style: DiagnosticsTreeStyle.errorProperty);
},
));
}
}
}
-
GestureBinding._handlePointerEventImmediately
, 如果是
if (event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent)
成立,就创建一个HitTestResult
,并且调用
hitTest(hitTestResult, event.position)
方法。
void _handlePointerEventImmediately(PointerEvent event) {
HitTestResult? hitTestResult;
if (event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent) {
assert(!_hitTests.containsKey(event.pointer));
hitTestResult = HitTestResult();
// 由于是根,所以直接把自己加进 HitTestResult 当中
hitTest(hitTestResult, event.position);
// 保存
if (event is PointerDownEvent) {
_hitTests[event.pointer] = hitTestResult;
}
assert(() {
if (debugPrintHitTestResults)
debugPrint('$event: $hitTestResult');
return true;
}());
}
// up 或者 cancel 的时候移除掉
else if (event is PointerUpEvent || event is PointerCancelEvent) {
hitTestResult = _hitTests.remove(event.pointer);
} else if (event.down) {
// Because events that occur with the pointer down (like
// [PointerMoveEvent]s) should be dispatched to the same place that their
// initial PointerDownEvent was, we want to re-use the path we found when
// the pointer went down, rather than do hit detection each time we get
// such an event.
hitTestResult = _hitTests[event.pointer];
}
assert(() {
if (debugPrintMouseHoverEvents && event is PointerHoverEvent)
debugPrint('$event');
return true;
}());
if (hitTestResult != null ||
// 第一次触发的为 PointerAddedEvent 进入 dispatchEvent
event is PointerAddedEvent ||
event is PointerRemovedEvent) {
assert(event.position != null);
// 分发
dispatchEvent(event, hitTestResult);
`}`
}
- 第一次触发的为
PointerAddedEvent
进入dispatchEvent
, 由于hitTestResult
为null
,直接调用renderView.hitTestMouseTrackers(event.position)
.
@override // from GestureBinding
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
if (hitTestResult != null ||
event is PointerAddedEvent ||
event is PointerRemovedEvent) {
assert(event.position != null);
_mouseTracker!.updateWithEvent(event, () => hitTestResult ?? renderView.hitTestMouseTrackers(event.position));
}
super.dispatchEvent(event, hitTestResult);
}
之后将从父节点一个一个向下去调用 hitTest
和 hitTestChildren
方法
-
RenderBox.hitTest
,我们遇到了处理增大点击范围的一个判断_size!.contains(position)
,点击区域必须在自己的大小范围内才会继续取判断hitTestChildren
和hitTestSelf
。 那是不是如果我们把这个判断去掉,这样child
或者children
就能接受hitTest
测试了呢?
bool hitTest(BoxHitTestResult result, { required Offset position }) {
... 省略部分代码
if (_size!.contains(position)) {
if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
result.add(BoxHitTestEntry(this, position));
return true;
}
}
return false;
}
- RenderBoxContainerDefaultsMixin.hitTestChildren,这是默认的多孩子的
hitTest
,注意一下,是从lastChild
开始判断的。不知道你们有没有zIndex
的概念,Flutter 里面Stack
,Row
,Column
等组件的children
当中后添加的child
会先接受hitTest
测试,给人的感觉就是lastChild
是在最上层。
@override
bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
return defaultHitTestChildren(result, position: position);
}
bool defaultHitTestChildren(BoxHitTestResult result, { required Offset position }) {
// The x, y parameters have the top left of the node's box as the origin.
ChildType? child = lastChild;
while (child != null) {
final ParentDataType childParentData = child.parentData! as ParentDataType;
final bool isHit = result.addWithPaintOffset(
offset: childParentData.offset,
position: position,
hitTest: (BoxHitTestResult result, Offset? transformed) {
assert(transformed == position - childParentData.offset);
return child!.hitTest(result, position: transformed!);
},
);
if (isHit)
return true;
child = childParentData.previousSibling;
}
return false;
}
小结
- 引擎通知 Flutter
GestureBinding
-
GestureBinding
通过hitTest
方法确定哪些RenderOject
通过了hitTest
测试, 并且加入BoxHitTestResult
。
关键点:
- size 限制
- children 接受
hitTest
的顺序
- 对
BoxHitTestResult
中的结果进行事件分发 - 通过
GestureDetector
,RawGestureDetector
等组件对Listener
获取的事件监听进行转换,转换成我们更容易接受的各种事件。
解决
A,B 两个按钮都跟附近的组件紧挨着。就是说如果要增大点击区域,必然需要考虑它们个附近的组件。
伪代码,大致的结构是这样的。我们怎么样才能让 ButtonA
和 ButtonB
的点击区域扩大呢?
Row(children: [
Text(''),
Column(children: [
Row(children: [
ButtonA(),
Text(''),
ButtonB(),
],),
Text(''),
],),
Text(''),
],)
如果扩大点击范围为下图的话,你的第一反应是什么?
- 我的第一反应是利用
stack
绘制出一个看不见的区域来接收hitTest
。但是其实很早就有听到过说stack
中溢出的部分是不会接收到hitTest
的,想想也是,溢出的部分已经超出size
了。
return Stack(
clipBehavior: Clip.none,
children: [
mockButtonUI(text),
Positioned(
left: -16,
right: -16,
top: -16,
bottom: -16,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
showToast('$text:onTap${i++}',
duration: const Duration(milliseconds: 500));
},
// 使用看不见的颜色来占位来接收 hitTest
child: const ColoredBox(
color: Color(0x00100000),
),
),
),
],
);
RenderBoxHitTestWithoutSizeLimit
我们先来创建一个 mixin
用来解除 hitTest
关于 size
的限制。
mixin RenderBoxHitTestWithoutSizeLimit on RenderBox {
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
assert(() {
if (!hasSize) {
if (debugNeedsLayout) {
throw FlutterError.fromParts([
ErrorSummary(
'Cannot hit test a render box that has never been laid out.'),
describeForError(
'The hitTest() method was called on this RenderBox'),
ErrorDescription(
"Unfortunately, this object's geometry is not known at this time, "
'probably because it has never been laid out. '
'This means it cannot be accurately hit-tested.'),
ErrorHint('If you are trying '
'to perform a hit test during the layout phase itself, make sure '
"you only hit test nodes that have completed layout (e.g. the node's "
'children, after their layout() method has been called).'),
]);
}
throw FlutterError.fromParts([
ErrorSummary('Cannot hit test a render box with no size.'),
describeForError('The hitTest() method was called on this RenderBox'),
ErrorDescription(
'Although this node is not marked as needing layout, '
'its size is not set.'),
ErrorHint('A RenderBox object must have an '
'explicit size before it can be hit-tested. Make sure '
'that the RenderBox in question sets its size during layout.'),
]);
}
return true;
}());
if (contains(position)) {
if (hitTestChildren(result, position: position) ||
hitTestSelf(position)) {
result.add(BoxHitTestEntry(this, position));
return true;
}
}
return false;
}
// 永远为 true
bool contains(Offset position) => true;
// size.contains(position);
}
StackHitTestWithoutSizeLimit
复制 Stack
的源码,为 RenderStack
混入 RenderBoxHitTestWithoutSizeLimit
。
class StackHitTestWithoutSizeLimit extends Stack {
/// Creates a stack layout widget.
///
/// By default, the non-positioned children of the stack are aligned by their
/// top left corners.
StackHitTestWithoutSizeLimit({
Key? key,
AlignmentDirectional alignment = AlignmentDirectional.topStart,
TextDirection? textDirection,
StackFit fit = StackFit.loose,
Clip clipBehavior = Clip.hardEdge,
List children = const [],
}) : super(
key: key,
children: children,
alignment: alignment,
textDirection: textDirection,
fit: fit,
clipBehavior: clipBehavior,
);
bool _debugCheckHasDirectionality(BuildContext context) {
if (alignment is AlignmentDirectional && textDirection == null) {
assert(
debugCheckHasDirectionality(context,
why: 'to resolve the \'alignment\' argument',
hint: alignment == AlignmentDirectional.topStart
? 'The default value for \'alignment\' is AlignmentDirectional.topStart, which requires a text direction.'
: null,
alternative:
'Instead of providing a Directionality widget, another solution would be passing a non-directional \'alignment\', or an explicit \'textDirection\', to the $runtimeType.'),
);
}
return true;
}
@override
RenderStack createRenderObject(BuildContext context) {
assert(_debugCheckHasDirectionality(context));
return RenderStackHitTestWithoutSizeLimit(
alignment: alignment,
textDirection: textDirection ?? Directionality.of(context),
fit: fit,
clipBehavior: clipBehavior,
);
}
}
class RenderStackHitTestWithoutSizeLimit extends RenderStack
with RenderBoxHitTestWithoutSizeLimit {
RenderStackHitTestWithoutSizeLimit({
List? children,
AlignmentGeometry alignment = AlignmentDirectional.topStart,
TextDirection? textDirection,
StackFit fit = StackFit.loose,
Clip clipBehavior = Clip.hardEdge,
}) : super(
alignment: alignment,
children: children,
textDirection: textDirection,
fit: fit,
clipBehavior: clipBehavior,
);
}
RowHitTestWithoutSizeLimit,ColumnHitTestWithoutSizeLimit
Row(children: [
Text(''),
Column(children: [
Row(children: [
ButtonA(),
Text(''),
ButtonB(),
],),
Text(''),
],),
Text(''),
],)
由于 Stack
溢出的部分已经达到 Row
和 Column
的中其他 child
的区域了,所以我们对 Row
和 Column
也需要进行特殊的处理。
class RowHitTestWithoutSizeLimit extends Row
with FlexHitTestWithoutSizeLimitmixin {
RowHitTestWithoutSizeLimit({
Key? key,
MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start,
MainAxisSize mainAxisSize = MainAxisSize.max,
CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.center,
TextDirection? textDirection,
VerticalDirection verticalDirection = VerticalDirection.down,
TextBaseline?
textBaseline, // NO DEFAULT: we don't know what the text's baseline should be
List children = const [],
}) : super(
children: children,
key: key,
mainAxisAlignment: mainAxisAlignment,
mainAxisSize: mainAxisSize,
crossAxisAlignment: crossAxisAlignment,
textDirection: textDirection,
verticalDirection: verticalDirection,
textBaseline: textBaseline,
);
}
mixin FlexHitTestWithoutSizeLimitmixin on Flex {
@override
RenderFlex createRenderObject(BuildContext context) {
return RenderFlexHitTestWithoutSizeLimit(
direction: direction,
mainAxisAlignment: mainAxisAlignment,
mainAxisSize: mainAxisSize,
crossAxisAlignment: crossAxisAlignment,
textDirection: getEffectiveTextDirection(context),
verticalDirection: verticalDirection,
textBaseline: textBaseline,
clipBehavior: clipBehavior,
);
}
}
class RenderFlexHitTestWithoutSizeLimit extends RenderFlex
with
RenderBoxHitTestWithoutSizeLimit,
RenderBoxChildrenHitTestWithoutSizeLimit {
RenderFlexHitTestWithoutSizeLimit({
List? children,
Axis direction = Axis.horizontal,
MainAxisSize mainAxisSize = MainAxisSize.max,
MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start,
CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.center,
TextDirection? textDirection,
VerticalDirection verticalDirection = VerticalDirection.down,
TextBaseline? textBaseline,
Clip clipBehavior = Clip.none,
}) : super(
children: children,
direction: direction,
mainAxisSize: mainAxisSize,
mainAxisAlignment: mainAxisAlignment,
crossAxisAlignment: crossAxisAlignment,
textDirection: textDirection,
verticalDirection: verticalDirection,
textBaseline: textBaseline,
clipBehavior: clipBehavior,
);
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return hitTestChildrenWithoutSizeLimit(
result,
position: position,
children: getChildrenAsList().reversed,
);
}
}
由于 children
默认是反序接受 hitTest
,我们需要让 RenderBoxHitTestWithoutSizeLimit
优先接受 hitTest
。
mixin RenderBoxChildrenHitTestWithoutSizeLimit {
bool hitTestChildrenWithoutSizeLimit(
BoxHitTestResult result, {
required Offset position,
required Iterable children,
}) {
final List normal = [];
for (final RenderBox child in children) {
if ((child is RenderBoxHitTestWithoutSizeLimit) &&
childIsHit(result, child, position: position)) {
return true;
} else {
normal.insert(0, child);
}
}
for (final RenderBox child in normal) {
if (childIsHit(result, child, position: position)) {
return true;
}
}
return false;
}
bool childIsHit(BoxHitTestResult result, RenderBox child,
{required Offset position}) {
final ContainerParentDataMixin childParentData =
child.parentData as ContainerParentDataMixin;
final Offset offset = (childParentData as BoxParentData).offset;
final bool isHit = result.addWithPaintOffset(
offset: offset,
position: position,
hitTest: (BoxHitTestResult result, Offset transformed) {
assert(transformed == position - offset);
return child.hitTest(result, position: transformed);
},
);
return isHit;
}
}
我们将写好的新组件替换掉之前的,就可以达到增大点击范围的效果了。
RowHitTestWithoutSizeLimit(children: [
Text(''),
ColumnHitTestWithoutSizeLimit(children: [
RowHitTestWithoutSizeLimit(children: [
ButtonA(),
Text(''),
ButtonB(),
],),
Text(''),
],),
Text(''),
],)
Widget ButtonA()
{
return StackHitTestWithoutSizeLimit(
clipBehavior: Clip.none,
children: [
mockButtonUI(text),
Positioned(
left: -16,
right: -16,
top: -16,
bottom: -16,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
showToast('$text:onTap${i++}',
duration: const Duration(milliseconds: 500));
},
// 使用看不见的颜色来占位来接收 hitTest
child: const ColoredBox(
color: Color(0x00100000),
),
),
),
],
);
}
extra_hittest_area | Flutter Package (flutter-io.cn)
为了方便大家使用,我将常用的组件封装了一下供大家使用。
Parent widgets
跟官方的 widgets 一样,使用它们来保证,当额外 hitTest 区域超出了父 widget的大小的时候,一样能接收到 hitTest。
StackHitTestWithoutSizeLimit
-
RowHitTestWithoutSizeLimit
,ColumnHitTestWithoutSizeLimit
,FlexHitTestWithoutSizeLimit
SizedBoxHitTestWithoutSizeLimit
监听点击事件的 widgets
GestureDetectorHitTestWithoutSizeLimit
RawGestureDetectorHitTestWithoutSizeLimit
ListenerHitTestWithoutSizeLimit
parameter | description | default |
---|---|---|
extraHitTestArea | 额外增加的 hitTest 区域 | EdgeInsets.zero |
debugHitTestAreaColor | 用于 debug 的 hitTest 区域背景色 | null |
你可以设置 ExtraHitTestBase.debugGlobalHitTestAreaColor
来替代在每个监听 widget 中单独设置 debugHitTestAreaColor
实现其他的 HitTestWithoutSizeLimit
如果这个 package 没有你需要的 widgets , 你可以使用下面的类自己实现。
RenderBoxHitTestWithoutSizeLimit
, RenderBoxChildrenHitTestWithoutSizeLimit
结语
这次我们尝试解决了实际开发中遇到的一个问题,重要的是理解了 Flutter
中手势事件的由来。至于从引擎传递过来的 raw
的 event
怎么转换成 Tap
,onLongPress
,Scale
等我们熟悉的事件,可以再开一篇了。
FlutterChallenges qq 群 321954965 喜欢折腾自己的童鞋欢迎加群,欢迎大家提供新的挑战或者解决挑战
。
爱 Flutter
,爱糖果
,欢迎加入[Flutter Candies]
最最后放上 Flutter Candies 全家桶,真香。