美文网首页
Flutter一种获取页面所有元素的方案

Flutter一种获取页面所有元素的方案

作者: 宋唐不送糖 | 来源:发表于2021-11-30 11:07 被阅读0次

    背景

    1.自动生成断言能力是自动化测试中很重要的一环节。断言本身可复杂可简单,当然复杂断言还是需人工生成,但是页面一些基础可见性断言还是有迹可循的。当我们获取到页面所有元素信息时,则可以自动加一些元素断言。
    2.当页面中有2个widget一模一样时(大小、类型、标识),如何区分和找到我们想要测试的widget呢?

    一种方案是根据控件坐标和大小来尽量确认,但是难免有偏差范围不准确和适配问题。
    另一种方案是根据widget树中的位置来标识控件的索引值index(XCTest的作法)。

    所以我们也需要获取页面的所有元素才能确定该widget的index值。

    方案

    如何获取页面的所有元素呢?
    1.根据flutter三棵树的特质,我们只要确定了页面的起点element,之后深度优先遍历记录树的节点,即可获取到页面所有的widget,之后再过滤出我们需要的widget和信息。
    2.页面的起点是Scaffold吗,答案是否定的。我们最常见的是走Navigator.push()的方式,其本质也是add了OverLay图层。但是还有一种是直接insert OverLay的方式添加图层到最顶层,其中并没有添加Scaffold元素,常见场景:ActionSheet。所以我们需要找出页面OverLay的起点Element。

    分析

    首先,我们知道widget树大概是这样的。

    MyApp -> WidgetApp -> Navigator
    

    我们进入Navigator看源码:

    @override
      Widget build(BuildContext context) {
        return HeroControllerScope.none(
          child: Listener(
            onPointerDown: _handlePointerDown,
            onPointerUp: _handlePointerUpOrCancel,
            onPointerCancel: _handlePointerUpOrCancel,
            child: AbsorbPointer(
              absorbing: false, // it's mutated directly by _cancelActivePointers above
              child: FocusScope(
                node: focusScopeNode,
                autofocus: true,
                child: Overlay(
                  key: _overlayKey,
                  initialEntries: overlay == null ?  _allRouteOverlayEntries.toList(growable: false) : const <OverlayEntry>[],
                ),
              ),
            ),
          ),
        );
      }
    

    NavigatorState的child是一个Overlay,里面有个initialEntries数组。
    再看下数组里放了什么?

     Iterable<OverlayEntry> get _allRouteOverlayEntries sync* {
        for (final _RouteEntry entry in _history)
          yield* entry.route.overlayEntries;
      }
    

    这里是从_history里获取_RouteEntry中的route的overlayEntries。这里先放着_history。我们再看Overlay里头有什么。

    @override
      Widget build(BuildContext context) {
        // This list is filled backwards and then reversed below before
        // it is added to the tree.
        final List<Widget> children = <Widget>[];
        bool onstage = true;
        int onstageCount = 0;
        for (int i = _entries.length - 1; i >= 0; i -= 1) {
          final OverlayEntry entry = _entries[I];
          if (onstage) {
            onstageCount += 1;
            children.add(_OverlayEntryWidget(
              key: entry._key,
              entry: entry,
            ));
            if (entry.opaque)
              onstage = false;
          } else if (entry.maintainState) {
            children.add(_OverlayEntryWidget(
              key: entry._key,
              entry: entry,
              tickerEnabled: false,
            ));
          }
        }
        return _Theatre(
          skipCount: children.length - onstageCount,
          children: children.reversed.toList(growable: false),
        );
      }
    

    OverlayState返回了一个_Theatre,里面逆序添加了children数组,children里面放了一个个_OverlayEntryWidget。这里skipCount用于计数当前离屏的页面个数。这里还有2个属性opaque和maintainState来决定是否计数。

    在_entries在倒序for循环的时候:
    1.在遇到 entry.opaque 为 ture 时,后续的 OverlayEntry就添加不进children中;
    2.entry.maintainState为true才会被添加到队列,否则在页面切换时,可能会被mount / unmount掉。

    _Theater是什么呢?它是一个剧院舞台,特殊的Stack结构,里面的页面一部分onStage会进行绘制,一部分offStage则会跳过。

    回归上面的_history数组,当调用Navigtor.push时,做了三件事:
    1.会将传入的Route组装成一个_RouteEntry添加进数组。
    2.通知overlay更新。
    3.取消当前活跃的点。

     Future<T> push<T extends Object>(Route<T> route) {
        _history.add(_RouteEntry(route, initialState: _RouteLifecycle.push));
        _flushHistoryUpdates();
        _afterNavigation(route);
        return route.popped;
      }
    

    这里重点讲下第二步,调用_RouteEntry的handlePush函数,通知观察者,并重新刷新overlay。

    void _flushHistoryUpdates({bool rearrangeOverlay = true}) {
        int index = _history.length - 1;
        _RouteEntry next;
        _RouteEntry entry = _history[index];
        _RouteEntry previous = index > 0 ? _history[index - 1] : null;
        final List<_RouteEntry> toBeDisposed = <_RouteEntry>[];
        while (index >= 0) {
          switch (entry.currentState) {
            ...
            case _RouteLifecycle.push:
            case _RouteLifecycle.pushReplace:
            case _RouteLifecycle.replace:
              entry.handlePush(
                navigator: this,
                previous: previous?.route,
                previousPresent: _getRouteBefore(index - 1, _RouteEntry.isPresentPredicate)?.route,
                isNewFirst: next == null,
              );
              if (entry.currentState == _RouteLifecycle.idle) {
                continue;
              }
              break;
          ...
          }
          index -= 1;
          next = entry;
          entry = previous;
          previous = index > 0 ? _history[index - 1] : null;
        }
    
        // Informs navigator observers about route changes.
        _flushObserverNotifications();
    
        // Now that the list is clean, send the didChangeNext/didChangePrevious
        // notifications.
        _flushRouteAnnouncement();
    
        // Announces route name changes.
        if (widget.reportsRouteUpdateToEngine) {
          final _RouteEntry lastEntry = _history.lastWhere(
            _RouteEntry.isPresentPredicate, orElse: () => null);
          final String routeName = lastEntry?.route?.settings?.name;
          if (routeName != _lastAnnouncedRouteName) {
            SystemNavigator.routeUpdated(
              routeName: routeName,
              previousRouteName: _lastAnnouncedRouteName
            );
            _lastAnnouncedRouteName = routeName;
          }
        }
    
        // Lastly, removes the overlay entries of all marked entries and disposes
        // them.
        for (final _RouteEntry entry in toBeDisposed) {
          for (final OverlayEntry overlayEntry in entry.route.overlayEntries)
            overlayEntry.remove();
          entry.dispose();
        }
        if (rearrangeOverlay)
          overlay?.rearrange(_allRouteOverlayEntries);
      }
    

    再看下_RouteEntry的.handlePush函数做了啥:调用route.install()和添加observer观察者。

    void handlePush({ @required NavigatorState navigator, @required bool isNewFirst, @required Route<dynamic> previous, @required Route<dynamic> previousPresent }) {
        final _RouteLifecycle previousState = currentState;
        route._navigator = navigator;
        route.install();
        if (previousState == _RouteLifecycle.replace || previousState == _RouteLifecycle.pushReplace) {
          navigator._observedRouteAdditions.add(
            _NavigatorReplaceObservation(route, previousPresent)
          );
        } else {
          navigator._observedRouteAdditions.add(
            _NavigatorPushObservation(route, previousPresent)
          );
        }
      }
    

    这里调用了具体route.install()函数,我们进入ModalRoute里查看,最终调用了父类OverlayRoute的install函数,根据子类创建overlayEntries添加到数组里。

    @override
      void install() {
    _overlayEntries.addAll(createOverlayEntries());
        super.install();
      }
    

    回到ModalRoute,发现这里创建了2个OverlayEntry,这就解释了为何每次push进一个new page都会获取到2个新创建的_OverlayEntryWidget

      @override
      Iterable<OverlayEntry> createOverlayEntries() sync* {
        yield _modalBarrier = OverlayEntry(builder: _buildModalBarrier);
        yield _modalScope = OverlayEntry(builder: _buildModalScope, maintainState: maintainState);
      }
    

    个人理解barrier作用如下:
    1、屏蔽作用,比如点击事件无法穿透page,动画间的影响。
    2、通过点击外部,dismiss掉dialog。
    3、发挥opaque属性作用,遮挡低层。

    Widget _buildModalBarrier(BuildContext context) {
      ...
      barrier = IgnorePointer(
        ...
        child: barrier, 
      ); 
      if (semanticsDismissible && barrierDismissible) {
        barrier = Semantics( 
            sortKey: const OrdinalSortKey(1.0), 
            child: barrier,
         );
      }
      return barrier; 
    }
    

    整体结构图:


    至此我们确定了页面的起始element是_OverlayEntryWidget,然后根据页面图层布局关系,最上层元素即为当前显示的Overlay。但是它是私有类型,我们该如何去获取所有的该元素呢?

    实现

    由于Navigator的唯一性,所以我们可以先获取Navigator对象,再根据子节点深度层序遍历获取_Theater的所有children对象,则数组最后一个元素为我们需要的当前图层的起始点。接下来只需要从该节点进行树的深度遍历即可获取所有元素。

    List<Element> findAllPagesOfRootElement(BuildContext context) {
      assert(context != null);
      var navigatorNode = _traversalDescendants((context as Element), (node) {
        return !(node.element.widget is Navigator);
      });
      var overlayNode = _traversalDescendants(navigatorNode.element, (node) {
        return !(node.element.widget is Overlay);
      });
      
      var elements = _getDescendantsWithDepth(overlayNode.element, FindPageRootDepth);
      return elements;
    }
    
    /*
    说明:根据深度参数层序遍历树
    parma:root:起始节点,depth:遍历层级
    return:某一层所有节点
    */
    List<Element> _getDescendantsWithDepth(Element root, int depth, {bool onstage = false}) {
      List<Element> nodes = List<Element>();
      if (depth < 1) return nodes;
      ElementNode curNode;
      int curDepth = 1;
      List<Element> childElements = List<Element>();
      Queue<ElementNode> nodeQueues = Queue<ElementNode>();
      nodeQueues.addLast(ElementNode(root, null));
    
      while (nodeQueues.isNotEmpty) {
        curNode = nodeQueues.removeFirst();
        if (curDepth != depth) {
          //继续添加子节点
          //debug模式可下使用
          if (onstage) {
            curNode.element.debugVisitOnstageChildren((e) {
              childElements.add(e);
            });
          } else {
            curNode.element.visitChildElements((e) {
              childElements.add(e);
            });
          }
        } else {
          nodes.add(curNode.element);
        }
    
        //表示当前层级遍历结束
        if (nodeQueues.isEmpty) {
          curDepth++;
          //子树遍历顺序从左到右添加到队列里
          for (int i = childElements.length; --i >= 0;) {
            nodeQueues.addFirst(ElementNode(childElements[i], curNode));
          }
          childElements.clear();
        }
      }
      return nodes;
    }
    

    扩展

    1.在观察driver源码时,发现其遍历树时只寻找了当前界面可见元素,并没有遍历所有节点。那么是如何做到的呢?跟随源码发现了系统自带的一个api。

    static Iterable<Element> _reverseChildrenOf(Element element, bool skipOffstage) {
        assert(element != null);
        final List<Element> children = <Element>[];
        if (skipOffstage) {
          element.debugVisitOnstageChildren(children.add);
        } else {
          element.visitChildren(children.add);
        }
        return children.reversed;
      }
    

    Element有暴露debugVisitOnstageChildren接口,让子类Offstage和Overlay等类去实现该函数。所以我们如果不仅仅需要页面所有元素,还需要获取当前屏幕所有可见元素时(比如toast透明弹窗的情况,只获取toast所有元素肯定是不够的,还需要底下可见部分的元素),可直接遍历api即可获取所有可见元素。

    相关文章

      网友评论

          本文标题:Flutter一种获取页面所有元素的方案

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