跳到主要内容

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 封装

设计理念

核心原则

  1. 职责分离 - Query 只负责查询,Element 负责执行动作
  2. 明确返回 - 每个方法的返回值都有明确含义,动作方法返回 (成功, 错误信息)
  3. 基于现实 - 只提供 AccessibilityService 真正支持的能力
  4. 统一接口 - App 控件和 WebView 控件共享相同的 Element 接口
  5. 安全便捷 - 提供 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() 时生效
统一 ElementApp 和 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 控件对象

属性(只读):

属性类型说明
.textstring文本内容
.hintstring提示文本(EditText)
.descstring描述
.idstring控件 ID
.typestring类型(className)
.pkgstring包名
.boundsRect位置大小(见下方说明)
.clickableboolean可点击
.longClickableboolean可长按
.scrollableboolean可滚动
.enabledboolean可用
.editableboolean可编辑
.focusableboolean可聚焦
.checkableboolean可选中
.checkedboolean选中
.focusedboolean焦点
.selectedboolean被选择
.visibleboolean可见
.childCountnumber子控件数
.depthnumber树深度
.indexnumber在父控件中的索引

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 位置的文本

下一步