New Node 模块设计
全新设计的控件自动化模块,基于 Android AccessibilityService
系统要求
- 目标平台:Android 13 (API 33) / Android 15 (API 35)
- 基础功能:Android 5.0 (API 21) 及以上
- 手势功能:Android 7.0 (API 24) 及以上
- 锁屏功能:Android 9.0 (API 28) 及以上
技术栈:C++ 实现 + Lua API 封装
设计理念
核心原则
- 职责分离 - Query 只负责查询,Element 负责执行动作
- 明确返回 - 每个方法的返回值都有明确含义,动作方法返回
(成功, 错误信息) - 基于现实 - 只提供 AccessibilityService 真正支持的能力
- 统一接口 - App 控件和 WebView 控件共享相同的 Element 接口
- 安全便捷 - 提供
try*快捷方法,自动处理 nil 检查
架构图
┌─────────────────────────────────────────────────────────────────┐
│ New Node 模块 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ node.app() node.webview() │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ │
│ │ Query │ ← 只负责查询 │ Query │ ← 定位 WebView 内 │
│ └────┬────┘ └────┬────┘ │
│ │ │ │
│ │ get() / all() / exists() │ │
│ ▼ ▼ │
│ ┌─────────┐ ┌─────────┐ │
│ │ Element │ ← 执行动作 │ Element │ │
│ └─────────┘ └─────────┘ │
│ │ │ │
│ └──────────┬──────────────────┘ │
│ ▼ │
│ click() / input() / scroll() / ... │
│ │
└─────────────────────────────────────────────────────────────────┘
核心特点
| 特点 | 说明 |
|---|---|
| 职责清晰 | Query 查询,Element 执行,不混淆 |
| 链式调用 | 约束方法返回 Query,终结方法返回 Element |
| 明确返回 | get() 返回 Element/nil,all() 返回空表(非 nil) |
| 错误信息 | 动作方法返回 (bool, err),可获知失败原因 |
| 快捷方法 | tryClick() 等自动处理 nil,简化代码 |
| 内置等待 | timeout() 配置等待,在 get() 时生效 |
| 统一 Element | App 和 WebView 的 Element 接口完全一致 |
快速示例
外部依赖
示例中的 sys.msleep() 来自系统模块,用于延时等待(毫秒)。
-- 加载模块
local node = require("new_node")
-- ========== 检查服务状态 ==========
if not node.isReady() then
error("请先启用无障碍服务")
end
-- ========== App 控件操作 ==========
-- 方式1:标准写法(推荐,可获取错误信息)
local btn = node.app():text("登录"):get()
if btn then
local ok, err = btn:click()
if not ok then print("点击失败:", err) end
end
-- 方式2:快捷写法(tryClick 自动处理 nil)
-- 返回 true 表示找到且点击成功,false 表示未找到或点击失败
-- 快捷方法继承 timeout 配置
local ok = node.app():text("登录"):timeout(3000):tryClick()
if not ok then print("点击失败或未找到") end
-- 带等待的查找
local btn = node.app():text("加载完成"):timeout(5000):get()
if btn then
print("找到了:", btn.text)
else
print("超时未找到")
end
-- 检查是否存在
if node.app():text("登录成功"):timeout(3000):exists() then
print("登录成功")
end
-- 等待消失
if node.app():text("加载中"):waitUntilGone(5000) then
print("加载完成")
end
-- 输入文本
local ok = node.app():id("com.example:id/search"):tryInput("关键词")
-- ========== WebView 控件操作 ==========
-- 在 WebView 内查找(自动定位第一个 WebView)
local link = node.webview():text("注册"):get()
if link then link:click() end
-- 在第 2 个 WebView 内查找
local link = node.webview(2):text("注册"):get()
-- 手动定位 WebView 后查找
local wv = node.app():type("WebView"):get()
if wv then
local btn = wv:query():text("提交"):get() -- 在 wv 子树内查找
if btn then btn:click() end
end
-- ========== 关系查询 ==========
-- 获取父控件
local label = node.app():text("商品名称"):get()
if label then
local card = label:parent()
local price = card:query():textContains("¥"):get() -- 在 card 子树内查找
end
-- 获取子控件
local list = node.app():id("container"):get()
if list then
local children = list:children()
for i, child in ipairs(children) do
print(i, child.text)
end
end
-- 遍历所有匹配(all() 返回空表,不是 nil)
for i, item in ipairs(node.app():type("CheckBox"):all()) do
item:click()
end
模块结构
new_node/
├── index.md # 模块概述(本文档)
├── architecture.md # 架构设计 + C++ 实现
├── global.md # 全局方法(返回、主页、手势等)
├── app/ # App 控件
│ ├── index.md # App 控件概述
│ ├── query.md # Query 查询器 API
│ └── element.md # Element 控件对象 API
└── webview/ # WebView 控件
├── index.md # WebView 控件概述
├── query.md # WebView Query 查询器
└── element.md # WebView Element 控件对象
API 概览
模块入口
| 方法 | 返回值 | 说明 |
|---|---|---|
node.app(mode) | Query | 创建 App 控件查询器(mode: 0=全部, 1=过滤系统控件, 2=仅可见) |
node.webview(n) | Query | 创建 WebView 内查询器(n=第几个,按 DFS 顺序,默认1) |
node.root() | Element | 获取控件树根节点 |
node.focused() | Element/nil | 获取当前焦点控件 |
node.isReady() | boolean | 检查无障碍服务是否就绪 |
全局动作
| 方法 | 返回值 | 说明 |
|---|---|---|
node.back() | boolean | 返回键 |
node.home() | boolean | 主页键 |
node.recents() | boolean | 最近任务 |
node.notifications() | boolean | 打开通知栏 |
node.quickSettings() | boolean | 打开快速设置 |
node.powerDialog() | boolean | 打开电源菜单 |
node.splitScreen() | boolean | 切换分屏模式 |
node.lockScreen() | boolean | 锁屏 |
手势(Android 7.0+)
| 方法 | 返回值 | 说明 |
|---|---|---|
node.tap(x, y) | boolean | 点击坐标 |
node.longTap(x, y, ms) | boolean | 长按坐标 |
node.swipe(x1,y1,x2,y2,ms) | boolean | 滑动 |
node.pinch(cx,cy,start,end,ms) | boolean | 缩放手势(双指捏合/张开) |
node.gesture(points, ms) | boolean | 自定义手势路径 |
工具方法
| 方法 | 返回值 | 说明 |
|---|---|---|
node.waitUntil(fn, timeout) | boolean | 等待条件成立 |
node.currentPackage() | string | 当前前台包名 |
node.currentActivity() | string | 当前 Activity |
node.root() | Element | 获取控件树根节点 |
node.focused() | Element/nil | 获取当前焦点控件 |
node.dump(maxDepth) | table | 获取控件树快照(调试用) |
Query 查询器
约束方法(返回 Query,可链式调用):
| 方法 | 说明 |
|---|---|
.id(id) | 控件 ID |
.idContains(text) | ID 包含 |
.idStartsWith(text) | ID 前缀 |
.idEndsWith(text) | ID 后缀 |
.idMatches(pattern) | ID Lua 模式匹配 |
.text(text) | 文本完全匹配 |
.textContains(text) | 文本包含 |
.textStartsWith(text) | 文本前缀 |
.textEndsWith(text) | 文本后缀 |
.textMatches(pattern) | 文本 Lua 模式匹配 |
.desc(desc) | 描述完全匹配 |
.descContains(desc) | 描述包含 |
.descStartsWith(desc) | 描述前缀 |
.descEndsWith(desc) | 描述后缀 |
.descMatches(pattern) | 描述 Lua 模式匹配 |
.hint(text) | 提示文本(EditText hint) |
.hintContains(text) | 提示文本包含 |
.hintStartsWith(text) | 提示文本前缀 |
.hintEndsWith(text) | 提示文本后缀 |
.hintMatches(pattern) | 提示文本 Lua 模式匹配 |
.type(className) | 控件类型 |
.pkg(packageName) | 包名 |
.clickable(bool) | 可点击(默认 true) |
.longClickable(bool) | 可长按 |
.scrollable(bool) | 可滚动 |
.enabled(bool) | 可用(默认 true) |
.editable(bool) | 可编辑 |
.focusable(bool) | 可聚焦 |
.checkable(bool) | 可选中 |
.checked(bool) | 选中状态 |
.focused(bool) | 焦点状态 |
.selected(bool) | 选择状态 |
.visible(bool) | 可见状态 |
.index(n) | 结果索引(1 开始,负数从末尾) |
.depth(n) | 控件深度 |
.childCount(n) | 子控件数量 |
配置方法(返回 Query):
| 方法 | 说明 |
|---|---|
.timeout(ms) | 等待超时(默认 0,最大 60000ms) |
.interval(ms) | 轮询间隔(默认 100ms) |
.limit(n) | 限制 all() 返回数量(避免内存问题) |
终结方法(返回结果):
| 方法 | 返回值 | 说明 |
|---|---|---|
.get() | Element/nil | 获取第一个匹配 |
.all() | table | 获取所有匹配(空时返回 {}) |
.exists() | boolean | 是否存在 |
.count() | number | 匹配数量 |
.waitUntilGone(ms) | boolean | 等待控件消失 |
.debug() | string | 打印查询条件(调试用) |
快捷方法(Query 上的便利方法,自动处理 nil):
| 方法 | 返回值 | 说明 |
|---|---|---|
.tryClick() | boolean | 找到则点击 |
.tryLongClick() | boolean | 找到则长按 |
.tryInput(text) | boolean | 找到则输入 |
.tryClear() | boolean | 找到则清空 |
.tryFocus() | boolean | 找到则聚焦 |
Element 控件对象
属性(只读):
| 属性 | 类型 | 说明 |
|---|---|---|
.text | string | 文本内容 |
.hint | string | 提示文本(EditText) |
.desc | string | 描述 |
.id | string | 控件 ID |
.type | string | 类型(className) |
.pkg | string | 包名 |
.bounds | Rect | 位置大小(见下方说明) |
.clickable | boolean | 可点击 |
.longClickable | boolean | 可长按 |
.scrollable | boolean | 可滚动 |
.enabled | boolean | 可用 |
.editable | boolean | 可编辑 |
.focusable | boolean | 可聚焦 |
.checkable | boolean | 可选中 |
.checked | boolean | 选中 |
.focused | boolean | 焦点 |
.selected | boolean | 被选择 |
.visible | boolean | 可见 |
.childCount | number | 子控件数 |
.depth | number | 树深度 |
.index | number | 在父控件中的索引 |
bounds 结构:
{
x = 100, -- 左边界(屏幕坐标)
y = 200, -- 上边界
width = 300, -- 宽度
height = 50, -- 高度
left = 100, -- = x
top = 200, -- = y
right = 400, -- = x + width
bottom = 250, -- = y + height
centerX = 250, -- = x + width/2
centerY = 225, -- = y + height/2
}
关系方法:
| 方法 | 返回值 | 说明 |
|---|---|---|
:parent() | Element/nil | 父控件 |
:children() | table | 所有直接子控件 |
:child(n) | Element/nil | 第 n 个子控件 |
:next() | Element/nil | 下一个兄弟 |
:prev() | Element/nil | 上一个兄弟 |
:sibling(n) | Element/nil | 第 n 个兄弟(相对偏移) |
:query() | Query | 在此控件内创建查询器 |
动作方法(返回 boolean, string?,第二个值为失败原因):
| 方法 | 说明 |
|---|---|
:click() | 点击 |
:doubleClick() | 双击 |
:longClick() | 长按 |
:input(text) | 输入文本(会清空原有) |
:append(text) | 追加文本 |
:clear() | 清空文本 |
:focus() | 获取焦点 |
:blur() | 清除焦点 |
:select() | 选中 |
:deselect() | 取消选中 |
:scrollUp() | 向上滚动 |
:scrollDown() | 向下滚动 |
:scrollLeft() | 向左滚动 |
:scrollRight() | 向右滚动 |
:scrollForward() | 向前滚动 |
:scrollBackward() | 向后滚动 |
:scrollIntoView() | 滚动使自身可见 |
:expand() | 展开 |
:collapse() | 折叠 |
:dismiss() | 关闭 |
:copy() | 复制 |
:cut() | 剪切 |
:paste() | 粘贴 |
:setSelection(from, to) | 设置选区 |
示例:
local ok, err = elem:click()
if not ok then
print("点击失败:", err)
-- err 可能是: "element is stale" / "action not supported" / "service disconnected"
end
实用方法:
| 方法 | 返回值 | 说明 |
|---|---|---|
:refresh() | boolean | 刷新控件信息 |
:isValid() | boolean | 控件是否仍有效 |
:screenshot() | Image/nil | 截取控件区域 |
:dump() | table | 获取控件子树结构 |
:toString() | string | 调试信息字符串 |
Element 生命周期说明:
Element 持有系统控件的引用,当界面变化时引用可能失效:
local btn = node.app():text("确定"):get()
sys.msleep(3000) -- 界面可能已变化
if btn:isValid() then -- 检查是否仍有效
btn:click()
else
btn = node.app():text("确定"):get() -- 重新查找
end
技术架构
┌─────────────────────────────────────────────────────────────────┐
│ Lua 脚本层 │
│ │
│ local btn = node.app():text("登录"):get() │
│ if btn then btn:click() end │
│ │
├─────────────────────────────────────────────────────────────────┤
│ Lua 绑定层 │
│ │
│ NodeModule Query (userdata) Element (userdata) │
│ │ │ │ │
│ └──────────────┴────────────────────┘ │
│ │
├─────────────────────────────────────────────────────────────────┤
│ C++ 实现层 │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ QueryEngine │ │ElementWrapper│ │ActionExecutor│ │
│ │ (约束+匹配) │ │ (属性+关系) │ │(performAction)│ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
├─────────────────────────────────────────────────────────────────┤
│ Android AccessibilityService │
│ │
│ getRootInActiveWindow() → AccessibilityNodeInfo │
│ performAction(ACTION_CLICK / ACTION_SET_TEXT / ...) │
│ performGlobalAction(GLOBAL_ACTION_BACK / ...) │
│ dispatchGesture() (Android 7.0+) │
│ │
└─────────────────────────────────────────────────────────────────┘
设计决策
为什么 Query 不直接执行动作?
-- ❌ 旧设计(职责混淆)
node.app():text("登录"):click() -- click() 在 Query 上
-- 问题:找不到时返回什么?静默失败?报错?
-- ✅ 新设计(职责分离)
local btn = node.app():text("登录"):get() -- Query 返回 Element
if btn then btn:click() end -- Element 执行动作
-- 明确:get() 返回 nil 表示找不到,click() 返回 (false, err) 表示执行失败
-- ✅ 快捷写法(tryClick 内部处理 nil)
node.app():text("登录"):tryClick() -- 找不到返回 false,不报错
为什么用 node.webview() 而不是 node.web(url)?
-- ❌ 旧设计(逻辑错误)
node.web("example.com"):tag("a"):click()
-- 问题:AccessibilityService 不是通过 URL 定位控件的!
-- ✅ 新设计(基于现实)
node.webview():text("注册"):get():click()
-- 实现:自动找到第一个 WebView,在其内部查找
node.webview(2):text("链接"):get() -- 第 2 个 WebView
为什么没有 CSS/XPath 选择器?
AccessibilityService 不提供 CSS/XPath 查询能力。
替代方案:使用 Lua 模式匹配
-- 等效于 css("#login-btn")
node.webview():id("login-btn"):get()
-- 模式匹配
node.app():textMatches("^价格:%d+") -- Lua 模式
为什么用 Lua 模式而不是正则表达式?
-- ❌ 正则表达式(跨平台不一致)
node.app():text("/login|register/i")
-- 问题:std::regex 在不同平台行为不一致
-- ✅ Lua 模式(一致可靠)
node.app():textMatches("[Ll]ogin")
node.app():textMatches("^hello") -- 以 hello 开头
node.app():textMatches("world$") -- 以 world 结尾
Query 重用行为
local q = node.app():text("按钮")
q:get() -- 第一次查找
q:get() -- 第二次查找(重新遍历控件树,获取最新结果)
-- Query 每次执行终结方法都会重新查找,不缓存结果
-- 这确保获取的是最新的界面状态
最佳实践
性能建议
-- ✅ 推荐:使用 limit() 限制返回数量
local items = node.app():type("TextView"):limit(10):all()
-- ✅ 推荐:使用多个精确约束缩小范围
local btn = node.app():text("确定"):clickable():enabled():get()
-- ❌ 避免:无约束的 all() 可能返回大量控件
local allNodes = node.app():all() -- 可能返回数千个控件
-- ✅ 推荐:interval() 不要设置太小
node.app():text("目标"):timeout(5000):interval(100):get() -- 合理
-- ❌ 避免:过小的 interval 增加 CPU 占用
node.app():text("目标"):timeout(5000):interval(10):get() -- 不推荐
处理弹窗遮挡
local function safeClick(query)
local elem = query:get()
if not elem then return false end
local ok, err = elem:click()
if not ok and err == "element not visible" then
-- 尝试关闭可能的遮挡弹窗
node.app():text("关闭"):tryClick()
node.app():desc("关闭"):tryClick()
node.app():text("跳过"):tryClick()
sys.msleep(300)
return query:tryClick() -- 重试
end
return ok
end
滚动查找列表项
local function scrollToFind(listQuery, itemQuery, maxScrolls)
maxScrolls = maxScrolls or 10
local list = listQuery:get()
if not list then return nil end
for i = 1, maxScrolls do
local item = itemQuery:get()
if item then return item end
local ok, err = list:scrollDown()
if not ok and err == "scroll reached end" then
break -- 已到底部
end
sys.msleep(300)
end
return nil
end
-- 使用
local item = scrollToFind(
node.app():type("RecyclerView"),
node.app():text("目标项")
)
等待页面切换
-- 点击后等待新页面出现
node.app():text("下一页"):tryClick()
-- 方式1:等待特定元素出现
if node.app():text("新页面标题"):timeout(5000):exists() then
print("页面切换成功")
end
-- 方式2:等待当前元素消失
if node.app():text("下一页"):waitUntilGone(5000) then
print("页面切换成功")
end
批量操作注意事项
-- ❌ 错误:操作过程中控件可能失效
local items = node.app():type("CheckBox"):all()
for _, item in ipairs(items) do
item:click() -- 后面的 item 可能已失效
end
-- ✅ 正确:每次重新查找
while true do
local item = node.app():type("CheckBox"):checked(false):get()
if not item then break end
item:click()
sys.msleep(200)
end
线程安全说明
-- ⚠️ 本模块所有 API 应在单一 Lua 协程中使用
-- AccessibilityService 的控件树遍历不是线程安全的
-- 不要在多个协程中同时操作同一个 Element 对象
异常处理
-- 服务未就绪时抛出错误
if not node.isReady() then
error("请先启用无障碍服务")
end
-- 动作方法不抛异常,通过返回值报告失败
local ok, err = elem:click()
if not ok then
print("点击失败:", err) -- 不需要 pcall
end
-- 如需捕获其他潜在错误
local success, result = pcall(function()
return node.app():text("目标"):get()
end)
多窗口说明
node.app()查询当前焦点窗口的控件树- 分屏模式下,查询的是用户最后交互的那个窗口
- 画中画窗口可能不在主控件树中
Toast 限制
- Toast 在某些 Android 版本可能不在控件树中
- 建议使用
timeout()等待可能出现的 Toast 位置的文本
下一步
- 架构设计 - C++ 实现细节
- 全局方法 - 返回、主页、手势等
- App 控件 - App 控件完整 API
- WebView 控件 - WebView 控件 API