背景
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即可获取所有可见元素。