手撸一个云手机:从 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 云手机产品架构
Logo

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐