作者:张义飞
一、背景
在物联网领域,Android 设备的广泛应用使其管理与运维需求变得不可或缺。对于非 Android 开发人员来说,如何操控和运维这些设备是个不小的挑战。比如本地要搭建 Android 的开发环境,要熟悉各种调试命令。如果使用 Web 端的图形化设备管理可以解决很多问题,随时随地管理设备,实时查看日志,批量执行命令,提高运维效率,降低管理成本。无论是在日常开发、测试,还是在设备维护、故障排查中,Web 版 Android Shell 终端都展现出了强大的功能和无限的潜力。
二、前期方案
当然在运维平台搭建之前我们也探索了一些其他方案。使用 mqtt 进行指令下发,android 设备收到指令后进行相关指令的执行,执行之后将执行结果上报到日志平台,然后再去日志平台去查询执行结果。

这种设计方式简单,前端和Android 开发人员设计好指令协议,就可以各自实现了,但是会存在一些问题,比如每加一个指令,Android 开发人员都要开发相关功能。而且如果是一个比较耗时,或者网络不好的时候。android 设备没收到 mqtt 指令或者没上报日志平台,对于发送指令的人就会感觉到比较疑惑,不知道运维指令是否下达或执行成功。后来我们探索出来了新的方案, 使用 adb 和 socket 进行长连接的方式,来进行各种指令的转发。
三、ADB
ADB(Android Debug Bridge)是一种允许计算机与 Android 设备通信的命令行工具。它常用于调试应用、安装 APK、复制文件等操作。通常 ADB 是通过 USB 协议或者 TCP 协议来进行数据传输的。
常用的一些命令
# 连接设备
adb devices # 列出已连接的设备
adb connect <IP地址>:<端口> # 通过Wi-Fi连接
# 应用管理
adb install path/to/app.apk # 安装APK
adb uninstall com.example.app # 卸载应用(包名)
# 文件传输
adb push local/path /sdcard/remote/path # 从电脑复制到设备
adb pull /sdcard/remote/path local/path # 从设备复制到电脑
# 执行shell
adb shell # 进入设备 shell 环境
adb shell <命令> # 直接执行 shell 命令(如 `adb shell ls /sdcard`)
更详细的一些操作可参考 Android 开发文档。
ADB的基本原理

-
ADB Client:运行在PC上,通过在命令行执行adb,就启动了ADB Client程序
-
ADB Server:运行于PC的后台进程,用于管理ADB Client和Daemon间的通信
-
ADB Daemon (即adbd) :运行在模拟器或移动设备上的后台服务。当Android系统启动时,由init程序启动adbd。如果adbd挂了,则adbd会由init重新启动。
ADB Clinet 和 ADB Server 进行通信时会建立一个本地 TCP 连接,在本地 5037 端口侦听,ADB Client 通过本地随机端口与 5037 端口进行连接。
Android Shell
介绍
Android Shell 是 Android 系统提供的一种命令行接口(CLI),允许用户通过输入文本命令与系统进行交互,执行各种底层操作。它本质上是一个 基于 Linux 内核的 shell 环境,是 Android 系统与开发者、运维人员之间的重要桥梁。
shell 环境
通过 usb 连接到设备,或者使用 adb connect 连接到设备后执行 adb shell
就可以执行各种命令了

比如上面的ls
命令,是列出当前目录下的文件和文件夹。
揭开Web与Android Shell交互的神秘面纱
大家应该听说过Jump Server(跳板机)吧?运维人员或开发人员可借助跳板机管理多台服务器,通过Web端利用SSH或其他协议登录服务器。类似地,Android设备的Shell环境可通过ADB连接实现——执行adb shell
命令后,即可进入设备的Shell交互界面,进而执行文件查看(如ls命令)、系统配置修改等操作。例如在RK3288设备中,通过adb shell
进入后,能查看INIT.RK30BOARD.RC
等系统初始化脚本,或访问/data
、/system
等目录结构。这种方式就像为Android设备搭建了专属的“跳板通道”,让开发者能高效管理设备的系统资源与运行状态。

服务器端我们需要 adb 环境和 node 环境,然后客户端通过 socket 客户端连接到服务器的 socket 端,服务器上的 socket 和 pty 终端来进行数据传输。
四、双向奔赴
当 Android 通过 usb 或者 使用 adb connect 进行连接后。 在终端中我们执行完 android shell
后,会进入到一个交互模式,可以输入各种指令。我们可以使用 node-pty 这个库来伪造终端。然后我们还需要编写 web 端的终端输入模拟器,xterm.js 可以给我们提供在浏览器中比较齐全的终端。我们现在有了 xterms.js 来输入我们各种命令,然后通过 node-pty 执行我们的输入命令,那么他们之间还需要一个桥梁来进行输入和输出的传输。我们可以通过 socket来进行数据传输。
建立连接
Android 设备可通过 usb,或者在局域网内通过 adb connect ip:port
方式进行连接。如果要控制非局域网内的设备,需要将 Android 端的端口通过 frp 映射到外网端口上,然后通过 adb connect ip:port
的方式建立连接。
await execa`adb connect ip:port`;
如果要控制远程设备需要将远程设备端口映射到外网的 ip 端口上。

frp 映射就是将某个端口的数据通过 tcp 或者其他协议,转发到服务器上的某个开发端口上。这里我们使用的是 tcp,我们在通过 node 服务器和 frp 服务器的端口进行连接。这样我们就能连接上远程设备了。但是当 adb 所在服务器如果连接了多台设备,我们应该怎么准确的去操控我们想要操作的设备。“都是端口”,每台设备都映射到 frp 服务器上某个端口上,node 服务器通过 adb connect ip:port
端口连接上远程服务器。
$ adb devices
List of devices attached
emulator-5554device
192.168.1.101:5555device
这个时候我们想要操控某台设备就需要需要在 adb 后面加个参数来进行了 例如 <font style="color:rgb(28, 31, 35);">adb -s 192.168.1.101:5554 shell</font>
,这个命令我们就能进入到192.168.1.101:5555这台设备的 shell 环境了。
进入 shell 环境
创建 pty 进程,进入 shell 环境
const ptyProcess = pty.spawn('adb', ['shell'], {
name: 'xterm-color',
cols: 80,
rows: 30
});
pty进程和socket绑定
const server = new Server(socket.server);
server.on('connection', (socket) => {
// 接收客户端发送数据
socket.on('data', (data: string) => {
// 发送数据到pty进程
ptyProcess.write(data);
});
});
ptyProcess.onData((data: string) => {
// 接收pty进程数据, 发送给客户端
server.emit('data', data);
});
客户端和服务器绑定
let socket: Socket<DefaultEventsMap, DefaultEventsMap>;
await fetch(`/api/node-pty`);
socket = io();
至此客户端和服务器端,以及服务器端和 Android 设备端都建立了绑定,这样客户端的输入和 android 端的输出都能有效到达对方了。
最终效果
上面给搭建介绍了 shell 的一个实现过程,当然我们的运维平台还有桌面控制,文件管理等功能。后续还会做一些运维脚本的管理。

五、总结
这里是一个比较简单的 demo 来解释,如何通过 web shell 控制 andoird 设备的,要想控制远程android 设备的话需要使用到 frp 技术将 android 端口映射到远程服务器上。当然一个好的运维平台要让非专业人员操作起来更加便捷才是我们做技术人员要考虑的问题。
六、附录
本示例通过 nextjs 实现,如果有想实现的小伙伴,可参考下发代码
- 服务器和 adb 建立连接
import { Server } from 'socket.io';
import { NextApiRequest, NextApiResponse } from 'next';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pty = require('node-pty');
const handler = async(req: NextApiRequest, res: NextApiResponse) => {
// 创建pty进程
const ptyProcess = pty.spawn('adb', ['shell'], {
name: 'xterm-color',
cols: 80,
rows: 30
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const socket = res.socket as any;
const server = new Server(socket.server);
server.on('connection', (socket) => {
// 接收客户端发送数据
socket.on('data', (data: string) => {
// 发送数据到pty进程
ptyProcess.write(data);
});
});
ptyProcess.onData((data: string) => {
// 接收pty进程数据, 发送给客户端
server.emit('data', data);
});
return res.status(200).json({ message: 'success' });
};
export default handler;
- 将客户端的输入通过socket 传送给服务端,并将服务端的响应显示到客户端上
'use client';
import React, { useEffect, useRef } from 'react';
import 'xterm/css/xterm.css';
import { io, Socket } from 'socket.io-client';
import { DefaultEventsMap } from '@socket.io/component-emitter';
const TerminalComponent: React.FC = () => {
const terminalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let socket: Socket<DefaultEventsMap, DefaultEventsMap>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let xterm: any;
const initializeTerminal = async() => {
const FitAddon = (await import('xterm-addon-fit')).FitAddon;
const Terminal = (await import('xterm')).Terminal;
await fetch(`/api/node-pty`);
socket = io();
if (terminalRef.current) {
xterm = new Terminal({
cols: 100,
rows: 60,
cursorBlink: true,
cursorStyle: 'block',
fontSize: 14,
convertEol: true,
theme: {
background: '#000000',
foreground: '#ffffff',
cursor: '#2dea5f'
}
});
const fitAddon = new FitAddon();
xterm.loadAddon(fitAddon);
xterm.open(terminalRef.current);
fitAddon.fit();
xterm.focus();
socket.on('data', (data: ArrayBuffer) => {
// 接收 socket server 数据
xterm.write(data);
});
xterm.onData((data: string) => {
// 发送数据导 socket server
socket.emit('data', data);
});
// 自动输入su
setTimeout(() => {
socket.emit('data', 's');
socket.emit('data', 'u');
socket.emit('data', '\r');
}, 1000);
}
};
initializeTerminal();
return () => {
if (xterm) xterm.dispose();
};
}, []);
return <div ref={terminalRef} className='h-full w-full' />;
};
export default TerminalComponent;