一、项目概述
在远程协助、在线教学、技术支持等多种场景下,实时获得另一部移动设备的屏幕画面,并对其进行操作,具有极高的应用价值。本项目旨在实现两台 Android 手机之间的屏幕共享与远程控制,其核心功能包括:
-
主控端(Controller):捕获自身屏幕并将实时画面编码后通过网络发送;同时监听用户在主控端的触摸、滑动和按键等输入操作,并将操作事件发送至受控端。
-
受控端(Receiver):接收屏幕画面数据并实时解码、渲染到本地界面;接收并解析主控端的输入操作事件,通过系统接口模拟触摸和按键,实现被控设备的操作。
通过这一方案,用户可以实时“看到”受控端的屏幕,并在主控端进行点触、滑动等交互,达到“远程操控”他机的效果。本项目的核心难点在于如何保证图像数据的实时性与清晰度,以及如何准确、及时地模拟输入事件。
二、相关知识
2.1 MediaProjection API
-
概述:Android 5.0(API 21)引入的屏幕录制和投影接口。通过
MediaProjectionManager
获取用户授权后,可创建VirtualDisplay
,将屏幕内容输送至Surface
或ImageReader
。 -
关键类:
-
MediaProjectionManager
:请求屏幕捕获权限 -
MediaProjection
:执行屏幕捕获 -
VirtualDisplay
:虚拟显示、输出到Surface
-
ImageReader
:以Image
帧的方式获取屏幕图像
-
2.2 Socket 网络通信
-
概述:基于 TCP 协议的双向流式通信,适合大块数据的稳定传输。
-
关键类:
-
ServerSocket
/Socket
:服务端监听与客户端连接 -
InputStream
/OutputStream
:数据读写
-
-
注意:需要设计简单高效的协议,在发送每帧图像前加上帧头(如长度信息),以便接收端正确分包、组帧。
2.3 输入事件模拟
-
概述:在非系统应用中无法直接使用
InputManager
注入事件,需要借助无障碍服务(AccessibilityService)或系统签名权限。 -
关键技术:
-
无障碍服务(AccessibilityService)注入触摸事件
-
使用
GestureDescription
构造手势并通过dispatchGesture
触发
-
2.4 数据压缩与传输优化
-
图像编码:将
Image
帧转为 JPEG 或 H.264,以减小带宽占用。 -
数据分片:对大帧进行分片发送,防止单次写入阻塞或触发
OutOfMemoryError
。 -
网络缓冲与重传:TCP 本身提供重传,但需控制合适的发送速率,防止拥塞。
2.5 多线程与异步处理
-
概述:屏幕捕获与网络传输耗时,需放在独立线程或
HandlerThread
中,否则 UI 会卡顿。 -
框架:
-
ThreadPoolExecutor
管理捕获、编码、发送任务 -
HandlerThread
配合Handler
处理 IO 回调
-
三、实现思路
3.1 架构设计
+--------------+ +--------------+
| |--(请求授权)------------------->| |
| MainActivity | | RemoteActivity|
| |<-(启动服务、连接成功)-----------| |
+------+-------+ +------+-------+
| |
| 捕获屏幕 -> MediaProjection -> ImageReader | 接收画面 -> 解码 -> SurfaceView
| 编码(JPEG/H.264) |
| 发送 -> Socket OutputStream |
| | 接收事件 -> 无障碍 Service -> dispatchGesture
|<--触摸事件包------------------------------------|
| 模拟触摸 => AccessibilityService |
+------+-------+ +------+-------+
| ScreenShare | | RemoteControl|
| Service | | Service |
+--------------+ +--------------+
3.2 协议与数据格式
-
帧头结构(12 字节)
-
4 字节:帧类型(0x01 表示图像,0x02 表示触摸事件)
-
4 字节:数据长度 N(网络字节序)
-
4 字节:时间戳(毫秒)
-
-
图像帧数据:
[帧头][JPEG 数据]
-
触摸事件数据:
-
1 字节:事件类型(0:DOWN,1:MOVE,2:UP)
-
4 字节:X 坐标(float)
-
4 字节:Y 坐标(float)
-
8 字节:时间戳
-
3.3 屏幕捕获与编码
-
主控端调用
MediaProjectionManager.createScreenCaptureIntent()
,请求授权。 -
授权通过后,获取
MediaProjection
,创建VirtualDisplay
并绑定ImageReader.getSurface()
。 -
在独立线程中,通过
ImageReader.acquireLatestImage()
不断获取原始Image
。 -
将
Image
转为Bitmap
,然后使用Bitmap.compress(Bitmap.CompressFormat.JPEG, 50, outputStream)
编码。 -
将 JPEG 字节根据协议拼接帧头,发送至受控端。
3.4 网络传输与解码
主控端
-
使用单例
SocketClient
管理连接。 -
将编码后的帧数据写入
BufferedOutputStream
,并在必要时调用flush()
。
受控端
-
启动
ScreenReceiverService
,监听端口,接受连接。 -
使用
BufferedInputStream
,先读取 12 字节帧头,再根据长度读完数据。 -
将 JPEG 数据用
BitmapFactory.decodeByteArray()
解码,更新到SurfaceView
。
3.5 输入事件捕获与模拟
主控端
-
在
MainActivity
上监听触摸事件onTouchEvent(MotionEvent)
,提取事件类型与坐标。 -
按协议封装成事件帧,发送至受控端。
受控端
-
RemoteControlService
接收事件帧后,通过无障碍接口构造GestureDescription
:
Path path = new Path();
path.moveTo(x, y);
GestureDescription.StrokeDescription stroke = new GestureDescription.StrokeDescription(path, 0, 1);
-
调用
dispatchGesture(stroke, callback, handler)
注入触摸。
四、完整代码
/************************** MainActivity.java **************************/
package com.example.screencast;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.Context;
import android.content.Intent;
import android.graphics.PixelFormat;
import android.media.Image;
import android.media.ImageReader;
import android.media.projection.MediaProjection;
import android.media.projection.MediaProjectionManager;
import android.os.Bundle;
import android.util.DisplayMetrics;
import android.view.MotionEvent;
import android.view.SurfaceView;
import android.view.View;
import android.widget.Button;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.OutputStream;
import java.net.Socket;
/*
* MainActivity:负责
* 1. 请求屏幕捕获权限
* 2. 启动 ScreenShareService
* 3. 捕获触摸事件并发送
*/
public class MainActivity extends Activity {
private static final int REQUEST_CODE_CAPTURE = 100;
private MediaProjectionManager mProjectionManager;
private MediaProjection mMediaProjection;
private ImageReader mImageReader;
private VirtualDisplay mVirtualDisplay;
private ScreenShareService mShareService;
private Button mStartBtn, mStopBtn;
private Socket mSocket;
private BufferedOutputStream mOut;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mStartBtn = findViewById(R.id.btn_start);
mStopBtn = findViewById(R.id.btn_stop);
// 点击开始:请求授权并启动服务
mStartBtn.setOnClickListener(v -> startCapture());
// 点击停止:停止服务并断开连接
mStopBtn.setOnClickListener(v -> {
mShareService.stop();
});
}
/** 请求屏幕捕获授权 */
private void startCapture() {
mProjectionManager = (MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE);
startActivityForResult(mProjectionManager.createScreenCaptureIntent(), REQUEST_CODE_CAPTURE);
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == REQUEST_CODE_CAPTURE && resultCode == RESULT_OK) {
mMediaProjection = mProjectionManager.getMediaProjection(resultCode, data);
// 初始化 ImageReader 和 VirtualDisplay
setupVirtualDisplay();
// 启动服务
mShareService = new ScreenShareService(mMediaProjection, mImageReader);
mShareService.start();
}
}
/** 初始化虚拟显示器用于屏幕捕获 */
private void setupVirtualDisplay() {
DisplayMetrics metrics = getResources().getDisplayMetrics();
mImageReader = ImageReader.newInstance(metrics.widthPixels, metrics.heightPixels,
PixelFormat.RGBA_8888, 2);
mVirtualDisplay = mMediaProjection.createVirtualDisplay("ScreenCast",
metrics.widthPixels, metrics.heightPixels, metrics.densityDpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
mImageReader.getSurface(), null, null);
}
/** 捕获触摸事件并发送至受控端 */
@Override
public boolean onTouchEvent(MotionEvent event) {
if (mShareService != null && mShareService.isRunning()) {
mShareService.sendTouchEvent(event);
}
return super.onTouchEvent(event);
}
}
/************************** ScreenShareService.java **************************/
package com.example.screencast;
import android.graphics.Bitmap;
import android.graphics.ImageFormat;
import android.media.Image;
import android.media.ImageReader;
import android.media.projection.MediaProjection;
import android.os.Handler;
import android.os.HandlerThread;
import android.util.Log;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.net.Socket;
/*
* ScreenShareService:负责
* 1. 建立 Socket 连接
* 2. 从 ImageReader 获取屏幕帧
* 3. 编码后发送
* 4. 接收触摸事件发送
*/
public class ScreenShareService {
private MediaProjection mProjection;
private ImageReader mImageReader;
private Socket mSocket;
private BufferedOutputStream mOut;
private volatile boolean mRunning;
private HandlerThread mEncodeThread;
private Handler mEncodeHandler;
public ScreenShareService(MediaProjection projection, ImageReader reader) {
mProjection = projection;
mImageReader = reader;
// 创建后台线程处理编码与网络
mEncodeThread = new HandlerThread("EncodeThread");
mEncodeThread.start();
mEncodeHandler = new Handler(mEncodeThread.getLooper());
}
/** 启动服务:连接服务器并开始捕获发送 */
public void start() {
mRunning = true;
mEncodeHandler.post(this::connectAndShare);
}
/** 停止服务 */
public void stop() {
mRunning = false;
try {
if (mSocket != null) mSocket.close();
mEncodeThread.quitSafely();
} catch (Exception ignored) {}
}
/** 建立 Socket 连接并循环捕获发送 */
private void connectAndShare() {
try {
mSocket = new Socket("192.168.1.100", 8888);
mOut = new BufferedOutputStream(mSocket.getOutputStream());
while (mRunning) {
Image image = mImageReader.acquireLatestImage();
if (image != null) {
sendImageFrame(image);
image.close();
}
}
} catch (Exception e) {
Log.e("ScreenShare", "连接或发送失败", e);
}
}
/** 发送图像帧 */
private void sendImageFrame(Image image) throws Exception {
// 将 Image 转 Bitmap、压缩为 JPEG
Image.Plane plane = image.getPlanes()[0];
ByteBuffer buffer = plane.getBuffer();
int width = image.getWidth(), height = image.getHeight();
Bitmap bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
bmp.copyPixelsFromBuffer(buffer);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
bmp.compress(Bitmap.CompressFormat.JPEG, 40, baos);
byte[] jpegData = baos.toByteArray();
// 写帧头:类型=1, 长度, 时间戳
mOut.write(intToBytes(1));
mOut.write(intToBytes(jpegData.length));
mOut.write(longToBytes(System.currentTimeMillis()));
// 写图像数据
mOut.write(jpegData);
mOut.flush();
}
/** 发送触摸事件 */
public void sendTouchEvent(MotionEvent ev) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
baos.write((byte) ev.getAction());
baos.write(floatToBytes(ev.getX()));
baos.write(floatToBytes(ev.getY()));
baos.write(longToBytes(ev.getEventTime()));
byte[] data = baos.toByteArray();
mOut.write(intToBytes(2));
mOut.write(intToBytes(data.length));
mOut.write(longToBytes(System.currentTimeMillis()));
mOut.write(data);
mOut.flush();
} catch (Exception ignored) {}
}
// …(byte/int/long/float 与 bytes 相互转换方法,略)
}
/************************** RemoteControlService.java **************************/
package com.example.screencast;
import android.accessibilityservice.AccessibilityService;
import android.graphics.Path;
import android.view.accessibility.GestureDescription;
import java.io.BufferedInputStream;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
/*
* RemoteControlService(继承 AccessibilityService)
* 1. 启动 ServerSocket,接收主控端连接
* 2. 循环读取帧头与数据
* 3. 区分图像帧与事件帧并处理
*/
public class RemoteControlService extends AccessibilityService {
private ServerSocket mServerSocket;
private Socket mClient;
private BufferedInputStream mIn;
private volatile boolean mRunning;
@Override
public void onServiceConnected() {
super.onServiceConnected();
new Thread(this::startServer).start();
}
/** 启动服务端 socket */
private void startServer() {
try {
mServerSocket = new ServerSocket(8888);
mClient = mServerSocket.accept();
mIn = new BufferedInputStream(mClient.getInputStream());
mRunning = true;
while (mRunning) {
handleFrame();
}
} catch (Exception e) {
e.printStackTrace();
}
}
/** 处理每个数据帧 */
private void handleFrame() throws Exception {
byte[] header = new byte[12];
mIn.read(header);
int type = bytesToInt(header, 0);
int len = bytesToInt(header, 4);
// long ts = bytesToLong(header, 8);
byte[] payload = new byte[len];
int read = 0;
while (read < len) {
read += mIn.read(payload, read, len - read);
}
if (type == 1) {
// 图像帧:解码并渲染到 SurfaceView
handleImageFrame(payload);
} else if (type == 2) {
// 触摸事件:模拟
handleTouchEvent(payload);
}
}
/** 解码 JPEG 并更新 UI(通过 Broadcast 或 Handler 通信) */
private void handleImageFrame(byte[] data) {
// …(略,解码 Bitmap 并 post 到 SurfaceView)
}
/** 根据协议解析并 dispatchGesture */
private void handleTouchEvent(byte[] data) {
int action = data[0];
float x = bytesToFloat(data, 1);
float y = bytesToFloat(data, 5);
// long t = bytesToLong(data, 9);
Path path = new Path();
path.moveTo(x, y);
GestureDescription.StrokeDescription sd =
new GestureDescription.StrokeDescription(path, 0, 1);
dispatchGesture(new GestureDescription.Builder().addStroke(sd).build(),
null, null);
}
@Override
public void onInterrupt() {}
}
<!-- AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.screencast">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<application
android:allowBackup="true"
android:label="ScreenCast">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<service android:name=".RemoteControlService"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService"/>
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config"/>
</service>
</application>
</manifest>
<!-- activity_main.xml -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical" android:layout_width="match_parent"
android:layout_height="match_parent" android:gravity="center">
<Button
android:id="@+id/btn_start"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="开始屏幕共享"/>
<Button
android:id="@+id/btn_stop"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="停止服务"/>
<SurfaceView
android:id="@+id/surface_view"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</LinearLayout>
五、代码解读
-
MainActivity
-
请求并处理用户授权,创建并绑定
VirtualDisplay
; -
启动
ScreenShareService
负责捕获与发送; -
重写
onTouchEvent
,将触摸事件传给服务。
-
-
ScreenShareService
-
在后台线程中建立 TCP 连接;
-
循环从
ImageReader
获取帧,将其转为Bitmap
并压缩后通过 Socket 发送; -
监听主控端触摸事件,封装并发送事件帧。
-
-
RemoteControlService
-
作为无障碍服务启动,监听端口接收数据;
-
读取帧头与载荷,根据类型分发到图像处理或触摸处理;
-
触摸处理时使用
dispatchGesture
注入轨迹,实现远程控制。
-
-
布局与权限
-
在
AndroidManifest.xml
中声明必要权限与无障碍服务; -
activity_main.xml
简单布局包含按钮与SurfaceView
用于渲染。
-
六、项目总结
通过本项目,我们完整地实现了 Android 平台上两台设备的屏幕共享与远程控制功能,掌握并综合运用了以下关键技术:
-
MediaProjection API:原生屏幕捕获与虚拟显示创建;
-
Socket 编程:设计帧协议,实现高效、可靠的图像与事件双向传输;
-
图像编码/解码:将屏幕帧压缩为 JPEG,平衡清晰度与带宽;
-
无障碍服务:通过
dispatchGesture
注入触摸事件,完成远程控制; -
多线程处理:使用
HandlerThread
保证捕获、编码、传输等实时性,避免 UI 阻塞。
这套方案具备以下扩展方向:
-
音频同步:在屏幕共享同时传输麦克风或系统音频。
-
视频编解码优化:引入硬件 H.264 编码,以更低延迟和更高压缩率。
-
跨平台支持:在 iOS、Windows 等平台实现对应客户端。
-
安全性增强:加入 TLS/SSL 加密,防止中间人攻击;验证设备身份。