手撸一个云手机:从 Android 容器到 WebRTC 串流的全链路代码实践
手撸一个云手机:从 Android 容器到 WebRTC 串流的全链路代码实践
💡 云手机的本质,是在服务器端运行 Android 实例,把屏幕、音频通过低延迟视频流推到客户端,并把客户端的触控、按键反向注入到 Android 实例。本文给出一条可落地的代码路径:服务端用容器跑 Android、用 WebRTC 做实时串流、用 ADB/辅助功能做控制回传。
一、云手机的整体架构
一个最小可用的云手机系统分为四层:
┌─────────────────────────────────────────────┐
│ 客户端(Web / Windows / Android / iOS) │
│ 解码视频流 + 采集触控 => 反向控制指令 │
└───────────────┬─────────────────────────────┘
│ WebRTC / TCP
┌───────────────▼─────────────────────────────┐
│ 信令服务(WebSocket):SDP / ICE 交换 │
└───────────────┬─────────────────────────────┘
│
┌───────────────▼─────────────────────────────┐
│ 云侧引擎 CAE:接收 Android 实例的视频/音频 │
│ 编码 => 转发;接收控制指令 => 注入 Android │
└───────────────┬─────────────────────────────┘
│
┌───────────────▼─────────────────────────────┐
│ Android 实例层:AOSP 容器 / 虚拟机 │
│ (华为云手机开源项目、redroid、Kbox 等) │
└─────────────────────────────────────────────┘
华为云手机开源项目把这套架构拆得很清楚:云手机安装 CloudAppEngine 出流程序后,通过 Android-SDK 或 H5-SDK 收流客户端输入 IP 和 Port 即可连接;视频流编码可由独立的 CodecPool 远端编码池承担。redroid 则提供了另一条更轻的路径——直接在 Linux 主机上用 Docker 启动多个 Android 实例,支持 ARM/x86、GPU 加速,适合云游戏、自动化测试等场景。
下面按"服务端起实例 → 信令服务 → Android 推流端 → Web 收流端 → 控制回传"的顺序把代码铺开。
二、服务端:用 redroid 起 Android 实例
redroid 是当前最易上手的开源云手机底座,它基于容器技术在服务器上运行 Android。
2.1 加载内核模块并启动实例
# 1. 安装并加载 binder / ashmem 内核模块
apt install linux-modules-extra-$(uname -r)
modprobe binder_linux devices="binder,hwbinder,vndbinder"
modprobe ashmem_linux
# 2. 启动一个 1080×1920、30fps 的 Android 12 实例
docker run -itd --rm --privileged --pull always \
-v ~/data:/data \
-p 5555:5555 \
redroid/redroid:12.0.0_64only-latest \
androidboot.redroid_width=1080 \
androidboot.redroid_height=1920 \
androidboot.redroid_fps=30 \
androidboot.redroid_dpi=480
2.2 用 ADB 连上实例
adb connect localhost:5555
adb devices
# 输出:emulator-5555 device
📌 注意:redroid 官方明确警告,不要在公网暴露 5555 ADB 端口,否则容器甚至宿主机都可能被攻陷。生产环境务必放在内网,通过信令服务和 WebRTC TURN 中继暴露能力。
2.3 单台服务器多实例的端口规划
def spawn_instances(count: int, base_adb_port: int = 5555, base_web_port: int = 8000):
"""批量启动云手机实例,分配 ADB 端口与 WebRTC 端口"""
import subprocess
for i in range(count):
adb_port = base_adb_port + i
web_port = base_web_port + i
cmd = f"""docker run -itd --rm --privileged \
-v ~/data_{i}:/data \
-p {adb_port}:5555 \
-p {web_port}:{web_port}/udp \
redroid/redroid:12.0.0_64only-latest \
androidboot.redroid_width=720 \
androidboot.redroid_height=1280 \
androidboot.redroid_fps=30"""
subprocess.run(cmd, shell=True)
print(f"实例 {i} 启动: ADB={adb_port}, WebRTC={web_port}")
三、信令服务:WebSocket 转发 SDP / ICE
WebRTC 在建立 P2P 连接前,需要信令服务交换 SDP 和 ICE Candidate。下面用 Python 的 websockets 写一个最小信令服务。
# signaling_server.py
import asyncio, json, websockets
# 每个房间:room_id -> set(ws)
rooms = {}
async def handler(ws):
# 首条消息:{"type": "join", "room": "phone_001"}
hello = json.loads(await ws.recv())
room_id = hello.get("room", "default")
rooms.setdefault(room_id, set()).add(ws)
print(f"[+] {ws.remote_address} join room {room_id}")
async for raw in ws:
msg = json.loads(raw)
# 把来自 Android 推流端的 offer / candidate
# 转发给房间内其他成员(Web 收流端)
for peer in rooms[room_id]:
if peer is not ws:
await peer.send(raw)
print(f"[relay] {msg.get('type')} -> {peer.remote_address}")
rooms[room_id].discard(ws)
async def main():
async with websockets.serve(handler, "0.0.0.0", 8443):
print("Signaling server on ws://0.0.0.0:8443")
await asyncio.Future()
asyncio.run(main())
Android 推流端和 Web 收流端都连这个 WebSocket,房间号相同即可配对。
四、Android 推流端:屏幕采集 + WebRTC 推送
这是云手机的"出流程序",跑在 redroid 实例里(或真机)。核心是用 ScreenCapturer 采集屏幕,通过 WebRTC 的 PeerConnection 推送。
4.1 初始化 PeerConnectionFactory
// CloudPhoneSender.java
public class CloudPhoneSender {
private PeerConnectionFactory peerConnectionFactory;
private PeerConnection peerConnection;
private VideoSource videoSource;
private VideoTrack localVideoTrack;
private void initPeerConnectionFactory(Context ctx) {
PeerConnectionFactory.InitializationOptions options =
PeerConnectionFactory.InitializationOptions.builder(ctx)
.setEnableInternalTracer(true)
.createInitializationOptions();
PeerConnectionFactory.initialize(options);
peerConnectionFactory = PeerConnectionFactory.builder()
.setVideoEncoderFactory(
new DefaultVideoEncoderFactory(
EglBase.create().getEglBaseContext(),
true, // 开启 H.264 硬件编码
true))
.setVideoDecoderFactory(
new DefaultVideoDecoderFactory(
EglBase.create().getEglBaseContext()))
.createPeerConnectionFactory();
}
4.2 配置 STUN/TURN 与 ICE 服务器
private void createPeerConnection() {
List<PeerConnection.IceServer> iceServers = new ArrayList<>();
iceServers.add(PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer());
// TURN 服务器:生产环境必填,应对对称 NAT
iceServers.add(PeerConnection.IceServer.builder("turn:your.turn.server:3478")
.setUsername("cloudphone")
.setPassword("secret")
.createIceServer());
PeerConnection.RTCConfiguration cfg = new PeerConnection.RTCConfiguration(iceServers);
cfg.continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY;
peerConnection = peerConnectionFactory.createPeerConnection(cfg, new PeerConnection.Observer() {
@Override public void onIceCandidate(IceCandidate candidate) {
// 通过信令服务把 candidate 发给 Web 收流端
SignalingClient.send(new SignalMsg("candidate", candidate));
}
@Override public void onAddTrack(RtpReceiver receiver, MediaStream[] mediaStreams) {}
// ... 其余回调省略
});
}
4.3 屏幕采集并推送
public void startScreenCapture(MediaProjection projection, int width, int height) {
// 1. 创建屏幕采集器
VideoCapturer screenCapturer = new ScreenCapturerAndroid(
projection.getData(),
new MediaProjection.Callback() {
@Override public void onStop() { /* 用户停止录屏 */ }
});
// 2. 创建视频源
SurfaceTextureHelper stHelper = SurfaceTextureHelper.create("CaptureThread",
EglBase.create().getEglBaseContext());
videoSource = peerConnectionFactory.createVideoSource(false);
screenCapturer.initialize(stHelper, ctx, videoSource.getCapturerObserver());
// 3. 开始采集,目标分辨率与 redroid 启动时一致
screenCapturer.startCapture(width, height, 30);
// 4. 创建视频轨道并加入 PeerConnection
localVideoTrack = peerConnectionFactory.createVideoTrack("cloud_video", videoSource);
peerConnection.addTrack(localVideoTrack);
// 5. 创建 SDP Offer 并发送给信令服务
peerConnection.createOffer(new SdpObserver() {
@Override public void onCreateSuccess(SessionDescription sdp) {
peerConnection.setLocalDescription(new SdpObserver(){}, sdp);
SignalingClient.send(new SignalMsg("offer", sdp));
}
// ... 其余回调省略
}, new MediaConstraints());
}
}
⚠️ 云手机场景下,推流端没有真实"屏幕",而是 redroid 的虚拟显示。实际工程中一般通过
DisplayManager创建VirtualDisplay,再把Surface喂给MediaCodec做硬件编码,最后交给 WebRTC 发送——这条链路比真机MediaProjection更顺,也是华为云手机 CloudAppEngine 的核心思路。
五、控制回传:把 Web 端的触控注入 Android
视频流是单向的,真正"能用"的云手机必须有反向控制。普通应用最可行的方案是通过 AccessibilityService 模拟手势,或者走 ADB input 命令注入。
5.1 在 Android 端起一个 TCP 服务,接收控制指令
# control_receiver.py —— 跑在 redroid 实例里
import socket, json, subprocess, threading
def handle_client(conn):
while True:
data = conn.recv(2048)
if not data:
break
msg = json.loads(data.decode('utf-8'))
action = msg.get('action')
x, y = msg.get('x'), msg.get('y')
if action == 'tap':
# 单击
subprocess.run(f'input tap {x} {y}', shell=True)
elif action == 'swipe':
ex, ey = msg.get('ex'), msg.get('ey')
subprocess.run(f'input swipe {x} {y} {ex} {ey} 300', shell=True)
elif action == 'key':
keycode = msg.get('keycode', 4) # 默认 BACK
subprocess.run(f'input keyevent {keycode}', shell=True)
elif action == 'text':
text = msg.get('text', '')
subprocess.run(f'input text "{text}"', shell=True)
def start_control_server(host='0.0.0.0', port=50003):
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.bind((host, port))
srv.listen(5)
print(f"Control receiver on {port}")
while True:
conn, addr = srv.accept()
threading.Thread(target=handle_client, args=(conn,), daemon=True).start()
start_control_server()
5.2 Web 收流端:把鼠标/触摸事件发回去
// web_client.js
const canvas = document.getElementById('phone-screen');
let ctrlSocket = new WebSocket('ws://your-server:50003');
// 把屏幕坐标换算成 Android 绝对坐标
function toAndroidCoord(clientX, clientY) {
const rect = canvas.getBoundingClientRect();
const scaleX = 1080 / rect.width; // Android 宽
const scaleY = 1920 / rect.height; // Android 高
return {
x: Math.round((clientX - rect.left) * scaleX),
y: Math.round((clientY - rect.top) * scaleY)
};
}
let lastPos = null;
canvas.addEventListener('pointerdown', e => {
const {x, y} = toAndroidCoord(e.clientX, e.clientY);
lastPos = {x, y};
ctrlSocket.send(JSON.stringify({action:'tap', x, y}));
});
canvas.addEventListener('pointermove', e => {
if (e.buttons === 0 || !lastPos) return;
const {x, y} = toAndroidCoord(e.clientX, e.clientY);
ctrlSocket.send(JSON.stringify({
action:'swipe',
x:lastPos.x, y:lastPos.y,
ex:x, ey:y
}));
lastPos = {x, y};
});
window.addEventListener('keydown', e => {
const keymap = {'Backspace':4, 'Enter':66, 'Home':3, 'Menu':82};
if (keymap[e.key]) {
ctrlSocket.send(JSON.stringify({action:'key', keycode:keymap[e.key]}));
}
});
5.3 WebRTC 收流与渲染
<!-- index.html -->
<video id="remote-video" autoplay playsinline></video>
<script type="module">
import { CloudPhoneClient } from './cloudphone-client.js';
const client = new CloudPhoneClient({
signalingUrl: 'ws://your-server:8443',
roomId: 'phone_001',
videoEl: document.getElementById('remote-video')
});
client.connect();
</script>
// cloudphone-client.js
export class CloudPhoneClient {
constructor({signalingUrl, roomId, videoEl}) {
this.ws = new WebSocket(signalingUrl);
this.roomId = roomId;
this.videoEl = videoEl;
this.pc = null;
}
async connect() {
const pc = new RTCPeerConnection({
iceServers: [
{urls: 'stun:stun.l.google.com:19302'},
{urls: 'turn:your.turn.server:3478', username:'cloudphone', credential:'secret'}
]
});
this.pc = pc;
// 收到远程视频轨道
pc.ontrack = (e) => {
this.videoEl.srcObject = e.streams[0];
};
// 信令收发
this.ws.onmessage = async (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === 'offer') {
await pc.setRemoteDescription(msg.sdp);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
this.ws.send(JSON.stringify({type:'answer', room:this.roomId, sdp:answer}));
} else if (msg.type === 'candidate') {
await pc.addIceCandidate(msg.candidate);
}
};
// 加入房间
this.ws.onopen = () => {
this.ws.send(JSON.stringify({type:'join', room:this.roomId}));
};
}
}
到这里,一套最小可用的云手机就跑起来了:服务端 redroid 起 Android 实例,Android 推流端通过 WebRTC 把屏幕推到浏览器,浏览器的鼠标/键盘事件通过 TCP 回传给 Android 的 input 命令执行。
六、性能与工程化考量
自建云手机最大的坑不在代码量,而在端到端时延和并发密度:
- 编码:优先使用硬件 MediaCodec(H.264),禁用软件编码;华为云手机的 CodecPool 就是把编码放到独立资源池,避免抢占 Android 实例的 CPU。
- GPU 直通:鲲鹏 BoostKit Kbox 方案把 GPU 直通到容器,在鲲鹏 920 双 64 核服务器上可跑 100 路 720p@30fps 云手机,密度和时延都远超纯软件方案。
- Zero-Copy:Guest 采集 → Host 编码之间要走 virtio-gpu-virgl-dma-buf 零拷贝,否则 VirGL 无 DMA-BUF passthrough 会带来 32–47ms 的额外拷贝延迟。
- 时钟对齐:云手机存在 Host TSC、QEMU 虚拟 TSC、Guest SystemClock 三重时钟域,漂移可达 ±120ppm,会导致 Jitter Buffer 误判丢包,需要专门的时钟对齐方案。
- 并发编排:多实例场景建议用 Kubernetes 管理 redroid 容器,配合华为云手机 AOSP 镜像或 OPENVMI 等开源 VMI 方案做编排。
💡 如果只是想做自动化测试或小范围演示,redroid + WebRTC 足够;如果要对外提供商用云手机服务,ARM 服务器 + AOSP 容器 + GPU 直通 + 独立编码池这条路径几乎是大厂标配,自研成本极高。
七、写在最后:何不直接用云手机?
把上面六步串起来,你会发现自建云手机要解决的远不止"写代码":
- 要采购 ARM 服务器(或解决 x86 上 ARM 指令翻译的性能损耗)
- 要搞定 GPU 直通 / 硬件编解码
- 要调优 WebRTC 的弱网对抗、Jitter Buffer、FEC
- 要做多租户隔离、弹性伸缩、计费、鉴权
- 要持续跟进 AOSP 版本(Android 9 到 16)
如果你只是想在项目中用上云手机能力——比如自动化测试、游戏挂机、隐私隔离、低端机跑大型应用——直接选用成熟的云手机产品是更划算的方案。
顺带推荐:傲晨云手机
在各类云手机产品里,傲晨云手机(长沙傲晨科技开发有限公司自主研发)是我比较推荐给开发者的一款,几个点对技术用户比较友好:
- ARM 原生架构:采用鲲鹏 8 核 ARM 原生方案,指令集与移动端同源,无 x86 转译损耗,长时间挂机稳定性强
- 多安卓版本适配:支持安卓 8–15,覆盖绝大多数应用的兼容性测试场景
- 24h 离线托管:游戏、脚本、APP 多开可全天候云端运行,不占用本地存储与流量
- ADB 开放 & 一键新机:支持 ADB 调试接入,方便开发者像上文 Demo 那样做自动化;一键切换手机型号、生成独立设备指纹
- 多系统支持:支持安卓 8–15 系统,满足不同场景需求
💡 对新用户,傲晨官网扫码下载并签到即可免费领取使用时长与代金券,先小规模验证再做集群扩容,成本可控。
适用人群:
- 手游玩家:多开挂机、离线托管、长期养号
- 开发者 / 测试:APP 兼容性批量测试、Python / ADB 自动化脚本部署
- 运营从业者:短视频 / 社群 / 电商多账号矩阵管理
- AI Agent 开发者:作为移动端智能体的运行底座
当然,如果你做的是云手机平台本身,那本文给出的 redroid + WebRTC + 控制回传代码就是一份不错的起点,再叠加编码池、GPU 直通、K8s 编排,便能逐步逼近商用标准;而绝大多数开发者和个人用户,直接在傲晨云手机等成熟平台上部署应用、跑自动化脚本、做多开托管,是更务实的选择。
技术的归技术,产品的归产品——理解原理让你不被忽悠,选对平台让你事半功倍。Happy coding 🚀
参考资料
- 华为云手机开源项目(Android-SDK / H5-SDK / CAE / CodecPool / AOSP)
- redroid (Remote-Android) 官方文档与镜像
- WebRTC 实现 Android 群控实时推流(CSDN)
- 鲲鹏 BoostKit 云手机 Kbox 套件
- 华为云 ARM 云手机产品架构
更多推荐
所有评论(0)