Appium uiautomatorviewer 二次开发之自动生成控件定位符

梦桥 · 2015年05月26日 · 最后由 wyel2000 回复于 2017年08月29日 · 5043 次阅读
本帖已被设为精华帖!

uiautomatorviewer 二次开发之自动生成控件定位符

前言
我们在使用 Appium 进行移动自动化测试脚本编写的时候,经常出现控件无法定位,如 ListView 下面的 item,控件基本属性一样的、某些控件没有 id、name 等,这个时候,如果单纯靠 id、name、text 可能无法完全唯一定位一个控件,这个时候就需要编写 xpath 了,可是 xpath 语法、写法对于没有接触过的测试人员来说,又是个门槛,接下来,我们就来讨论如何通过二次开发 uiautomatorviewer 自动生成 xpath 供用户直接 copy 使用。
原理
uiautomatorviewer 是 android SDK 包中原生的开源工具,给用户提供一种查看当前终端布局、控件属性的一个辅佐工具,
该工具的 GUI 是使用 RCP 组件进行开发的,然后通过 uiautomator dump 把当前终端布局文件 dump 到本地,uiautomatorviewer
通过 xml 布局文件,构造一棵 tree,放到 Canvas SWT 组件中,和当前 png 截图叠加在一起,同时监听鼠标 move 等事件,自动
获取该 tree 的 node 节点,并且把该 node 节点的所有属性获取显示出来。


代码结构

com.android.uiautomator:存放 uiautomatorviewer 工具的 GUI 界面代码,其中主入口 UiAutomatorViewer.java 文件里面有 main 函数入口,工具的窗口就在此创建。
com.android.uiautomator.actions:存放所有 anction 操作,如:Device screenshot 、open 等。
com.android.uiautomator.tree:存放 tree 封装,dump 出来的 xml 解析成一棵完整的 tree,这个包是核心包。

二次开发
首先,dump 出来的 xml 文件被 uiautomationviewer 解析成自定义的 tree,每个节点代表一个控件,所以,如何添加 Xpath 属性呢?只需要在 node 节点中添加一个字段即可,其实很简单。通过阅读代码,在 com.android.uiautomator.tree 包下,有个 node 节点封装类,UiNode.java,看下以下代码片源:

public class UiNode extends BasicTreeNode {
    private static final Pattern BOUNDS_PATTERN = Pattern
            .compile("\\[-?(\\d+),-?(\\d+)\\]\\[-?(\\d+),-?(\\d+)\\]");

    private final Map<String, String> mAttributes = new LinkedHashMap();
    private String mDisplayName = "ShouldNotSeeMe";
    private Object[] mCachedAttributesArray;

    public void addAtrribute(String key, String value) {
        this.mAttributes.put(key, value);
        updateDisplayName();
        if ("bounds".equals(key))
            updateBounds(value);
    }

    public Map<String, String> getAttributes() {
        return Collections.unmodifiableMap(this.mAttributes);
    }

    private void updateDisplayName() {
        String className = (String) this.mAttributes.get("class");
        if (className == null)
            return;
        String text = (String) this.mAttributes.get("text");
        if (text == null)
            return;
        String contentDescription = (String) this.mAttributes
                .get("content-desc");
        if (contentDescription == null)
            return;
        String index = (String) this.mAttributes.get("index");
        if (index == null)
            return;
        String bounds = (String) this.mAttributes.get("bounds");
        if (bounds == null) {
            return;
        }

        className = className.replace("android.widget.", "");
        className = className.replace("android.view.", "");
        StringBuilder builder = new StringBuilder();
        builder.append('(');
        builder.append(index);
        builder.append(") ");
        builder.append(className);
        if (!text.isEmpty()) {
            builder.append(':');
            builder.append(text);
        }
        if (!contentDescription.isEmpty()) {
            builder.append(" {");
            builder.append(contentDescription);
            builder.append('}');
        }
        builder.append(' ');
        builder.append(bounds);
        this.mDisplayName = builder.toString();

private final Map<String, String> mAttributes = new LinkedHashMap();UiNode 节点下定义一个 mAttributes LinkedHashMap,用于存储节点所有 key-value 属性,如:className、text 、index 等等,所以,只需要在这个 UiNode 类下添加一个获取 xpath 方法,如下:

public String getXpath()
{
    String className=getNodeClassAttribute();
    String xpath="//"+className;
    String text = getAttribute("text");
    if(text !=null&& !text.equals(""))
    {
        xpath += "[@text='"+text+"']";
        return xpath;
    }else 
    {
        return getAttribute("content-desc") !=""?
                xpath+"[@content-desc='"+getAttribute("content-desc")+"']"
                :xpath+"[@index='"+getAttribute("index")+"']";
    }


}

根据约定的优先级,进行筛选(text>content-desc>index),方法定义完毕后,如何触发 getXpath() 代码呢?
同样,在此包下 UiHierarchyXmlLoader.java 中,该类是用于处理把 dump xml 转换为 BasicTreeNode 对象,UiHierarchyXmlLoader 引用 org.xml.sax.helpers 处理基本 xml 文件 (Default base class for SAX2 event handlers.),实现了 ContentHandler 接口下的 startElement、endElement 接口,

    public void endElement(String uri, String localName, String qName)
            throws SAXException {
        if (this.mParentNode != null) {
            this.mWorkingNode = this.mParentNode;
            this.mParentNode = this.mParentNode.getParent();
            `mTmpNode.addAtrribute("xpath",mTmpNode.getXpath());`
        }
    }
};

到这里,xpath 就会自动出现在 uiautomatorviewer 界面上了,效果如下:

补充
其实 uiautomatorviewer 二次开发还不止这些,我们可以在 uiautomatorviewer 中加入录制自动生成 java、python 等 appium 脚本,还可以每次用户点击 uiautomatorviewer 界面,同步刷新(目前需要用户手动点击 device screenshot 这个 action)等等,如下:

第一次发分享贴,写的不好,希望大家多多鼓励,后续会继续分享

共收到 87 条回复 时间 点赞

顶,终于分享出来了

赞一个

赞 也就是说即便开发没有定义控件的 text description 或者 device ID,我们依然可以对这个控件进行唯一性的自主命名对吗?

#3 楼 @james88233 一开始,想做成你说这个思路的,完全通过布局如://FrameLayout/View/TextView 类似这样的方式,后来发现这样做,如果 app 布局一旦做了调整,解析出来的 xpath 就不能复用,所以,后来就改成了一个控件,按照 text>content-desc>index 的优先级进行唯一定位,如:有 text 内容的,优先,其次是 content-desc

最近也在想弄这种比较快速定位的东东,没想到你先弄出来了。

可以的。我支持你

#4 楼 @cpfeng0124 首先我明白你关于不能复用的那个解释,然后我不太理解的是你接下来说的这个操作,如果 uiautomatorviewer 可以获取到控件的 text desc 等信息,我还要 xpath 的信息做什么呢?

#7 楼 @james88233 我们这边遇到这种问题,2 个控件,id、text 都一样,只有 desc 或者 index 不一样,这个时候,就需要些 xpath 来区分这 2 个控件了

#8 楼 @cpfeng0124 谢谢分享,如果能上传 uiautomatorviewer 未修改的源码更棒了

梦桥 #10 · 2015年05月26日 Author

#9 楼 @yuwuhen333 uiautomatorviewer 本身就是开源的哦,可以通过反编译直接生成代码

#10 楼 @cpfeng0124 反编译是直接解压缩 jar 包吗?还是需要工具进行反编译

梦桥 #12 · 2015年05月26日 Author

#11 楼 @yuwuhen333 z 直接解压缩 jar 包哦

你好,请教下通过这个进行二次开发,是否也可以解决自定义控件无法定位的问题呢?谢谢~

赞!
有个疑问:如果遇到有两个控件 text 相同,那你生成的 xpath 是否就会一样?

梦桥 #15 · 2015年05月26日 Author

#13 楼 @mistyrain 基本可以的唯一定位的,目前通过 xpath 方式,还没发现定位不到的问题哦

梦桥 #16 · 2015年05月26日 Author

#14 楼 @chenhengjie123 吼吼,如果两个 text 相同的话,生成的 xpath 确实是一样的哦,有没有啥可以指点一下,如何改进啊?

@chenhengjie123 @cpfeng0124 看他这个代码逻辑只是判断了 text 是否为空,如果 text 相同,应该返回的 xpath 是一致的

@cpfeng0124 ,如果是自定义控件的话,请问有没有思路呢,有人提议用 HierachyViewer,但是感觉这个速度比较慢

#12 楼 @cpfeng0124 压缩之后,文件夹中没有 com.android.uiautomator.tree 也没有 UiNode.java,我的是 Android19

#8 楼 @cpfeng0124 我现在用的工具是黑盒的,所以我也不太清楚具体的架构,当你说的这种情况出现的时候,我们一般就用 index=1 或者 index=2 来区分了 但是面对上图那种钟表式样的控件,就无能为力了,因为基本上 text desc 这些都没有,classname 相同的一大堆,如果可以用 xpath 编译的话还是挺方便的,不过可能需要增加一些封装,让这个操作傻瓜化,比如 tester 点击控件可以直接对其命名

梦桥 #22 · 2015年05月26日 Author

#19 楼 @app_testing HierachyViewer 我之前也做过,思路基本差不多,当时的需求是,需要测试手机整机全系统测试:如打电话、发短型、甚至并发,打电话的时候,上网啥的,当时就是基于 HierachyViewer 二次开发,全系统自动化测试的

#16 楼 @cpfeng0124 大致思路:
在 dump 出来的 xml 文件中使用生成的 xpath 来查找节点,如果找到不止一个节点,就加上更多属性匹配条件(同样按照 text->content-desc->index,例如 android.view.view[@text="a" and @content-desc="b" and @index="1"])。如果都加上后还是不止一个元素,直接给个绝对路径吧。

可以录制脚本这个太赞了!能分享一下思路不?

@cpfeng0124 我的意思是 HierachyViewer 这个速度比较慢,你对这种情况有没有别的解决思路

梦桥 #25 · 2015年05月26日 Author

#23 楼 @chenhengjie123 录制脚本这个,我打算下周再做一次移动录制生成代码,因为第一次写这些分享,效率有点低啊,吼吼,谢谢你的指点!

梦桥 #26 · 2015年05月26日 Author

#24 楼 @app_testing 速度比较慢,主要卡在 dump 这个动作需要一定时间,这个我也在研究哦,吼吼!有啥想法,记得分享一下哈

#25 楼 @cpfeng0124 期待你的分享!

#12 楼 @cpfeng0124 能不能具体讲下怎么进行反编译,我的 uiautomator.jar 解压缩后,目录结构如下图
一直没看到你说的修改的文件

#28 楼 @yuwuhen333 你找错包啦,是这个 uiautomatorviewer.jar 包,在 sdk\tools\lib\uiautomatorviewer.jar 这个目录下哦

#29 楼 @cpfeng0124 太感谢了,顺便再问一句解压缩之后,按照文章中的说明进行修改后,怎么重新打包成 jar?

不好意思,楼主,您的源码能 share 一下么

梦桥 #32 · 2015年05月26日 Author

#30 楼 @yuwuhen333 正常打包就 ok 啦,要么导出来 jar 来,要么用 maven 打包

梦桥 #33 · 2015年05月26日 Author

#31 楼 @xxfcxx 额,这个源码暂时不能开放哦,因为里面还有其他功能,不止这个简单的 xpath 哦

#32 楼 @cpfeng0124 将 uinode 变为 java 类型添加你的代码后,在转换为 uinode.class 报错了 UiNode.java:123: 错误: 非法的 Unicode 转义,继续调整。能不能先把你修改成生成的 uiautomatorviewer.jar 分享让我们感受下

35楼 已删除

#32 楼 @cpfeng0124 在配置 UINode.java.中一直报 String className=getNodeClassAttribute();找不到这个方法,这个方法是从哪里来的啊

梦桥 #37 · 2015年05月26日 Author

#36 楼 @yuwuhen333 getNodeClassAttribute(),这个方法,需要你自己添加一下哦,骚瑞啊,没说清楚,getNodeClassAttribute这个方法很简单,就返回private final Map<String, String> mAttributes = new LinkedHashMap();这里定义的mAttributes即可

#37 楼 @cpfeng0124 能不能贴出来下?mTmpNode.addAtrribute("xpath",mTmpNode.getXpath());这个为什么在两边还有符号呢

#37 楼 @cpfeng0124 不是返回这个 map 吧,String className=getNodeClassAttribute(), 这是 String 类型啊

#37 楼 @cpfeng0124 是不是 要 private String getNodeClassAttribute() {
// TODO Auto-generated method stub
return mAttributes.get("className");
}

#40 楼 @xxfcxx 我写的是 return this.mAttributes.get("class");

#41 楼 @cpfeng0124 public void endElement(String uri, String localName, String qName)
throws SAXException {
if (this.mParentNode != null) {
this.mWorkingNode = this.mParentNode;
this.mParentNode = this.mParentNode.getParent();
mTmpNode.addAtrribute("xpath",mTmpNode.getXpath());
}
}
};这个代码中你加的那行为什么多了``呢

我按照你这里改完之后,重新打成 uiautomator.jar,然后启动,提示 uiautomatorviewer.jar 中没有主清单属性

#44 楼 @xxfcxx UiHierarchyXmlLoader.java 你修改好了吗

#45 楼 @yuwuhen333 我修改了,但是在 eclipse 里一直报错,我是改完之后 export jar,然后从 jar 包里 copy 这 2 个 class 文件,然后复制到 uiautomator.jar 解压后的文件夹,然后 jar cvf 打包。。。

#46 楼 @xxfcxx 相同的提示,我也是修改 UiHierarchyXmlLoader.java 的时候各种错误
#41 楼 @cpfeng0124 能不能提供个你只是添加了 xpath 功能的 jar 包出来?

mTmpNode 这个变量是哪来的,源码没有啊,是你加的一个成员变量么,有没有初始化啊

#37 楼 @cpfeng0124 能不能提供个 xpath 的 uiautomatorviewer.jar?

同问有木有提供 jar?谢谢

很棒的分享啊,大赞

#37 楼 @cpfeng0124
这样的元素,该如何定位?类似你文章中的这个元素,见下图红色框

这个元素的 xpath 能给一下吗?

梦桥 #53 · 2015年05月29日 Author

#52 楼 @yuwuhen333 老实说,这样的元素,比较苦恼啊,唯一可以是别的就是 index,但是这个 index 又不是控件的属性,所以,可以结合 layout 控件进行定位吧,从 layout 找起,//linearlayout//android.view.View[7] 这种类似的语法,自动生成 xpath 有待改进哈

#53 楼 @cpfeng0124 self.driver.find_element_by_xpath("//linearlayout//android.view.View[7]").click() 是类似这样的写法吗?//linearlayout//android.view.View[7] 根据实际几层 linearlayout 就写几个?如图这样的该怎么写呢

梦桥 #55 · 2015年05月29日 Author

#54 楼 @yuwuhen333 driver.findElementByAndroidUIAutomator("new UiSelector().text('返回').fromParent(new UiSelector()......)");链式调用,不过,你这个是查找 WebView 里面的元素,可能就难定位了,WebView 里面可以用 WebElement 来查找

梦桥 #56 · 2015年05月29日 Author

new UiSelector().className("android.view.View").getChild(new UiSelector().index(1)),试试吧

梦桥 #57 · 2015年05月29日 Author

#54 楼 @yuwuhen333 new UiSelector().className("android.view.View").getChild(new UiSelector().index(1)),试试吧

#57 楼 @cpfeng0124 好的,我稍后试试,到时候反馈个结果给大家。执行报错:
Traceback (most recent call last):
File "zb_rules.py", line 57, in test_rules
self.driver.find_elements_by_android_uiautomator("new UiSelector().className('android.view.View').getChild(new UiSelector().index(1))").click()
File "D:\Python27\lib\site-packages\appium\webdriver\webdriver.py", line 123, in find_elements_by_android_uiautomator
return self.find_elements(by=By.ANDROID_UIAUTOMATOR, value=uia_string)
File "D:\Python27\lib\site-packages\selenium\webdriver\remote\webdriver.py", line 679, in find_elements
{'using': by, 'value': value})['value']
File "D:\Python27\lib\site-packages\selenium\webdriver\remote\webdriver.py", line 175, in execute
self.error_handler.check_response(response)
File "D:\Python27\lib\site-packages\appium\webdriver\errorhandler.py", line 29, in check_response
raise wde
WebDriverException: Message: The requested resource could not be found, or a request was received using an HTTP method that is not supported by the mapped resource.

Appium 报错如下
info: --> POST /wd/hub/session/f34d3f04-3964-4d6c-a0bf-777e4ae40a6e/elements {"using":"-android uiautomator","sessionId":"f34d3f04-3964-4d6c-a0bf-777e4ae40a6e","value":"new UiSelector().className('android.view.View').getChild(new UiSelector().index(1))"}

info: [debug] Waiting up to 0ms for condition
info: [debug] Pushing command to appium work queue: ["find",{"strategy":"-android uiautomator","selector":"new UiSelector().className('android.view.View').getChild(new UiSelector().index(1))","context":"","multiple":true}]
info: [debug] [BOOTSTRAP] [debug] Got data from client: {"cmd":"action","action":"find","params":{"strategy":"-android uiautomator","selector":"new UiSelector().className('android.view.View').getChild(new UiSelector().index(1))","context":"","multiple":true}}
info: [debug] [BOOTSTRAP] [debug] Got command of type ACTION
info: [debug] [BOOTSTRAP] [debug] Got command action: find
info: [debug] [BOOTSTRAP] [debug] Finding new UiSelector().className('android.view.View').getChild(new UiSelector().index(1)) using ANDROID_UIAUTOMATOR with the contextId: multiple: true
info: [debug] [BOOTSTRAP] [debug] Parsing selector: new UiSelector().className('android.view.View').getChild(new UiSelector().index(1))
info: [debug] [BOOTSTRAP] [debug] UiSelector coerce type: java.lang.Class arg: 'android.view.View'
info: [debug] [BOOTSTRAP] [debug] UiSelector coerce type: class java.lang.String arg: 'android.view.View'
info: [debug] Responding to client with error: {"status":9,"value":{"message":"The requested resource could not be found, or a request was received using an HTTP method that is not supported by the mapped resource.","origValue":"Could not parse UiSelector argument: 'android.view.View' is not a string"},"sessionId":"f34d3f04-3964-4d6c-a0bf-777e4ae40a6e"}
info: <-- POST /wd/hub/session/f34d3f04-3964-4d6c-a0bf-777e4ae40a6e/elements 500 11.186 ms - 308
info: [debug] [BOOTSTRAP] [debug] Returning result: {"value":"Could not parse UiSelector argument: 'android.view.View' is not a string","status":9}
info: --> DELETE /wd/hub/session/f34d3f04-3964-4d6c-a0bf-777e4ae40a6e {}
info: Shutting down appium session

您好,我想请问一下做这个 uiautomatorviewer 二次开发的时候怎么进行调试呢,运行后,点击 Device Screenshot 提示不能与 adb 进行通讯,只能把打包好的 jar 放到 lib 下去运行。另外我给您发了一封邮件,还望不吝赐教。

谢谢分享!!!!

真是希望谷歌用 标准的 java swing 来开发 android sdk 的相关 UI, Eclipse 的 rcp 真是不熟悉.

#59 楼 @adfghzhang https://plus.google.com/108487870030743970488/posts/2TrMqs1ZGQv 这个链接网上说可以解决问题,但是我修改失败了 所以我直接修改 DebugBridge 中

f (toolsDir == null) {
            toolsDir ="D:\\AndroidSDK\\android-sdk-windows\\android-sdk-windows\\tools";
//          return null;;
        }

就可以了

#62 楼 @zsx10110 我自己解决了,多谢

#63 楼 @adfghzhang 其实我想知道你是怎么解决的。。

#64 楼 @zsx10110 就是用的替换 DebugBridge


这个是直接解压出来的, 还是有加密的,后来用工具反编译出来的

#66 楼 @darker50 其实不用反编译,google 搜下直接就可以下到源代码的。

感谢分享,昨天被触发 getXpath() 代码的那一部分难住了,今天看代码跑了几遍,改了一下位置,成功完成,但是不知是作者故意留的坑还是什么原因,所以,暂时不贴出我的修改方法,不好意思。

另外,二次修改的这个想法太酷了!

努力向大神靠拢。

@cpfeng0124 请问各位大神,uiautomatorviewer 怎么没有刷新功能呢,每次换一个界面都要重新打开一次吗

我反编译了这个包,在重新打包的时候有几个保报错,修改之后打包正常,但是有如下几个问题,请赐教!1.多个手机连接不管选哪个序列号都是默认第一个 2.图片显示界面没有自动缩放,鼠标点在上面也没有方框显示。

—— 来自 TesterHome 官方 安卓客户端

弱弱的问一下,我怎么再打成 jar 包就不能运行了

#72 楼 @everflier 请问反编译以后修好了那两个文件怎么重新打包

为什么我修改以后有错误

76楼 已删除

hi 你好,请问一下,这里的文本输入、校验元素,是基于 XML 文件的么,还是和手机

#48 楼 @xxfcxx 请问你这个控件弄好了吗

@cpfeng0124 private String getNodeClassAttribute() {
// TODO Auto-generated method stub
return mAttributes.get("className");
}这个方法 get 里传的参数试过 className 和 class,打印出来的 xpath 都是这样的://null[@content-desc='null'] 这种,感觉是 class 获取不到,并且在界面中没有新增 xpath 属性这一栏,全程无报错

提供一个思路参考下:使用目标元素的 index 和父节点直到根节点的 index 组合索引生成的 xpath 唯一定位一个元素

已经尝试,只能获取简单的 xpath,如果是稍微复杂点的 xpah 完全不准。。。

已经尝试,我是修改源码后,生成 class 文件,替换 uiautomatorviewer.jar 中的那两个 class 文件实现的。刚接触自动化测试,这帖子给我很大启发,以后可以通过修改源码来实现自己想要的功能。谢谢楼主。

#58 楼 @yuwuhen333 你好,请问这个问题你最后是如何解决的,元素如何定位?非常感谢~~

#59 楼 @adfghzhang 您好,我也碰到连接不到 adb 设备的情况,你最后是怎么解决的,没有明白打个 jar 放在 lib 下去运行,我打成 jar 运行时报找不到主类方法,求赐教,谢谢

#84 楼 @binger8296 直接修改 DebugBridge.java 中的部分代码即可。

//        String toolsDir = System.getProperty("com.android.uiautomator.bindir"); //$NON-NLS-1$
//        if (toolsDir == null) {
//            return null;
//        }

//        File sdk = new File(toolsDir).getParentFile();
        String toolsDir = System.getenv("ANDROID_HOME");
        if (toolsDir == null) {
            toolsDir = new FileOp().GetConfigString("Android SDK Path");
            if (toolsDir == null) {
                return null;
            }
        }
        File sdk = new File(toolsDir);
        // check if adb is present in platform-tools
        File platformTools = new File(sdk, "platform-tools");
        File adb = new File(platformTools, SdkConstants.FN_ADB);
        if (adb.exists()) {
            return adb.getAbsolutePath();
        }

#85 楼 @adfghzhang 谢谢,还有个问题 FileOp 是自己写的类吗?我这个地方报错

#86 楼 @binger8296 这是自己写的,可以把它注释掉,我是做了两手准备。保证没配环境变量也可以通过配置文件识别到路径

雪怪 [该话题已被删除] 中提及了此贴 08月19日 16:31
雪怪 uiautomatorviewer 功能扩展实践 中提及了此贴 12月06日 22:35

#10 楼 @cpfeng0124 编译依赖的其他库怎么处理,根据.mk 文件提到的信息,手动添加了这四个库,但是 eclipse 工程一直报错!

bauul 基于 uiautomator 与 shell 的自动化测试工具 中提及了此贴 05月02日 10:12

楼主方便分享下源码吗,或者 git 的地址,感谢。

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册