logo头像
Snippet 博客主题

UIAutomatorViewer无法获取动态页面的UI布局的解决办法

转载声明:商业转载请联系作者获得授权,非商业转载请注明出处 © soho.tips

1 问题

在Android开发中,使用UI Automator Viewer获取UI布局结构是常用的开发debug手段,但UI Automator Viewer有个缺陷,就是无法获取到动态页面的UI布局。会出现如下的错误信息: Error obtaining UI hierarchy… UIAutomatorViewerErrorDlg 根据网上的解释,这是由于uiautomator在获取界面状态信息时,首先要等界面处于idle空闲状态才会做dump操作,假如界面一直在动,没有idle状态,那么就会一直不dump从而超时错误。 针对这种一直是动态的界面,我们需要自己手动去获取UI布局信息。如何才能获取到每个UI控件及布局信息呢?这里可以使用android.accessibilityservice这个包。

2 android.accessibilityservice

The classes in this package are used for development of accessibility service that provide alternative or augmented feedback to the user.

这是一个用于开发辅助功能,提供交互或增音反馈给用户的包。Android系统中辅助功能里面的talkback功能是这个package的最为典型的应用。 android.accessibilityservice这个package中有多个类,我们这里主要使用AccessibilityService这个类。一般的用法是继承AccessibilityService,并重写某些方法来实现我们的功能。 这个类的继承类在AndroidManifest中的声明如同其他Service的声明一样,但额外需要添加两个东西: 1.需要响应处理”android.permission.BIND_ACCESSIBILITY_SERVICE”这个intent 2.需要获取”BIND_ACCESSIBILITY_SERVICE”权限

AndroidManifest
1
2
3
4
5
6
7
<service android:name=".MyAccessibilityService"
android:permission="android.permission.BIND\_ACCESSIBILITY\_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
. . .
</service>

AccessibilityService还需要配置一下接收哪种类型的AccessibilityEvent,可以通过XML文件的方式来配置,或者在代码中动态地调用setServiceInfo(AccessibilityServiceInfo)这个方法来设置配置。具体的配置选项请参考Android官方文档 https://developer.android.com/reference/android/accessibilityservice/AccessibilityService.html

3 解决

UIAutomatorViewer无法获取动态页面的UI布局的解决办法,这里通过继承AccessibilityService,然后调用getRootInActiveWindow()获取到当前活动窗口的root节点,这里返回的是一个AccessibilityNodeInfo,包含了节点的所有UI相关信息,如text、resource-id、class、package、content-desc、checkable…等UI Automator Viewer能获取到的UI信息。并且可以通过父节点获取到子节点,那么就可以通过获取到root节点,通过深度优先来遍历所有UI节点的信息。具体代码如下: AndroidManifest:

AndroidManifest
1
2
3
4
5
6
7
8
9
10
<service android:name=".service.DumpService"
android:permission="android.permission.BIND\_ACCESSIBILITY\_SERVICE">
<intent-filter>
<action android:name="com.zzlys.dumphierarchy.service.DumpService"/>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/dumpservice" />
</service>

dumpservice.xml:
dumpservice.xml
1
2
3
4
5
6
7
8
9
10
11
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged|typeViewHoverEnter|typeViewHoverExit|typeWindowContentChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagDefault|flagIncludeNotImportantViews|flagRequestTouchExplorationMode|flagRequestEnhancedWebAccessibility|flagReportViewIds|flagRequestFilterKeyEvents|flagRetrieveInteractiveWindows"
android:canRequestEnhancedWebAccessibility="true"
android:canRequestFilterKeyEvents="true"
android:canRequestTouchExplorationMode="false"
android:canRetrieveWindowContent="true"
android:settingsActivity=".MainActivity"
android:description="@string/accessibility\_service\_description" />

DumpService.java:
DumpService.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
package com.zzlys.dumphierarchy.service;
import android.accessibilityservice.AccessibilityService;
import android.content.Intent;
import android.graphics.Rect;
import android.os.Environment;
import android.os.Handler;
import android.os.Message;
import android.util.Log;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityWindowInfo;
import android.widget.Toast;
import com.zzlys.dumphierarchy.ScreenShotListenManager;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.text.SimpleDateFormat;
import java.util.Collections;
import java.util.Date;
import java.util.List;
/\*\*
\* Created by ziliang.z on 2017/3/2.
*/
public class DumpService extends AccessibilityService {
private static final String TAG = "DumpHierarchy";
Handler _handler;
String _outputString;
ScreenShotListenManager _screenShotListener;
@Override
public void onAccessibilityEvent(AccessibilityEvent event) {
Log.d("DumpEvent", event.toString());
}
@Override
public void onCreate() {
super.onCreate();
_handler = new Handler(new Handler.Callback() {
@Override
public boolean handleMessage(Message msg) {
dump(true);
return false;
}
});
_screenShotListener = ScreenShotListenManager.newInstance(getApplicationContext());
_screenShotListener.setListener(
new ScreenShotListenManager.OnScreenShotListener() {
public void onShot(String imagePath) {
Log.d(TAG, "Screen Capture detected! Dump UI Hierarchy in 1s");
_handler.sendEmptyMessageDelayed(1,1000);
}
}
);
_screenShotListener.startListen();
}
@Override
public void onDestroy() {
super.onDestroy();
_screenShotListener.stopListen();
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
Log.d(TAG,"onStartCommand");
//收回下拉的状态栏
Intent it = new Intent("android.intent.action.CLOSE\_SYSTEM\_DIALOGS");
sendBroadcast(it);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
dump(false);
return super.onStartCommand(intent, flags, startId);
}
private void dump(boolean dump2file) {
AccessibilityNodeInfo root = getRootInActiveWindow();
if (root == null)
root = getRootInTopWindow();
if (root == null) {
Toast.makeText(getApplicationContext(),"Get root view failed, cannot dump UI hierarchy...", Toast.LENGTH_SHORT).show();
return;
}
Toast.makeText(getApplicationContext(),"Dumping UI Hierarchy...", Toast.LENGTH_SHORT).show();
dumpHierarchy(root);
if (dump2file &amp;&amp; root!= null) {
logNode2Xml(root);
SimpleDateFormat formatter = new SimpleDateFormat("yyyyMMddhhmmss");
Date curDate = new Date(System.currentTimeMillis());//获取当前时间
String time = formatter.format(curDate);
String dir = Environment.getExternalStorageDirectory() + "/DumpHierarchy";
File file = new File(dir);
if(!file.exists()) {
file.mkdir();
}
String name = file.getPath() + "/dump_" + time;
write2file(name + ".uix", _outputString);
copyLastestScreenshot(name + ".png");
}
Toast.makeText(getApplicationContext(),"Dump UI Hierarchy completed.", Toast.LENGTH_SHORT).show();
}
@Override
public void onInterrupt() {
}
public void dumpHierarchy(AccessibilityNodeInfo root) {
Log.d(TAG, "================ dump hierarchy start ================");
dumpHierarchy(root, "\[0\]");
Log.d(TAG, "================ dump hierarchy end ================");
}
public void dumpHierarchy(AccessibilityNodeInfo root, String prefix) {
if(root == null) {
Log.d(TAG, prefix + " is null");
return;
}
Log.d(TAG, prefix + logNodeInfo(root) + " childs(" + root.getChildCount() + ")");
String spaces = "";
for(int i = 0; i&lt;prefix.length(); i++) spaces = spaces + " "; if (root.getChildCount() &gt; 0) {
Log.d(TAG, spaces + "{");
for(int i = 0; i&lt;root.getChildCount(); i++) {
dumpHierarchy(root.getChild(i), prefix + "\[" + i + "\]");
if (root.getChild(i) != null)
root.getChild(i).recycle();
}
Log.d(TAG, spaces + "}");
}
}
private boolean DEBUG_WINDOW = true;
public AccessibilityNodeInfo getRootInTopWindow() {
Log.d(TAG, "getRootInTopWindow");
AccessibilityNodeInfo topRoot = null;
List windowInfoList = getWindows();
int windowInfoSize = windowInfoList.size();
if (windowInfoSize &gt; 0) {
Collections.reverse(windowInfoList);
// find focus window
for (int i = 0; i &lt; windowInfoSize; i++) {
AccessibilityWindowInfo windowInfo = windowInfoList.get(i);
if (DEBUG_WINDOW) {
Log.d(TAG, "getRootInTopWindow, i: " + i + ", windowInfo: " + windowInfo);
}
if (windowInfo.getType() == AccessibilityWindowInfo.TYPE_APPLICATION
&amp;&amp; windowInfo.isActive()) {
topRoot = windowInfo.getRoot();
if (topRoot == null) {
Log.d(TAG, "getRootInTopWindow, getRoot is null. i: " + i
\+ ", windowInfo: " + windowInfo);
break;
}
String pkgName = (String) topRoot.getPackageName();
if (DEBUG_WINDOW) {
Log.d(TAG, "getRootInTopWindow, active i: " + i
\+ ", pkgName: " + pkgName
\+ ", windowInfo: " + windowInfo);
}
if (i + i &lt; windowInfoSize) {
for (int topIdx = i + 1; topIdx &lt; windowInfoSize; topIdx++) {
AccessibilityWindowInfo topWindowInfo = windowInfoList.get(topIdx);
if (DEBUG_WINDOW) {
Log.d(TAG, "getRootInTopWindow, candi topIdx: " + topIdx
\+ ", topWindowInfo: " + topWindowInfo);
}
if (topWindowInfo.getType() == AccessibilityWindowInfo.TYPE_APPLICATION) {
AccessibilityNodeInfo candiRoot = topWindowInfo.getRoot();
if (candiRoot == null) {
Log.d(TAG, "getRootInTopWindow, candiRoot is null");
continue;
}
if (pkgName.equals(candiRoot.getPackageName())) {
topRoot = topWindowInfo.getRoot();
if (DEBUG_WINDOW) {
Log.d(TAG, "getRootInTopWindow, found topRoot: " + topRoot);
}
}
} else {
break;
}
}
}
break;
}
}
}
if (DEBUG_WINDOW) {
Log.d(TAG, "getRootInTopWindow, ret topRoot: " + topRoot);
}
return topRoot;
}
@Override
public List getWindows() {
List wInfoList = super.getWindows();
Log.d(TAG, "getWindows, wInfoList size: " + wInfoList.size());
if (DEBUG_WINDOW) {
for (AccessibilityWindowInfo windowInfo : wInfoList) {
Log.d(TAG, "getWindows, ret windowInfo: " + windowInfo);
}
}
return wInfoList;
}
public String logNodeInfo(AccessibilityNodeInfo node) {
String rsl = "";
Rect rc = new Rect();
node.getBoundsInScreen(rc);
rsl = ""
\+ " text="" + node.getText()
\+ "" resource-id="" + node.getViewIdResourceName()
\+ "" class="" + node.getClassName()
\+ "" package="" + node.getPackageName()
\+ "" content-desc="" + node.getContentDescription()
\+ "" checkable="" + node.isCheckable()
\+ "" checked="" + node.isChecked()
\+ "" clickable="" + node.isClickable()
\+ "" enabled="" + node.isEnabled()
\+ "" focusable="" + node.isFocusable()
\+ "" focused="" + node.isFocused()
\+ "" scrollable="" + node.isScrollable()
\+ "" long-clickable="" + node.isLongClickable()
\+ "" password="" + node.isPassword()
\+ "" selected="" + node.isSelected()
\+ "" bounds="\[" + rc.left + "," + rc.top + "\]\[" + rc.right + "," + rc.bottom + "\]""
\+ "";
return rsl;
}
private void logNode2Xml(AccessibilityNodeInfo node) {
_outputString = "<!--?xml version='1.0' encoding='utf-8' standalone='yes'?-->";
logNode2Xml(node, 0);
_outputString += "";
}
private void logNode2Xml(AccessibilityNodeInfo node, int index) {
if (node == null)
return;
if (node.getChildCount()&gt;0) {
_outputString += "&lt;node " + "index="" + index + """ +logNodeInfo(node) + "&gt;";
for(int i = 0; i&lt;node.getChildCount(); i++) {
logNode2Xml(node.getChild(i), i);
}
_outputString += "";
}else{
_outputString += "&lt;node " + "index="" + index + """ +logNodeInfo(node) + "/&gt;";
}
}
private void write2file(String filename, String content){
try{
File file = new File(filename);
if(!file.exists()) {
file.createNewFile();
}
FileOutputStream fout = new FileOutputStream(file);
byte\[\] bytes = content.getBytes();
fout.write(bytes);
fout.close();
Toast.makeText(getApplicationContext(),"file saved at " + filename, Toast.LENGTH_SHORT);
}
catch(Exception e){
Log.d(TAG, e.toString());
}
}
private void copyLastestScreenshot(String filename) {
String PATH = Environment.getExternalStorageDirectory() + "/";
File screenshotDir = new File(PATH + "/DCIM/Screenshots");
if (!screenshotDir.exists()) {
Toast.makeText(getApplicationContext(), "未找到/DCIM/Screenshots文件夹", Toast.LENGTH_SHORT).show();
return;
}
final File\[\] files = screenshotDir.listFiles();
File lastFile = null;
for(File file : files) {
lastFile = file;
}
if(lastFile == null)
return;
try {
int bytesum = 0;
int byteread = 0;
String oldPath = lastFile.getPath();
String newPath = filename;
File oldfile = new File(oldPath);
if (oldfile.exists()) { //文件存在时
InputStream inStream = new FileInputStream(oldPath); //读入原文件
FileOutputStream fs = new FileOutputStream(newPath);
byte\[\] buffer = new byte\[1444\];
int length;
while ( (byteread = inStream.read(buffer)) != -1) {
bytesum += byteread; //字节数 文件大小
System.out.println(bytesum);
fs.write(buffer, 0, byteread);
}
inStream.close();
}
}
catch (Exception e) {
System.out.println("复制单个文件操作出错");
e.printStackTrace();
}
}
}

最后,放一个编好的apk,通过在手机中运行代码来获取界面UI层次结构,并输出到log或者sdcard/DumpHierarchy文件夹内,copy到电脑即可使用UI Automator Viewer打开查看。 使用方法如下: 1.安装附件的apk, 2.打开apk弹出储存权限请求时请同意, 3.点击应用中唯一按钮,进入“辅助功能”设置菜单, 4.向下滚动菜单至末端,有“服务”一栏,里面会有“DumpHierarchy”这一项,点进去并开启 5.安装已完成,进入你想获取UI结构的界面,按下截屏的组合键或者使用系统截屏功能, 6.截屏后即可在/sdcard/DumpHierarchy文件夹内找到已dump_时间.png和dump_时间.uix两个文件,copy到电脑即可使用UI Automator Viewer查看 PS1.你还可以使用notification中的DUMP TO LOG直接将UI结构输出到log PS2.此应用还集成了AccessibilityEvent采集功能,把系统所有AccessibilityEvent输出到log中,Log TAG为DumpEvent,如下图:

4 APK下载及源码

DumpHierarchy
源码已上传至github

转载声明:商业转载请联系作者获得授权,非商业转载请注明出处 © soho.tips
微信打赏

不考虑赞赏一个?