美文网首页高级Android知识Android开发
Android 辅助功能进阶使用

Android 辅助功能进阶使用

作者: 天煞魔猎手 | 来源:发表于2017-04-05 18:11 被阅读664次

    基础

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

    进阶&注意点

    定位目标节点

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

    • 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

    相关文章

      网友评论

        本文标题:Android 辅助功能进阶使用

        本文链接:https://www.haomeiwen.com/subject/tpjxattx.html