@jinjun0620 这么神奇?今晚回去研究研究。这是一个很有趣的问题。因为根据上面的分析应该无论哪个平台都找不到的(bootstrap 是在 android 上运行的)。
@doctorq 执行成功的标志是最后一行是
---- reset.sh completed successfully ----
用 appium 的 bootstrap debug 过了, 确实 appium 1.3.5 在 android 平台下的 findElementByXPath 不支持中文。
appium 用 xpath 定位的原理是先用 dump 把当前界面所有元素保存到一个 xml 文件里面,然后再读取 dump 出来的 xml 文件,最后用 xpath 去查那个 xml 文件。(xpath 本来设计就是用来做 xml 的元素定位的)
不能支持中文(严格地说是不支持所有非 ascii 字符)的原因是这个 dump 用的是 bootstrap 里面自己的 dump,而不是 uiautomator 的,这个 dump 会把所有不支持的字符替换成?
,所以你用包含中文的 xpath 去找会找不到。
借个地方贴一下相关源码方便后面研究:
通过 xpath 查找元素的函数:
io.appium.android.bootstrap.utils.XMLHierarchy.java
public static ArrayList<ClassInstancePair> getClassInstancePairs(String xpathExpression)
throws ElementNotFoundException, InvalidSelectorException, ParserConfigurationException {
XPath xpath = XPathFactory.newInstance().newXPath();
XPathExpression exp = null;
try {
exp = xpath.compile(xpathExpression);
} catch (XPathExpressionException e) {
throw new InvalidSelectorException(e.getMessage());
}
Node formattedXmlRoot;
formattedXmlRoot = getFormattedXMLDoc();
return getClassInstancePairs(exp, formattedXmlRoot);
}
public static ArrayList<ClassInstancePair> getClassInstancePairs(XPathExpression xpathExpression, Node root) throws ElementNotFoundException {
NodeList nodes;
try {
nodes = (NodeList) xpathExpression.evaluate(root, XPathConstants.NODESET);
} catch (XPathExpressionException e) {
e.printStackTrace();
throw new ElementNotFoundException("XMLWindowHierarchy could not be parsed: " + e.getMessage());
}
ArrayList<ClassInstancePair> pairs = new ArrayList<ClassInstancePair>();
for (int i = 0; i < nodes.getLength(); i++) {
if (nodes.item(i).getNodeType() == Node.ELEMENT_NODE) {
try {
pairs.add(getPairFromNode(nodes.item(i)));
} catch (PairCreationException e) { }
}
}
return pairs;
}
替换字符的函数:
io.appium.uiautomator.core.AccessibilityNodeInfoDumper.java
private static String stripInvalidXMLChars(CharSequence cs) {
StringBuilder ret = new StringBuilder();
char ch;
for (int i = 0; i < cs.length(); i++) {
ch = cs.charAt(i);
// code below from Html#withinStyle, this is a temporary workaround because XML
// serializer does not support surrogates
if (ch >= 0xD800 && ch <= 0xDFFF) {
if (ch < 0xDC00 && i + 1 < cs.length()) {
char d = cs.charAt(i + 1);
if (d >= 0xDC00 && d <= 0xDFFF) {
i++;
ret.append("?");
}
}
} else if (ch > 0x7E || ch < ' ') {
ret.append("?");
} else {
ret.append(ch);
}
}
return ret.toString();
}
其他方法(如 findElementByAccessibilityId)能找到包含中文的元素的原因是它直接使用 uiautomator 的对应方法。而 uiautomator api 并没有根据 xpath 查找元素的方法,所以 appium 是自己另外实现的。
这里替换掉字符的原因应该是基于安全性考虑( evaluate 是一个权限很高的函数,不对参数进行过滤的话很危险)。
@doctorq 我们那个项目第一个任务可以考虑修复这个问题,让 appium 支持通过含有非 ascii 字符的 xpath 来查找元素。
我的意思是你的 Java 文件应该使用 utf-8 编码。这样中文的编码才和 app 对应元素一致(android testview 内容默认使用 utf-8 编码)。
不过我也没使用 Java 的 client 试验过。明天试一下。
你用的什么编码?是 utf-8 吗?
要支持中文应该要 utf-8 的吧。
可以肯定 appium 的 xpath 定位应该是支持中文的。
@doctorq 对,其实就是一种流行的 http api 编写风格。因为没有严格的规定,所以连规范都说不上。
曾经尝试写一个系统的 REST api,查了不少资料。当时天真地以为 REST api+html5 可以一套代码通杀 Web+Mobile,结果被 Android 的 html5 性能打败了……
@doctorq ok,我更新一下文章
REST 是指 RESTful 的 api 吗。个人理解就是 http 的 GET、POST、PUT 和 DELETE 对应资源的 CRUD(增删改查),把所有的对象(例如论坛里的用户、用户组、帖子、专区)都设计成资源。然后就能像写 CRUD 那样使用 api 了。
话说 appium 使用 Http REST 方式是因为遵循了webDriver 的规范吧?
你的代码是直接复制粘贴上来的吗?第一个代码的 xpath 有错,@text=‘中文'
第一个引号是中文引号。
学习了!这样的原理剖析不仅学会了原理,还知道了应该如何正确使用这个功能。
能附上 Appium 端的错误信息吗?
@cosyman 所以我注明了 需要能连接国外网络。确实 chromedriver 没代理下不下来。你有调整过 reset.sh 来让它下载 chromedriver 的节点改为国内镜像吗?有的话麻烦在这里说一下,我更新到帖子内容里。谢谢。
@lihuazhang 现在还没做到这一步。我后面跑一下测试后再加上。看官方文档的话 unittest 只需要一个命令就可以跑了,不过里面有没有坑现在还不清楚……
@lihuazhang 好的,明白了。第一组双星号前面必须有一个空格。谢谢指导。
@lihuazhang 我这边确实有问题。用输入法输的:粗体。复制页面底部示例的:粗体。我用的浏览器:Safari version 8.0.2 (10600.2.5)
可以确定我没用全角或者加了空格。全角的写法是××粗体××,在这个字体下还是很明显的。
markdown 是浏览器 js 处理还是发到服务器才处理的?如果是浏览器 js 处理可能是我网速问题导致 Js 加载不完整,或者是 js 的浏览器兼容性问题,所以粗体没有被正确转换把。
后面我再深入看看。
现在可以了。粗体。昨天确实不行,修复了就好。谢谢!
@monkey 论坛的 markdown 粗体是不是有问题?怎么粗体不会转成粗体的?
能把解决方法同时附到主内容里面吗?这样方便后面的人快速参考。
data:text/html,chromewebdata
应该是 chromedriver 启动浏览器的默认 url。至于加载不成功为啥会返回这个我只找到 selenium 有个相关 issue。官方标记为 fixed 但后面有人说在部分浏览器中还存在。
传送门(请科学上网):
Issue 4301: getCurrentUrl should return the current URL on a 404 page
建议换个方式来验证页面是否加载成功吧。
PS: 话说神马是 H5 自动化
?该不会是 html5 自动化
吧?
@doctorq 谢谢支持!后面我会放到我自己的 blog 里面的,也会发个总结帖说明一下,还会发些分享帖说明如何进行 remote debug 来查看 app 使用 android api 时内部具体是怎么做的。现在在这里记录的只是原始资料,所以也只是跟帖而已。
今天看了findAccessibilityNodeInfoByAccessibilityIdUiThread
,虽然还没完全了解它的流程,但基本找到--compress
影响的位置了。
这里
先回到上次的地方:
private void findAccessibilityNodeInfoByAccessibilityIdUiThread(Message message) {
final int flags = message.arg1;
SomeArgs args = (SomeArgs) message.obj;
final int accessibilityViewId = args.argi1;
final int virtualDescendantId = args.argi2;
final int interactionId = args.argi3;
final IAccessibilityInteractionConnectionCallback callback =
(IAccessibilityInteractionConnectionCallback) args.arg1;
final MagnificationSpec spec = (MagnificationSpec) args.arg2;
args.recycle();
List<AccessibilityNodeInfo> infos = mTempAccessibilityNodeInfoList;
infos.clear();
try {
if (mViewRootImpl.mView == null || mViewRootImpl.mAttachInfo == null) {
return;
}
mViewRootImpl.mAttachInfo.mAccessibilityFetchFlags = flags;
View root = null;
if (accessibilityViewId == AccessibilityNodeInfo.UNDEFINED) {
root = mViewRootImpl.mView;
} else {
root = findViewByAccessibilityId(accessibilityViewId);
}
if (root != null && isShown(root)) {
mPrefetcher.prefetchAccessibilityNodeInfos(root, virtualDescendantId, flags, infos);
}
} finally {
try {
mViewRootImpl.mAttachInfo.mAccessibilityFetchFlags = 0;
applyAppScaleAndMagnificationSpecIfNeeded(infos, spec);
if (spec != null) {
spec.recycle();
}
callback.setFindAccessibilityNodeInfosResult(infos, interactionId);
infos.clear();
} catch (RemoteException re) {
/* ignore - the other side will time out */
}
}
}
这里对flags
进行了几个操作:
flags
。flags
赋值给mViewRootImpl.mAttachInfo.mAccessibilityFetchFlags
在查出 root 且root != null && isShown(root)
(这里暂时先不探究root
是什么),执行mPrefetcher.prefetchAccessibilityNodeInfos
方法。isShown(root)
是判断当前节点是否会显示在界面上,相关源码:
android.view.AccessibilityInteractionController
private boolean isShown(View view) {
// The first two checks are made also made by isShown() which
// however traverses the tree up to the parent to catch that.
// Therefore, we do some fail fast check to minimize the up
// tree traversal.
return (view.mAttachInfo != null
&& view.mAttachInfo.mWindowVisibility == View.VISIBLE
&& view.isShown());
}
里面的view.isShown()
源码:
android.view.View
/**
* Returns the visibility of this view and all of its ancestors
*
* @return True if this view and all of its ancestors are {@link #VISIBLE}
*/
public boolean isShown() {
View current = this;
//noinspection ConstantConditions
do {
if ((current.mViewFlags & VISIBILITY_MASK) != VISIBLE) {
return false;
}
ViewParent parent = current.mParent;
if (parent == null) {
return false; // We are not attached to the view root
}
if (!(parent instanceof View)) {
return true;
}
current = (View) parent;
} while (current != null);
return false;
}
回到正题,我们来看看mPrefetcher.prefetchAccessibilityNodeInfos
方法:
/**
* This class encapsulates a prefetching strategy for the accessibility APIs for
* querying window content. It is responsible to prefetch a batch of
* AccessibilityNodeInfos in addition to the one for a requested node.
*/
private class AccessibilityNodePrefetcher {
private static final int MAX_ACCESSIBILITY_NODE_INFO_BATCH_SIZE = 50;
private final ArrayList<View> mTempViewList = new ArrayList<View>();
public void prefetchAccessibilityNodeInfos(View view, int virtualViewId, int fetchFlags,
List<AccessibilityNodeInfo> outInfos) {
AccessibilityNodeProvider provider = view.getAccessibilityNodeProvider();
if (provider == null) {
AccessibilityNodeInfo root = view.createAccessibilityNodeInfo();
if (root != null) {
outInfos.add(root);
if ((fetchFlags & AccessibilityNodeInfo.FLAG_PREFETCH_PREDECESSORS) != 0) {
prefetchPredecessorsOfRealNode(view, outInfos);
}
if ((fetchFlags & AccessibilityNodeInfo.FLAG_PREFETCH_SIBLINGS) != 0) {
prefetchSiblingsOfRealNode(view, outInfos);
}
if ((fetchFlags & AccessibilityNodeInfo.FLAG_PREFETCH_DESCENDANTS) != 0) {
prefetchDescendantsOfRealNode(view, outInfos);
}
}
} else {
AccessibilityNodeInfo root = provider.createAccessibilityNodeInfo(virtualViewId);
if (root != null) {
outInfos.add(root);
if ((fetchFlags & AccessibilityNodeInfo.FLAG_PREFETCH_PREDECESSORS) != 0) {
prefetchPredecessorsOfVirtualNode(root, view, provider, outInfos);
}
if ((fetchFlags & AccessibilityNodeInfo.FLAG_PREFETCH_SIBLINGS) != 0) {
prefetchSiblingsOfVirtualNode(root, view, provider, outInfos);
}
if ((fetchFlags & AccessibilityNodeInfo.FLAG_PREFETCH_DESCENDANTS) != 0) {
prefetchDescendantsOfVirtualNode(root, provider, outInfos);
}
}
}
}
其中fetchFlags
就是之前的flags
,根据前面return findAccessibilityNodeInfoByAccessibilityId(connectionId,AccessibilityNodeInfo.ACTIVE_WINDOW_ID, AccessibilityNodeInfo.ROOT_NODE_ID,false, AccessibilityNodeInfo.FLAG_PREFETCH_DESCENDANTS);
,此处进入的是(fetchFlags & AccessibilityNodeInfo.FLAG_PREFETCH_DESCENDANTS) != 0
为 true 后进入的方法。
至于是进入prefetchDescendantsOfRealNode
还是prefetchDescendantsOfVirtualNode
,目前还不能确定(不是调试环境,确定不了 root 的值)。咱们逐个看:
private void prefetchDescendantsOfRealNode(View root,
List<AccessibilityNodeInfo> outInfos) {
if (!(root instanceof ViewGroup)) {
return;
}
HashMap<View, AccessibilityNodeInfo> addedChildren =
new HashMap<View, AccessibilityNodeInfo>();
ArrayList<View> children = mTempViewList;
children.clear();
try {
root.addChildrenForAccessibility(children);
final int childCount = children.size();
for (int i = 0; i < childCount; i++) {
if (outInfos.size() >= MAX_ACCESSIBILITY_NODE_INFO_BATCH_SIZE) {
return;
}
View child = children.get(i);
if (isShown(child)) {
AccessibilityNodeProvider provider = child.getAccessibilityNodeProvider();
if (provider == null) {
AccessibilityNodeInfo info = child.createAccessibilityNodeInfo();
if (info != null) {
outInfos.add(info);
addedChildren.put(child, null);
}
} else {
AccessibilityNodeInfo info = provider.createAccessibilityNodeInfo(
AccessibilityNodeInfo.UNDEFINED);
if (info != null) {
outInfos.add(info);
addedChildren.put(child, info);
}
}
}
}
} finally {
children.clear();
}
if (outInfos.size() < MAX_ACCESSIBILITY_NODE_INFO_BATCH_SIZE) {
for (Map.Entry<View, AccessibilityNodeInfo> entry : addedChildren.entrySet()) {
View addedChild = entry.getKey();
AccessibilityNodeInfo virtualRoot = entry.getValue();
if (virtualRoot == null) {
prefetchDescendantsOfRealNode(addedChild, outInfos);
} else {
AccessibilityNodeProvider provider =
addedChild.getAccessibilityNodeProvider();
prefetchDescendantsOfVirtualNode(virtualRoot, provider, outInfos);
}
}
}
}
root
是不是ViewGroup
的实例(这里应该是,否则返回值就是空了)addedChildren
, children
(此时 children 使用过 clear() 方法,所以已经是空列表了)root.addChildrenForAccessibility(children)
获取root
的子节点并添加到children
列表中。children
的元素,然后把所有会显示的元素都加到outInfos
。outInfos
就是最终会返回到 callback 中的节点列表。outInfos
小于MAX_ACCESSIBILITY_NODE_INFO_BATCH_SIZE
,那就在前面找到的 child 节点中继续找(递归),直到root
不是ViewGroup
实例或者outInfos
大小达标。所以关键语句就是root.addChildrenForAccessibility(children)
。咱们进去看看:
android.view.View
/**
* Adds the children of a given View for accessibility. Since some Views are
* not important for accessibility the children for accessibility are not
* necessarily direct children of the view, rather they are the first level of
* descendants important for accessibility.
*
* @param children The list of children for accessibility.
*/
public void addChildrenForAccessibility(ArrayList<View> children) {
if (includeForAccessibility()) {
children.add(this);
}
}
然后进去includeForAccessibility()
:
/**
* Whether to regard this view for accessibility. A view is regarded for
* accessibility if it is important for accessibility or the querying
* accessibility service has explicitly requested that view not
* important for accessibility are regarded.
*
* @return Whether to regard the view for accessibility.
*
* @hide
*/
public boolean includeForAccessibility() {
//noinspection SimplifiableIfStatement
if (mAttachInfo != null) {
return (mAttachInfo.mAccessibilityFetchFlags
& AccessibilityNodeInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS) != 0
|| isImportantForAccessibility();
}
return false;
}
比较接近了,这个地方就是判断这个 view 对 accessibility 而言是否重要的地方。如果不重要且AccessibilityNodeInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS
标志位为 0(我们的 compress 就是做了这件事),那就返回 false。
既然第一个条件mAttachInfo.mAccessibilityFetchFlags & AccessibilityNodeInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS) != 0
的结果我们已经知道是 false 了,那就去看isImportantForAccessibility()
/**
* Gets whether this view should be exposed for accessibility.
*
* @return Whether the view is exposed for accessibility.
*
* @hide
*/
public boolean isImportantForAccessibility() {
final int mode = (mPrivateFlags2 & PFLAG2_IMPORTANT_FOR_ACCESSIBILITY_MASK)
>> PFLAG2_IMPORTANT_FOR_ACCESSIBILITY_SHIFT;
if (mode == IMPORTANT_FOR_ACCESSIBILITY_NO
|| mode == IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS) {
return false;
}
// Check parent mode to ensure we're not hidden.
ViewParent parent = mParent;
while (parent instanceof View) {
if (((View) parent).getImportantForAccessibility()
== IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS) {
return false;
}
parent = parent.getParent();
}
return mode == IMPORTANT_FOR_ACCESSIBILITY_YES || isActionableForAccessibility()
|| hasListenersForAccessibility() || getAccessibilityNodeProvider() != null
|| getAccessibilityLiveRegion() != ACCESSIBILITY_LIVE_REGION_NONE;
}
这就是prefetchDescendantsOfRealNode
最终的判断位置了。主要有 3 部分:
final int mode = (mPrivateFlags2 & PFLAG2_IMPORTANT_FOR_ACCESSIBILITY_MASK)
>> PFLAG2_IMPORTANT_FOR_ACCESSIBILITY_SHIFT;
if (mode == IMPORTANT_FOR_ACCESSIBILITY_NO
|| mode == IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS) {
return false;
}
其中IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
的解释:
/**
* The view is not important for accessibility, nor are any of its
* descendant views.
*/
public static final int IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS = 0x00000004;
// Check parent mode to ensure we're not hidden.
ViewParent parent = mParent;
while (parent instanceof View) {
if (((View) parent).getImportantForAccessibility()
== IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS) {
return false;
}
parent = parent.getParent();
}
IMPORTANT_FOR_ACCESSIBILITY_YES
及是否具有其它和 Accessibility 相关的特性:
return mode == IMPORTANT_FOR_ACCESSIBILITY_YES || isActionableForAccessibility()
|| hasListenersForAccessibility() || getAccessibilityNodeProvider() != null
|| getAccessibilityLiveRegion() != ACCESSIBILITY_LIVE_REGION_NONE;
先记着。咱们去看另一个路线prefetchDescendantsOfVirtualNode
private void prefetchDescendantsOfVirtualNode(AccessibilityNodeInfo root,
AccessibilityNodeProvider provider, List<AccessibilityNodeInfo> outInfos) {
SparseLongArray childNodeIds = root.getChildNodeIds();
final int initialOutInfosSize = outInfos.size();
final int childCount = childNodeIds.size();
for (int i = 0; i < childCount; i++) {
if (outInfos.size() >= MAX_ACCESSIBILITY_NODE_INFO_BATCH_SIZE) {
return;
}
final long childNodeId = childNodeIds.get(i);
AccessibilityNodeInfo child = provider.createAccessibilityNodeInfo(
AccessibilityNodeInfo.getVirtualDescendantId(childNodeId));
if (child != null) {
outInfos.add(child);
}
}
if (outInfos.size() < MAX_ACCESSIBILITY_NODE_INFO_BATCH_SIZE) {
final int addedChildCount = outInfos.size() - initialOutInfosSize;
for (int i = 0; i < addedChildCount; i++) {
AccessibilityNodeInfo child = outInfos.get(initialOutInfosSize + i);
prefetchDescendantsOfVirtualNode(child, provider, outInfos);
}
}
}
流程差不多,先找 children 节点,然后遍历,最后如果 size 不够,继续在 child 节点中找,直到节点数达标。咱们来看`:
android.view.accessibility.AccessibilityNodeInfo`
/**
* @return The ids of the children.
*
* @hide
*/
public SparseLongArray getChildNodeIds() {
return mChildNodeIds;
}
很直接,直接返回一个列表。
注意这里并没有校验这个 node 对 Accessibility 重要。估计是因为 VirtualNode 通常出现在自定义的 view,这些 view 的元素不一定都有 flag 或者可以判断是否重要的属性。
这里说明一下,每个 view 里面的 node 既可以作为在整个页面中都可以找到的真正的 node(ReadNode),也可以使用仅在 view 内有效、仅能通过 view 的 provider 来查找的的 node(VirtualNode,详情可看AccessibilityNodeProvider)。因此会存在两个遍历 node 的方法。
至于为什么前面容器类控件(FrameLayout 等)会被 compress 干掉,目前还没找到确切的原因,不过已经可以肯定是在下面三个地方其中一个确定的:
final int mode = (mPrivateFlags2 & PFLAG2_IMPORTANT_FOR_ACCESSIBILITY_MASK)
>> PFLAG2_IMPORTANT_FOR_ACCESSIBILITY_SHIFT;
if (mode == IMPORTANT_FOR_ACCESSIBILITY_NO
|| mode == IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS) {
return false;
}
其中IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
的解释:
/**
* The view is not important for accessibility, nor are any of its
* descendant views.
*/
public static final int IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS = 0x00000004;
// Check parent mode to ensure we're not hidden.
ViewParent parent = mParent;
while (parent instanceof View) {
if (((View) parent).getImportantForAccessibility()
== IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS) {
return false;
}
parent = parent.getParent();
}
IMPORTANT_FOR_ACCESSIBILITY_YES
及是否具有其它和 Accessibility 相关的特性:
return mode == IMPORTANT_FOR_ACCESSIBILITY_YES || isActionableForAccessibility()
|| hasListenersForAccessibility() || getAccessibilityNodeProvider() != null
|| getAccessibilityLiveRegion() != ACCESSIBILITY_LIVE_REGION_NONE;
但在android.view.View
里面找到一个有点关系的属性:
/**
* Shift for the bits in {@link #mPrivateFlags2} related to the
* "importantForAccessibility" attribute.
*/
static final int PFLAG2_IMPORTANT_FOR_ACCESSIBILITY_SHIFT = 20;
由于这部分地方涉及到不少位运算,暂时留待后面详细研究。