☰
C# 封装 ADB 工具:从命令行到桌面应用的工程实践
2026/10/15 18:00:22 网站建设 项目流程

简介:这是一份面向Android开发调试场景的C#实战项目源码,适合具备一定C#基础、希望深入理解ADB底层调用与桌面工具开发的开发者。项目以图形化界面封装ADB常用能力,涵盖设备枚举与状态展示、应用安装、文件上传下载、批量命令执行及权限配置引导等模块,并涉及进程通信、异步编程、错误处理与Windows Forms界面设计等关键知识点。压缩包共96个文件,约4.77MB,包含14个cs源码文件、1个sln解决方案与1个csproj工程文件,另有10个exe、9个dll等编译产物,以及manifest、resources、resx等资源与配置文件,结构完整可直接运行调试。目前已有3382人学习下载。通过阅读源码,读者可掌握用System.Diagnostics.Process调用adb并解析输出、用async/await避免UI阻塞、用FileStream处理文件传输等实用技巧,是学习C#与移动设备调试结合的高性价比参考。

1. 把 ADB 塞进 C# 里:一个能省掉一半重复劳动的桌面工具

连着调三台 Android 设备的时候,我盯着命令行里刷屏的adb devices、adb install、adb logcat发呆——每换一台机器就要重敲一遍路径,每抓一次日志都要手动重定向到文件,同事过来问“帮我装个包”还得把命令复制给他。这种重复劳动做多了,就会想:能不能把 ADB 包一层,做成一个双击就能用的桌面程序?这份 C# 实现的 Android ADB 工具,解决的正是这个场景。它把 ADB 的常用能力——设备枚举、应用安装卸载、日志抓取、文件推拉、Shell 执行——封装成 C# 可调用的接口,再配一个 WinForm/WPF 界面,让不熟悉命令行的人也能完成设备调试。适合谁?做 Android 自动化测试的、需要批量给设备装包的、以及想用 C# 写上位机去控制手机的人。它不是什么黑科技,就是把命令行那套东西翻译成了代码,但翻译得好不好,差别很大。

2. 拆开看:ADB 协议、进程调用与 C# 封装的三层结构

2.1 为什么不是直接 Process.Start 就完事

很多人第一反应是:C# 调 ADB 不就是Process.Start("adb.exe", "devices")吗?能跑,但只能算玩具。真要做成工具,得先想清楚 ADB 到底怎么跟设备说话。ADB 本身是个 C/S 架构:PC 上跑 adb server(默认 5037 端口),设备上跑 adbd,中间通过 USB 或 TCP 通信。你在命令行敲的adb命令,其实是 adb client,它把请求发给 server,server 再转发给设备。

这意味着两件事。第一,如果你直接调adb.exe,每次都要等 client 启动、连接 server、发指令、收结果,进程创建开销不小,批量操作时肉眼可见地慢。第二,ADB 的返回是文本流,adb devices输出里混着标题行、空行、设备状态,直接字符串切割很容易翻车。所以一个合格的 C# 封装,至少要在“进程调用”之上再做一层“输出解析”和“状态管理”。

常见做法是保留adb.exe作为执行体,但把它的路径、端口、超时都做成可配置项,同时用正则去解析输出。更激进的做法是直接走 ADB 的 socket 协议,自己实现 client,但那样工作量翻几倍,除非你要做的是 ADB 替代品,否则没必要。

2.2 进程调用层的参数设计

先看最核心的进程执行封装。下面这段代码是我一般会写的骨架,重点在参数拼接和超时控制:

public class AdbExecutor { private readonly string _adbPath; private readonly int _timeoutMs; public AdbExecutor(string adbPath = "adb.exe", int timeoutMs = 30000) { _adbPath = adbPath; _timeoutMs = timeoutMs; } public AdbResult Execute(string arguments, int? timeoutOverride = null) { var psi = new ProcessStartInfo { FileName = _adbPath, Arguments = arguments, UseShellExecute = false, // 必须 false,否则拿不到输出流 RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true, // 不弹黑框 StandardOutputEncoding = Encoding.UTF8 }; using (var process = new Process { StartInfo = psi }) { var output = new StringBuilder(); var error = new StringBuilder(); process.OutputDataReceived += (s, e) => { if (e.Data != null) output.AppendLine(e.Data); }; process.ErrorDataReceived += (s, e) => { if (e.Data != null) error.AppendLine(e.Data); }; process.Start(); process.BeginOutputReadLine(); process.BeginErrorReadLine(); var timeout = timeoutOverride ?? _timeoutMs; if (!process.WaitForExit(timeout)) { process.Kill(); // 超时直接杀,避免卡死界面 return new AdbResult { Success = false, Error = "执行超时" }; } return new AdbResult { Success = process.ExitCode == 0, Output = output.ToString().Trim(), Error = error.ToString().Trim(), ExitCode = process.ExitCode }; } } }

逻辑说明:UseShellExecute = false是硬性要求,设成 true 就拿不到标准输出,这是新手最容易踩的坑。BeginOutputReadLine配合事件回调,避免输出量大时缓冲区写满导致进程挂起——抓 logcat 的时候尤其明显。超时后Kill()是必要的,否则一个卡住的 adb 进程会拖死整个界面线程。

参数说明:_adbPath建议做成配置项,因为有人把 platform-tools 装在 C 盘,有人放在 D 盘,硬编码路径换台机器就废。_timeoutMs默认 30 秒,但adb install大包可能要几分钟,所以留了timeoutOverride让调用方按命令类型覆盖。StandardOutputEncoding设 UTF8,否则中文设备名会乱码。

2.3 设备列表解析:别用 Split 硬切

adb devices的输出长这样:

List of devices attached emulator-5554 device 0123456789ABCDEF unauthorized

直接按空格 Split 会得到一堆空字符串,而且unauthorized、offline这些状态必须识别出来,否则后续操作全报错。我一般用正则:

private static readonly Regex DeviceLineRegex = new Regex(@"^(?<serial>\S+)\s+(?<state>device|offline|unauthorized|bootloader|recovery)\s*$", RegexOptions.Compiled | RegexOptions.Multiline); public List<AdbDevice> ParseDevices(string rawOutput) { var devices = new List<AdbDevice>(); foreach (Match m in DeviceLineRegex.Matches(rawOutput)) { devices.Add(new AdbDevice { Serial = m.Groups["serial"].Value, State = m.Groups["state"].Value }); } return devices; }

逻辑说明:正则里\S+匹配序列号,\s+吃掉中间空白,状态用枚举白名单限定,避免把标题行“List of devices attached”误匹配进来。RegexOptions.Multiline让^和$按行生效。

参数说明:状态白名单里device表示正常,unauthorized表示设备没授权 USB 调试,offline表示连接异常,bootloader和recovery是特殊模式。解析出状态后,界面上应该用不同颜色标出来,而不是只显示序列号——这是工具好不好用的分水岭。

2.4 多设备场景下的 -s 参数

当连接了多台设备,所有 adb 命令都必须带-s <serial>,否则 adb 会报“more than one device”。封装层要自动处理这件事:

public AdbResult ExecuteOnDevice(string serial, string command) { if (string.IsNullOrEmpty(serial)) throw new ArgumentException("serial 不能为空"); return Execute($"-s {serial} {command}"); }

逻辑说明:把-s拼接收口到一个方法里,调用方只传序列号和业务命令,避免每处都手写-s导致漏写。

参数说明:serial来自上一步解析结果。如果只有一台设备,可以允许 serial 为空,走默认设备;但多设备时必须强制指定,否则行为不确定。我一般会在界面上做一个设备下拉框,选中哪台就传哪台的序列号。

3. 从装包到抓日志:四个高频功能的落地实现

3.1 应用安装:install 的参数比你想的多

adb install看着简单,但参数组合直接影响成功率。下面是我常用的安装方法:

public AdbResult InstallApk(string serial, string apkPath, bool replace = true, bool grantAll = true) { if (!File.Exists(apkPath)) return new AdbResult { Success = false, Error = $"APK 不存在: {apkPath}" }; var args = new List<string> { "install" }; if (replace) args.Add("-r"); // 覆盖安装,保留数据 if (grantAll) args.Add("-g"); // 自动授予所有运行时权限 args.Add($"\"{apkPath}\""); // 路径带空格必须加引号 return ExecuteOnDevice(serial, string.Join(" ", args)); }

逻辑说明:-r是覆盖安装,不写的话已存在同名包会直接失败。-g是 Android 6.0 以后才支持的自动授权,测试场景下能省掉一堆点击弹窗的麻烦。路径加引号是因为 Windows 下Program Files这类目录带空格,不加引号 adb 会把路径截断。

参数说明:replace默认 true,适合迭代测试;如果要测全新安装流程,传 false。grantAll在测权限相关逻辑时应该关掉,否则测不出授权弹窗。安装失败时,AdbResult.Error里通常会有INSTALL_FAILED_开头的错误码,比如INSTALL_FAILED_VERSION_DOWNGRADE表示版本降级被拒,需要加-d参数。

3.2 日志抓取:logcat 的缓冲区和过滤

logcat 是另一个重灾区。直接adb logcat会刷屏,而且默认只输出主缓冲区。实际用的时候要指定缓冲区和过滤规则:

public void StartLogcat(string serial, string tagFilter, string outputFile) { var args = $"-s {serial} logcat -b main,system,crash -v threadtime"; if (!string.IsNullOrEmpty(tagFilter)) args += $" {tagFilter}:V *:S"; // 指定 tag 输出,其余静默 var psi = new ProcessStartInfo { FileName = _adbPath, Arguments = args, UseShellExecute = false, RedirectStandardOutput = true, CreateNoWindow = true }; var process = new Process { StartInfo = psi }; process.OutputDataReceived += (s, e) => { if (e.Data != null) File.AppendAllText(outputFile, e.Data + Environment.NewLine); }; process.Start(); process.BeginOutputReadLine(); }

逻辑说明:-b main,system,crash指定抓取主缓冲区、系统缓冲区和崩溃缓冲区,只抓 main 会漏掉系统层错误。-v threadtime输出线程 ID 和时间戳,排查并发问题必需。tagFilter:V *:S是 logcat 的过滤语法,意思是“指定 tag 输出 Verbose 级别,其余全部静默”,比抓全量再 grep 高效得多。

参数说明:tagFilter传空则抓全量,适合复现问题时用;传具体 tag 适合日常调试。输出文件建议按时间戳命名,避免覆盖。注意 logcat 是持续输出的,进程不会自己退出,界面上要提供“停止抓取”按钮,内部调process.Kill()。

3.3 文件推拉:路径里的空格和权限

adb push和adb pull的坑主要在路径和权限:

public AdbResult PushFile(string serial, string localPath, string remotePath) { if (!File.Exists(localPath)) return new AdbResult { Success = false, Error = "本地文件不存在" }; // 远程路径如果是 /sdcard/ 下,一般不需要额外权限 // 如果是 /data/local/tmp/,需要确保目录可写 var args = $"push \"{localPath}\" \"{remotePath}\""; return ExecuteOnDevice(serial, args); }

逻辑说明:本地和远程路径都加引号,防止空格截断。推送到/sdcard/通常没问题,推送到/data/local/tmp/是常见做法,因为该目录可写且不需要 root。推送到/system/需要先adb remount,而且设备必须解锁。

参数说明:localPath用绝对路径,相对路径会以进程工作目录为基准,容易出错。remotePath建议以/开头,写相对路径会落到设备当前目录,行为不确定。大文件推送时超时要调大,默认 30 秒不够。

3.4 Shell 执行:返回码和交互式命令

adb shell能跑大部分 Linux 命令,但有两个坑:一是返回码不一定可靠,二是交互式命令会卡住。

public AdbResult Shell(string serial, string command, int timeoutMs = 15000) { // 非交互式执行,命令末尾加分号确保返回 var args = $"-s {serial} shell \"{command}\""; return Execute(args, timeoutMs); }

逻辑说明:命令用引号包起来,避免 shell 解析歧义。超时设短一点,因为 shell 命令通常很快,卡住多半是命令本身在等待输入。

参数说明:不要用这个接口跑top、logcat这类持续输出的命令,它们不会自己退出,会一直等到超时。交互式命令如su也不要走这里,需要单独处理输入流。返回码方面,部分设备上adb shell的退出码始终是 0,所以判断成功与否要看输出内容,不能只看 ExitCode。

4. 避坑与排查:五个让我返工的血泪经验

4.1 设备显示 unauthorized 却不知道怎么处理

现象:adb devices列出设备,但状态是unauthorized,所有操作报device unauthorized。

原因:设备端没有确认 USB 调试授权,或者之前授权的密钥失效了(比如换了电脑、重装了系统)。

解决:在设备屏幕上会弹出“允许 USB 调试吗”的对话框,勾选“始终允许”并确认。如果没弹窗,到开发者选项里点“撤销 USB 调试授权”,然后重新插拔数据线。还不行就检查%USERPROFILE%\.android\下的adbkey文件是否存在,删掉后重启 adb server 会重新生成。

4.2 adb server 端口被占用

现象:执行任何 adb 命令都报cannot connect to daemon或adb server didn't ACK。

原因:5037 端口被其他程序占用,常见的是另一个版本的 adb、手机助手类软件、或者上次 adb 进程没退干净。

解决:先adb kill-server,再adb start-server。如果还不行,用netstat -ano | findstr 5037找到占用进程的 PID,在任务管理器里结束它。注意有些手机助手会自带 adb 并常驻,装了这个工具后要把那些助手关掉。

4.3 安装 APK 报 INSTALL_FAILED_INSUFFICIENT_STORAGE

现象:adb install返回失败,错误码INSTALL_FAILED_INSUFFICIENT_STORAGE。

原因:设备存储空间不足,或者/data分区满了。有时候设备显示还有空间,但/data分区实际已满。

解决:先adb shell df /data看实际剩余空间。清理的话可以adb shell pm clear <包名>清应用数据,或者卸载不用的应用。测试机建议定期恢复出厂设置,比手动清理彻底。

4.4 logcat 抓不到崩溃日志

现象:应用闪退了,但 logcat 里没有对应的崩溃堆栈。

原因:崩溃日志可能落在crash缓冲区而不是main缓冲区,或者应用被系统杀死时日志还没刷到缓冲区。

解决:抓日志时加上-b main,system,crash三个缓冲区。如果还是没有,试试adb shell dumpsys dropbox --print看系统 dropbox 里的崩溃记录。另外,抓日志要在复现问题之前就开始,事后抓往往已经丢了。

4.5 中文路径导致 push/pull 失败

现象:本地路径含中文时,adb push报No such file or directory。

原因:adb 对非 ASCII 路径的处理在不同版本上不一致,部分版本会乱码。

解决:把待推送的文件放到纯英文路径下,比如C:\adb_tmp\。如果必须用中文路径,确保StandardOutputEncoding和进程编码都设成 UTF8,并且路径加引号。最稳妥的做法是在工具里做一层路径校验,检测到非 ASCII 字符就提示用户换路径。

5. 进阶:把 ADB 封装成可复用的服务层

5.1 用接口隔离,方便替换和测试

前面写的AdbExecutor是具体类,直接依赖它会让单元测试很难做——总不能每次测试都插一台真机。我一般会抽一个接口:

public interface IAdbService { List<AdbDevice> GetDevices(); AdbResult InstallApk(string serial, string apkPath, bool replace = true); AdbResult Shell(string serial, string command); void StartLogcat(string serial, string tagFilter, string outputFile); void StopLogcat(); }

逻辑说明:接口把“做什么”和“怎么做”分开。测试时可以写一个FakeAdbService返回预设数据,不用真机就能跑通业务逻辑。生产环境用AdbService实现,内部持有AdbExecutor。

参数说明:接口方法尽量用简单类型(string、bool),避免暴露Process这类实现细节。StartLogcat和StopLogcat成对出现,因为 logcat 是长驻进程,需要显式停止。

5.2 异步化:别让界面卡死

ADB 命令是阻塞的,装在 UI 线程上跑必然卡界面。正确做法是全部异步化:

public async Task<AdbResult> InstallApkAsync(string serial, string apkPath) { return await Task.Run(() => InstallApk(serial, apkPath)); }

逻辑说明:Task.Run把阻塞调用丢到线程池,UI 线程继续响应。注意不要在Task.Run里更新 UI 控件,要用Invoke或Dispatcher切回来。

参数说明:如果要做进度提示,可以在AdbExecutor里加一个进度回调,安装大包时按输出行数估算进度。但 adb install 的输出不是实时的,进度条只能做“不确定进度”样式,别硬做百分比。

5.3 配置持久化:adb 路径和超时存哪

工具要给别人用,就不能要求人家每次手动选 adb 路径。我一般用 JSON 存配置:

public class AdbConfig { public string AdbPath { get; set; } = "adb.exe"; public int DefaultTimeoutMs { get; set; } = 30000; public int LogcatTimeoutMs { get; set; } = 0; // 0 表示不超时 }

逻辑说明:配置类保持扁平,方便序列化。LogcatTimeoutMs设 0 表示不超时,因为日志抓取是用户手动停止的。

参数说明:配置文件放在%APPDATA%\<工具名>\config.json,不要放在程序目录,否则 Program Files 下没有写权限。首次启动时如果检测不到 adb,可以引导用户选择 platform-tools 目录,自动拼出 adb.exe 路径。

5.4 一个验证封装是否可靠的小技巧

写完封装后,别急着做界面。先写一个控制台测试,把每个方法跑一遍:

static void Main() { var adb = new AdbService(); var devices = adb.GetDevices(); Console.WriteLine($"发现 {devices.Count} 台设备"); foreach (var d in devices.Where(x => x.State == "device")) { var result = adb.Shell(d.Serial, "getprop ro.build.version.release"); Console.WriteLine($"{d.Serial} Android 版本: {result.Output}"); } }

逻辑说明:先验证设备枚举,再对每台正常设备跑一个简单 shell 命令,确认通信链路通畅。这一步能提前暴露 90% 的环境问题。

参数说明:getprop ro.build.version.release返回 Android 版本号,是个轻量且必然存在的属性,适合做连通性测试。如果这一步就失败,后面装包抓日志都不用试了。

从那以后我每次封装 ADB 相关的东西,都强制先跑一遍这个控制台验证,确认设备枚举和 shell 通了再动界面。这个习惯帮我省掉了无数次“界面报错但不知道是封装问题还是环境问题”的纠结。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询