基础
已经有挺多的教程了,可以参照下面两篇
进阶&注意点
定位目标节点
定位目标节点我们常用的可能就是两个
-
findAccessibilityNodeInfosByViewId
: 根据ResourceId(可以通过uiautomatorviewer来解析布局获取)来进行定位节点 -
findAccessibilityNodeInfosByText
: 根据指定文字搜索当前界面布局来进行定位节点
一般而言,我们可能会更加偏向使用ResourceId的方法来定位,因为肯定准确,而用文字的方式还得考虑系统语言兼容问题,毕竟用户手动切换系统语言,app的文案可能会发生变化,那么我们的工作就比较多了
但是,也不能一概而论都用ResourceId的方法去定位
比如:在ListView 之类的组件面前,用ResourceId的方法去定位就不行了,有的时候还得根据文字来定位
e.g.
假设我们需要通过辅助功能,自动点击上面 "启用小部件" 选项,让它打勾,那么你的做法就基本不可能用ResourceId来定位 "启用小部件" 这个节点了,因为从布局分析你应该看出来,它是ListView的一个item,也就是说其他item都是有同样的ResourceId,单纯根据ResourceId你还真的不能定位这个节点,然后点击
所以这个时候一般的做法就有两种:
第一种方案为直接通过
findAccessibilityNodeInfosByText
从跟布局开始查找-
第二种方案为遍历ListView:
- 先通过ResourceId的方法定位到ListView,
- 遍历ListView的子节点,找到ResourceId为
android:id/title
的节点,然后获取其文字,判断是否为 "启用小部件"
两种方案都可以,这里主要是为了说明,定位目标节点,得认真考虑下两种查找方法是否适用于当前情景
当然,上面的例子里面还存在语言问题,比如用户语言是英文的话,那么你得去适配 "启用小部件" 的英文,不然还是找不到的样子
findAccessibilityNodeInfosByText(String) 方法的注意事项
这个方法是根据传入的文字去查找对应的AccessibilityNodeInfo。查找时,除了大小写忽略之外,还不是用equals的方式去找,而是用类似contain的方式去找
e.g.
用下面代码去找我们系统安装界面中,文字为 "安装" 的node,得到的会是好几个node。
String installStr = "安装";
List<AccessibilityNodeInfo> installNodes = rootNodeInfo.findAccessibilityNodeInfosByText(installStr);
DLog.i("textId: %s 在 rootActiveWindow 中有%d个node", installStr, installNodes == null ? 0 : installNodes.size());
if (installNodes == null || installNodes.isEmpty()) {
continue;
}
for (AccessibilityNodeInfo temp : installNodes) {
// 注意,不是所有的node getText都有值,加上这个判断来避免NPE空指针问题吧
if (temp.getText() == null) {
continue;
}
DLog.i("* id: %s text: %s", temp.getViewIdResourceName(), temp.getText().toString());
}
如果要精准匹配的话,那么其实就只需要我们加多步,比如在上面的for循环中,加入
if (!temp.getText().toString().equals("安装") {
continue;
}
本质还是Service
意味着我们在辅助功能的服务的各个回调方法不能做太多耗时操作,因为Service还是运行在主线程中的,进行太多耗时操作会影响UI更新,即便如下面这样子的代码,判断是否在应用详细设置页面中,只要在写多几个id完善精准判断,耗时就会越来越长,就会开始明显感觉到UI卡顿了
/**
* 是否在应用详细设置界面
*
* @return
*/
Override
protected boolean isInAppSettingsPage() {
long startTime = System.currentTimeMillis();
boolean isInAppSettingsPage = isNodeExistInRootActiveWindowByViewIds(
// 顶部app布局信息
"com.android.settings:id/app_snippet",
"com.android.settings:id/app_icon",
"com.android.settings:id/app_name",
"com.android.settings:id/app_size",
// force stop 卸载布局信息
"com.android.settings:id/control_buttons_panel",
"com.android.settings:id/left_button",
"com.android.settings:id/right_button",
// 存储信息布局
"com.android.settings:id/total_size_prefix",
"com.android.settings:id/application_size_prefix",
"com.android.settings:id/data_size_prefix",
// 清除数据布局
"com.android.settings:id/data_buttons_panel",
"com.android.settings:id/right_button"
);
if (isInAppSettingsPage) {
mCurrentAppName = getTextByViewIdFromRootActiveWindow("com.android.settings:id/app_name");
DLog.i("当前在 %s 的详情设置页面中", mCurrentAppName);
}
DLog.i("判断是否为应用详细设置页面耗时 : %d ms", System.currentTimeMillis() - startTime);
DLog.i("判断当前是否在UI线程中 : %b", UIHandler.isInUIThread());
return isInAppSettingsPage;
}
解决办法
- 将这些放到单线程池中去完成,值得一提的是performAction方法是可以自行在非UI线程中的,所以你可能担心的点也不存在了。
- 还是在主线程中操作,但是将搜索的内容优化,可能不需要搜索那么多节点
可以不用在xml中指定包名
这样子就是会全部包名都能监听到,如果你要做一些可动态更新的逻辑,比如某个时刻下发,支持某应用的辅助功能支持,那么这个时候,不在xml中指定包名明显会是你的选择
可能你会说,用 setServiceInfo
也能动态更新,没错,是可以,但是有个小问题,为了说明这个问题,我们用实际情景说明下:
假设 你的辅助功能现在支持应用A和B ,然后你希望用 setServiceInfo
准备支持应用C,那么并不是说立即就能支持,因为 serSericeInfo
的触发时机基本在
onServiceConnected
onInterrupt
onAccessibilityEvent
三者之一,也就是说你最起码得先触发到这三者其中一个方法,才能真的调用到 setServiceInfo
,而一般而言,onServiceConnected
和 onInterrupt
基本不会多次触发。那么剩下的就是 onAccessibilityEvent
这个方法,而这个方法按照我们前面假设(指定了目标包名为A和B),是必须要在进入过A或者B才能真的回调到的,因此这里的问题就在于,如果你想设置支持C应用,那么用户必须得先打开过A或者B才能真的设置C应用,不然,是用于不会设置成功的,所以从效果上来说,不设置包名是最好的
当然,实际上,你也可以用更加巧妙的方法(比如回调等)通知你的辅助功能服务器调用 setServiceInfo
而不是等待上面说到的3个回调方法
Switch CheckBox 处理
上面我们说到常用的找节点的就是两种方法
-
findAccessibilityNodeInfosByViewId
: 根据ResourceId(可以通过uiautomatorviewer来解析布局获取)来进行定位节点 -
findAccessibilityNodeInfosByText
: 根据指定文字搜索当前界面布局来进行定位节点
但是,有的页面,部分类型的组件是没有ResourceId的,根据text来查找也查找不到,这个时候就需要通过getClassName 来匹配定位
e.g.
/**
* 从指定的节点开始向下查找指定类名的组件(深度遍历),在找到一个符合之后就会结束
*
* @param nodeInfo 起始节点
* @param classNames 类名(可多个),每进行一次节点的深度遍历,都会遍历一遍这里传入来的类名,找到了就立即返回
*
* @return 最后找到的节点
*/
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
protected AccessibilityNodeInfo getNodeByClassName(@NonNull AccessibilityNodeInfo nodeInfo, @NonNull String... classNames) {
if (nodeInfo.getChildCount() == 0) {
return null;
}
for (int i = 0; i < nodeInfo.getChildCount(); i++) {
AccessibilityNodeInfo childNodeInfo = nodeInfo.getChild(i);
if (DLog.isDebug()) {
StringBuilder sb = new StringBuilder(classNames.length);
for (String className : classNames) {
sb.append(className).append(" ");
}
DLog.i("index: %d className: %s target: %s", i, childNodeInfo.getClassName().toString(), sb.toString());
}
for (String className : classNames) {
if (childNodeInfo.getClassName().toString().equals(className)) {
return childNodeInfo;
}
}
AccessibilityNodeInfo switchOrCheckBoxNodeInfo = getNodeByClassName(childNodeInfo, classNames);
if (switchOrCheckBoxNodeInfo != null) {
return switchOrCheckBoxNodeInfo;
}
}
return null;
}
/**
* 从根节点节点开始向下查找指定类名的组件(深度遍历),在找到一个符合之后就会结束
*
* @param classNames 类名(可多个),每进行一次节点的深度遍历,都会遍历一遍这里传入来的类名,找到了就立即返回
*
* @return 最后找到的节点
*/
@Nullable
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
protected AccessibilityNodeInfo getNodeByClassName(@NonNull String... classNames) {
AccessibilityNodeInfo rootNodeInfo = getAccessibilityService().getRootInActiveWindow();
if (rootNodeInfo == null) {
return null;
}
AccessibilityNodeInfo result = getNodeByClassName(rootNodeInfo, classNames);
rootNodeInfo.recycle();
return result;
}
e.g. : 从根节点开始向下查找
AccessibilityNodeInfo nodeInfo = getNodeByClassName(
CheckBox.class.getName(),
Switch.class.getName()
);
DLog.i("%s Switch 或者 Checkbox", switchNodeInfo == null ? "不存在" : "存在");
滚动处理
有的时候你要点击ListView中某个项,但是ListView很多内容,一页可能是显示不完全的,假设我们需要点击某个item,但是这个item不在当前页,而是在下面几页,那么我们是没法定位到该item的。
这个时候,我们就需要控制ListView进行向下滚动了,在滚动之后产生的新事件AccessibilityEvent.TYPE_VIEW_SCROLLED
中在进行搜索,看看是否已经找到我们的item
在深入一步,
- 刚刚我们是让ListView向下滚动,但是我们怎么知道是否已经滚动到底部?
- 如果ListView已经滚动到底部,我又该如何在令它向上滚动呢?毕竟我需要的内容可能也在最前面
页面确定
一个APP中一般存在多个页面,可能是Activity,可能是Fragment,如果是Activity构成的页面,我们比较好处理,毕竟接收到的事件中,我们可以通过 event.getClassName().toString().equal("xxxActivity")
方法类定位这个页面是不是我们的目标页面,是的话就操作
但是,如果是一个Activity嵌套很多个Fragment的时候(比如GooglePlay),这种方法就不行了,这个时候如果我们希望精确定位某个指定的页面,我们可以尝试需要用该页面的特征来定位,比如该页面(Fragment)存在某些指定的ResourceId或者文字之类的,而其他Fragment则不会有
同一页面多次进入问题
开启某个应用的辅助功能,我们一般是要先进入辅助功能列表页,找到我们的目标应用,然后进入他的详情页,然后才能点击开关
假设你的应用A要辅助开启另外两个应用(B和C)的辅助功能,两次都用intent去打开辅助功能的话,那么在你开启完B之后,再次通过intent去打开C的时候,就会发现intent并不是进入到列表页,而是B的详情页,这个时候得加点逻辑处理,在每次完成一个应用的辅助功能的时候,主动回调两次goback,然后才能进行下个任务
自动输入文字
Android 5.0之后比较好处理,因为直接有新的API支持输入文字
我们这里主要讲述Android 5.0之前的处理
if (Build.VERSION.SDK_INT >= 21) {
//android>=21 = 5.0时可以用ACTION_SET_TEXT
Bundle arg = new Bundle();
arg.putString(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text);
return node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, arg);
} else if (Build.VERSION.SDK_INT >= 18) {
// android>=18
// 可以通过复制我们的需要写入的文字,然后粘贴到目标EditText进行写入,算是一种绕路吧
// 默认粘贴是仅仅append到EditText,所以我们需要清空原有的内容先,但是没有方法,所以我们只能绕路,将当前所有的文字全选然后在粘贴,算是一种清空(替换)方案
Bundle arguments = new Bundle();
// 这里为设置仅仅是选中一行,也可以设置选中一个单词 或者一整页之类,看具体需要吧
arguments.putInt(AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT, AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE);
arguments.putBoolean(AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN, true);
// 这里是因为有的EditText的光标默认是在文字最后面,有的则是默认在文字最前面
// 所以我们加多一个判断,究竟是从前往后全选,还是从后往前全选
if (isFromStart) {
node.performAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY, arguments);
} else {
node.performAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY, arguments);
}
// 2. 保存目标文字到剪切板
ClipboardManagerUtil.setText(getAccessibilityService().getApplicationContext(), text);
// 3. 最后将剪切板中的文字复制到节点中已经全选的文字
node.performAction(AccessibilityNodeInfo.ACTION_FOCUS);
return node.performAction(AccessibilityNodeInfo.ACTION_PASTE);
}
模拟键盘的按键事件
场景为:在搜索框,很多app都已经没有搜索按钮了,基本是靠软键盘的 搜索按键 来对输入的内容触发搜索的操作,因此我们可能需要做的是跨进程发送按键事件
经过尝试,发现基本需要root权限
adb shell input keyevent 66
因此暂时没发现什么好方案
补充findAccessibilityNodeInfosByText
这个方法在兼容系统方面可能会十分强悍,比如网上很多教程所说到的 利用辅助功能实现自动安装APK 我们以这个为例
首先,我们肯定没有那么多不同厂商的机子以及不同版本的rom来获取他们的安装界面究竟是长咋样的,那么这里就肯定存在我们的辅助功能不能成功自动安装apk的情况,我们需要做的是提高成功率,减少不适配率
实际过程中,你可能发现安装一个APK,在不同的rom上,可能都是大同小异,比如安装界面一般都存在
- app名字
- 取消按钮
- 下一步按钮/安装按钮
这个时候,我们就可以根据这些抽象属性,来处理:根据上面说到的文字来搜索页面,找出页面中是否存在app名字,是否为我们的目标自动安装app,然后在判断是否同时存在取消按钮、下一步/安装按钮,如果是的话,那么就基本确定为安装界面,剩下的就是点击了
实际测试的情况下,这种通过 findAccessibilityNodeInfosByText
方法的定位可能真的能让你蒙对一些rom,并能在该rom上运行处理
当然这个只是特例,如果换个场合可能就不行了,比如卸载apk的页面,就没有大同小异的说法了,很多rom都不同的
这个例子中的一些实现代码,可以参考FuzzyApkInstallASHandler.java
最后
上面列到的一些方案或者代码,都可以在我这边弄的库中找到,欢迎星星 AccessibilityDispatcher