从零构建Android远程控制工具:MediaProjection与WebSocket实战

📅 发布时间:2026/7/15 7:43:54
从零构建Android远程控制工具:MediaProjection与WebSocket实战 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.0API 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包含CSDCodec 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表示控制消息文本JSON0x01表示视频关键帧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(timeout1) except Empty: continue # 解析协议头 (18413字节) 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:13data_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喂给解码器。 # 使用OpenCVGStreamer管道是一种方式。另一种方式是使用avcodecffmpeg-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_moveon_move, on_clickon_click) keyboard_listener keyboard.Listener(on_presson_press, on_releaseon_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, 正常关闭)后台服务如果希望屏幕共享在后台持续运行需要启动一个ForegroundServiceAndroid 9.0以上必须并在通知栏显示持续运行的通知否则进程容易被系统回收。8. 项目总结与扩展方向走完这一整套流程一个最基本的Android屏幕共享与远程控制原型就搭建起来了。核心在于理解MediaProjection将屏幕内容送到MediaCodec编码器再通过WebSocket管道将数据泵送到网络对端这个数据流。而反向控制则依赖于一个可靠的事件传输通道和Android端的事件注入机制实践中首选AccessibilityService。这个原型还有很多可以打磨和扩展的地方音频同步MediaProjection同样可以捕获系统音频Android 10MediaProjection.createAudioRecord()。可以增加音频捕获、编码AAC、传输和解码播放的流程实现音画同步。传输优化将WebSocket替换为WebRTC能获得更专业的实时通信能力包括更好的网络适应性和更低的延迟。可以使用libwebrtc库。安全加固目前的通信是明文的。对于敏感操作务必加入TLS/SSL加密WSS并对控制指令进行身份验证。跨平台控制端用Electron或Flutter编写一个跨平台的桌面控制端提供更好的用户界面集成文件传输、剪贴板同步等高级功能。云端信令与穿透实现一个简单的信令服务器帮助内网中的手机和电脑发现彼此并建立连接使用STUN/TURN穿越NAT。我个人在实现过程中最大的体会是延迟是感知最明显的指标。优化编码参数、实现智能丢帧、选择高效传输协议每一步的微小改进都能带来体验的提升。另一个深刻的教训是Android权限体系的严格想要实现“无感”的全局控制几乎不可能必须引导用户进行适当的权限配置如辅助功能在用户体验和系统安全之间找到平衡点。最后所有代码的核心片段都已在上文给出将它们有机组合并处理好生命周期和异常边界你就能拥有一个属于自己的、可深度定制的Android远程控制工具了。