手撸一个云手机:从 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 🚀


参考资料

  1. 华为云手机开源项目(Android-SDK / H5-SDK / CAE / CodecPool / AOSP)
  2. redroid (Remote-Android) 官方文档与镜像
  3. WebRTC 实现 Android 群控实时推流(CSDN)
  4. 鲲鹏 BoostKit 云手机 Kbox 套件
  5. 华为云 ARM 云手机产品架构

更多推荐