微调试技术解密(一)——不用电脑,手机也能无线ADB,我们是怎么做到的?


微调试技术解密(一)——不用电脑,手机也能无线ADB,我们是怎么做到的?

微调试之所以能够摆脱传统电脑的束缚,实现无线ADB调试,得益于@yume-chan/adb和微信TCPSocket这两项核心技术,本文主要介绍微调试小程序直连设备端adbd守护进程的具体实现思路。

01

ADB 的三代演进:我们站在了第四代的起点

Android Debug Bridge(ADB)自诞生以来,经历过三代主流形态:

第一代:有线 + 命令行

原理adb client通过 USB 与adbd通信,中间经过adb server管理多设备。

痛点:必须插数据线、装驱动、敲命令。对非开发者极不友好。

第二代:桌面图形化工具(如 scrcpy、Vysor)

原理:在 PC 上运行一个笨重的GUI,将常见的adb命令封装为可视化的鼠标操作。

痛点:需要下载几十 MB 甚至上百 MB 的安装包(尤其 Electron 应用),电脑仍然是必需品。

第三代:Web 化尝试(基于 WebUSB / WebSocket 桥接)

原理:浏览器通过 WebUSB 直连设备(仍需数据线),或让 PC 跑一个 WebSocket 服务端,转发 ADB 指令。

痛点:要么还在用线,要么依赖一个本地代理服务器,没有彻底摆脱 PC。

我们做的 —— 第四代:纯无线,零依赖,手机即控制台

原理:微信小程序通过wx.createTCPSocket与设备adbd的 5555 端口建立 TCP 连接,直接发送 ADB 协议报文。

效果不需要数据线、不需要电脑、不需要安装任何 exe、不需要WebSocket 中转

打开小程序,连上 WiFi,你的手机就成了一台能执行adb shellinstallscreencap的无线调试器。

02

先搞懂 ADB 的原生架构,才知道我们绕过了什么

ADB 的经典结构分为三层:

ADB Client:你敲命令的那个终端(比如adb devices),其底层调用的是adb.exe这个可执行文件。

ADB Server:PC 上的后台进程(监听localhost:5037),负责管理所有连接的设备,转发 client 的请求。

adbd:Android 设备里的守护进程,真正执行命令。

这种设计在 PC 时代很合理:一个 server 可以同时服务多个 client

但到了移动端,尤其是小程序这种没有“本地进程”的环境,adb server就成了累赘。

其实,从本质上来讲,只要能让应用程序直接与adbd对话,整个 PC 端的client + server都可以扔掉。而adbd本身就支持 TCP 通信 —— 无线 ADB 开启后,它会在端口5555上监听。

于是问题转化为:应用程序能否建立到设备 IP:5555 的 TCP 连接?

很遗憾,传统的网页 JavaScript 运行在浏览器沙箱中,没有暴露原始 TCP Socket API,无法直接与设备的 5555 端口建立连接。因此,大多数 Web 方案只能借助 WebUSB(仍需插线)或依赖本地 WebSocket 桥接服务(仍离不开 PC)。

而微信小程序从基础库 2.18.0 开始提供了wx.createTCPSocket,恰好填补了这一空白——它允许应用发起纯 TCP 连接,且局域网内不受IP限制。这正是微调试能够直连adbd的关键基础设施。

03

从零到一:封装一个 AdbDaemonDirectSocketsDevice

裸 TCP 连接只是第一步,我们还需要处理 ADB协议层的细节。

ADB 的通信不是简单的字符串传输,而是一种二进制报文协议,每个报文必须包含如下字段:

手工拼这些报文既容易出错,又耗时。

我们选择站在巨人的肩膀上—— 使用开源库@yume-chan/adb(代号 Tango)。

Tango 已经把 ADB 协议抽象成了Web Streams

interface AdbStream {  readable: ReadableStream<AdbPacket>;  writable: WritableStream<AdbPacket>;}
它不关心底层数据是通过 USB、TCP 还是 WebSocket 传输的,只要你能提供原始字节流的ReadableStream和WritableStream,它就能自动解析成 ADB 报文。

我们做的,就是给 Tango 接上微信的WxTCPSocket

export class AdbDaemonDirectSocketsDevice {  private hoststring;  private portnumber;  private serialstring;  constructor(optionsAdbDaemonDirectSocketsDeviceOptions) {    this.host = options.host;    this.port = options.port || 5555;    this.serial = `${this.host}:${this.port}`;  }  getSerial(): string {    return this.serial;  }  async connect() {    // 1. 建立原始 TCP 字节流    const tcpSocket = new WxTCPSocket(this.hostthis.port);    const { readable: rawReadable, writable: rawWritable } = await tcpSocket.opened;    // 2. 字节流 → AdbPacket 对象流(反序列化)    const packetReadable = rawReadable.pipeThrough(new StructDeserializeStream(AdbPacket));    // 3. AdbPacket 对象流 → 字节流(序列化)    const serializeStream = new AdbPacketSerializeStream();    // 4. 将序列化后的字节流(Consumable<Uint8Array>)通过 WrapWritableStream 写入原始 socket    const wrappedRawWritable = new WrapWritableStream(rawWritable);    serializeStream.readable.pipeTo(wrappedRawWritable).catch((err) => {      console.error("Serialization pipe failed:", err);    });    // 5. 返回 AdbPacket 对象流    return {      readable: packetReadable,      writable: serializeStream.writable// WritableStream<AdbPacket>    };  }}

这个ADBDaemonSocketDevice就是我们无线 ADB 的核心类,不超过 50 行代码。

然后,在小程序中,我们可以这样使用:

const device = new AdbDaemonDirectSocketsDevice({    host'192.168.116.209',    port5555});let adbInstance = {    valuenull};// 导出一个Promise,在需要时初始化adb实例const initAdb = async () => {    if (adbInstance.value) {        return adbInstance.value;    }    try {        const connection = await device.connect();        const transport = await adbDaemonAuthenticate({            serial: device.getSerial(),            connection,            credentialManagernew CustomLocalStorageCredentialManager('adb-credentials'),        });        adbInstance.value = new Adb(transport);        return adbInstance.value;    } catch (error) {        console.error('初始化adb失败:', error);        throw error;    }};// 导出初始化函数和adb实例module.exports = {    initAdb,    getAdb() => adbInstance.value}

后续的所有操作都将基于此处创建的ADB实例对象实现。

04

背压(Backpressure):一个容易忽视但至关重要的细节

当设备端的adbd向小程序发送大量数据(比如screencap -p返回几 MB 的 PNG)时,如果小程序消费速度跟不上,缓冲区会爆掉,导致连接断开。

原生socket.onMessage是一个“推”模式,没有背压控制。

而 Web Streams 天然支持背压:当ReadableStream的消费者处理不过来时,它会自动向数据源发出信号,让生产端“慢一点”。

我们在ADBDaemonSocketDevice中把TCPSocket的数据源包装成ReadableStream,然后整个处理链路(Tango 内部的报文解析、业务逻辑)都通过pipeTo串联,背压自动生效,无需额外代码。

这就是用 Web Streams 重构传输层的最大好处。

05

隧道之上的轻量扩展:文件管理与虚拟遥控

前文我们打通了无线 ADB 隧道。有了这条直连adbd的通道,上层功能只需调用对应的 ADB 命令即可实现,无需任何额外网络开销。

以文件管理为例,我们使用 ADB 的sync协议,列出设备上的目录和文件:

const sync = await this.adbInstance.sync?.();const entriesany[] = [];const generator = await sync.opendir(path + "/");for await (const entry of generator) {  // 此处省略一些简单的处理操作...  if (entry.name === "." || entry.name === "..") {    console.log("跳过", entry.name, entry.size.toString());    continue;  }  entries.push({    ...entry,  });  // 实时更新 UI(流式反馈)  this.setData({ files: [...entries] });}this.setData({ files: [...entries] });

多路复用:调用sync方法,会在现有 ADB 会话上派生一个新的逻辑通道,这个通道专门用于文件操作。

流式返回generator逐条yield条目,并在每次迭代过程中更新UI,确保UI可见渐进渲染。

至于虚拟键盘,我们则可以使用input keyevent 进行图形化映射
Android 的input命令可模拟几乎所有物理按键。我们将其封装为如图所示的 UI 按钮:
  /**   * 通过 ADB 发送按键事件   * @param keycode 按键码   * @param longpress 是否长按   */  async sendAdbKeyEvent(keycodenumberlongpressboolean = false) {    if (!this.adbInstance) {      wx.showToast({ title"未连接设备"icon"error" });      return;    }    const cmd = longpress      ? `input keyevent --longpress ${keycode}`      : `input keyevent ${keycode}`;    try {      await this.execAdbShell(cmd);      // 可选:震动反馈(保留全局震动设置)      if (getApp().globalData.isVibrateOn) {        wx.vibrateShort();      }    } catch (err) {      console.error("发送按键失败", err);      wx.showToast({ title"按键发送失败"icon"none" });    }  }
可以看到,这些日常生活中非常实用的功能,通过几行API调用就能迅速实现。而其之所以能够“信手拈来”,正是因为底层无线 ADB 隧道已经解决了最核心的通信问题。

06

总结

本篇完整展示了无线 ADB 直连的工程实现,以及如何快速扩展文件管理和虚拟键盘。下一篇,我们将在这条隧道上跑起scrcpy—— 低延迟 H.264 投屏、WASM+Worker 解码、Canvas 渲染与反向触摸控制。敬请期待。

#来微信做个小程序