flutter滚动视图之ScrollPositionAlignmentPolicy、ScrollPosition源码解析(二)

ScrollPositionAlignmentPolicy

ruby 复制代码
/// 在应用 [ScrollPosition.ensureVisible] 的 `alignment` 参数时使用的策略。
enum ScrollPositionAlignmentPolicy {
  /// 使用 [ScrollPosition.ensureVisible] 的 `alignment` 参数来决定
  /// 目标对象在滚动后如何对齐。
  explicit,

  /// 找到滚动容器的底部边界,如果有需要则滚动容器,以保证对象的底部可见。
  ///
  /// 例如,找到滚动容器的底部边界。如果目标项的底部在容器的底部边界之下,
  /// 则滚动使该目标项的底部正好出现在容器的可见区域内。
  /// 如果该目标项已经完全可见,则不进行滚动。
  keepVisibleAtEnd,

  /// 找到滚动容器的顶部边界,如果有需要则滚动容器,以保证对象的顶部可见。
  ///
  /// 例如,找到滚动容器的顶部边界。如果目标项的顶部在容器的顶部边界之上,
  /// 则滚动使该目标项的顶部正好出现在容器的可见区域内。
  /// 如果该目标项已经完全可见,则不进行滚动。
  keepVisibleAtStart,
}

是一个 枚举 ,主要用于控制 ScrollPosition.ensureVisible 在滚动时如何对齐目标 Widget。

1. explicit

  • 行为 :完全依赖传入的 alignment 参数(0.0 表示顶部对齐,1.0 表示底部对齐,0.5 表示居中等)。
  • 应用场景 :你希望 自定义精确的对齐方式

2. keepVisibleAtEnd

  • 行为 :如果目标 Widget 的 底部超出容器的可见范围 ,就滚动让它的底部正好显示在容器底部;
    如果目标 Widget 已经完整可见,则不滚动。
  • 应用场景 :适合 聊天窗口 这类场景,新消息进来时保持在底部。

3. keepVisibleAtStart

  • 行为 :如果目标 Widget 的 顶部在容器上方不可见 ,就滚动让它的顶部正好显示在容器顶部;
    如果目标 Widget 已经完整可见,则不滚动。
  • 应用场景 :适合需要保持 列表头部元素可见 的情况,比如目录导航。

ScrollPosition

ScrollPosition 就是 记录和管理滚动视图当前滚到哪、能滚到哪、怎么滚的对象,它保存了偏移量、边界、滚动状态,并能通知监听器变化。

  • 存数据 :保存当前滚动位置(pixels)、最大/最小能滚多远(maxScrollExtent / minScrollExtent)。

  • 管滚动 :配合 physics 决定滚动方式(比如弹性、惯性)。

  • 控行为 :通过 activity 表示现在是拖拽、惯性还是静止。

  • 能监听Listenable,随时通知你「滚动发生了」。

  • 配套用 :常和 ScrollControllerNotificationListener 一起使用。

scala 复制代码
/// 确定在滚动视图中内容的哪一部分是可见的。
///
/// [pixels] 值决定了滚动视图的滚动偏移量,用于选择显示内容的哪一部分。
/// 当用户滚动视口时,这个值会发生变化,从而改变显示的内容。
///
/// [ScrollPosition] 会对滚动应用 [physics],并保存 [minScrollExtent] 和 [maxScrollExtent]。
///
/// 滚动由当前的 [activity] 控制,它是通过 [beginActivity] 设置的。
/// [ScrollPosition] 本身并不会启动任何 activity。
/// 相反,具体的子类(如 [ScrollPositionWithSingleContext])通常会在响应用户输入
/// 或 [ScrollController] 的指令时启动 activity。
///
/// 该对象是一个 [Listenable],当 [pixels] 发生变化时会通知监听器。
///
/// {@template flutter.widgets.scrollPosition.listening}
/// ### 获取滚动信息
///
/// 有几种方式可以获取有关滚动和可滚动组件的信息,
/// 但它们提供的信息类型各不相同,
/// 包括滚动行为、位置以及 [Viewport] 的尺寸等。
///
/// [ScrollController] 是一个 [Listenable]。
/// 当任意一个附加到它的 [ScrollPosition] 通知其监听器时,
/// 它也会通知自己的监听器(比如在滚动发生时)。
/// 这和使用 [ScrollNotification] 类型的 [NotificationListener]
/// 来监听滚动位置变化非常相似,但两者不同之处在于:
///
/// - [NotificationListener] 会提供有关"滚动活动"的信息;  
/// - [ScrollController] 则可以直接访问滚动位置对象本身。  
///
/// 另外,监听 [ScrollNotification] 还可以细分为监听其特定子类,
/// 比如 [UserScrollNotification]。
///
/// {@tool dartpad}
/// 这个示例展示了使用 [ScrollController] 与使用 [ScrollNotification] 类型的
/// [NotificationListener] 来监听滚动的区别。
/// 切换 [Radio] 按钮可以在两种方式间切换。
/// 使用 [ScrollNotification] 可以获取滚动活动的详细信息以及 [ScrollPosition] 的指标,
/// 但不能直接获取滚动位置对象本身;
/// 使用 [ScrollController] 则可以直接访问位置对象。
/// 这两种方式都只会在"滚动行为发生时"触发。
///
/// **示例代码见:examples/api/lib/widgets/scroll_position/scroll_controller_notification.0.dart**
/// {@end-tool}
///
/// 注意:  
/// [ScrollController] 不会在附加到它的 [ScrollPosition] 列表发生变化时通知监听器。  
/// 如果需要监听滚动位置的附加和分离,可以使用 [ScrollController.onAttach] 和
/// [ScrollController.onDetach] 方法。  
/// 这在 [Scrollable] 的 build 方法中创建滚动位置时,
/// 想要为其 [ScrollPosition.isScrollingNotifier] 添加监听器时也很有用。
///
/// 当一个滚动位置刚附加上时,
/// 它的 [ScrollMetrics](例如 [ScrollMetrics.maxScrollExtent])还不可用,  
/// 因为这些值要等到 [Scrollable] 完成布局并计算内容范围后才会确定。  
/// 可以通过 [ScrollPosition.hasContentDimensions] 来判断指标是否已可用,  
/// 或者使用 [ScrollMetricsNotification](后面会详细讨论)。
///
/// {@tool dartpad}
/// 这个示例展示了如何通过 [ScrollController.onAttach]
/// 为 [ScrollPosition.isScrollingNotifier] 添加监听器。  
/// 它用于在滚动发生时改变 [AppBar] 的颜色。
///
/// **示例代码见:examples/api/lib/widgets/scroll_position/scroll_controller_on_attach.0.dart**
/// {@end-tool}
///
/// #### 从不同的上下文中获取
///
/// 当需要在"滚动组件内部的上下文"中获取滚动信息时,
/// 可以使用 [Scrollable.of] 获取 [ScrollableState],
/// 然后通过 [ScrollableState.position] 获取 [ScrollPosition]。  
/// 这个位置对象就是附加在 [ScrollController] 上的那个。
///
/// 当需要在"滚动组件外部的上下文"中获取滚动信息时,使用 [ScrollNotificationObserver]。  
/// 例如 [AppBar] 的"滚动置底效果"就是这样实现的。  
/// 因为 [Scaffold.appBar] 与 [Scaffold.body] 属于不同的子树,  
/// 滚动通知不会冒泡到 app bar。  
/// 可以通过 [ScrollNotificationObserverState.addListener] 监听在当前上下文之外发生的滚动通知。
///
/// #### 尺寸变化
///
/// 需要注意的是:  
/// 监听 [ScrollController] 或 [ScrollPosition] **不会**在滚动指标([ScrollMetrics])发生变化时触发,  
/// 比如窗口大小改变导致 [Viewport] 尺寸或可滚动范围发生变化。  
/// 如果需要监听滚动指标的变化,应使用 [ScrollMetricsNotification]。  
/// 与 [ScrollNotification] 不同,  
/// [ScrollMetricsNotification] 并不与"滚动活动"相关,  
/// 而是与滚动区域的尺寸变化有关(例如窗口大小)。
///
/// {@tool dartpad}
/// 这个示例展示了当 `windowSize` 改变时,
/// 如何派发一个 [ScrollMetricsNotification]。  
/// 点击浮动按钮可以增加可滚动窗口的大小。
///
/// **示例代码见:examples/api/lib/widgets/scroll_position/scroll_metrics_notification.0.dart**
/// {@end-tool}
/// {@endtemplate}
///
/// ## 继承 ScrollPosition
///
/// 随着时间推移,一个 [Scrollable] 可能会有多个不同的 [ScrollPosition] 对象。  
/// 例如,当 [Scrollable.physics] 的类型发生变化时,  
/// [Scrollable] 会使用新的 physics 创建一个新的 [ScrollPosition]。  
/// 为了将旧实例的状态转移到新实例,子类需要实现 [absorb] 方法(详见 [absorb])。  
///
/// 此外,子类在 [userScrollDirection] 值变化时还需要调用 [didUpdateScrollDirection]。
///
/// 参见:
///
///  * [Scrollable]:使用 [ScrollPosition] 来决定显示内容的哪一部分。  
///  * [ScrollController]:可与 [ListView]、[GridView] 等可滚动组件一起使用,用于控制 [ScrollPosition]。  
///  * [ScrollPositionWithSingleContext]:最常用的 [ScrollPosition] 具体子类。  
///  * [ScrollNotification] 和 [NotificationListener]:在不使用 [ScrollController] 的情况下监听滚动位置。  
abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {}

ScrollPosition

kotlin 复制代码
  /// 创建一个对象,用来确定滚动视图中哪一部分内容是可见的。
  ScrollPosition({
    required this.physics,        // 滚动物理特性(决定滚动行为,比如惯性、回弹等)
    required this.context,        // 上下文信息(通常是 Scrollable 的 BuildContext)
    this.keepScrollOffset = true, // 是否保持滚动偏移量(通常用于恢复滚动位置)
    ScrollPosition? oldPosition,  // 旧的滚动位置对象,用来在重建时继承状态
    this.debugLabel,              // 调试标签
  }) {
    if (oldPosition != null) {
      absorb(oldPosition);        // 如果有旧的滚动位置,吸收它的状态
    }
    if (keepScrollOffset) {
      restoreScrollOffset();      // 如果需要保持滚动偏移,就尝试恢复滚动位置
    }
  }

}

当创建一个 ScrollPosition 时,它会做三件事:

  1. 设置基础属性 :保存滚动物理特性(physics)、上下文(context)、是否保持偏移(keepScrollOffset)等。
  2. 继承旧状态 :如果传入了 oldPosition,会调用 absorb(oldPosition),把旧的滚动状态(如 pixels、方向等)转移过来。
  3. 恢复滚动偏移 :如果 keepScrollOffset = true,会尝试调用 restoreScrollOffset() 恢复之前的滚动位置(比如应用重建后保持列表滚动到原来的位置)。

假设有一个 ListView,滑到了中间,然后因为主题切换或屏幕旋转导致 Widget 重建:

  • Flutter 会创建一个新的 ScrollPosition
  • 它会把旧的 ScrollPosition 的状态吸收过来(所以列表不会跳到顶部)。
  • 如果设置了 keepScrollOffset = true,它还会自动恢复到之前的滚动位置。

physics、context、keepScrollOffset、minScrollExtent、maxScrollExtent

dart 复制代码
  /// 滚动位置应该如何响应用户输入。
  ///
  /// 例如:决定当用户停止拖动滚动视图后,
  /// 该组件应该如何继续执行动画。
  final ScrollPhysics physics;

  /// 滚动发生的上下文。
  ///
  /// 通常由 [ScrollableState] 实现。
  final ScrollContext context;

  /// 是否使用 [PageStorage] 保存当前滚动偏移,并在该滚动位置的
  /// 可滚动组件被重新创建时恢复它。
  ///
  /// 另见:
  ///
  ///  * [ScrollController.keepScrollOffset] 和 [PageController.keepPage],
  ///    它们会创建滚动位置并初始化这个属性。
  // TODO(goderbauer): 当状态恢复支持 PageStorage 的所有功能时,弃用这个属性。
  final bool keepScrollOffset;

  /// 一个用于 [toString] 输出的标签。
  ///
  /// 主要用于在调试输出中帮助区分不同的动画控制器实例。
  final String? debugLabel;

  @override
  double get minScrollExtent => _minScrollExtent!;
  double? _minScrollExtent;   // 最小可滚动范围

  @override
  double get maxScrollExtent => _maxScrollExtent!;
  double? _maxScrollExtent;   // 最大可滚动范围

  @override
  bool get hasContentDimensions => _minScrollExtent != null && _maxScrollExtent != null;
  // 是否已经有内容的滚动范围(即 min/max 都已确定)

  /// 在单帧内 [forcePixels] 改变时,额外添加的速度。
  ///
  /// 这个值和当前 [activity] 的 [ScrollActivity.velocity] 一起
  /// 用于 [recommendDeferredLoading],来询问 [physics] 是否需要延迟加载。
  ///
  /// 这是因为一次 [forcePixels] 调用可能对应的 [ScrollActivity] 速度为 0,
  /// 但实际上滚动视图瞬间从当前位置跳到一个很远的位置。
  /// 调用方需要考虑这种情况。
  ///
  /// 举例:
  /// 如果当前滚动位置在 5000 像素,我们 [jumpTo] 到 0(回到顶部),
  /// 则隐含速度为 -5000,而 `activity.velocity` 为 0。
  /// 在这个跳转过程中,可能会跨过大量资源消耗大的 Widget,
  /// 它们应该避免执行不必要的工作。
  double _impliedVelocity = 0;

  @override
  double get pixels => _pixels!;
  double? _pixels;   // 当前滚动偏移量

  @override
  bool get hasPixels => _pixels != null;   // 是否有有效的滚动偏移

  @override
  double get viewportDimension => _viewportDimension!;
  double? _viewportDimension;   // 视口(可见区域)的尺寸

  @override
  bool get hasViewportDimension => _viewportDimension != null; 
  // 是否已经有视口尺寸

  /// 是否已经有 [viewportDimension]、[minScrollExtent]、[maxScrollExtent]、
  /// [outOfRange] 和 [atEdge] 等值。
  ///
  /// 在第一次调用 [applyNewDimensions] 之前设置为 true。
  bool get haveDimensions => _haveDimensions;
  bool _haveDimensions = false;

  /// 该位置上的可滚动组件是否应该吸收指针事件。
  ///
  /// 该值取决于当前的 [ScrollActivity],
  /// 决定滚动视图或其子元素是否还能接收额外的触摸输入。
  /// 
  /// 比如:在 [BouncingScrollPhysics] 允许越界滚动时,
  /// 当滚动视图从越界状态回弹时,
  /// 子组件仍然可以接收指针事件。
  bool get shouldIgnorePointer => !outOfRange && (activity?.shouldIgnorePointer ?? true);
  • 核心属性

    • physics 👉 滚动规则(决定怎么滚)。
    • context 👉 滚动发生的环境。
    • keepScrollOffset 👉 要不要保存和恢复滚动位置。
    • debugLabel 👉 调试用的名字。
  • 滚动范围

    • minScrollExtent / maxScrollExtent 👉 最小、最大可滚动位置。
    • hasContentDimensions 👉 是否已经知道内容的滚动范围。
  • 滚动状态

    • pixels 👉 当前滚动偏移量。
    • viewportDimension 👉 可见窗口的大小。
    • haveDimensions 👉 是否已经知道了完整的滚动尺寸信息。
  • 特殊逻辑

    • _impliedVelocity 👉 处理「瞬间跳跃」时的隐含速度。
    • shouldIgnorePointer 👉 决定滚动时,子组件是否还能响应触摸事件。

absorb

scss 复制代码
  /// 从给定的 [ScrollPosition] 中接管所有当前可用的状态。
  ///
  /// 如果构造函数传入了 `oldPosition`,就会调用此方法。
  /// `other` 参数的 [runtimeType] 可能和当前对象不同。
  ///
  /// 这个方法可能会破坏另一个 [ScrollPosition] 的状态。
  /// 调用后,另一个对象必须立即被销毁(在相同的调用栈中,
  /// 在 microtask 执行前,由调用当前构造函数的代码负责销毁)。
  ///
  /// 如果旧的 [ScrollPosition] 对象和当前对象的 [runtimeType] 不同,
  /// 那么会在新接管的 [ScrollActivity] 上调用 [ScrollActivity.resetActivity]。
  ///
  /// ## 重写注意事项
  ///
  /// 如果子类要重写此方法:
  /// - 必须在设置任何和滚动指标或活动相关的状态后,调用 `super.absorb`,
  ///   因为此方法可能会重启 activity,而滚动活动通常依赖这些指标。  
  /// - 如果无法吸收另一个对象的 activity,可能需要手动启动一个 [IdleScrollActivity]。  
  /// - 如果子类本身充当了 [ScrollActivityDelegate],可能需要更新已吸收的
  ///   scroll activities 的 delegate。  


void absorb(ScrollPosition other) {
  assert(other.context == context);     // 确保两个位置的上下文一致
  assert(_pixels == null);              // 确保当前还没有设置滚动位置

  // 如果旧位置有内容范围,继承过来
  if (other.hasContentDimensions) {
    _minScrollExtent = other.minScrollExtent;
    _maxScrollExtent = other.maxScrollExtent;
  }

  // 如果旧位置有像素偏移,继承过来
  if (other.hasPixels) {
    _pixels = other.pixels;
  }

  // 如果旧位置有视口尺寸,继承过来
  if (other.hasViewportDimension) {
    _viewportDimension = other.viewportDimension;
  }

  // 接管 activity
  assert(activity == null);
  assert(other.activity != null);
  _activity = other.activity;
  other._activity = null;               // 旧的 activity 被"掏空"

  // 如果两个对象的类型不同,需要重置 activity
  if (other.runtimeType != runtimeType) {
    activity!.resetActivity();
  }

  // 更新 context 和 isScrollingNotifier 的状态
  context.setIgnorePointer(activity!.shouldIgnorePointer);
  isScrollingNotifier.value = activity!.isScrolling;
}

absorb 就是 把旧的 ScrollPosition 的状态(滚动范围、偏移、视口大小、activity 等)吸收过来,然后让旧的对象"失效",再用新的继续工作。

场景举例:

  • 你在 ListView 里滚动到一半。
  • 因为某些原因(比如 physics 改变)需要创建一个新的 ScrollPosition
  • 新的对象会调用 absorb(oldPosition),把旧的滚动位置和状态都继承过来。
  • 这样一来,用户不会看到"跳回到顶部"的情况,滚动是连续的。

setPixels(非常重要)

scss 复制代码
  /// 将滚动位置([pixels])更新为指定的像素值。
  ///
  /// 只能由当前的 [ScrollActivity] 调用,
  /// 要么是在临时回调阶段,要么是响应用户输入时调用。
  ///
  /// 返回值表示是否有越界(overscroll)。
  /// - 如果返回 0.0,说明 [pixels] 已成功更新为给定的 `value`。
  /// - 如果返回值 > 0,说明新的 [pixels] 小于请求值(超出了最大滚动范围)。  
  /// - 如果返回值 < 0,说明新的 [pixels] 大于请求值(超出了最小滚动范围)。  
  ///
  /// 越界量由 [applyBoundaryConditions] 计算。
  ///
  /// 实际应用的偏移变化量通过 [didUpdateScrollPositionBy] 上报。  
  /// 如果有越界,则通过 [didOverscrollBy] 上报。


double setPixels(double newPixels) {
  assert(hasPixels); // 必须已有有效的像素位置

  // 不能在 build/layout/paint 阶段修改滚动位置,否则会干扰渲染流程
  assert(
    SchedulerBinding.instance.schedulerPhase != SchedulerPhase.persistentCallbacks,
    "不能在 build/layout/paint 阶段修改滚动位置!"
  );

  // 如果新的像素和当前不同
  if (newPixels != pixels) {
    // 计算边界条件(检查是否超出 min/max)
    final double overscroll = applyBoundaryConditions(newPixels);

    // debug 校验:overscroll 不能比实际移动量还大
    assert(() {
      final double delta = newPixels - pixels;
      if (overscroll.abs() > delta.abs()) {
        throw FlutterError("applyBoundaryConditions 返回了非法的 overscroll 值!");
      }
      return true;
    }());

    // 保存旧值,并更新为「newPixels - overscroll」
    final double oldPixels = pixels;
    _pixels = newPixels - overscroll;

    // 如果确实有变化
    if (_pixels != oldPixels) {
      if (outOfRange) {
        context.setIgnorePointer(false); // 越界时允许子组件接收触摸事件
      }
      notifyListeners(); // 通知监听器滚动位置改变
      didUpdateScrollPositionBy(pixels - oldPixels); // 报告实际偏移变化量
    }

    // 如果存在越界(大于精度容忍范围)
    if (overscroll.abs() > precisionErrorTolerance) {
      didOverscrollBy(overscroll); // 报告越界
      return overscroll; // 返回越界值
    }
  }

  return 0.0; // 没有变化 / 没有越界
}

setPixels 就是 "把滚动条拉到某个位置" 的底层实现,流程是:

  1. 检查新位置 newPixels
  2. 看看会不会超出范围(用 applyBoundaryConditions 算出 overscroll)。
  3. 如果能更新,就更新 _pixels 并通知监听器。
  4. 如果越界了,调用 didOverscrollBy 上报越界情况。

📌 举个例子

  • 列表最大滚动范围 = 1000,当前滚动到 900

  • 调用 setPixels(1200)

    • 目标是 1200,但超过最大范围 1000,overscroll = 200
    • 实际 _pixels 设置为 1000,返回 200,并触发 didOverscrollBy(200)

correctPixels

arduino 复制代码
/// 将 [pixels] 的值直接改为新的值,但不会通知任何监听者。
///
/// 这个方法主要用于 **布局过程 (layout)** 中调整位置。
/// 特别是在 [applyViewportDimension] 或 [applyContentDimensions] 的响应中调用。
/// (在这两种情况下,如果调用了该方法,那么它们通常应该返回 `false`,
/// 以表明位置已经被调整过了。)
///
/// 在其他情况下调用这个方法几乎都是不正确的。
/// 因为它不会立即触发渲染变化 ------
/// 依赖这个对象的 widget 或 render object 不会立刻被通知,
/// 它们只有在下一次读取该值时才会发现变化(可能会很晚才发生)。
///
/// 因此,通常只有在 **布局阶段修正值** 时才适用,
/// 尤其是 [ScrollPosition] 只有一个 viewport 客户端时。
///
/// 如果你想让位置立即跳转或动画滚动到一个新值,
/// 请考虑使用 [jumpTo] 或 [animateTo],
/// 它们会遵循正常的滚动偏移更新规则。
///
/// 如果你想绕过正常规则,强制把 [pixels] 设为某个值,
/// 可以使用 [forcePixels](不过请注意它的注释,
/// 即便如此,这通常仍然不是一个好主意)。
///
/// 另见:
///
///  * [correctBy] ------ [ViewportOffset] 的一个方法,
///    用于在布局过程中修正 offset,但不会通知监听者。
///  * [jumpTo] ------ 在不处于布局阶段时,
///    立即修改位置并应用新的偏移。
///  * [animateTo] ------ 类似 [jumpTo],但会通过动画滚动到目标偏移。

void correctPixels(double value) {
  _pixels = value;
}

非常简单:就是直接改 _pixels,完全 不触发任何通知或回调

🌟 使用场景

  • 什么时候用?

    • 布局过程中,因为内容尺寸或视口尺寸变化,需要"强制修正"滚动位置。
    • 比如:屏幕旋转、父布局大小变化、列表内容突然减少等。
  • 为什么不能乱用?

    • 因为它不会调用 notifyListeners(),所以界面不会立刻更新。
    • 如果不是在布局阶段用,UI 可能"卡住",直到下一次系统主动读取 pixels
相关推荐
小周同学:12 分钟前
在 Vue2 中使用 pdf.js + pdf-lib 实现 PDF 预览、手写签名、文字批注与高保真导出
开发语言·前端·javascript·vue.js·pdf
m0_4947166829 分钟前
CSS中实现一个三角形
前端·css
teeeeeeemo1 小时前
跨域及解决方案
开发语言·前端·javascript·笔记
JSON_L1 小时前
Vue Vant应用-数据懒加载
前端·javascript·vue.js
可爱小仙子1 小时前
vue-quill-editor上传图片vue3
前端·javascript·vue.js
じòぴé南冸じょうげん1 小时前
解决ECharts图表上显示的最小刻度不是设置的min值的问题
前端·javascript·echarts
小高0071 小时前
第一章 桃园灯火初燃,响应义旗始揭
前端·javascript·vue.js
小高0071 小时前
第二章 虎牢关前初试Composition,吕布持v-model搦战
前端·javascript·vue.js
清和已久2 小时前
nginx高性能web服务器
服务器·前端·nginx
SoaringHeart2 小时前
Flutter进阶:高内存任务的动态并发执行完美实现
前端·flutter