Android 辅助功能进阶使用

基础

已经有挺多的教程了,可以参照下面两篇

进阶&注意点

定位目标节点

定位目标节点我们常用的可能就是两个

  • findAccessibilityNodeInfosByViewId: 根据ResourceId(可以通过uiautomatorviewer来解析布局获取)来进行定位节点
  • findAccessibilityNodeInfosByText: 根据指定文字搜索当前界面布局来进行定位节点

一般而言,我们可能会更加偏向使用ResourceId的方法来定位,因为肯定准确,而用文字的方式还得考虑系统语言兼容问题,毕竟用户手动切换系统语言,app的文案可能会发生变化,那么我们的工作就比较多了

但是,也不能一概而论都用ResourceId的方法去定位

比如:在ListView 之类的组件面前,用ResourceId的方法去定位就不行了,有的时候还得根据文字来定位

e.g.

Paste_Image.png

假设我们需要通过辅助功能,自动点击上面 "启用小部件" 选项,让它打勾,那么你的做法就基本不可能用ResourceId来定位 "启用小部件" 这个节点了,因为从布局分析你应该看出来,它是ListView的一个item,也就是说其他item都是有同样的ResourceId,单纯根据ResourceId你还真的不能定位这个节点,然后点击

所以这个时候一般的做法就有两种:

  • 第一种方案为直接通过findAccessibilityNodeInfosByText从跟布局开始查找

  • 第二种方案为遍历ListView:

    1. 先通过ResourceId的方法定位到ListView,
    2. 遍历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());
}
Paste_Image.png

如果要精准匹配的话,那么其实就只需要我们加多步,比如在上面的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;
}

解决办法

  1. 将这些放到单线程池中去完成,值得一提的是performAction方法是可以自行在非UI线程中的,所以你可能担心的点也不存在了。
  2. 还是在主线程中操作,但是将搜索的内容优化,可能不需要搜索那么多节点

可以不用在xml中指定包名

这样子就是会全部包名都能监听到,如果你要做一些可动态更新的逻辑,比如某个时刻下发,支持某应用的辅助功能支持,那么这个时候,不在xml中指定包名明显会是你的选择

可能你会说,用 setServiceInfo也能动态更新,没错,是可以,但是有个小问题,为了说明这个问题,我们用实际情景说明下:

假设 你的辅助功能现在支持应用A和B ,然后你希望用 setServiceInfo 准备支持应用C,那么并不是说立即就能支持,因为 serSericeInfo 的触发时机基本在

  • onServiceConnected
  • onInterrupt
  • onAccessibilityEvent

三者之一,也就是说你最起码得先触发到这三者其中一个方法,才能真的调用到 setServiceInfo ,而一般而言,onServiceConnectedonInterrupt 基本不会多次触发。那么剩下的就是 onAccessibilityEvent 这个方法,而这个方法按照我们前面假设(指定了目标包名为A和B),是必须要在进入过A或者B才能真的回调到的,因此这里的问题就在于,如果你想设置支持C应用,那么用户必须得先打开过A或者B才能真的设置C应用,不然,是用于不会设置成功的,所以从效果上来说,不设置包名是最好的

当然,实际上,你也可以用更加巧妙的方法(比如回调等)通知你的辅助功能服务器调用 setServiceInfo 而不是等待上面说到的3个回调方法

Switch CheckBox 处理

上面我们说到常用的找节点的就是两种方法

  • findAccessibilityNodeInfosByViewId: 根据ResourceId(可以通过uiautomatorviewer来解析布局获取)来进行定位节点
  • findAccessibilityNodeInfosByText: 根据指定文字搜索当前界面布局来进行定位节点

但是,有的页面,部分类型的组件是没有ResourceId的,根据text来查找也查找不到,这个时候就需要通过getClassName 来匹配定位

e.g.

Paste_Image.png
/**
 * 从指定的节点开始向下查找指定类名的组件(深度遍历),在找到一个符合之后就会结束
 *
 * @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

在深入一步,

  1. 刚刚我们是让ListView向下滚动,但是我们怎么知道是否已经滚动到底部?
  2. 如果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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,834评论 25 707
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,637评论 18 139
  • 有哪3个工具或者方法是极大程度提高你的工作效率的利器? 1. To-do list 就像《生活大爆炸》中谢耳朵说的...
    悦来成长阅读 316评论 0 0
  • 如果每天能给我一个小时的时间,我想用来跟不同行业的人交流,我很好奇他们的经历故事,每个人都有自己的故事,每个平凡人...
    小九坛阅读 213评论 0 0
  • 如果你生活在20年前,让你突然穿越到今天,什么是最另你最震撼的?什么又是你最难适应的? 应该是沟通方式。 20年前...
    清水一点通阅读 302评论 0 0