Android自动化系列——如何定位界面节点

1,194 阅读4分钟

好久没有好好写博文了,20年基本都在搞自己的事情,白天上班,晚上回去写写脚本。虽然没有学到很多东西,但还是想把自己在自动化脚本这块的一点点心得体会,写出来分享一下哈。

前言

之前有分享过关于 android 辅助服务的一些介绍文章,目前网上相关的文章肯定也是数不胜数,不再稀缺了,这里贴一下之前分享的文章的链接,还不熟悉的朋友可以看看之前的文章。如下:

1. Android辅助服务的介绍与配置

2. Android辅助服务与悬浮窗

3. Android辅助服务之微信自动评论与点赞

基础方法

辅助服务默认提供两个核心方法,可通过 text 或 id,寻找界面节点,具体使用方法可以参考上面链接

1、findAccessibilityNodeInfosByText

2、findAccessibilityNodeInfosByViewId

但实践过的朋友,应该知道这两个方法是有局限性的。就是有时候我们 dump 一个界面的时候,会出现 text 、 id 都没有的情况,那这种情况我们要怎么去定位控件节点呢? 请继续往下看

扩展方法

在没有 text、id的时候,我们是可以通过根节点信息,循环遍历,找到我们想到的控件,例如根据属性 desc、classname、bounds等,来定位我们想要的节点。那其实有没有现成的一些代码能够供我们使用呢?

做过 android 的朋友应该知道自动化测试框架 Uiautomator,其中提供了非常多好用的方法和接口。而且 uiautomator 核心实现也是基于辅助服务搞得,这里参考 Auto.js(目前市面上比较流行的自动化脚本项目) 的源码,github 地址是 github.com/hyb1996/Aut…

站在别人的肩膀上,看看是怎么把 uiautomator 与 accessibilityService 结合起来的。打开 Auto.js 的源码,找到 UiSelector 这个类,可以看到它的构造函数传入 AccessibilityBridge,这个类可以理解为桥梁,getService() 方法联通UiSelectoraccessibilityService

open class UiSelector(accessibilityBridge: AccessibilityBridge?, allocator: AccessibilityNodeInfoAllocator?) {
    private val mSelector = Selector()
    private var mSearchAlgorithm: SearchAlgorithm = DFS
    
    
    open fun id(id: String): UiSelector {
        mSelector.add(IdFilter.equals(id))
        return this
    }

    fun idContains(str: String): UiSelector {
        mSelector.add(IdFilter.contains(str))
        return this
    }
    
    //省略部分代码...
    
    public abstract AccessibilityService getService();
    
    //核心方法
    protected open fun findImpl(max: Int): UiObjectCollection {
        val start = System.currentTimeMillis()
        val roots = mAccessibilityBridge!!.windowRoots()
        if (BuildConfig.DEBUG) Log.w(TAG, "find: roots = $roots")
        if (roots.isEmpty()) {
            return EMPTY
        }
        val result: MutableList<UiObject?> = ArrayList()
        for (root in roots) {
            if (root == null) {
                continue
            }
            if (root.packageName != null && mAccessibilityBridge!!.config.whiteListContains(root.packageName.toString())) {
                Log.d(TAG, "package in white list, return null")
                return EMPTY
            }
            result.addAll(findAndReturnList(createRoot(root, mAllocator), max - result.size))
            if (result.size >= max) {
                break
            }
        }
        val end = System.currentTimeMillis()
        return of(result)
    }
    
    //实际查找方法
    fun findAndReturnList(node: UiObject, max: Int = Int.MAX_VALUE): List<UiObject> {
        return mSearchAlgorithm.search(node, mSelector, max)
    }
    
}

这里的 mSearchAlgorithm 是搜索接口,分别对应 DFS(深度优先搜索算法) 、BFS(广度优先搜索) 算法,我们知道android视图,可以理解为“树”结构,通过这样查找能够快速的遍历节点。

object DFS : SearchAlgorithm {

    override fun search(root: UiObject, filter: Filter, limit: Int): ArrayList<UiObject> {
        val start = System.currentTimeMillis()
        val result = ArrayList<UiObject>()
        val stack = LinkedList<UiObject>()
        stack.push(root)
        while (stack.isNotEmpty()) {
            val parent = stack.pop()
            for (i in parent.childCount - 1 downTo 0) {
                val child = parent.child(i) ?: continue
                stack.push(child)
            }
            if (filter.filter(parent)) {
                result.add(parent)
                if (result.size >= limit) {
                    break
                }
            } else {
                if (parent !== root) {
                    parent.recycle()
                }
            }
        }
        val end = System.currentTimeMillis()
        if (BuildConfig.DEBUG)
            Log.w("DFS", "耗时:" + (end - start) + "ms")
        return result
    }
}

另外我们可以看到,在遍历过程中,压栈的时候,留意到 Filter 接口,我们继续看看 Filter 是怎么实现的,可以看到 Filter 的作用,其实就是匹配 Selector 中的 mFilters 各个选择器的条件,只有都满足,才返回对应的节点。

interface Filter {

    fun filter(node: UiObject): Boolean

}


class Selector : Filter {
    private val mFilters = LinkedList<Filter>()

    override fun filter(node: UiObject): Boolean {
        for (filter in mFilters) {
            if (!filter.filter(node)) {
                return false
            }
        }
        return true
    }

    fun add(filter: Filter) {
        mFilters.add(filter)
    }

    override fun toString(): String {
        val str = StringBuilder()
        for (filter in mFilters) {
            str.append(filter.toString()).append(".")
        }
        if (str.isNotEmpty()) {
            str.deleteCharAt(str.length - 1)
        }
        return str.toString()
    }

}

节点窗口

在做自动化的时候,有个地方需要注意的是窗口的获取,需要区分 getRootInActiveWindow()getWindows() 方法 ,往往定位不到节点的时候,需要看看你这里的 window 是用的哪个方法,一个表示当前活跃的窗口,一个表示所有窗口。如:ViewPager 搭配 Fragment的情况,会出现层叠的情况,如果用第一个方法,可能会找不到节点信息。但如果用第二个方法,因为是在所有的 windows 里面搜索,因此花费的时间比仅一个窗口的时间长。

    @Override
    public AccessibilityNodeInfo getActiveRoot() {
        return getRootInActiveWindow();
    }

    @Override
    public List<AccessibilityWindowInfo> getActiveWindow() {
        return getWindows();
    }

结尾

文章学习了 auto.js 的是怎么在 uiautomator 的基础上扩展接口,以提供丰富的 api ,给用户使用,因为单单依靠 text 、id来定位节点,确实有点缺陷。

【注:文中源码学习分析使用】