flutter事件分发原理详解

不管是android,ios还是rn,js,一涉及到手势都会有事件的分发处理,像你在手机上点击了某个控件,手机是怎么交个这个控件处理的,同一时间内的事件是被不同的控件一起消耗还是被一个控件单独消耗,同时消耗会不会产生事件点击、滑动、抬起等的混乱。

像典型的android的事件分发,它是从当前正在交互的Activity开始的,然后传递给view树的根View,再传到你自己布局的根view,开始一层一层向下处理,除非上层viewGroup拦截处理(拦截之后直接事件将有当前拦截的View消费,直到手指up的事件都交由它处理),如果不拦截则一直传到最后的叶子节点(如果页子是view的话,则会走到分发事件判断它的onTouchEvent方法是否返回true)返回true以后的事件都交给它处理,否则最终将被Activity处理,也就是同一时间(从手指down到手指up的全过程)只会有一个控件负责消耗这个事件,不会同时存在多个控件消费的情况。

又比如说ios的事件传递是从最后的叶子节点开始的,这和android相反,但是同一时间也只有一个控件消费事件。

为什么要了解分发事件呢?想要搞好前端的开发,那么你就不得不自定义控件,分发事件是你自定义控件的内功心法罢了。

好了,进入正题,flutter到底是怎么实现事件分发的呢?其实Flutter的跨平台的原理就会在android和ios加了它们的壳,也就是说用flutter开发android,那么最终产物还是apk,而这个apk负责将flutter编写的小部件解析成RGBA的二进制数据交给Gpu渲染出图像而已。那么分发事件肯定也就是从原生的分发事件再传到flutter的dart的sdk中,也就是事件的传递是android - c - dart,ios- c -dart

c就是flutter的底层库engine所做的事情,了解事件的分发我们只关注dart这边就好了。

事件分发到dart这边的入口类是GestureBinding这个类,不管是事件还是渲染还是垂直信号控制回调还是其他辅助功能都是在WidgetsFlutterBinding这个类处理的,它在runApp之后构造,是创建的单例类。

 

事件到达dart层之后调用的第一个方法为

 void _handlePointerDataPacket(ui.PointerDataPacket packet) {

    // We convert pointer data to logical pixels so that e.g. the touch slop can be
    // defined in a device-independent 
//将所有的手指的触摸事件转化成本地对象
    _pendingPointerEvents.addAll(PointerEventConverter.expand(packet.data, window.devicePixelRatio));

    if (!locked)
      _flushPointerEventQueue();
  }

这个方法的意思就是将原生传来的手势数据全部转化为Dart对应的对象保存数据,这里可能是多个手指的数据,所以用了集合来储存。


  void _flushPointerEventQueue() {
    assert(!locked);
    while (_pendingPointerEvents.isNotEmpty)
      _handlePointerEvent(_pendingPointerEvents.removeFirst());
  }

_flushPointerEventQueue方法就是循环处理每根手指的的事件。

void _handlePointerEvent(PointerEvent event) {
    assert(!locked);
    HitTestResult hitTestResult;
    //如果是手指按下的话
    if (event is PointerDownEvent || event is PointerSignalEvent) {
      assert(!_hitTests.containsKey(event.pointer));
      hitTestResult = HitTestResult();
      //得到碰撞的控件组
      hitTest(hitTestResult, event.position);
      if (event is PointerDownEvent) {
        _hitTests[event.pointer] = hitTestResult;
      }
      assert(() {
        if (debugPrintHitTestResults)
          debugPrint('$event: $hitTestResult');
        return true;
      }());
    }
    //手指抬起
    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
      // PointerMoveEvents) 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 ||
        event is PointerHoverEvent ||
        event is PointerAddedEvent ||
        event is PointerRemovedEvent) {
      dispatchEvent(event, hitTestResult);
    }
  }

这个方法的主要目的就是得到HitTestResult这个类,就是根据你手机按下的坐标位置找出view树中哪些控件处在你点击的范围内,可以认定它为候选处理事件的人,找候选人的事在手指按下的时候找,手指在移动和抬起的时候都复用当前点击找到的事件,区别在于不同的手指有不同的索引值(在原生的那一层被赋值)。

那么接下来来看一下手指按下时是怎么装在后选人的,hitTest首先会进入RendererBinding处理

void hitTest(HitTestResult result, Offset position) {
    assert(renderView != null);
    renderView.hitTest(result, position: position);
    super.hitTest(result, position);
  }

renderView可以理解为view树的根View,在flutter中叫widget ,一个widget 对应一个element ,一个element 并不一定有一个RenderObject与之对应,也就是说widget 对应一个element 只负责view的信息而真正渲染到屏幕上的只有RenderObject ,接着进入RenderViewhitTest方法

 bool hitTest(HitTestResult result, { Offset position }) {
    /**
     * 先放入的孩子
     */
    if (child != null)
      child.hitTest(BoxHitTestResult.wrap(result), position: position);
    result.add(HitTestEntry(this));
    return true;
  }

可以看到根view是先从子view开始放进集合,放完子View再放自己,那么这和前端js点击事件冒泡法是一个道理。

这里的child是RenderBox(RenderObject的子类)的类型,进入RenderBox 的这个方法,依然是先添加满足条件的子孩子

 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;
  }

接下来来看一下Stack小部件hitTestChildren的实现

  @override
  bool hitTestChildren(BoxHitTestResult result, { Offset position }) {
    return defaultHitTestChildren(result, position: position);
  }
bool defaultHitTestChildren(BoxHitTestResult result, { 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;
      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;
  }

这个方法的意思就是父控件的限制比如padding,和子控件的位置做混合之后实际在位置是否包括点击的位置,stack是从最后一个view开始计算的,最后一个满足命中,则其他的直接忽视,还是先取的最后的叶子节点。

接着看hitTestSelf,默认返回false,如果你要自定义控件的话,为了有手势事件你需要让hitTestSelf返回true,一般情况下为子部件添加点击事件,当然我们添加点击事件都是为嵌套一层

GestureDetectorListener
bool hitTestSelf(Offset position) => false

看到这,大体可以看出flutter的获得处理事件的候选人就是判断点击的坐标知否在控件范围内,而且还是从叶子开始添加的,那么事件是从下往上传的,冒泡的形式

现在回到主流程,获得的候选人之后开始处理事件

 void dispatchEvent(PointerEvent event, HitTestResult hitTestResult) {
    assert(!locked);
    // No hit test information implies that this is a hover or pointer
    // add/remove event.这种情况出在指针悬停屏幕上方,微微接触或不接触,是手机敏感而言
    if (hitTestResult == null) {
      assert(event is PointerHoverEvent || 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<PointerEvent>('Event', event, style: DiagnosticsTreeStyle.errorProperty);
          },
        ));
      }
      return;
    }
//开始循环执行事件的传递
    for (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<PointerEvent>('Event', event, style: DiagnosticsTreeStyle.errorProperty);
            yield DiagnosticsProperty<HitTestTarget>('Target', entry.target, style: DiagnosticsTreeStyle.errorProperty);
          },
        ));
      }
    }
  }

这个方法才是真正的实现事件的分发以冒泡的形式从底部到根部开始分发事件,那么这就分情况了,看似只要我的子部件成为候选人和父部件也成为后选人的话,它们是都可以收到事件的处理的,就是说可以同时存在多个部件在实现事件的处理,前提是它的handleEvent有自己的处理逻辑,默认是空实现。

下面拿使用GestureDetector和Listener来举例事件分发的不同。

如果用Listener的话,Listener的小部件最终对应的RenderObject是RenderPointerListener,它的监测当前点击是否命中的方法如下

bool hitTest(BoxHitTestResult result, { Offset position }) {
    bool hitTarget = false;
    if (size.contains(position)) {
      hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position);
      if (hitTarget || behavior == HitTestBehavior.translucent)
        result.add(BoxHitTestEntry(this, position));
    }
    return hitTarget;
  }

  @override
  bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque;

这里我们默认使用Listener的情况下是命中的,因为很多子部件例如Text、Image等等的hitTestSelf返回True,假如我们为Text嵌套了Listener,那么事件分发的时候,就会走

void handleEvent(PointerEvent event, HitTestEntry entry) {
    assert(debugHandleEvent(event, entry));
    if (onPointerDown != null && event is PointerDownEvent)
      return onPointerDown(event);
    if (onPointerMove != null && event is PointerMoveEvent)
      return onPointerMove(event);
    if (onPointerUp != null && event is PointerUpEvent)
      return onPointerUp(event);
    if (onPointerCancel != null && event is PointerCancelEvent)
      return onPointerCancel(event);
    if (onPointerSignal != null && event is PointerSignalEvent)
      return onPointerSignal(event);
  }

所以你嵌套多层的Listener只要实现了这些方法的回调都会被执行,事件并不会被一个Listener完全消耗,如果你要Listener自定义事件的话所有控件都可以监听到事件的处理,也就是所有命中控件共享事件的处理。

 

如果用GestureDetector的话,build的方法为我们添加了很多处理手势的方法类,例如TapGestureRecognizer,然后最终返回了

RawGestureDetector的 小部件
 final Map<Type, GestureRecognizerFactory> gestures = <Type, GestureRecognizerFactory>{};

    if (
      onTapDown != null ||
      onTapUp != null ||
      onTap != null ||
      onTapCancel != null ||
      onSecondaryTapDown != null ||
      onSecondaryTapUp != null ||
      onSecondaryTapCancel != null
    ) {
      gestures[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
        () => TapGestureRecognizer(debugOwner: this),
        (TapGestureRecognizer instance) {
          instance
            ..onTapDown = onTapDown
            ..onTapUp = onTapUp
            ..onTap = onTap
            ..onTapCancel = onTapCancel
            ..onSecondaryTapDown = onSecondaryTapDown
            ..onSecondaryTapUp = onSecondaryTapUp
            ..onSecondaryTapCancel = onSecondaryTapCancel;
        },
      );
    }

    if (onDoubleTap != null) {
      gestures[DoubleTapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
        () => DoubleTapGestureRecognizer(debugOwner: this),
        (DoubleTapGestureRecognizer instance) {
          instance
            ..onDoubleTap = onDoubleTap;
        },
      );
    }

    if (onLongPress != null ||
        onLongPressUp != null ||
        onLongPressStart != null ||
        onLongPressMoveUpdate != null ||
        onLongPressEnd != null) {
      gestures[LongPressGestureRecognizer] = GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
        () => LongPressGestureRecognizer(debugOwner: this),
        (LongPressGestureRecognizer instance) {
          instance
            ..onLongPress = onLongPress
            ..onLongPressStart = onLongPressStart
            ..onLongPressMoveUpdate = onLongPressMoveUpdate
            ..onLongPressEnd =onLongPressEnd
            ..onLongPressUp = onLongPressUp;
        },
      );
    }

    if (onVerticalDragDown != null ||
        onVerticalDragStart != null ||
        onVerticalDragUpdate != null ||
        onVerticalDragEnd != null ||
        onVerticalDragCancel != null) {
      gestures[VerticalDragGestureRecognizer] = GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>(
        () => VerticalDragGestureRecognizer(debugOwner: this),
        (VerticalDragGestureRecognizer instance) {
          instance
            ..onDown = onVerticalDragDown
            ..onStart = onVerticalDragStart
            ..onUpdate = onVerticalDragUpdate
            ..onEnd = onVerticalDragEnd
            ..onCancel = onVerticalDragCancel
            ..dragStartBehavior = dragStartBehavior;
        },
      );
    }

    if (onHorizontalDragDown != null ||
        onHorizontalDragStart != null ||
        onHorizontalDragUpdate != null ||
        onHorizontalDragEnd != null ||
        onHorizontalDragCancel != null) {
      gestures[HorizontalDragGestureRecognizer] = GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>(
        () => HorizontalDragGestureRecognizer(debugOwner: this),
        (HorizontalDragGestureRecognizer instance) {
          instance
            ..onDown = onHorizontalDragDown
            ..onStart = onHorizontalDragStart
            ..onUpdate = onHorizontalDragUpdate
            ..onEnd = onHorizontalDragEnd
            ..onCancel = onHorizontalDragCancel
            ..dragStartBehavior = dragStartBehavior;
        },
      );
    }

    if (onPanDown != null ||
        onPanStart != null ||
        onPanUpdate != null ||
        onPanEnd != null ||
        onPanCancel != null) {
      gestures[PanGestureRecognizer] = GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
        () => PanGestureRecognizer(debugOwner: this),
        (PanGestureRecognizer instance) {
          instance
            ..onDown = onPanDown
            ..onStart = onPanStart
            ..onUpdate = onPanUpdate
            ..onEnd = onPanEnd
            ..onCancel = onPanCancel
            ..dragStartBehavior = dragStartBehavior;
        },
      );
    }

    if (onScaleStart != null || onScaleUpdate != null || onScaleEnd != null) {
      gestures[ScaleGestureRecognizer] = GestureRecognizerFactoryWithHandlers<ScaleGestureRecognizer>(
        () => ScaleGestureRecognizer(debugOwner: this),
        (ScaleGestureRecognizer instance) {
          instance
            ..onStart = onScaleStart
            ..onUpdate = onScaleUpdate
            ..onEnd = onScaleEnd;
        },
      );
    }

    if (onForcePressStart != null ||
        onForcePressPeak != null ||
        onForcePressUpdate != null ||
        onForcePressEnd != null) {
      gestures[ForcePressGestureRecognizer] = GestureRecognizerFactoryWithHandlers<ForcePressGestureRecognizer>(
        () => ForcePressGestureRecognizer(debugOwner: this),
        (ForcePressGestureRecognizer instance) {
          instance
            ..onStart = onForcePressStart
            ..onPeak = onForcePressPeak
            ..onUpdate = onForcePressUpdate
            ..onEnd = onForcePressEnd;
        },
      );
    }

    return RawGestureDetector(
      gestures: gestures,
      behavior: behavior,
      excludeFromSemantics: excludeFromSemantics,
      child: child,
    );
 Widget build(BuildContext context) {
    Widget result = Listener(
      onPointerDown: _handlePointerDown,
      behavior: widget.behavior ?? _defaultBehavior,
      child: widget.child,
    );
    if (!widget.excludeFromSemantics)
      result = _GestureSemantics(
        child: result,
        assignSemantics: _updateSemanticsForRenderObject,
      );
    return result;
  }

RawGestureDetector默认使用的也是Listener,它注册了手指down下的方法,分发的时候down事件是sdk默认处理的

 void _handlePointerDown(PointerDownEvent event) {
    print("_handlePointerDown");
    assert(_recognizers != null);
    for (GestureRecognizer recognizer in _recognizers.values)
      recognizer.addPointer(event);
  }

这个方法会向Binding路由器中注册那些需要处理的事件,假如我们只是声明了点击事件,那么集合中的负责添加的GestureRecognizer的实现类就是TapGestureRecognizer

 void addPointer(PointerDownEvent event) {
    _pointerToKind[event.pointer] = event.kind;
    if (isPointerAllowed(event)) {
      addAllowedPointer(event);
    } else {
      handleNonAllowedPointer(event);
    }
  }
bool isPointerAllowed(PointerDownEvent event) {
    switch (event.buttons) {
      case kPrimaryButton:
        if (onTapDown == null &&
            onTap == null &&
            onTapUp == null &&
            onTapCancel == null)
          return false;
        break;
      case kSecondaryButton:
        if (onSecondaryTapDown == null &&
            onSecondaryTapUp == null &&
            onSecondaryTapCancel == null)
          return false;
        break;
      default:
        return false;
    }
    return super.isPointerAllowed(event);
  }

这个方法用来判定能否添加当前手指的手势以便接下来的调用,很明显在所有的回调方法中,你不声明的话就会返回false,既然用了它就是来监听手势的不可能不写,不写的话后续事件不会经过GestureRecognizer过来,handleNonAllowedPointer将会移除当前事件的处理。

void addAllowedPointer(PointerDownEvent event) {
    startTrackingPointer(event.pointer, event.transform);
    if (state == GestureRecognizerState.ready) {
      state = GestureRecognizerState.possible;
      primaryPointer = event.pointer;
      initialPosition = OffsetPair(local: event.localPosition, global: event.position);
      if (deadline != null)
        _timer = Timer(deadline, () => didExceedDeadlineWithEvent(event));
    }
void startTrackingPointer(int pointer, [Matrix4 transform]) {
    GestureBinding.instance.pointerRouter.addRoute(pointer, handleEvent, transform);
    _trackedPointers.add(pointer);
    assert(!_entries.containsValue(pointer));
    _entries[pointer] = _addPointerToArena(pointer);
  }

这两个方法主要用来将当前的handleEvent方法添加到GestureBinding路由器里面去,而_addPointerToArena是朝竞技场中添加

备选处理事件的人员,上面提到hitTest的添加,倒数后两个hitTestRenderViewGestureBinding

 void handleEvent(PointerEvent event, covariant HitTestEntry entry) { }

RenderView没有实现handleEvent的事件的分发,那么来看GestureBinding

void handleEvent(PointerEvent event, HitTestEntry entry) {
//路由处理手势
    pointerRouter.route(event);
    if (event is PointerDownEvent) {
//从竞技场中强行获得手势处理的人员
      gestureArena.close(event.pointer);
    } else if (event is PointerUpEvent) {
/从竞技场中强行获得手势处理的人员
      gestureArena.sweep(event.pointer);
    } else if (event is PointerSignalEvent) {
      pointerSignalResolver.resolve(event);
    }
  }
}

手指down的时候GestureRecognizerhandleEvent的方法,被添加到路由器中,那么此时并没有决策出到底哪一个备选者控件将会成为事件的处理者,那么执行  gestureArena.close(event.pointer);

void close(int pointer) {
    final _GestureArena state = _arenas[pointer];
    if (state == null)
      return; // This arena either never existed or has been resolved.
    state.isOpen = false;
    assert(_debugLogDiagnostic(pointer, 'Closing', state));
    _tryToResolveArena(pointer, state);
  }

state.isOpen此时被标记为false,也就是当前的需要关闭当前手指的竞技

void _tryToResolveArena(int pointer, _GestureArena state) {
    assert(_arenas[pointer] == state);
    assert(!state.isOpen);
    if (state.members.length == 1) {
      scheduleMicrotask(() => _resolveByDefault(pointer, state));
    } else if 
//如果当前手指没有members不处理
(state.members.isEmpty) {
      _arenas.remove(pointer);
      assert(_debugLogDiagnostic(pointer, 'Arena empty.'));
    } //如果已经有胜利者了
else if (state.eagerWinner != null) {
      assert(_debugLogDiagnostic(pointer, 'Eager winner: ${state.eagerWinner}'));
      _resolveInFavorOf(pointer, state, state.eagerWinner);
    }
  }

而_tryToResolveArena的实现是,如果当前手指的竞技场只有一个GestureRecognizer,也就是说你只使用了一个GestureDetector的话,所有的事件都由GestureDetector一个处理,也就是说此时的事件只有一个控件可以处理,如果state.eagerWinner不为null,也就是在down的时候,我们可以主动决出胜利者交给binding。

void _resolveInFavorOf(int pointer, _GestureArena state, GestureArenaMember member) {
    assert(state == _arenas[pointer]);
    assert(state != null);
    assert(state.eagerWinner == null || state.eagerWinner == member);
    assert(!state.isOpen);
    _arenas.remove(pointer);
    //其他的命中全部拒绝
    for (GestureArenaMember rejectedMember in state.members) {
      if (rejectedMember != member)
        rejectedMember.rejectGesture(pointer);
    }
    member.acceptGesture(pointer);
  }

这个方法用来处理其他候选人再接下来的事件中将不会接受事件,竞争成功的控件将会调用 member.acceptGesture方法,处理GestureRecognizer的down事件相关。 当手指抬起时

void sweep(int pointer) {
    final _GestureArena state = _arenas[pointer];
    if (state == null)
      return; // This arena either never existed or has been resolved.
    assert(!state.isOpen);
    if (state.isHeld) {
      state.hasPendingSweep = true;
      assert(_debugLogDiagnostic(pointer, 'Delaying sweep', state));
      return; // This arena is being held for a long-lived member.
    }
    assert(_debugLogDiagnostic(pointer, 'Sweeping', state));
    _arenas.remove(pointer);
    if (state.members.isNotEmpty) {
      // First member wins.
      assert(_debugLogDiagnostic(pointer, 'Winner: ${state.members.first}'));
      state.members.first.acceptGesture(pointer);
      // Give all the other members the bad news.
      for (int i = 1; i < state.members.length; i++)
        state.members[i].rejectGesture(pointer);
    }
  }

如果还没能决策出到底谁该处理事件的分发的话,将会默认采用最底层的的叶子节点控件作为事件处理者,也就是说最内层的那个控件将消耗事件。也就是说你如果你用GestureRecognizer的话,最终事件会被最内层的GestureRecognizer消耗,这个android单个控件消耗事件差不多了,因为Flutter自带滚动控件都是类似实现了GestureRecognizer,所以嵌套滚动总是先滚动内层,先被内层消耗。

这里还有一个点就是长安事件并不是经过的路由器回调,而是直接Timer定时,所以长安的down事件内外层都是可以监听到的。

 

 

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值