From 21b0862e9ffd41d3a50336cf16d21569e1389547 Mon Sep 17 00:00:00 2001 From: zyj Date: Tue, 10 Mar 2026 15:52:38 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E5=8C=85=E6=96=87?= =?UTF-8?q?=E6=A1=A3=EF=BC=8C=E5=8C=B9=E9=85=8D=20ADB=20=E9=9A=A7=E9=81=93?= =?UTF-8?q?=E6=9E=B6=E6=9E=84=E5=92=8C=E5=BD=93=E5=89=8D=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adb/connect.go | 14 +++++++ adb/doc.go | 26 +++++++----- adb/sync.go | 79 +++++++++++++++++++++++++++++++++++++ assets/embed.go | 24 +++++++++++ libs/device.go | 26 +++++------- libs/doc.go | 51 +++++++++++++----------- services/doc.go | 7 +++- services/install_service.go | 52 ++++++------------------ 8 files changed, 188 insertions(+), 91 deletions(-) create mode 100644 assets/embed.go diff --git a/adb/connect.go b/adb/connect.go index d30ae41..190b584 100644 --- a/adb/connect.go +++ b/adb/connect.go @@ -233,6 +233,20 @@ func PushFile(addr, serial, localPath, remotePath string, mode int, debug bool) return s.SyncPushFile(localPath, remotePath, mode, debug) } +// PushData 通过 ADB 推送内存数据到设备 +// 与 PushFile 功能相同,但数据来源是 []byte 而非本地文件 +// 适用于通过 go:embed 嵌入的资源文件 +func PushData(addr, serial string, data []byte, remotePath string, mode int, debug bool) (int64, error) { + conn, err := ConnectToDevice(addr, serial, 15*time.Second) + if err != nil { + return 0, err + } + defer conn.Close() + + s := InitSync(conn) + return s.SyncPushData(data, remotePath, mode, debug) +} + // ---------- ADB 隧道(与 Python uiautomator2 完全一致) ---------- // CreateTunnel 建立到设备指定端口的 ADB 隧道 diff --git a/adb/doc.go b/adb/doc.go index b61c43b..afe276b 100644 --- a/adb/doc.go +++ b/adb/doc.go @@ -1,21 +1,27 @@ // Package adb 实现了 Android Debug Bridge (ADB) 协议的核心功能。 // -// 本包提供了纯 Go 实现的 ADB 客户端,支持以下功能: +// 本包提供了纯 Go 实现的 ADB 客户端,零外部依赖,支持以下功能: // - 设备发现与连接管理 // - Shell 命令执行 -// - 文件同步传输(推送文件到设备) +// - ADB 隧道(transport + tcp:,与 Python adbutils 一致) +// - 文件同步传输(Sync Push 协议) // - APK 安装 -// - UIAutomator2 服务启动 +// +// ADB 隧道是本包的核心功能,用于替代 adb forward 端口转发。 +// 每次调用 CreateTunnel 会建立一条直通设备端口的原始 TCP 管道, +// 无需占用本地端口,无需额外清理。 // // 使用示例: // -// // 连接 ADB 服务器 -// conn, err := adb.DialADB("127.0.0.1:5037", 15*time.Second) +// // 列出所有设备 +// payload, _ := adb.ListDevicesRaw("127.0.0.1:5037", 15*time.Second) +// devices := adb.ParseDevicesPayload(payload) +// +// // 建立 ADB 隧道到设备端口 9008 +// conn, err := adb.CreateTunnel("127.0.0.1:5037", "emulator-5554", 9008) // defer conn.Close() +// // conn 现在是一条直通设备 9008 端口的 TCP 管道 // -// // 路由到指定设备 -// adb.TransportTo(conn, "emulator-5556") -// -// // 执行 Shell 命令 -// output, err := adb.ExecShell(conn, "getprop ro.product.model") +// // 推送文件到设备 +// adb.PushFile("127.0.0.1:5037", "emulator-5554", "local.txt", "/sdcard/remote.txt", 0644, false) package adb diff --git a/adb/sync.go b/adb/sync.go index 93d1460..b6e5354 100644 --- a/adb/sync.go +++ b/adb/sync.go @@ -1,6 +1,7 @@ package adb import ( + "bytes" "encoding/binary" "fmt" "io" @@ -119,6 +120,84 @@ func (s *Sync) SyncPushFile(localPath, remotePath string, mode int, debug bool) return total, nil } +// SyncPushData 将内存中的数据推送到设备 +// 与 SyncPushFile 功能相同,但数据来源是 []byte 而非本地文件 +// 适用于通过 go:embed 嵌入的资源文件 +func (s *Sync) SyncPushData(data []byte, remotePath string, mode int, debug bool) (int64, error) { + // 初始化同步模式 + if err := s.StartSync(); err != nil { + return 0, err + } + + // 构造 SEND 请求 + modeStr := strconv.Itoa(syscall.S_IFREG | mode) + sendPayload := []byte(remotePath + "," + modeStr) + + hdr := make([]byte, 8) + copy(hdr[:4], []byte("SEND")) + binary.LittleEndian.PutUint32(hdr[4:], uint32(len(sendPayload))) + if _, err := s.Conn.Write(hdr); err != nil { + return 0, err + } + if _, err := s.Conn.Write(sendPayload); err != nil { + return 0, err + } + if debug { + fmt.Printf("[调试] 发送 SEND 请求: 长度=%d 路径=%s 权限=%s 数据大小=%d\n", len(sendPayload), remotePath, modeStr, len(data)) + } + + // 分块写入数据 + reader := bytes.NewReader(data) + var total int64 + buf := make([]byte, maxChunk) + for { + n, rerr := reader.Read(buf) + if n > 0 { + dataHdr := make([]byte, 8) + copy(dataHdr[:4], []byte("DATA")) + binary.LittleEndian.PutUint32(dataHdr[4:], uint32(n)) + if _, err := s.Conn.Write(dataHdr); err != nil { + return total, err + } + if _, err := s.Conn.Write(buf[:n]); err != nil { + return total, err + } + total += int64(n) + } + if rerr != nil { + if rerr == io.EOF { + break + } + return total, rerr + } + } + + // 发送 DONE 命令(使用当前时间戳) + done := make([]byte, 8) + copy(done[:4], []byte("DONE")) + mtime := uint32(time.Now().Unix()) + binary.LittleEndian.PutUint32(done[4:], mtime) + if _, err := s.Conn.Write(done); err != nil { + return total, err + } + if debug { + fmt.Println("[调试] 发送 DONE,等待响应") + } + + // 读取最终响应 + resp, msg, err := ReadSyncStatus(s.Conn) + if err != nil { + return total, err + } + if resp != "OKAY" { + if len(msg) > 0 { + return total, fmt.Errorf("同步失败: %s", string(msg)) + } + return total, fmt.Errorf("同步失败: %s", resp) + } + return total, nil +} + // StartSync 启动 ADB 同步模式 // 发送 "sync:" 命令并等待 "OKAY" 响应 func (s *Sync) StartSync() error { diff --git a/assets/embed.go b/assets/embed.go new file mode 100644 index 0000000..f7f0483 --- /dev/null +++ b/assets/embed.go @@ -0,0 +1,24 @@ +// Package assets 嵌入 UIAutomator2 运行所需的资源文件。 +// +// 通过 go:embed 将 u2.jar 和 APK 编译到 Go 二进制中, +// 使用者无需手动管理资源文件路径。 +package assets + +import "embed" + +// 嵌入 UIAutomator2 运行所需的资源文件 +var ( + //go:embed u2.jar + JarData []byte + + //go:embed app-uiautomator.apk + ApkData []byte + + //go:embed app-uiautomator-test.apk + ApkTestData []byte +) + +// FS 提供对嵌入文件的文件系统访问 +// +//go:embed *.jar *.apk +var FS embed.FS diff --git a/libs/device.go b/libs/device.go index 64e69e4..c0f01c8 100644 --- a/libs/device.go +++ b/libs/device.go @@ -27,11 +27,10 @@ type Device struct { serial string // 设备序列号 // UIAutomator2 服务配置 - serverPort int // 设备端服务端口(默认 9008) - debug bool // 调试模式 - jarPath string // 本地 u2.jar 路径(空字符串使用默认路径) + serverPort int // 设备端服务端口(默认 9008) + debug bool // 调试模式 - // 设备连接接口(通过 adb forward 连接) + // 设备连接接口(通过 ADB 隧道直连) dev AdbDevice // JSON-RPC 调用器 @@ -49,16 +48,15 @@ type Device struct { } // NewDevice 创建一个新的 Device 客户端并启动 UIAutomator2 服务 -// 使用 adb forward 端口转发,与 Python uiautomator2 相同的方案 +// 通过 ADB 隧道直连设备,与 Python uiautomator2 完全一致 // // serial: 设备序列号(如 "emulator-5554") // addr: 可选,ADB 服务器地址,不传则使用默认值 "127.0.0.1:5037" // // 创建后会自动执行: -// 1. 设置 adb forward 端口转发 -// 2. 推送 u2.jar 到设备(如果尚未存在) -// 3. 启动 UIAutomator2 服务 -// 4. 等待服务就绪 +// 1. 推送内嵌的 u2.jar 到设备(如果尚未存在) +// 2. 启动 UIAutomator2 服务 +// 3. 等待服务就绪 func NewDevice(serial string, addr ...string) (*Device, error) { a := DefaultADBAddr if len(addr) > 0 && addr[0] != "" { @@ -80,8 +78,8 @@ func NewDevice(serial string, addr ...string) (*Device, error) { return d.jsonrpcCall(method, params, timeout) }) - // 推送 u2.jar 到设备(仅在文件不存在时推送) - if err := services.InstallServiceJar(a, serial, d.jarPath, false); err != nil { + // 推送内嵌的 u2.jar 到设备(仅在文件不存在时推送) + if err := services.InstallServiceJar(a, serial, false); err != nil { return nil, fmt.Errorf("安装 u2.jar 失败: %w", err) } @@ -200,12 +198,6 @@ func (d *Device) checkAlive() bool { return string(resp.Content) == "pong" } -// SetJarPath 设置本地 u2.jar 路径 -// 传空字符串则使用默认路径 (assets/u2.jar) -func (d *Device) SetJarPath(path string) { - d.jarPath = path -} - // launchAndWait 启动 UIAutomator2 进程并等待就绪 func (d *Device) launchAndWait() error { // 通过 ADB shell 启动 UIAutomator2 diff --git a/libs/doc.go b/libs/doc.go index 9859742..ae18a5e 100644 --- a/libs/doc.go +++ b/libs/doc.go @@ -1,45 +1,50 @@ // Package libs 提供了完整的 Android UIAutomator2 自动化框架。 // -// 本包是 Python uiautomator2 的 Go 语言实现,通过 ADB 协议与运行在 Android 设备上的 -// UIAutomator2 HTTP 服务通信,提供设备控制、UI 操作、文本输入等功能。 +// 本包是 Python uiautomator2 的 Go 语言实现,通过 ADB 隧道协议与运行在 Android 设备上的 +// UIAutomator2 HTTP 服务直连,提供设备控制、UI 操作、文本输入等功能。 +// 零外部依赖,仅使用 Go 标准库。 // // 核心组件: // - Device:设备客户端,管理 UIAutomator2 服务生命周期,提供所有设备操作 -// - UiObject:UI 控件对象,支持点击、输入、滑动、等待等操作 -// - Selector:UI 元素选择器,支持文本、类名、资源 ID 等多种查询条件 -// - JsonRpcWrapper:JSON-RPC 2.0 调用封装 -// - InputMethod:通过 AdbKeyboard 输入法实现快速文本输入 +// - UiObject:UI 控件对象,支持点击、输入、滑动、等待、滚动等操作 +// - Selector:UI 元素选择器,支持文本、类名、资源 ID、描述等多种查询条件 +// - JsonRpcWrapper:JSON-RPC 2.0 调用封装,自动错误映射和重试 +// - InputMethod:通过 AdbKeyboard 输入法实现快速文本输入(支持中文) // - SwipeExt:扩展滑动操作(按方向、比例滑动) -// - WatchContext/Watcher:弹窗/对话框自动监控和处理 -// - Session:应用会话管理,自动检测应用状态 -// - Settings:设备配置管理(等待超时、操作延迟等) +// - WatchContext:弹窗/对话框自动监控和处理 +// - Session:应用会话管理,自动检测应用存活状态 +// - Settings:线程安全的设备配置管理(等待超时、操作延迟等) // // 通信层: -// - AdbHTTPConnection:通过 ADB 隧道发送 HTTP 请求 +// - AdbTunnelDevice:通过 ADB 隧道直连设备,与 Python uiautomator2 方案完全一致 +// - http.Transport:自定义 DialContext 将 HTTP 连接替换为 ADB 隧道 // - HttpRequest:高层 HTTP 请求封装 // - JsonRpcCall:JSON-RPC 2.0 请求/响应处理 // // 使用示例: // -// // 创建设备连接 -// device, err := libs.NewDevice("emulator-5554") +// // 创建设备连接(自动推送 u2.jar、启动服务) +// d, err := libs.NewDevice("emulator-5554") // // 自定义 ADB 地址: libs.NewDevice("emulator-5554", "192.168.1.100:5037") // if err != nil { // log.Fatal(err) // } -// defer device.StopUiautomator() +// defer d.Close() // -// // 查找并点击按钮 -// btn, _ := device.FindElement(map[string]interface{}{"text": "登录"}) -// btn.Click() +// // 便捷选择器,链式调用 +// d.ByText("登录").Click() +// d.ByResourceId("com.example:id/input").SetText("admin") // -// // 输入文本 -// input, _ := device.FindElement(map[string]interface{}{"resourceId": "com.example:id/username"}) -// input.SetText("admin") +// // 多条件组合查找 +// d.By(libs.P{"className": "android.widget.Button", "text": "确定"}).Click() +// +// // 使用 AdbKeyboard 输入中文 +// im := libs.NewInputMethod(d) +// im.SendKeys("你好世界") // // // 使用 Watcher 自动处理弹窗 -// watcher := libs.NewWatcher(device) -// watcher.WhenText("同意").Click() -// watcher.Start(2.0) -// defer watcher.Stop() +// w := libs.NewWatchContext(d, true) +// w.WhenText("允许").Click() +// w.Start() +// defer w.Stop() package libs diff --git a/services/doc.go b/services/doc.go index da39f36..0c76c37 100644 --- a/services/doc.go +++ b/services/doc.go @@ -1,5 +1,8 @@ // Package services 提供了 UIAutomator2 服务的安装和部署功能。 // -// 本包负责将 UIAutomator2 相关的资源文件(u2.jar、APK) -// 推送到 Android 设备并完成安装。 +// 本包负责将 UIAutomator2 相关的资源文件推送到 Android 设备并完成安装: +// - InstallServiceJar:推送 u2.jar 到设备(自动检查设备端是否已存在,避免重复推送) +// - InstallServiceApk:推送并安装 UIAutomator2 APK +// +// 通常无需直接调用本包,libs.NewDevice() 会自动完成服务安装和启动。 package services diff --git a/services/install_service.go b/services/install_service.go index 0b426e6..32d5a4a 100644 --- a/services/install_service.go +++ b/services/install_service.go @@ -2,22 +2,17 @@ package services import ( "fmt" - "os" - "path/filepath" "strings" "time" "github.com/zhuy1228/go-mobile-uiautomator/adb" + "github.com/zhuy1228/go-mobile-uiautomator/assets" ) -// 默认路径常量 +// 设备端路径常量 const ( - // DefaultJarLocal 默认本地 JAR 路径 - DefaultJarLocal = "assets/u2.jar" // DefaultJarRemote 默认设备端 JAR 路径 DefaultJarRemote = "/data/local/tmp/u2.jar" - // DefaultApkLocal 默认本地 APK 路径 - DefaultApkLocal = "assets/app-uiautomator.apk" // DefaultApkRemote 默认设备端 APK 临时路径 DefaultApkRemote = "/data/local/tmp/app-uiautomator.apk" ) @@ -39,63 +34,42 @@ func fileExistsOnDevice(addr, serial, remotePath string) bool { return strings.TrimSpace(string(out)) == remotePath } -// InstallServiceJar 将 u2.jar 推送到设备的 /data/local/tmp/ 目录 +// InstallServiceJar 将内嵌的 u2.jar 推送到设备的 /data/local/tmp/ 目录 +// JAR 文件通过 go:embed 编译到二进制中,使用者无需关心文件路径 +// // addr 为 ADB 服务器地址,serial 为设备序列号 -// localPath 为本地 JAR 路径,传空字符串则使用默认路径 // force 为 true 时跳过存在性检查,强制推送 -func InstallServiceJar(addr, serial, localPath string, force bool) error { - if localPath == "" { - localPath = DefaultJarLocal - } - - // 检查本地文件是否存在 - if _, err := os.Stat(localPath); os.IsNotExist(err) { - return fmt.Errorf("本地 JAR 文件不存在: %s", localPath) - } - +func InstallServiceJar(addr, serial string, force bool) error { // 非强制模式下,检查设备端文件是否已存在 if !force && fileExistsOnDevice(addr, serial, DefaultJarRemote) { return nil // 文件已存在,跳过推送 } - _, err := adb.PushFile(addr, serial, localPath, DefaultJarRemote, 0644, false) + _, err := adb.PushData(addr, serial, assets.JarData, DefaultJarRemote, 0644, false) if err != nil { return fmt.Errorf("推送 u2.jar 失败: %w", err) } return nil } -// InstallServiceApk 将 UIAutomator2 APK 推送到设备并安装 +// InstallServiceApk 将内嵌的 UIAutomator2 APK 推送到设备并安装 +// APK 文件通过 go:embed 编译到二进制中,使用者无需关心文件路径 +// // addr 为 ADB 服务器地址,serial 为设备序列号 -// localPath 为本地 APK 路径,传空字符串则使用默认路径 // force 为 true 时跳过存在性检查,强制推送 -func InstallServiceApk(addr, serial, localPath string, force bool) error { - if localPath == "" { - localPath = DefaultApkLocal - } - - abs, err := filepath.Abs(localPath) - if err != nil { - return fmt.Errorf("解析 APK 路径失败: %w", err) - } - - // 检查本地文件是否存在 - if _, err := os.Stat(abs); os.IsNotExist(err) { - return fmt.Errorf("本地 APK 文件不存在: %s", abs) - } - +func InstallServiceApk(addr, serial string, force bool) error { // 非强制模式下,检查设备端文件是否已存在 if !force && fileExistsOnDevice(addr, serial, DefaultApkRemote) { // 文件已存在,跳过推送,但仍需确保已安装 } else { - _, err = adb.PushFile(addr, serial, abs, DefaultApkRemote, 0644, false) + _, err := adb.PushData(addr, serial, assets.ApkData, DefaultApkRemote, 0644, false) if err != nil { return fmt.Errorf("推送 APK 失败: %w", err) } } // 在设备上安装 APK(覆盖安装) - _, err = adb.InstallApkOnDevice(addr, serial, DefaultApkRemote, "-r", false) + _, err := adb.InstallApkOnDevice(addr, serial, DefaultApkRemote, "-r", false) if err != nil { return fmt.Errorf("安装 APK 失败: %w", err) }