1. 项目概述:从零构建一个Android远程控制工具
最近在折腾一个挺有意思的东西:如何把安卓手机的屏幕实时共享到电脑上,并且还能用鼠标键盘反向控制手机。市面上虽然有向日葵、TeamViewer这类成熟方案,但作为一个开发者,我更想搞清楚背后的技术原理,并且打造一个完全可控、可定制的“轮子”。这个需求在远程协助、游戏投屏、甚至是一些自动化测试场景下都挺有用。
核心的技术栈其实很明确:在Android端,我们需要MediaProjection API来捕获屏幕画面;为了将捕获到的视频流高效、低延迟地传输出去,我们选择WebSocket作为通信协议;最后,在接收端(比如一个桌面应用或网页)解析视频流并渲染,同时将鼠标键盘事件通过同一个WebSocket通道回传给手机,完成闭环控制。听起来流程清晰,但每一步都有不少坑要踩,比如权限处理、编码延迟、网络抖动、事件同步等等。
这篇文章,我就把自己从零搭建这个项目的完整过程、核心代码以及踩过的那些坑,毫无保留地分享出来。无论你是想学习Android底层图形捕获、探索实时音视频传输,还是单纯想做一个属于自己的远程控制工具,相信都能从中找到可复用的代码和思路。我们会从最基础的权限申请和屏幕捕获开始,一步步走到双向通信和事件处理,最终呈现一个可运行的原型。
2. 核心思路与技术选型解析
2.1 为什么是MediaProjection + WebSocket?
在Android上捕获屏幕,主流方案有几种:adb screencap命令、SurfaceView录屏、以及MediaProjectionAPI。adb screencap效率太低,且依赖USB连接,不适合实时流。SurfaceView录屏更偏向于应用内录制。而MediaProjection是Android 5.0(API 21)引入的官方API,它允许应用在获得用户明确授权(弹窗确认)后,捕获整个屏幕或指定应用的画面,并输出为ImageReader的Image对象或编码到MediaCodec,这是实现高效屏幕共享的基石。
传输协议的选择上,我们排除了传统的HTTP轮询(延迟高、开销大),也暂时不考虑更复杂的RTP/RTSP(虽然更适合流媒体,但实现复杂度高)。WebSocket成为了一个平衡点:它基于TCP,提供全双工通信,连接建立后即可持续、低开销地双向传输数据,非常适合我们“视频流下行 + 控制事件上行”的场景。虽然理论上不如UDP的延迟低,但在大多数局域网或良好网络环境下,其延迟已经可以满足远程操作的体验。
整个架构的流程图可以这样理解:
- Android端(Sender):启动
MediaProjection捕获屏幕 -> 将原始帧(Image)进行压缩编码(如H.264) -> 通过WebSocket连接发送编码后的数据包。 - 控制端(Receiver,如PC):建立WebSocket连接接收数据包 -> 解码视频帧 -> 渲染到UI(如Canvas)。
- 反向控制:控制端捕获鼠标移动、点击、键盘按键等事件 -> 将这些事件数据封装成特定格式(如JSON) -> 通过同一个WebSocket连接上行发送给Android端 -> Android端解析并模拟输入(使用
Instrumentation或InputManager)。
2.2 关键挑战与应对策略
低延迟是远程控制的灵魂。延迟主要来自四个环节:捕获延迟、编码延迟、网络传输延迟、解码渲染延迟。
- 捕获延迟:
MediaProjection本身很快,但获取到的Image是YUV格式的原始数据,数据量巨大(例如1080p的一帧约6MB),直接传输是不可能的。因此,我们必须进行编码压缩。 - 编码延迟:这是最大的延迟来源之一。使用软件编码器(如
MediaCodec配置为软件编码)虽然兼容性好,但CPU占用高,延迟大。我们的策略是优先启用硬件编码器。Android设备普遍支持H.264/H.265的硬件编码,通过MediaCodec调用,能极大降低CPU负载和编码耗时。 - 网络延迟:WebSocket over TCP已经不错,但我们还可以优化。一是控制编码后的帧大小,通过调节编码参数(如码率、关键帧间隔);二是实现简单的丢帧策略,当网络拥塞时,优先丢弃非关键帧(P帧、B帧),只保证关键帧(I帧)的传输,避免缓冲区堆积导致延迟越来越高。
- 解码渲染延迟:在PC端,我们可以利用硬件加速解码(如FFmpeg + GPU)来快速解码H.264流。
另一个挑战是事件同步。鼠标在电脑屏幕上移动,这个坐标需要映射到手机屏幕上。由于电脑和手机屏幕分辨率、比例不同,需要进行坐标转换。通常的公式是:手机X坐标 = (电脑鼠标X / 电脑视图宽度) * 手机屏幕宽度。同时,还需要处理触摸事件(ACTION_DOWN,ACTION_MOVE,ACTION_UP)的序列模拟,确保操作的连贯性。
3. Android端实现:屏幕捕获与编码
3.1 申请MediaProjection权限与初始化
这是第一步,也是必须由用户交互完成的一步。我们不能静默开启录屏。
// 在Activity或Fragment中 private val REQUEST_CODE_SCREEN_CAPTURE = 1 fun startScreenCapture() { val mediaProjectionManager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager val captureIntent = mediaProjectionManager.createScreenCaptureIntent() startActivityForResult(captureIntent, REQUEST_CODE_SCREEN_CAPTURE) } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == REQUEST_CODE_SCREEN_CAPTURE) { if (resultCode == Activity.RESULT_OK && data != null) { val mediaProjection = mediaProjectionManager.getMediaProjection(resultCode, data) // 保存这个mediaProjection,它是我们后续所有操作的核心句柄 startScreenStreaming(mediaProjection) } else { // 用户拒绝了权限 Toast.makeText(this, "屏幕捕获权限被拒绝", Toast.LENGTH_SHORT).show() } } }注意:这个权限弹窗只会出现一次。一旦用户授权,只要不卸载应用,下次就可以直接用
MediaProjection对象(需要保存resultCode和data)来重新创建,无需再次弹窗。但最佳实践是每次启动都检查并重新申请,以应对系统或用户权限变更。
3.2 配置MediaCodec硬件编码器
拿到MediaProjection后,我们需要创建一个虚拟显示(VirtualDisplay),将屏幕内容投射到MediaCodec的输入表面(Surface)上进行编码。
private fun startScreenStreaming(mediaProjection: MediaProjection) { val screenWidth = resources.displayMetrics.widthPixels val screenHeight = resources.displayMetrics.heightPixels val screenDpi = resources.displayMetrics.densityDpi // 1. 创建MediaCodec编码器(H.264 AVC) val encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC) val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, screenWidth, screenHeight) // 关键参数配置 format.setInteger(MediaFormat.KEY_BIT_RATE, 3_000_000) // 码率,影响清晰度和带宽。1080p下3Mbps是个平衡点。 format.setInteger(MediaFormat.KEY_FRAME_RATE, 30) // 帧率 format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2) // 关键帧间隔(秒),影响seek和抗丢包,但I帧体积大。 format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface) // 必须为Surface // 尝试配置硬件编码器(通常系统默认就是,但可以明确指定) try { // 查找编码器信息,优先选择硬件编码器 val codecInfo = findHardwareEncoder(MediaFormat.MIMETYPE_VIDEO_AVC) if (codecInfo != null) { val codecName = codecInfo.name encoder = MediaCodec.createByCodecName(codecName) } } catch (e: Exception) { Log.w(TAG, "无法创建硬件编码器,使用默认编码器", e) } encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) val inputSurface = encoder.createInputSurface() // 获取编码器的输入Surface encoder.start() // 2. 创建VirtualDisplay,将屏幕内容投射到编码器的Surface val virtualDisplay = mediaProjection.createVirtualDisplay( "ScreenCapture", screenWidth, screenHeight, screenDpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, inputSurface, // 关键:这里传入编码器的Surface null, null ) // 保存virtualDisplay和encoder引用,用于后续停止释放资源 this.virtualDisplay = virtualDisplay this.encoder = encoder // 3. 启动一个线程,循环从编码器输出缓冲区获取编码后的数据(H.264 NAL单元) startEncoderOutputThread() }findHardwareEncoder是一个辅助函数,用于遍历系统编解码器,找出名称包含"OMX."前缀(通常是硬件编解码器)且支持COLOR_FormatSurface的编码器。
3.3 编码数据提取与WebSocket发送
编码器启动后,它会不断将Surface上的图像编码成H.264码流。我们需要在另一个线程中循环取出这些数据。
private fun startEncoderOutputThread() { thread { val bufferInfo = MediaCodec.BufferInfo() while (isStreaming) { // 等待编码器输出缓冲区可用,超时时间设为10ms,避免长时间阻塞。 val outputBufferId = encoder.dequeueOutputBuffer(bufferInfo, 10000L) when { outputBufferId >= 0 -> { val outputBuffer = encoder.getOutputBuffer(outputBufferId) outputBuffer?.let { buffer -> // bufferInfo.flags 标识帧类型,如 BUFFER_FLAG_KEY_FRAME(关键帧) if (bufferInfo.size > 0) { // 将数据读取到字节数组 buffer.position(bufferInfo.offset) buffer.limit(bufferInfo.offset + bufferInfo.size) val data = ByteArray(bufferInfo.size) buffer.get(data) // 此处是关键:将H.264数据通过WebSocket发送出去 sendVideoData(data, bufferInfo.flags) } encoder.releaseOutputBuffer(outputBufferId, false) } } outputBufferId == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { // 编码器输出格式发生变化(通常只在第一次),可以获取新的MediaFormat,包含CSD(Codec Specific Data,即SPS/PPS) val newFormat = encoder.outputFormat val csdBuffer = ByteBuffer.allocate(1024) // 提取SPS和PPS,这些信息对于解码器初始化至关重要,需要在发送视频数据前先发送。 sendCodecSpecificData(newFormat) } // INFO_TRY_AGAIN_LATER 和 INFO_OUTPUT_BUFFERS_CHANGED 处理略... } } } } private fun sendVideoData(data: ByteArray, flags: Int) { // 简单封装:将数据和帧类型标识一起发送 // 可以设计一个简单的协议头,例如 [帧类型(1 byte)][数据长度(4 bytes)][数据...] if (webSocketClient != null && webSocketClient.isOpen) { val packet = assembleVideoPacket(data, flags) webSocketClient.send(packet) } else { // WebSocket未连接,可以考虑丢弃帧或缓存关键帧 if (flags and MediaCodec.BUFFER_FLAG_KEY_FRAME != 0) { // 缓存关键帧,待连接建立后立即发送,帮助解码器快速恢复画面。 lastKeyFrame = data } } }实操心得:
MediaCodec的dequeueOutputBuffer超时时间不宜设置过长,我通常设为10ms。这样既能及时取到数据,又不会过度占用CPU。另外,SPS/PPS(序列参数集和图像参数集)是H.264解码必需的元信息,它们包含在MediaCodec的outputFormat中。必须在发送任何视频帧之前,将这些信息发送给接收端,否则解码器无法初始化。我通常会在INFO_OUTPUT_FORMAT_CHANGED时提取并立即发送。
4. 网络传输层:WebSocket连接与协议设计
4.1 建立稳定的WebSocket连接
Android端我们使用okhttp库的WebSocket实现,它稳定且易于集成。
// 添加依赖:implementation 'com.squareup.okhttp3:okhttp:4.x.x' private fun connectWebSocket(serverUrl: String) { val client = OkHttpClient.Builder() .pingInterval(20, TimeUnit.SECONDS) // 设置心跳保活,防止连接被中间设备断开 .build() val request = Request.Builder() .url(serverUrl) // 例如 "ws://192.168.1.100:8080/screen" .build() webSocketClient = client.newWebSocket(request, object : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { Log.d(TAG, "WebSocket连接已打开") // 连接建立后,立即发送缓存的SPS/PPS和关键帧(如果有) sendCodecSpecificDataIfNeeded() lastKeyFrame?.let { sendVideoData(it, MediaCodec.BUFFER_FLAG_KEY_FRAME) lastKeyFrame = null } } override fun onMessage(webSocket: WebSocket, text: String) { // 接收来自控制端的文本消息,通常是控制事件(JSON格式) handleControlEvent(text) } override fun onMessage(webSocket: WebSocket, bytes: ByteString) { // 如果双向传输二进制数据(如音频),可以在这里处理 } override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { Log.d(TAG, "WebSocket连接正在关闭: $code, $reason") } override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { Log.d(TAG, "WebSocket连接已关闭") // 可以尝试重连 scheduleReconnect() } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { Log.e(TAG, "WebSocket连接失败", t) scheduleReconnect() } }) }4.2 设计简单的应用层协议
直接发送原始的H.264 NAL单元虽然可以,但不利于接收端解析和同步。我们需要一个简单的封装协议。这里设计一个极简的二进制协议:
+---------------+---------------+---------------+----------------+ | 帧类型 (1) | 时间戳 (8) | 数据长度 (4) | 数据 (N) | +---------------+---------------+---------------+----------------+- 帧类型 (1 byte):0x00表示控制消息(文本JSON),0x01表示视频关键帧(I帧),0x02表示视频非关键帧(P帧),0x03表示编码器特定数据(SPS/PPS)。
- 时间戳 (8 bytes, long):该帧的呈现时间戳(PTS),用于接收端音画同步(本例只有视频,可用于计算延迟和帧率)。
- 数据长度 (4 bytes, int):后面跟随的实际数据长度。
- 数据 (N bytes):具体的视频NAL单元数据或控制事件JSON字符串的字节。
在Android发送端,我们需要在sendVideoData函数中完成封装:
private fun assembleVideoPacket(data: ByteArray, flags: Int): ByteArray { val frameType = when { flags and MediaCodec.BUFFER_FLAG_KEY_FRAME != 0 -> 0x01.toByte() else -> 0x02.toByte() } val timestamp = System.currentTimeMillis() // 简单使用当前时间,更应用系统时钟 val dataSize = data.size val buffer = ByteBuffer.allocate(1 + 8 + 4 + dataSize) buffer.put(frameType) buffer.putLong(timestamp) buffer.putInt(dataSize) buffer.put(data) return buffer.array() }对于控制事件(从PC到手机),我们使用文本WebSocket消息,格式为JSON,例如:
{"type": "touch", "action": "down", "x": 500, "y": 300} {"type": "key", "keycode": 66} // 回车键5. 控制端实现:视频解码与事件回传
控制端可以用任何支持WebSocket和H.264解码的语言实现,这里以Python(使用websockets库和opencv)为例,展示核心逻辑。实际生产环境可能会用C++/Qt或Electron以获得更好性能。
5.1 建立WebSocket服务器并接收数据
# server.py import asyncio import websockets import json import struct from queue import Queue import threading # 全局队列,用于将网络线程接收的数据传递给解码渲染线程 video_data_queue = Queue() control_event_queue = Queue() # 用于接收控制事件(本例中未使用) async def handle_client(websocket, path): print(f"客户端连接: {websocket.remote_address}") try: async for message in websocket: if isinstance(message, bytes): # 二进制消息,是视频数据包 video_data_queue.put(message) elif isinstance(message, str): # 文本消息,是控制事件 try: event = json.loads(message) control_event_queue.put(event) except json.JSONDecodeError: print(f"无法解析控制事件: {message}") except websockets.exceptions.ConnectionClosed: print("客户端断开连接") async def main(): async with websockets.serve(handle_client, "0.0.0.0", 8765): print("WebSocket服务器启动在 ws://0.0.0.0:8765") await asyncio.Future() # 永久运行 if __name__ == "__main__": asyncio.run(main())5.2 解析协议并解码H.264流
我们需要另一个线程从video_data_queue中取出数据,解析协议头,并根据帧类型处理。
# decoder_thread.py import cv2 import numpy as np import struct from queue import Empty def decode_h264_stream(): # 初始化OpenCV的H.264解码器 # 注意:OpenCV默认可能不包含H.264解码,需要从源码编译或使用GStreamer后端。 # 这里假设环境已配置好。 decoder = cv2.VideoCapture() # 我们需要先收到SPS/PPS来初始化解码器。这里简化处理,假设第一包就是SPS/PPS。 sps_pps_received = False sps = None pps = None while True: try: packet = video_data_queue.get(timeout=1) except Empty: continue # 解析协议头 (1+8+4=13字节) if len(packet) < 13: continue frame_type = packet[0] timestamp = struct.unpack('>Q', packet[1:9])[0] # 大端序 data_size = struct.unpack('>I', packet[9:13])[0] data = packet[13:13+data_size] if frame_type == 0x03: # SPS/PPS # 解析SPS和PPS,这里简化处理,直接拼接成extradata供解码器初始化 # 实际应按照H.264规范解析NAL单元 (0x00 0x00 0x00 0x01 + NAL) print("收到编码器特定数据") # 此处应保存sps和pps,并在初始化VideoCapture时设置 # 例如:`decoder.open('appsrc ! h264parse ! avdec_h264 ! videoconvert ! appsink', cv2.CAP_GSTREAMER)` sps_pps_received = True elif frame_type in (0x01, 0x02): # I帧或P帧 if not sps_pps_received: print("错误:未收到SPS/PPS,无法解码") continue # 将数据包装成H.264 Annex B格式(添加起始码 0x00 0x00 0x00 0x01) nal_unit = b'\x00\x00\x00\x01' + data # 这里需要将nal_unit喂给解码器。 # 使用OpenCV+GStreamer管道是一种方式。另一种方式是使用`avcodec`(ffmpeg-python)。 # 以下是一个概念性示例,实际实现更复杂: # feed_data_to_decoder(nal_unit) # 假设我们通过某种方式获得了解码后的帧 `frame` (numpy array) # cv2.imshow('Remote Screen', frame) # if cv2.waitKey(1) & 0xFF == ord('q'): # break decoder.release() cv2.destroyAllWindows()重要提示:在实际项目中,H.264解码是复杂的一环。在PC端,更推荐使用FFmpeg库(如通过
ffmpeg-python绑定)或GStreamer进行软/硬件解码。它们对H.264的流处理更完善。上面的OpenCV示例仅作流程示意。
5.3 捕获输入事件并回传
控制端需要捕获鼠标和键盘事件,并将其转换为约定的JSON格式,通过WebSocket发送回Android端。
# 以Python的pynput库为例捕获全局输入事件 from pynput import mouse, keyboard import json import asyncio import websockets async def send_control_event(event_dict): # 假设已经有一个到Android的WebSocket连接 `control_ws` if control_ws and control_ws.open: await control_ws.send(json.dumps(event_dict)) def on_move(x, y): # 坐标需要根据PC显示窗口和手机屏幕分辨率进行缩放映射 # pc_width, pc_height = window_size # phone_width, phone_height = 1080, 2340 (从SPS/PPS或初始握手获取) # scaled_x = int(x / pc_width * phone_width) # scaled_y = int(y / pc_height * phone_height) asyncio.run(send_control_event({'type': 'move', 'x': x, 'y': y})) def on_click(x, y, button, pressed): action = 'down' if pressed else 'up' button_str = 'left' if button == mouse.Button.left else 'right' asyncio.run(send_control_event({'type': 'touch', 'action': action, 'button': button_str, 'x': x, 'y': y})) def on_press(key): try: keycode = key.vk if hasattr(key, 'vk') else None # 需要将PC键码映射到Android KeyEvent键码 android_keycode = map_keycode(keycode) if android_keycode: asyncio.run(send_control_event({'type': 'key', 'action': 'down', 'keycode': android_keycode})) except AttributeError: pass def on_release(key): # 类似on_press,发送key up事件 pass # 启动监听 mouse_listener = mouse.Listener(on_move=on_move, on_click=on_click) keyboard_listener = keyboard.Listener(on_press=on_press, on_release=on_release) mouse_listener.start() keyboard_listener.start()6. Android端事件注入:模拟触摸与按键
当Android端通过WebSocket收到控制事件后,需要将其转换为系统的输入事件。这里有两个主要方法:
6.1 使用Instrumentation(需系统权限或APP为Instrumentation测试目标)
Instrumentation通常用于测试,它可以在APP内部模拟输入。但在非测试环境下,普通APP无法直接使用Instrumentation发送事件到其他应用或系统界面。不过,如果你的目标就是控制自己的应用,或者设备已root/具有特定权限,可以使用。
private fun injectTouchEvent(x: Int, y: Int, action: Int) { val downTime = SystemClock.uptimeMillis() val eventTime = SystemClock.uptimeMillis() val event = MotionEvent.obtain( downTime, eventTime, action, // MotionEvent.ACTION_DOWN, ACTION_MOVE, ACTION_UP x.toFloat(), y.toFloat(), 0 // metaState ) try { // 这行代码在普通APP中会抛出 SecurityException Instrumentation().sendPointerSync(event) } catch (e: SecurityException) { Log.e(TAG, "无权限使用Instrumentation注入事件", e) } }6.2 使用InputManager与反射(需要INJECT_EVENTS权限)
更通用的方法是使用InputManager的injectInputEvent方法。但这需要应用持有android.permission.INJECT_EVENTS权限,该权限是系统签名(signature)级别或**系统应用(system app)**才能获取的。对于非系统应用,几乎不可能。在已root的设备上,可以通过su命令临时提升权限来执行。
private fun injectInputEvent(event: InputEvent) { try { val inputManager = context.getSystemService(Context.INPUT_SERVICE) as InputManager val method = inputManager.javaClass.getMethod("injectInputEvent", InputEvent::class.java, Int::class.javaPrimitiveType) // 第二个参数是注入模式,例如 InputManager.INJECT_INPUT_EVENT_MODE_ASYNC method.invoke(inputManager, event, 2) } catch (e: Exception) { Log.e(TAG, "注入输入事件失败", e) } }重要警告:上述两种方法对于非系统、未root的普通应用均不可行。这是Android系统出于安全考虑的限制。因此,一个纯粹的第三方APP,无法实现真正的全局远程控制(像TeamViewer那样)。这也是许多类似工具需要用户开启“辅助功能(Accessibility Service)”或者“投影(投射屏幕)”权限的原因。
6.3 实际可行的方案:辅助功能服务(AccessibilityService)
这是实现远程控制最可行的无root方案。辅助功能服务本来是为帮助残障人士而设计,但它能监听全局事件、模拟特定操作(如点击、滑动)。许多远程控制软件都利用了这个服务。
- 创建AccessibilityService:在
AndroidManifest.xml中声明服务,并配置其可接收的事件类型。 - 在服务中处理事件:当收到控制端发来的
{'type':'touch', 'action':'down', 'x':500, 'y':300}时,使用GestureDescription来模拟点击。// 在AccessibilityService子类中 fun performClick(x: Int, y: Int) { val clickPath = Path().apply { moveTo(x.toFloat(), y.toFloat()) } val clickGesture = GestureDescription.Builder() .addStroke(GestureDescription.StrokeDescription(clickPath, 0, 10)) // 立即开始,持续10ms .build() dispatchGesture(clickGesture, null, null) } - 权限:用户需要在系统设置中手动开启该服务的辅助功能权限。这比系统签名权限容易获取得多。
因此,完整的远程控制Android APP,其事件注入部分应基于AccessibilityService实现。本文为聚焦核心流媒体技术,事件注入部分仅作原理性介绍,具体AccessibilityService的实现需另辟章节详述。
7. 性能优化与常见问题排查
7.1 降低延迟与提升流畅度
- 关键帧间隔与码率权衡:
KEY_I_FRAME_INTERVAL设置得越小,关键帧越多,网络丢包后恢复越快,但I帧体积大,瞬时带宽高。在局域网可设为5-10秒,在移动网络建议2-5秒。码率(KEY_BIT_RATE)直接影响清晰度和带宽,需要根据网络状况动态调整(但MediaCodec动态调整码率在部分设备上支持不好)。 - 分辨率与帧率下调:如果延迟过高,可以考虑降低
VirtualDisplay创建时的分辨率(如720p)和编码帧率(如15fps)。这是最直接有效的手段。 - 编码器Profile和Level:使用
MediaFormat.KEY_PROFILE和MediaFormat.KEY_LEVEL设置更低的配置(如Profile.Baseline),可以减少编码复杂度,降低延迟。 - 发送缓冲区管理:在WebSocket发送前,检查发送队列长度。如果队列过长,说明网络发送速度跟不上编码速度,此时应主动丢弃非关键帧(P帧),只保留关键帧(I帧),直到队列恢复正常。
- 使用更高效的传输:对于追求极限延迟的场景,可以考虑用WebRTC替代WebSocket。WebRTC内置了抗丢包(NACK、FEC)、拥塞控制、以及更低的传输延迟(基于UDP的SRTP/RTCP),但实现复杂度陡增。
7.2 常见问题与解决方案速查表
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 连接建立后,接收端黑屏或绿屏 | 1. SPS/PPS未发送或发送时机不对。 2. 接收端解码器初始化失败。 3. H.264码流不符合Annex B格式。 | 1. 确保在INFO_OUTPUT_FORMAT_CHANGED时第一时间发送SPS/PPS。2. 在接收端打印/log解码器初始化错误信息。使用FFmpeg命令行工具( ffplay -i pipe:0)测试接收到的原始流是否能播放。3. 确保发送给解码器的数据以 0x00 0x00 0x00 0x01作为NAL单元分隔符。 |
| 画面卡顿,延迟越来越高 | 1. 编码延迟大(CPU占用高)。 2. 网络发送阻塞。 3. 接收端解码或渲染慢。 | 1. 检查是否使用了硬件编码器。监控手机CPU使用率。 2. 实现发送队列监控和丢帧策略(优先丢P帧)。 3. 在PC端,使用性能分析工具查看解码线程CPU占用。考虑使用硬件解码(如CUDA、QuickSync)。 |
| 鼠标控制位置不准 | 坐标映射计算错误。 | 1. 确保获取了正确的手机屏幕分辨率(应从DisplayMetrics或编码格式中获取,而非写死)。2. PC端显示视频的窗口大小可能与原始视频分辨率不同,映射公式应为: phoneX = (pcX / pcViewWidth) * phoneScreenWidth。 |
| WebSocket频繁断开 | 1. 网络不稳定。 2. 中间设备(路由器、防火墙)断开了空闲长连接。 | 1. 实现心跳包(Ping/Pong)和自动重连机制。OkHttp的pingInterval已设置。2. 在服务器端(控制端)也设置心跳保活。 |
| 在某些设备上无法启动录屏 | 1. 设备不支持MediaProjection(极老设备)。2. 与其他录屏应用冲突。 3. 权限被其他应用占用。 | 1. 检查API Level >= 21。 2. 提示用户关闭其他录屏或投屏软件。 3. 尝试在 onPause/onStop时正确释放MediaProjection和VirtualDisplay资源。 |
7.3 内存与资源管理
- 及时释放
Image:如果使用ImageReader方式获取帧(本文未展开),务必在onImageAvailable回调中调用image.close(),否则会迅速耗尽内存。 - 停止时清理:在Activity/Fragment的
onDestroy或停止共享时,必须按顺序释放资源:virtualDisplay?.release() encoder?.stop() encoder?.release() mediaProjection?.stop() webSocketClient?.close(1000, "正常关闭") - 后台服务:如果希望屏幕共享在后台持续运行,需要启动一个
ForegroundService(Android 9.0以上必须),并在通知栏显示持续运行的通知,否则进程容易被系统回收。
8. 项目总结与扩展方向
走完这一整套流程,一个最基本的Android屏幕共享与远程控制原型就搭建起来了。核心在于理解MediaProjection将屏幕内容送到MediaCodec编码器,再通过WebSocket管道将数据泵送到网络对端这个数据流。而反向控制则依赖于一个可靠的事件传输通道和Android端的事件注入机制(实践中首选AccessibilityService)。
这个原型还有很多可以打磨和扩展的地方:
- 音频同步:
MediaProjection同样可以捕获系统音频(Android 10+,MediaProjection.createAudioRecord())。可以增加音频捕获、编码(AAC)、传输和解码播放的流程,实现音画同步。 - 传输优化:将WebSocket替换为WebRTC,能获得更专业的实时通信能力,包括更好的网络适应性和更低的延迟。可以使用
libwebrtc库。 - 安全加固:目前的通信是明文的。对于敏感操作,务必加入TLS/SSL加密(WSS),并对控制指令进行身份验证。
- 跨平台控制端:用
Electron或Flutter编写一个跨平台的桌面控制端,提供更好的用户界面,集成文件传输、剪贴板同步等高级功能。 - 云端信令与穿透:实现一个简单的信令服务器,帮助内网中的手机和电脑发现彼此并建立连接(使用STUN/TURN穿越NAT)。
我个人在实现过程中最大的体会是,延迟是感知最明显的指标。优化编码参数、实现智能丢帧、选择高效传输协议,每一步的微小改进都能带来体验的提升。另一个深刻的教训是Android权限体系的严格,想要实现“无感”的全局控制几乎不可能,必须引导用户进行适当的权限配置(如辅助功能),在用户体验和系统安全之间找到平衡点。
最后,所有代码的核心片段都已在上文给出,将它们有机组合,并处理好生命周期和异常边界,你就能拥有一个属于自己的、可深度定制的Android远程控制工具了。