Files
go-mobile-uiautomator/README.md
2026-03-10 16:32:43 +08:00

715 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Go Mobile UIAutomator
<p align="center">
<a href="https://github.com/zhuy1228/go-mobile-uiautomator">
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat-square&logo=go" alt="Go Version">
</a>
<a href="LICENSE">
<img src="https://img.shields.io/badge/License-MIT-green?style=flat-square" alt="License">
</a>
<a href="#">
<img src="https://img.shields.io/badge/dependencies-0-brightgreen?style=flat-square" alt="Zero Dependencies">
</a>
</p>
Go 语言实现的 Android UIAutomator2 客户端库。纯 Go 实现,零外部依赖,通过 ADB 隧道协议与 Android 设备直连,提供完整的 UI 自动化测试能力。
参考 Python [openatx/uiautomator2](https://github.com/openatx/uiautomator2) 项目实现,连接方案与 Python 版本完全一致。
## 功能特性
-**ADB 隧道** - 纯 Go 实现 ADB TCP 协议,通过 ADB 隧道直连设备,无需端口转发
-**资源内嵌** - 通过 `go:embed` 将 u2.jar/APK 编译进二进制,`go get` 即用,无需额外文件
-**设备管理** - 设备发现、连接、信息查询
-**UIAutomator2 服务** - 自动启动和管理 UIAutomator2 服务端
-**UI 元素操作** - 点击、滑动、输入、拖拽、手势等完整 UI 操作
-**元素定位** - 支持 text、resourceId、className、description 等多种定位方式
-**便捷选择器** - `d.ByText("登录").Click()` Python 风格链式调用
-**输入法集成** - AdbKeyboard 输入法支持,支持中文输入
-**Watcher 监控** - 自动监控并处理弹窗(权限提示、广告等)
-**文件传输** - 通过 ADB Sync 协议推送文件
-**应用管理** - 启动、停止、卸载、清除应用数据
-**截图功能** - 设备屏幕截图
-**剪贴板** - 读写设备剪贴板
-**Session 会话** - 应用会话管理,自动检测应用存活状态
-**配置管理** - 线程安全的设备参数配置
-**错误体系** - 完整的错误类型定义和自动重试机制
-**JSON-RPC** - 完整的 JSON-RPC 2.0 通信层
## 安装
```bash
go get github.com/zhuy1228/go-mobile-uiautomator
```
## 快速开始
### 连接设备
```go
package main
import (
"fmt"
"github.com/zhuy1228/go-mobile-uiautomator/libs"
)
func main() {
// 创建设备连接(自动推送 u2.jar、启动 UIAutomator2 服务)
d, err := libs.NewDevice("设备序列号")
if err != nil {
panic(err)
}
defer d.Close()
// 自定义 ADB 地址
// d, err := libs.NewDevice("设备序列号", "192.168.1.100:5037")
// 获取设备信息
info, err := d.Info()
if err != nil {
panic(err)
}
fmt.Printf("屏幕尺寸: %v x %v\n", info["displayWidth"], info["displayHeight"])
}
```
### 设备发现
```go
import (
"fmt"
"time"
"github.com/zhuy1228/go-mobile-uiautomator/adb"
"github.com/zhuy1228/go-mobile-uiautomator/libs"
)
// 列出所有连接的设备
payload, err := adb.ListDevicesRaw(libs.DefaultADBAddr, 15*time.Second)
devices := adb.ParseDevicesPayload(payload)
for _, dev := range devices {
fmt.Printf("设备: %s 状态: %s\n", dev.Serial, dev.State)
}
```
### 应用管理
```go
// 启动应用stop=true 先停止再启动)
d.AppStart("com.example.app", "", true)
// 启动应用并指定 Activity
d.AppStart("com.example.app", ".MainActivity", false)
// 获取当前运行的应用信息
info, _ := d.AppCurrent()
fmt.Printf("当前应用: %s PID: %s\n", info["package"], info["pid"])
// 等待应用启动(超时 20 秒front=true 等待应用到前台)
pid, err := d.AppWait("com.example.app", 20.0, true)
// 停止应用
d.AppStop("com.example.app")
// 清除应用数据
d.AppClear("com.example.app")
// 卸载应用
d.AppUninstall("com.example.app")
```
### UI 元素查找
```go
// ===== 便捷方法(推荐) =====
// 通过 text 查找并操作
d.ByText("登录").Click()
// 通过 textContains 模糊查找
d.ByTextContains("登").Click()
// 通过 resourceId 查找
d.ByResourceId("com.example:id/btn_login").Click()
// 通过 description 查找
d.ByDescription("搜索").Click()
// 通过 className 查找
d.ByClassName("android.widget.EditText").SetText("Hello")
// 多条件组合查找
el := d.By(libs.P{
"className": "android.widget.TextView",
"text": "确定",
})
el.Click()
// ===== FindElement 方法 =====
el, err := d.FindElement(map[string]interface{}{
"resourceId": "com.example:id/btn_login",
})
if err != nil {
panic(err)
}
el.Click()
// 检查元素是否存在
exists, _ := d.Exists(map[string]interface{}{"text": "登录"})
```
### 等待操作
```go
el := d.ByText("登录")
// 等待元素出现(超时 10 秒)
found, _ := el.Wait(true, 10.0)
// 等待元素消失
gone, _ := el.WaitGone(10.0)
// 等待元素出现,不存在则返回错误
err := el.MustWait(10.0)
// 设置全局隐式等待超时
d.ImplicitlyWait(20.0)
```
### 点击操作
```go
// 坐标点击
d.Click(500, 800)
// 双击
d.DoubleClick(500, 800, 0)
// 长按坐标2 秒)
d.LongClick(500, 800, 2.0)
// 元素点击
d.ByText("确定").Click()
// 带偏移的点击相对元素宽高的比例0.5 = 中心)
d.ByText("确定").ClickWithOffset(0.5, 0.5)
// 点击元素(如果存在),返回是否成功
clicked := d.ByText("确定").ClickExists(5.0)
// 持续点击直到元素消失(最多重试 5 次,间隔 1 秒)
d.ByText("确定").ClickGone(5, 1.0)
// 元素长按1.5 秒)
d.ByText("确定").LongClick(1.5)
```
### 文本输入
```go
el := d.ByResourceId("com.example:id/input")
// 获取元素文本
text, _ := el.GetText()
// 设置文本(先清空再输入)
el.SetText("Hello World")
// 清除文本
el.ClearText()
// 追加文本
el.SendKeys("additional text")
// 使用 AdbKeyboard 输入法(支持中文)
im := libs.NewInputMethod(d)
im.SendKeys("你好世界")
im.ClearText() // 清除文本
im.SendAction("done") // 模拟输入法动作
```
### 滑动操作
```go
// 坐标滑动steps 控制速度,每步约 5ms
d.Swipe(500, 1500, 500, 500, 55)
// 按持续时间滑动0.5 秒)
d.SwipeWithDuration(500, 1500, 500, 500, 0.5)
// 多点连续滑动
d.SwipePoints([][2]int{{500, 1500}, {500, 1000}, {500, 500}}, 0.2)
// 使用 SwipeExt 扩展滑动
se := libs.NewSwipeExt(d)
se.Up(0.8, 55) // 向上滑动 80% 屏幕
se.Down(0.5, 55) // 向下滑动 50% 屏幕
se.Left(0.6, 55) // 向左滑动 60% 屏幕
se.Right(0.4, 55) // 向右滑动 40% 屏幕
// 元素内滑动
d.ByClassName("android.widget.ListView").UiSwipe("up", 55)
d.ByClassName("android.widget.ListView").UiSwipe("down", 55)
// 拖拽
d.Drag(100, 200, 500, 600, 0.5)
d.ByText("拖拽我").DragTo(500, 600, 0.5)
```
### 滚动查找
```go
list := d.ByClassName("android.widget.ListView")
// 向前/向后滚动
list.ScrollForward(true, 55) // 垂直向前
list.ScrollBackward(true, 55) // 垂直向后
// 滚动到顶部/底部
list.ScrollToBeginning(true, 10, 55) // 最多 10 次
list.ScrollToEnd(true, 10, 55)
// 滚动直到目标元素可见
list.ScrollTo(map[string]interface{}{"text": "目标文本"}, true)
// 快速滑动Fling
list.FlingForward(true)
list.FlingBackward(true)
list.FlingToBeginning(true, 10)
list.FlingToEnd(true, 10)
```
### 手势操作
```go
el := d.ByResourceId("com.example:id/image")
// 捏合(缩小)
el.PinchIn(50, 10)
// 捏合(放大)
el.PinchOut(50, 10)
```
### 子元素和兄弟元素
```go
// 查找子元素
parent := d.ByClassName("android.widget.LinearLayout")
child, _ := parent.Child(map[string]interface{}{"className": "android.widget.Button"})
child.Click()
// 查找兄弟元素
sibling, _ := d.ByText("标题").Sibling(map[string]interface{}{"className": "android.widget.Button"})
sibling.Click()
// 通过文本在子元素中搜索
item, _ := parent.ChildByText("目标文本", map[string]interface{}{"className": "android.widget.TextView"})
// 获取匹配元素数量
count, _ := d.ByClassName("android.widget.Button").Count()
// 获取指定索引的元素
d.ByClassName("android.widget.Button").Instance(0).Click() // 第一个
d.ByClassName("android.widget.Button").Instance(-1).Click() // 最后一个
```
### Watcher 弹窗监控
```go
// 创建 Watcher 上下文builtin=true 自动添加常见弹窗规则)
w := libs.NewWatchContext(d, true)
// 添加监控规则:点击"同意"按钮
w.WhenText("同意").Click()
// 添加监控规则:点击"允许"按钮
w.WhenText("ALLOW").Click()
// 添加监控规则:按下返回键关闭弹窗
w.WhenText("取消").Press("back")
// 添加监控规则:自定义回调
w.WhenDescription("广告").Call(func(d *libs.Device) error {
fmt.Println("检测到广告弹窗")
return d.Press("back")
})
// 启动监控(后台运行)
w.Start()
// 等待页面稳定5 秒内无弹窗,超时 30 秒)
w.WaitStable(5.0, 30.0)
// 停止监控
w.Stop()
```
### Session 会话管理
```go
// 创建应用会话attach=false 启动新实例)
session, err := libs.NewSession(d, "com.example.app", false)
if err != nil {
panic(err)
}
// 检查应用是否仍在运行
if session.Running() {
fmt.Println("应用运行中, PID:", session.PID())
}
// 会话中的 JSON-RPC 调用会自动检查应用状态
// 如果应用退出,会返回 SessionBrokenError
// 重启应用
session.Restart()
// 关闭会话
session.Close()
```
### 截图
```go
// 获取截图JPEG 格式字节数据)
imgData, err := d.Screenshot()
if err != nil {
panic(err)
}
// 保存到文件
os.WriteFile("screenshot.png", imgData, 0644)
```
### 设备控制
```go
// 亮屏/息屏
d.ScreenOn()
d.ScreenOff()
// 解锁屏幕
d.Unlock()
// 按键操作
d.Press("home")
d.Press("back")
d.Press("recent")
d.Press("volume_up")
d.Press("volume_down")
d.Press("power")
d.Press("enter")
// 发送按键码
d.PressKeyCode(4, 0) // KEYCODE_BACK
// 长按按键
d.LongPress("power")
// 发送按键事件
d.Keyevent("KEYCODE_MENU")
// 屏幕方向
orientation, _ := d.Orientation() // 获取当前方向
d.SetOrientation("natural") // 竖屏
d.SetOrientation("left") // 左横屏
d.FreezeRotation(true) // 冻结旋转
// 打开通知栏/快速设置
d.OpenNotification()
d.OpenQuickSettings()
// 打开 URL
d.OpenURL("https://www.example.com")
// 剪贴板
d.SetClipboard("复制的文本")
text, _ := d.GetClipboard()
// Toast
d.MakeToast("提示消息", 2000) // 2000 毫秒
toast, _ := d.GetLastToast()
d.ClearToast()
// 获取页面层级结构
hierarchy, _ := d.DumpHierarchy(false, 50)
// 执行 Shell 命令
result, _ := d.Shell("ls", "/sdcard/")
fmt.Println(result.Output)
// 获取窗口大小
w, h, _ := d.WindowSize()
fmt.Printf("窗口大小: %d x %d\n", w, h)
// 获取设备 WLAN IP
ip, _ := d.WlanIP()
// 推送文件到设备
d.Push("local/file.txt", "/sdcard/file.txt")
```
### 配置管理
```go
// 设置等待超时(秒)
d.Settings().Set("wait_timeout", 30.0)
// 获取配置
timeout := d.Settings().GetFloat64("wait_timeout")
// 设置操作延迟
d.Settings().Set("operation_delay", []float64{0.5, 1.0}) // [操作前延迟, 操作后延迟]
// 设置需要延迟的操作方法
d.Settings().Set("operation_delay_methods", []string{"click", "swipe"})
// 设置隐式等待超时
d.ImplicitlyWait(20.0)
```
### UI 选择器
```go
// 创建选择器
selector := libs.MustNew(map[string]interface{}{
"className": "android.widget.TextView",
"text": "登录",
})
// 添加子元素选择
selector.Child(map[string]interface{}{
"resourceId": "com.example:id/button",
"instance": 0,
})
// 序列化为 JSON
jsonData, _ := selector.ToJSON()
```
### UIAutomator2 服务部署
```go
import "github.com/zhuy1228/go-mobile-uiautomator/services"
// 安装 UIAutomator2 JAR自动从内嵌资源推送检查设备端是否已存在
err := services.InstallServiceJar(addr, serial, false)
// 安装 UIAutomator2 APK自动从内嵌资源推送
err = services.InstallServiceApk(addr, serial, false)
// 强制重新安装force=true 跳过存在检查)
err = services.InstallServiceJar(addr, serial, true)
```
> 资源文件通过 `go:embed` 编译进二进制,无需在本地维护 JAR/APK 文件。
## 项目结构
```
go-mobile-uiautomator/
├── adb/ # ADB 协议实现
│ ├── connect.go # 连接管理、ADB 隧道、命令发送/接收
│ ├── device.go # 设备发现、APK 安装
│ ├── sync.go # 文件同步协议Sync Push
│ └── doc.go # 包文档
├── libs/ # UIAutomator2 客户端核心库
│ ├── device.go # 核心设备客户端(连接、操作、应用管理)
│ ├── uiobject.go # UI 元素操作(点击、输入、滑动、滚动)
│ ├── jsonrpc.go # JSON-RPC 2.0 通信层
│ ├── selector.go # UI 选择器(定位条件构建)
│ ├── request.go # ADB 隧道 + 自定义 HTTP Transport
│ ├── input.go # AdbKeyboard 输入法集成
│ ├── watcher.go # Watcher 弹窗自动监控
│ ├── session.go # 应用会话管理
│ ├── swipe_ext.go # 扩展滑动操作
│ ├── settings.go # 线程安全配置管理
│ ├── errors.go # 完整错误类型定义
│ ├── proto.go # 协议常量和枚举
│ └── doc.go # 包文档
├── services/ # 服务模块
│ ├── install_service.go # UIAutomator2 安装服务
│ └── doc.go # 包文档
├── assets/ # 资源文件(通过 go:embed 编译进二进制)
│ ├── embed.go # go:embed 指令,导出 JarData/ApkData/ApkTestData
│ ├── u2.jar # UIAutomator2 服务端 JAR
│ ├── app-uiautomator.apk # UIAutomator2 APK
│ ├── app-uiautomator-test.apk # UIAutomator2 测试 APK
│ └── sync.sh # 资源同步脚本
├── cmd/ # 主程序入口
│ └── main.go
├── go.mod # Go 模块定义(零外部依赖)
└── README.md
```
## 架构说明
```
┌─────────────────────────────────────────────────┐
│ 用户代码 │
├─────────────────────────────────────────────────┤
│ libs.Device / libs.UiObject / libs.Session │
│ (设备操作 / UI 元素 / 会话管理) │
├─────────────────────────────────────────────────┤
│ libs.JsonRpcWrapper │
│ (JSON-RPC 2.0 通信层) │
├─────────────────────────────────────────────────┤
│ libs.AdbTunnelDevice + http.Transport │
│ (ADB 隧道 + 标准 HTTP 客户端) │
├─────────────────────────────────────────────────┤
│ adb.CreateTunnel (纯 Go ADB 协议) │
│ (TCP → ADB Server → transport → tcp:9008) │
├─────────────────────────────────────────────────┤
│ assets.JarData / assets.ApkData (go:embed) │
│ (资源文件编译进二进制,零配置部署) │
├─────────────────────────────────────────────────┤
│ Android 设备 │
│ UIAutomator2 HTTP 服务 (端口 9008) │
└─────────────────────────────────────────────────┘
```
### 连接原理
与 Python uiautomator2 采用完全相同的 ADB 隧道方案:
1. **每次 HTTP 请求**通过 `adb.CreateTunnel()` 建立一条 ADB 隧道到设备端口 9008
2. `http.Transport``DialContext` 被替换为 ADB 隧道连接
3. Go 标准 `http.Client` 在隧道上发送完整的 HTTP/1.1 请求
4. 请求完成后隧道自动关闭,**无需 `adb forward`,不占用本地端口**
```
Go http.Client → http.Transport.DialContext → adb.CreateTunnel()
↓ ↓
HTTP/1.1 请求 ←→ ADB Server ←→ 设备:9008 (UIAutomator2)
```
## ADB 协议实现
本项目实现了以下 ADB 协议功能:
| 功能 | 命令 | 说明 |
|------|------|------|
| 设备列表 | `host:devices-l` | 列出所有连接的设备 |
| 设备路由 | `host:transport:<serial>` | 切换到指定设备 |
| Shell 执行 | `shell:<command>` | 执行 Shell 命令 |
| ADB 隧道 | `tcp:<port>` | 建立到设备端口的直连隧道 |
| 文件同步 | `sync:` | 启动文件传输协议 |
| 文件推送 | `SEND/DATA/DONE` | Sync 协议传输文件 |
## 错误类型
本库定义了完整的错误类型层次,便于精确错误处理:
| 错误类型 | 说明 |
|---------|------|
| `DeviceError` | 设备通用错误 |
| `ConnectError` | 连接错误 |
| `HTTPError` | HTTP 请求错误 |
| `HTTPTimeoutError` | HTTP 超时错误 |
| `AdbShellError` | ADB Shell 执行错误 |
| `RPCError` | JSON-RPC 调用错误 |
| `RPCUnknownError` | 未知 RPC 错误 |
| `RPCInvalidError` | 无效 RPC 响应 |
| `RPCStackOverflowError` | Java 端栈溢出 |
| `UiObjectNotFoundError` | UI 元素未找到 |
| `UiAutomationNotConnectedError` | UIAutomation 服务断开 |
| `HierarchyEmptyError` | UI 层级为空 |
| `LaunchUiAutomationError` | UIAutomator2 启动失败 |
| `SessionBrokenError` | 应用会话中断 |
| `AppNotFoundError` | 应用未安装 |
| `InputIMEError` | 输入法错误 |
## 常见问题
### Q: 如何获取设备序列号?
运行 `adb devices` 或使用代码:
```go
payload, _ := adb.ListDevicesRaw(libs.DefaultADBAddr, 15*time.Second)
devices := adb.ParseDevicesPayload(payload)
for _, dev := range devices {
fmt.Println("Serial:", dev.Serial)
}
```
### Q: UIAutomator2 服务启动失败?
确保:
1. 设备已正确连接且已授权 USB 调试
2. ADB Server 正在运行(默认 `127.0.0.1:5037`
3. 使用 `NewDevice()` 会自动从内嵌资源推送 JAR/APK 并启动服务,无需手动管理
> u2.jar 和 APK 通过 `go:embed` 编译进二进制,`go get` 后即可直接使用,无需额外下载资源文件。
### Q: 如何处理弹窗干扰自动化?
使用 Watcher 机制自动处理弹窗:
```go
w := libs.NewWatchContext(d, true) // builtin=true 自带常见弹窗规则
w.WhenText("允许").Click()
w.Start()
defer w.Stop()
```
### Q: 如何输入中文?
使用 AdbKeyboard 输入法:
```go
im := libs.NewInputMethod(d)
im.SendKeys("你好世界")
```
### Q: 连接已有服务(不启动新服务)?
```go
// 适用于 UIAutomator2 已经在设备上运行的场景
d := libs.NewDeviceWithoutStart("emulator-5554")
d.ByText("Hello").Click()
```
## 开发计划
- [x] 纯 Go ADB 协议实现
- [x] go:embed 资源内嵌(零配置部署)
- [x] ADB 隧道直连(与 Python 一致)
- [x] 设备发现和连接管理
- [x] JSON-RPC 2.0 通信层
- [x] 完整 UI 元素操作(点击、滑动、输入、拖拽)
- [x] 便捷选择器ByText、ByResourceId 等)
- [x] Watcher 弹窗自动监控
- [x] Session 会话管理
- [x] AdbKeyboard 输入法集成
- [x] 截图功能
- [x] 应用生命周期管理
- [x] 完整错误类型体系
- [x] 线程安全配置管理
- [ ] XPath 选择器支持
- [ ] 设备录屏功能
- [ ] 批量设备管理
- [ ] 完整单元测试
## 依赖
**零外部依赖** — 仅使用 Go 标准库。
```go
module github.com/zhuy1228/go-mobile-uiautomator
go 1.25.1
```
## 许可证
MIT License
## 致谢
本项目参考了以下开源项目:
- [openatx/uiautomator2](https://github.com/openatx/uiautomator2) — Python Android 自动化框架
- [Android ADB Protocol](https://android.googlesource.com/platform/packages/modules/adb/) — ADB 协议规范