基于i.MX RT1170与安卓的无线投屏交互系统实现详解
1. 项目概述与核心价值最近在折腾一个嵌入式项目需要把安卓手机上的画面实时投到一块基于恩智浦i.MX RT1170的开发板上并且还能反向用手机去控制开发板上的应用。听起来像是把手机投屏到电视的“增强版”但实际做下来发现这里面涉及的技术栈比想象中要深从安卓的图形捕获、视频编码到无线传输协议选型再到RT1170这种跨界MCU上的解码与显示每一步都有不少门道。这个方案的价值在于它打通了移动智能终端与高性能嵌入式设备之间的交互壁垒特别适合用于工业HMI远程监控、智能家居中控屏扩展、教育演示设备或者是一些需要移动设备作为富交互界面的嵌入式系统中。你不是简单地在看一个静态画面而是实现了一个低延迟、可交互的双向通道。我选择i.MX RT1170作为接收端看中的就是它“跨界”的特性一颗Cortex-M7内核主频高达1GHz外加一颗Cortex-M4协处理器同时它还集成了2D图形加速器PXP和视频编解码硬件加速模块。这意味着它既有MCU的实时性和低功耗又能处理通常需要应用处理器AP才能搞定的多媒体任务。用手机作为控制端则是利用了其强大的计算能力、丰富的传感器和成熟的无线连接能力。两者结合能做出很多有意思的产品原型。2. 整体方案设计与技术选型2.1 核心需求拆解与方案对比要实现“安卓到RT1170的无线投屏与控制”我们可以把它拆解成几个核心子任务画面捕获与编码安卓端获取手机屏幕内容并压缩成视频流。无线传输双向建立安卓端与RT1170端之间稳定、低延迟的数据通道。视频解码与显示RT1170端接收视频流解码并渲染到显示屏上。控制指令传输双向将手机上的触摸、按键等事件发送给RT1170并可能接收RT1170的状态反馈。对于无线传输协议常见的有几种选择Wi-Fi Direct点对点直连不依赖路由器延迟较低。但安卓和嵌入式端的协议栈支持和兼容性调试比较麻烦。传统TCP/UDP over Wi-Fi设备连接到同一个局域网AP。这种方式最通用网络栈成熟。延迟取决于网络状况但在一个不拥堵的局域网内完全可以接受。蓝牙不适合传输高码率的视频流仅适合传输控制指令。考虑到开发的便利性、通用性和性能我最终选择了基于Wi-Fi局域网Infrastructure模式的UDP传输方案。手机和RT1170开发板都连接到同一个无线路由器。视频流使用UDP协议传输容忍少量丢包以换取最低的延迟控制指令则使用可靠的TCP连接传输。这套方案的好处是网络部分非常标准无论是安卓的SocketAPI还是RT1170的LwIP协议栈都有成熟的示例和社区支持。2.2 安卓端技术栈选型安卓端是整个系统的“主播”。核心任务有两个录屏和编码。录屏从Android 5.0 (API 21) 开始系统提供了MediaProjectionAPI它可以捕获屏幕、音频并输出到Surface。这是最官方、最稳定的方式不需要root权限。我们需要申请录屏权限获取到一个VirtualDisplay将捕获的画面输出到我们提供的Surface上。编码MediaProjection捕获的原始YUV数据量巨大必须编码。这里我直接使用了安卓的MediaCodec硬件编码器。它的优势是效率高、功耗低。我们可以配置一个H.264编码器将Surface作为输入编码器就会自动从Surface中消费图像数据进行编码输出H.264码流Annex-B格式或AVCC格式。编码参数码率、帧率、关键帧间隔、Profile/Level需要根据RT1170解码器的能力和网络带宽仔细调优。注意MediaProjection会有一个系统弹窗告知用户正在录屏这是出于安全考虑无法绕过。你的应用需要妥善处理用户授权和授权令牌的保存。2.3 i.MX RT1170端技术栈选型RT1170端是“观众”兼“执行者”。它的任务更重网络接收使用LwIP协议栈创建UDP Socket接收视频流数据创建TCP Socket接收控制指令。视频解码这是性能关键。RT1170的H.264解码器硬件加速模块是首选。恩智浦的SDK如MCUXpresso SDK通常提供了基于这个硬件的解码器驱动或中间件例如可能集成在“NXP Media Stack”或相关演示中。我们需要将接收到的H.264码流可能需要重组包、处理NAL单元喂给解码器硬件。图像后处理与显示解码器输出通常是YUV格式的帧缓冲区。我们需要使用PXPPixel Pipeline2D加速器来将YUV转换为目标显示屏所需的RGB格式并且进行缩放、旋转等操作。最后通过LCDIF或MIPI DSI等显示控制器将最终图像刷新到屏幕上。控制指令解析与响应解析TCP通道传来的指令例如JSON格式的{“event”: “touch”, “x”: 100, “y”: 200}将其转化为对RT1170上运行的GUI应用如LVGL、Embedded Wizard的输入事件或者直接控制某个GPIO、执行某个函数。整个RT1170端的软件架构可以基于一个RTOS如FreeRTOS来构建创建多个任务分别处理网络、解码、显示和控制逻辑并通过消息队列等方式进行通信。3. 安卓端实现细节与核心代码3.1 录屏与编码的启动流程首先在AndroidManifest.xml中声明权限uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-feature android:nameandroid.software.screen_record /录屏本身不需要在Manifest声明权限但需要运行时动态申请。核心的启动步骤在Activity或Service中申请录屏权限private static final int REQUEST_CODE_SCREEN_CAPTURE 1; MediaProjectionManager projectionManager (MediaProjectionManager) getSystemService(Context.MEDIA_PROJECTION_SERVICE); startActivityForResult(projectionManager.createScreenCaptureIntent(), REQUEST_CODE_SCREEN_CAPTURE);处理授权结果Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); if (requestCode REQUEST_CODE_SCREEN_CAPTURE resultCode RESULT_OK) { mediaProjection projectionManager.getMediaProjection(resultCode, data); // 保存这个token以便在Service中重建MediaProjection startScreenCapture(mediaProjection); } }配置编码器并创建VirtualDisplayprivate void startScreenCapture(MediaProjection projection) { DisplayMetrics metrics getResources().getDisplayMetrics(); int width metrics.widthPixels; int height metrics.heightPixels; int dpi metrics.densityDpi; // 建议根据RT1170屏幕分辨率调整避免传输不必要的高分辨率数据 // width 800; height 480; // 1. 创建并配置H.264编码器 mediaCodec MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC); MediaFormat format MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height); format.setInteger(MediaFormat.KEY_BIT_RATE, 2000000); // 2 Mbps需根据网络调整 format.setInteger(MediaFormat.KEY_FRAME_RATE, 30); format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2); // 关键帧间隔2秒 format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); // 使用Baseline Profile保证兼容性 format.setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline); format.setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31); mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); Surface inputSurface mediaCodec.createInputSurface(); mediaCodec.start(); // 2. 创建VirtualDisplay将屏幕内容投射到编码器的Surface virtualDisplay projection.createVirtualDisplay( ScreenCapture, width, height, dpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, inputSurface, // 关键输出到编码器 null, null); // 3. 启动一个线程从编码器输出端循环获取编码后的数据 new Thread(new EncoderOutputThread()).start(); }3.2 编码数据获取与网络发送编码器输出数据是在另一个线程中异步进行的。我们需要实现EncoderOutputThreadprivate class EncoderOutputThread implements Runnable { private ByteBuffer[] outputBuffers; private MediaCodec.BufferInfo bufferInfo new MediaCodec.BufferInfo(); private DatagramSocket udpSocket; private InetAddress rt1170Address; public EncoderOutputThread() { try { udpSocket new DatagramSocket(); // RT1170开发板的IP地址需要预先知道或通过发现协议获取 rt1170Address InetAddress.getByName(192.168.1.100); } catch (Exception e) { e.printStackTrace(); } } Override public void run() { outputBuffers mediaCodec.getOutputBuffers(); while (!Thread.interrupted()) { int outputBufferId mediaCodec.dequeueOutputBuffer(bufferInfo, 10000); // 10ms超时 if (outputBufferId 0) { ByteBuffer outputBuffer outputBuffers[outputBufferId]; // bufferInfo.flags 包含了BUFFER_FLAG_CODEC_CONFIG编码器配置信息和BUFFER_FLAG_KEY_FRAME关键帧 // bufferInfo.presentationTimeUs 是时间戳 // 将编码数据复制出来 outputBuffer.position(bufferInfo.offset); outputBuffer.limit(bufferInfo.offset bufferInfo.size); byte[] encodedData new byte[bufferInfo.size]; outputBuffer.get(encodedData); // **关键步骤处理并发送数据** sendEncodedDataOverUDP(encodedData, bufferInfo.flags); mediaCodec.releaseOutputBuffer(outputBufferId, false); } else if (outputBufferId MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { // 编码器输出格式发生变化可以在这里获取新的MediaFormat如SPS/PPS信息 MediaFormat newFormat mediaCodec.getOutputFormat(); // 提取SPS和PPS并作为特殊的配置帧优先发送给RT1170 sendCodecConfigData(newFormat); } } } private void sendEncodedDataOverUDP(byte[] data, int flags) { try { // 简单的封装可以添加一个小的包头标识是否为关键帧、时间戳等 // 这里为了简化直接发送。注意UDP包有大小限制通常1500字节需要分片 if (data.length 1400) { // 预留IP/UDP头空间 // 实现分片逻辑为每个分片添加序号、总片数等信息 splitAndSend(data, flags); } else { DatagramPacket packet new DatagramPacket(data, data.length, rt1170Address, 5000); // 假设RT1170 UDP端口5000 udpSocket.send(packet); } } catch (Exception e) { e.printStackTrace(); } } }实操心得直接发送大的H.264 NAL单元很容易超过MTU导致IP分片降低效率且易丢包。更好的做法是在发送前进行RTP打包。RTP协议为实时流媒体设计有序列号、时间戳、负载类型等字段能更好地处理乱序、丢包和同步。安卓有android.net.rtp包但比较底层。对于原型可以自己实现一个简化的RTP头12字节包含序列号和时间戳这样RT1170端重组和缓冲会更可靠。3.3 控制指令的捕获与发送控制指令主要指触摸事件。我们在一个全屏的TextureView或SurfaceView上覆盖一个透明的View来捕获触摸事件controlOverlayView.setOnTouchListener(new View.OnTouchListener() { Override public boolean onTouch(View v, MotionEvent event) { int action event.getActionMasked(); float x event.getX(); float y event.getY(); // 将坐标归一化或转换为RT1170屏幕坐标 int targetX (int) (x / v.getWidth() * TARGET_SCREEN_WIDTH); int targetY (int) (y / v.getHeight() * TARGET_SCREEN_HEIGHT); // 构造JSON指令 JSONObject json new JSONObject(); try { json.put(event, touch); json.put(action, action); // MotionEvent.ACTION_DOWN, ACTION_MOVE, ACTION_UP json.put(x, targetX); json.put(y, targetY); json.put(pointerId, event.getPointerId(0)); } catch (JSONException e) { e.printStackTrace(); } // 通过TCP Socket发送 if (controlTcpSocket ! null controlTcpSocket.isConnected()) { new Thread(() - { try { OutputStream os controlTcpSocket.getOutputStream(); os.write((json.toString() \n).getBytes()); // 添加换行作为分隔符 os.flush(); } catch (IOException e) { e.printStackTrace(); } }).start(); } return true; // 消费事件 } });4. i.MX RT1170端实现细节4.1 系统初始化与任务划分RT1170端程序基于FreeRTOS和MCUXpresso SDK。主要创建以下几个任务Network Task负责初始化LwIP创建UDP和TCP Socket接收数据并将视频数据和控制数据分别放入不同的队列。Video Decoder Task从视频队列取数据喂给H.264硬件解码器将解码后的帧放入显示队列。Display Task从显示队列取YUV帧使用PXP转换为RGB并刷新到LCD。Control Task从控制指令队列取数据解析并触发本地GUI事件或执行控制动作。主函数初始化流程如下伪代码风格int main(void) { // 硬件初始化时钟、引脚、缓存等 BOARD_InitHardware(); // 初始化显示设备LCDIF/DSI, 背光等 DISPLAY_Init(); // 初始化视频解码硬件H.264 Decoder IP VIDEO_DECODER_Init(); // 初始化2D加速器PXP PXP_Init(); // 创建FreeRTOS任务 xTaskCreate(network_task, Net, configMINIMAL_STACK_SIZE 1024, NULL, 4, NULL); xTaskCreate(video_decoder_task, Dec, configMINIMAL_STACK_SIZE 2048, NULL, 5, NULL); // 解码任务优先级稍高 xTaskCreate(display_task, Disp, configMINIMAL_STACK_SIZE 1024, NULL, 3, NULL); xTaskCreate(control_task, Ctrl, configMINIMAL_STACK_SIZE 512, NULL, 2, NULL); vTaskStartScheduler(); while (1) {} }4.2 网络数据接收与分包处理network_task的核心是建立Socket并接收数据。由于UDP可能乱序、丢包我们需要一个简单的重组逻辑。void network_task(void *pvParameters) { struct netconn *udp_conn, *tcp_conn, *new_conn; struct netbuf *buf; ip_addr_t target_addr; err_t err; // 创建UDP Socket绑定端口5000 udp_conn netconn_new(NETCONN_UDP); netconn_bind(udp_conn, IP_ADDR_ANY, 5000); // 创建TCP Server Socket监听端口6000 tcp_conn netconn_new(NETCONN_TCP); netconn_bind(tcp_conn, IP_ADDR_ANY, 6000); netconn_listen(tcp_conn); // 接受TCP连接安卓控制端 netconn_accept(tcp_conn, new_conn); while (1) { // 接收UDP数据视频流 err netconn_recv(udp_conn, buf); if (err ERR_OK) { void *data netbuf_data(buf, NULL); u16_t len netbuf_len(buf); // 解析自定义的简单RTP头或分片头获取序列号、时间戳、帧类型等 // 将有效的视频数据包放入视频队列 video_queue if (is_video_packet_valid(data, len)) { xQueueSend(video_queue, packet_info, portMAX_DELAY); } netbuf_delete(buf); } // 接收TCP数据控制指令 err netconn_recv(new_conn, buf); if (err ERR_OK) { // 控制指令通常较小可能一次接收多条以换行分隔 process_control_data(buf); netbuf_delete(buf); } vTaskDelay(1 / portTICK_PERIOD_MS); // 短暂让出CPU } }注意事项网络接收任务不要做复杂的处理应尽快将数据放入队列让解码和显示任务去消费。视频队列建议使用xQueueSendToBack并设置足够大的长度如30个包以应对网络抖动。解码任务从队列取数据时可以根据序列号和时间戳进行排序和缓冲处理丢包和乱序。对于关键帧丢失可以请求重传或等待下一个关键帧在实现初期可以简单地丢弃直到下一个关键帧到来。4.3 H.264硬件解码与PXP显示这是RT1170端最核心也最依赖厂商SDK的部分。恩智浦通常提供解码器驱动API。流程大致如下解码器初始化与配置调用类似DECODER_Init()的函数并配置输入缓冲区格式、输出图像格式如YUV420半平面、分辨率等。喂数据将网络接收并重组好的H.264码流包含SPS/PPS/IDR Slice/P Slice等NAL单元放入解码器的输入缓冲区。需要处理start code0x00000001或0x000001。取帧查询解码器状态当一帧解码完成时从解码器的输出缓冲区获取YUV图像数据。PXP处理解码器输出的YUV数据需要转换成RGB才能显示。使用PXP模块可以高效完成色彩空间转换CSC和缩放。// 配置PXP进行YUV到RGB的转换 pxp_output_buffer_config_t outputConfig { .pixelFormat kPXP_OutputPixelFormatRGB565, // 匹配LCD格式 .interlacedMode kPXP_OutputProgressive, .buffer0Addr (uint32_t)rgb_buffer, .buffer1Addr 0, .pitchBytes LCD_WIDTH * 2, .width LCD_WIDTH, .height LCD_HEIGHT, }; PXP_SetOutputBufferConfig(PXP, outputConfig); pxp_ps_buffer_config_t psBufferConfig { .pixelFormat kPXP_PsPixelFormatYUV1P444, // 根据解码器输出格式调整 .swapByte false, .bufferAddr (uint32_t)yuv_buffer_from_decoder, .pitchBytes DECODED_WIDTH, }; PXP_SetProcessSurfaceBufferConfig(PXP, psBufferConfig); PXP_SetProcessSurfacePosition(PXP, 0, 0, DECODED_WIDTH, DECODED_HEIGHT); PXP_SetOutputWindowConfig(PXP, 0, 0, LCD_WIDTH, LCD_HEIGHT); // 启动PXP转换 PXP_Start(PXP); while (!(kPXP_CompleteFlag PXP_GetStatusFlags(PXP))) {} PXP_ClearStatusFlags(PXP, kPXP_CompleteFlag);刷新显示将PXP处理好的RGB数据缓冲区地址通过MEMCpy或DMA方式更新到LCD的帧缓冲区FrameBuffer。如果使用LVGL等GUI库可以将这个缓冲区设置为LVGL的显示缓冲区。4.4 控制指令解析与GUI事件注入控制任务从TCP队列中取出JSON字符串解析后转化为本地事件。如果使用LVGL可以这样模拟触摸void control_task(void *pvParameters) { char cmd_buffer[256]; while (1) { if (xQueueReceive(control_queue, cmd_buffer, portMAX_DELAY) pdTRUE) { cJSON *root cJSON_Parse(cmd_buffer); if (root) { cJSON *event cJSON_GetObjectItem(root, event); cJSON *action cJSON_GetObjectItem(root, action); cJSON *x cJSON_GetObjectItem(root, x); cJSON *y cJSON_GetObjectItem(root, y); if (cJSON_IsString(event) strcmp(event-valuestring, touch) 0) { lv_indev_data_t data; data.point.x x-valueint; data.point.y y-valueint; if (action-valueint MOTION_ACTION_DOWN) { data.state LV_INDEV_STATE_PR; } else if (action-valueint MOTION_ACTION_UP) { data.state LV_INDEV_STATE_REL; } else { data.state LV_INDEV_STATE_PR; // MOVE也视为按下状态 } // 将这个data传递给LVGL的输入设备驱动 your_lvgl_indev_read_callback(data); } cJSON_Delete(root); } } } }5. 性能调优与问题排查实录5.1 延迟分析与优化点整个链路的延迟 安卓端编码延迟 网络传输延迟 RT1170解码显示延迟。编码延迟主要来自MediaCodec的输入缓冲区队列和编码本身。使用Surface输入模式延迟较低。关键帧间隔不宜过小增加码率也不宜过大影响seek和丢包恢复。实测在720p30fps2Mbps码率下编码延迟通常在30-60ms。网络延迟在良好的5GHz Wi-Fi环境下局域网内UDP RTT可以做到5ms以下。但需注意路由器性能、信道干扰和广播风暴。优化方法使用UDP而非TCP视频流和服务质量QoS优先级发送端实现FEC前向纠错或重传关键帧。解码显示延迟这是优化的重点。RT1170的硬件解码器解码一帧720p H.264通常在10ms内。PXP转换和内存拷贝是瓶颈。一定要使用双缓冲甚至三缓冲当一个缓冲区正在解码时另一个缓冲区正在被PXP处理第三个缓冲区正在被LCD读取。使用DMA来搬运数据解放CPU。实测数据在优化后双缓冲、PXP硬件加速、轻量级网络协议从手机触摸到RT1170屏幕有视觉反馈整体延迟可以控制在100ms到150ms之间对于非精确操作如幻灯片控制、菜单选择已足够流畅。5.2 常见问题与解决方案速查表问题现象可能原因排查步骤与解决方案RT1170端花屏、绿屏、马赛克1. 视频流数据损坏网络丢包2. SPS/PPS未正确发送或接收3. 解码器初始化参数如Profile/Level不匹配4. 解码器输出缓冲区格式与PXP输入格式不匹配1. 检查网络信号在接收端打印接收到的包序列号查看丢包率。可尝试降低码率或分辨率。2. 确保安卓端在INFO_OUTPUT_FORMAT_CHANGED时将包含SPS/PPS的MediaFormat作为首个“配置帧”发送。RT1170端在收到首个关键帧前必须先配置解码器。3. 确认安卓端编码配置Baseline Profile, Level 3.1在RT1170解码器支持范围内。4. 仔细核对解码器输出的YUV格式如NV12/YUV420SP与PXP配置的输入格式是否完全一致。延迟巨大500ms1. 编码帧率或码率设置过高2. RT1170端缓冲区队列堆积3. 未使用硬件加速4. 显示刷新率低1. 降低编码分辨率和帧率如480p15fps。2. 检查video_queue长度如果持续满队说明解码任务处理不过来。提高解码任务优先级或优化解码/显示流程。3. 确认MediaCodec和RT1170解码器都使用了硬件加速。4. 确保LCD刷新率配置正确如60Hz并检查帧缓冲区更新是否及时。控制指令响应慢或不响应1. TCP连接阻塞或断开2. 控制任务优先级过低3. JSON解析耗时过长4. 坐标映射错误1. 添加TCP心跳包并处理断线重连。2. 适当提高control_task的FreeRTOS优先级。3. 使用更高效的解析方法如sscanf解析简单格式或确保JSON解析库如cJSON不开启动态内存分配。4. 在安卓和RT1170端打印坐标日志确认映射公式正确。安卓端应用卡顿或发热严重1. 编码参数过高手机算力不足2. 网络发送线程阻塞3. 未及时释放MediaCodec缓冲区1. 选择更均衡的编码预设如MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline。2. 确保网络发送在独立线程且UDPsend操作不阻塞主线程。3. 在EncoderOutputThread中取出数据后立即调用mediaCodec.releaseOutputBuffer。RT1170运行一段时间后死机1. 内存泄漏队列、缓冲区未释放2. 堆栈溢出3. 中断冲突或硬件访问冲突1. 使用FreeRTOS的堆栈溢出检测工具检查各任务堆栈使用情况并适当增加。2. 确保netbuf_delete、cJSON_Delete等资源释放函数被正确调用。3. 检查PXP、解码器、LCDIF等外设的中断优先级配置避免嵌套中断或长时间关中断。5.3 独家避坑技巧关键帧请求当RT1170端检测到持续花屏可能因为丢包导致参考帧错误可以通过TCP控制通道反向发送一个“请求关键帧”的指令。安卓端收到后可以在编码时通过MediaCodec.setParameters方法设置一个关键帧请求标志KEY_REQUEST_SYNC_FRAME让编码器尽快生成一个IDR帧。这比等待固定关键帧间隔能更快恢复画面。动态码率调整可以在安卓端监控发送队列的堆积情况或接收RT1170端反馈的网络状态如丢包率动态调整MediaCodec的码率。网络好时用高码率保证清晰度网络差时降低码率保证流畅性。PXP的“零拷贝”优化如果解码器输出的物理地址和LCD帧缓冲区的物理地址都位于PXP可访问的内存空间如SEMC SDRAM可以尝试配置PXP直接读取解码器的输出缓冲区进行转换并将结果直接写入LCD帧缓冲区避免一次额外的CPU内存拷贝。这需要对内存管理和缓存一致性有较深理解。使用RT1170的M4核心可以考虑将网络接收、协议解析等对实时性要求高但计算量不大的任务放在Cortex-M4核心上运行而将解码、显示等重计算任务放在M7核心上。利用双核优势进一步降低整体延迟。这需要仔细设计双核间的通信机制如使用RPMSG。