Skip to content

选择器

一个类似 CSS 选择器的选择器, 能联系节点上下文信息, 更容易也更精确找到目标节点

为什么需要选择器

我们的目的是查询获取屏幕上的某个节点, 最简单的情况下, 目标节点已经有唯一的 id/text 标识

这时只需要配置 id/text 就能简单执行点击, 但实际情况很多节点都缺失了这些信息, 无法简单查询获取

节点还存在很多额外信息没有被利用到, 节点所有属性可以在 快照-14045424 的属性面板看到

另一种常见的情况是节点没有有效属性, 这时我们需要根据周围的节点去定位

选择器就是为了解决这些问题而开发的, 下面是对选择器语法的详细介绍

提示

本文是从 抽象语法树(AST) 的角度去完备地介绍选择器结构
如果你是初学者, 建议你直接看 选择器-示例

语法

与 CSS 类似, 一个选择器由 属性选择器 和 关系选择器 交叉组成, 并且开头末尾必须是 属性选择器

示例 div > img 的结构是 属性选择器 关系选择器 属性选择器, 它表示选择父节点是 divimg 节点, 这与 相同 CSS 语法 语义一致

另外 属性选择器 和 关系选择器 之间必须强制用 空白字符(空格/换行/回车/制表) 隔开

div>img 非法, 必须写成 div > img

下面分别介绍 属性选择器关系选择器

属性选择

它和 CSS 语法的 属性选择器很相似, 但更强大, 如下是一个示例

@TextView[a=1][b^='2'][c*='a'||d.length>7&&e=false][!(f=true)][g.plus(1)>0]

@ 表示选择此节点, 一条规则最后属性选择器 @ 生效, 如果没有 @, 取最后一个属性选择器

TextView 代表节点的 name 属性, 而且与 CSS 相似, * 表示匹配任意属性

由于该选择器主要用于 Android 平台, 节点的 name 都是 java 类如 android.text.TextView 这种形式

为了方便书写规则, 上述 TextView 等价 [name='TextView'||name$='.TextView']

[] 内部是一个 逻辑表达式/布尔表达式/取反表达式

  • 逻辑表达式 -> name='TextView'||name$='.TextView'
  • 布尔表达式 -> name='TextView'
  • 取反表达式 -> !(name$='TextView'), !(name='TextView'||name$='.TextView')

注意 取反表达式 后面必须是 (...), !!(...) 是非法的

逻辑表达式 有两个操作符 ||&&. && 优先级更高

[a>1||b>1&&c>1||d>1] 等价于 [a>1||(b>1&&c>1)||d>1]

并列的 [] 视为使用 && 的逻辑表达式, 即 [a=1][b=1] 等价于 [a=1&&b=1]

布尔表达式 由 左值 操作符 右值 构成, 左值/右值 是一个 值表达式

下面分别介绍 值表达式操作符

值表达式

值表达式分两类: 变量 和 字面量, 下图是关系表格

值表达式
示例
变量标识符 a
成员表达式a.b
调用表达式a(b,c)
字面量nullnull
booleanfalse
int114514
string'ikun'

每个值表达式都有其类型, 可细分为两类

基础类型: null boolean int string

对象类型: object

对象类型可细分为 contextnode 两种类型

比如选择器 [parent=null] 代表选择一个父节点是 null 的节点, 即根节点

上面的 parent 属于 值表达式/变量/标识符, 类型是 node

context 类型指代当前节点的上下文, 当想使用不属于 node 上的属性方法时就需要 context

你可以在 选择器-属性方法 查看所有类型和对应属性方法

变量

首先需要了解 标识符: 正则匹配 ^[_a-zA-Z][a-zA-Z0-9_]*$ 并且不是 null/true/false 的字符串

示例合法变量名: a ikun manbaout, 下面可输入字符测试否是合法

非法标识符❌

接下来了解 成员表达式 a.b, 它被 . 分为两个部分, 前部分是另一个变量, 后部分是一个标识符作为属性

同理 a.b.c 也是一个 成员表达式, 其中 a.b 是它的变量部分, c 是一个合法的标识符作为属性

根据上面标识符的规则, a.1, a.null, a.true 都是非法成员表达式


最后了解 调用表达式 a(b,c), 它由两个部分构成, ( 的左侧 a 是一个变量作为 调用者

(c,d) 作为调用参数(值类型), 调用参数数量可以是 0 或任意个, 即 a() 也是合法的

合法的其它例子: a.b(c,d).e(f).g(1,2,true)

需要注意调用者不能是 调用表达式, 即 a()() 非法

字面量

根据上面的表格, 字面量有 4 种: null, boolean, int, string

  • null
  • boolean 使用 true/false
  • int 匹配 ^-?[0-9]$, 即10进制自然数, 示例 -1,0,1, 不支持 +1 这种写法
  • string 使用 ' ` " 之一成对包裹, 内部字符转义使用 \
    所有的转义字符示例 \\, \', \", \`, \n, \r, \t, \b, \xfF, \uffFF
    不支持多行字符, 处于 [0, 0x1F] 的控制字符必须使用转义字符表示

此外使用 string 时需要了解 嵌套转义字符 以避免出现错误

操作符

操作符 用于连接两个 值表达式

操作符名称说明
=equal
!=notEqual
>more
>=moreEqual
<less
<=lessEqual
^=startsWith以...开头
!^=notStartsWith不以...开头
*=contains包含...
!^=notContains不包含...
$=endsWith以...结尾
!$=notEndsWith不以...结尾
~=matches正则匹配...
!~=notMatches正则不匹配...

附加说明: matches/notMatches 要求 值 必须是合法的 Java/Kotlin 正则表达式, 否则提示语法错误

并且 matches/notMatches 右侧的值只能是 字面量/字符串

操作符只能使用在对应的类型的值, 比如 a>'' 类型不匹配, 将提示 非法类型

下面表格中 - 表示类型不匹配

nullbooleanintstring
=
!=
>---
>=---
<---
<=---
^=---
!^=---
*=---
!*=---
$=---
!$=---
~=---
!~=---

=/!= 以外的操作符, 当节点属性是 null 时表达式为 false

  • a > 233
  • a >= 233
  • a < 233
  • a <= 233
  • a ^= 'xxx'
  • a !^= 'xxx'
  • a *= 'xxx'
  • a !* 'xxx'
  • a $= 'xxx'
  • a !$= 'xxx'
  • a ~= 'xxx'
  • a !~= 'xxx'

即当 a 是 null 时以上表达式为 false

你可能会对 a !$= 'xxx' 在 a 是 null 表达式为 false 感到奇怪

那你可以换一种写法使用 a=null || a!$='xxx'!(a$='xxx')

关系选择

关系选择器 由 关系操作符 和 关系表达式 构成, 用于连接两个属性选择器

简单示例: div > a, 它 表示/约束 两个节点之间的关系, 下面介绍 关系操作符关系表达式

关系表达式

关系表达式 表示查找节点的范围, 有两种

  • 元组表达式 (a1,a2,a3,a_n), 其中 a1, a2, a3, a_n 是常量有序递增正整数, 示例 (1), (2,3,5)
  • 多项式表达式 (an+b), 其中 a 和 b 是常量整数, 它是元组表达式的另一种表示, 这个元组的数字满足集合 {an+b|an+b>=1,n>=1} 如果集合为空集则表达式非法
    当 a<=0 时, 它具有等价的元组表达式
    示例 (-n+4) 等价于 (1,2,3)
    示例 (-3n+10) 等价于 (1,4,7)
    当 a>0 时, 它表示无限的元组表达式
    示例 (n), 它表示 (1,2,3,...) 一个无限的元组
    示例 (2n-1), 它表示 (1,3,5,...) 一个无限的元组

关系操作符

关系操作符 表示查找节点的方向, 有 5 种关系操作符, +, -, >, <, <<

操作符名称图例选择器
+前置兄弟节点* + [_id=33]
-后置兄弟节点* - [_id=32]
>祖先节点* > [_id=90]
<直接子节点* < [_id=89]
<<子孙节点(深度先序)* <<2 [_id=29]

将 关系操作符 和 关系表达式 连接起来就得到了 关系选择器

A +(a1,a2,a3,a_n) B : A 是 B 的前置兄弟节点, 并且 A.index 满足 B.index-(a_m), 其中 a_m 是元组的任意一个数字

A -(a1,a2,a3,a_n) B : A 是 B 的后置兄弟节点, 并且 A.index 满足 B.index+(a_m)

A >(a1,a2,a3,a_n) B : A 是 B 的祖先节点, 并且 A.depth 满足 B.depth-(a_m), 根节点的 depth=0

A <(a1,a2,a3,a_n) B : A 是 B 的直接子节点, 并且 A.index 满足 a_m-1

A <<(a1,a2,a3,a_n) B : A 是 B 的子孙节点, 并且 A.order 满足 a_m-1, A.order 是深度优先先序遍历的索引

表达式简写

一般情况下, 并不需要写严格完整的表达式, 使用简化写法更方便快捷

下面是一些特殊情况下的简写表示

当 a=0 或 b=0 时, 括号可以省略, 以 + 为例

  • A +(3n+0) B -> A +(3n) B -> A +3n B
  • A +(0n+3) B -> A +(+3) B -> A +3 B

当 a=0 且 b=1 时, an+b 可以省略, 以 + 为例

  • A +(0n+1) B -> A + B

此外 A + B,A > B 都与等价的 CSS 语法语义相同

当 a=1 且 b=0 且操作符是 >, 可以进一步简写, 比如

  • A >(1n+0) B -> A >n B -> A B

这与等价的 CSS 语法语义相同

示例

txt
@LinearLayout > TextView[id=`com.byted.pangle:id/tt_item_tv`][text=`不感兴趣`]

选择器从末尾开始查找, 首先找到 id=`com.byted.pangle:id/tt_item_tv` 和 text=`不感兴趣` 的 TextView, 并且父节点是 LinearLayout 的节点

此时我们得到两个节点 [LinearLayout, TextView] 根据 @ 知道目标节点是 LinearLayout

实际上它与

txt
TextView[id=`com.byted.pangle:id/tt_item_tv`][text=`不感兴趣`] <n LinearLayout

的目标匹配节点是等价的, 但是在查询算法时间复杂度上, 后者更慢

如下是网页无障碍快照审查工具, 使用它的搜索框的选择器查询可以实时测试编写的选择器

一些注意

匹配顺序

选择器的匹配顺序是 从右往左匹配

例如 FrameLayout > TextView, 它是先从 根节点/事件节点 找到 TextView, 然后再判断 parent 是不是 FrameLayout

并不是先找到 FrameLayout 然后再判断 child 是否有 TextView

同时在 选择器路径视图 也会标明匹配顺序和查找方向

正则表达式优化

matches/notMatches 的优化: 如果正则表达式满足下面的条件, 选择器将使用内置的简单的函数匹配, 而不是真正地去运行一个正则表达式

  • [text~="(?is)abc.*"] -> startsWith('abc', ignoreCase = true)
  • [text~="(?is).*abc.*"] -> contains('abc', ignoreCase = true)
  • [text~="(?is).*abc"] -> endsWith('abc', ignoreCase = true)
  • [text!~="(?is)abc.*"] -> notStartsWith('abc', ignoreCase = true)
  • [text!~="(?is).*abc.*"] -> notContains('abc', ignoreCase = true)
  • [text!~="(?is).*abc"] -> notEndsWith('abc', ignoreCase = true)

上面的 abc 指代不包含 \^$.?*|+()[]{} 这类特殊正则字符的任意字符串, 如 ikun 符合, ikun? 不符合

简单来说就是如果你只想忽略大小写去简单匹配或不匹配一些字符, 那么直接使用上面的格式

正则表达式一致性

由于 选择器 需要同时满足 浏览器/Js(审查工具), Android/Java(GKD) 运行, 而这两个平台的正则表达式的底层实现和语法表示略有不同

因此为了在 Js 端实现和 Java 一致的正则表达式规范, 网页审查工具借助 Kotlin Wasm 将正则表达式的 matches 函数接口编译为 wasm 提供给 Js 调用

Kotlin Wasm 需要你的浏览器支持 WasmGC, 也就是版本需要满足下列条件

如果你在 nodejs 使用 gkd 选择器, 需要 node>=22 版本

如果你的 浏览器/nodejs 版本不满足, 正则表达式将自动回退到 Js 端实现, 以下是在 Js 端使用正则表达式需要注意的地方

比如上面的正则表达式优化例子中开头的 (?is) 是 Java 正则表达式的 inline flags 语法, 但实际上 Js 并不支持这样写, 只是选择器内部做了一些兼容让它支持

并且选择器的 Js 端只兼容在开头的 flags, 在内部的 flags 不支持, 此外 Java 和 Js 支持的 flags 也有不同, 某些特殊的表达式也表现也不一致

总之不要使用太过复杂(多复杂我也不知道)的正则表达式, 某些正则表达式有可能在审查工具上匹配, 但是在 GKD 上不匹配

如果你能确保正则表达式在 Js 和 Java/Kotlin 的匹配行为一致, 那就没问题

总之两种情况

  • 高版本 浏览器/nodejs 可以使用 完整一致 的正则表达式
  • 低版本 浏览器/nodejs 使用 小范围不完整不一致 的正则表达式

如果你的选择器没有使用正则表达式则不用关心此问题

嵌套转义字符

转义字符一般用于表示不能直接显示具体特殊意义的字符

对于 GKD 的选择器而言, 转义字符使用示例如下

txt
TextView[text="\\\n"]

这表达选择一个 TextView 它的 text 长度为 2, 开头是 \ 末尾是换行

这和平常写的代码里面的 string 声明用法一模一样

但是选择器最终放在规则的字段里面, 也就是这个选择器字符串处于 JSON 的 string 内

如果你想当然地直接手动复制插入到编辑器里出现下面这种规则

json5
{
  matches: 'TextView[text="\\\n"]',
}

由于 JSON 也有自己的转义, 规则与 GKD 的转义基本一致, 因此你的实际选择器实际变成了

txt
TextView[text="\
"]

这明显是一个非法的选择器

解决方式也很简单, 手动插入的时候将你的 \ 变成 \\, 正确的规则如下

json5
{
  matches: 'TextView[text="\\\\\\n"]',
}

这是普通字符串的情况, 如果你使用 ~=/!~=, 则还需要多转义一次, 因为正则表达式也有自己的转义字符

比如我们想匹配一个 TextView 并且 text 是任意一个数字, 我们想当然地用 \d, 所以你可能会写

txt
TextView[text~="\d"]

但是你在网页审查工具输入并回车搜索会提示 非法选择器, 这是因为 \d 处于 GKD 的 string 内, 会被转义一次, 正确的用法是写 \\d

txt
TextView[text~="\\d"]

实际上这部分等价的 Kotlin/Java 代码可以简单是

txt
Regex("\\d").matches(text)

接下来把选择器放到规则里面, 根据上面的描述, 规则应该是

json5
{
  matches: 'TextView[text~="\\\\d"]',
}

如果想用正则匹配一个 \ 字符, 最终规则应该写成

json5
{
  matches: 'TextView[text~="\\\\\\\\"]',
}

实现和上面相同的结果, 即用 = 匹配一个 \ 字符, 最终规则应该写成

json5
{
  matches: 'TextView[text="\\\\"]',
}

类似的问题 gkd-kit/gkd#603

Released under the GPL-v3 License.