十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android手机远程监控源码解析:MJPEG流实现与调优

Android手机远程监控源码解析:MJPEG流实现与调优 简介『Android应用源码之手机远程监控.zip』是一份面向Android初中级开发者的示例工程演示如何通过手机实现远程查看和控制设备。源码基于Java编写在Android Studio中构建涵盖网络通信、多媒体采集与解码、权限管理及UI交互等核心环节适合正在学习Android网络编程或想要快速搭建监控类应用的读者。压缩包共包含68个文件其中以java源代码、class编译文件、xml配置与布局、apk安装包为主另有项目工程文件与说明文档整体大小仅146KB结构精简便于逐文件分析。目前已有337人学习下载。通过研读源码可梳理远程监控的完整实现链路从AndroidManifest权限声明到SurfaceView视频流展示、Handler异步消息处理再到网络请求与数据加密等关键细节能够直接借鉴其工程组织方式减少自行摸索的时间成本。1. 手机远程监控源码包到底在解决什么问题手机远程监控源码包解决的是把一台手机的摄像头画面实时送到另一个设备上查看的问题。最典型的使用方式是把旧手机立在婴儿床边或客厅角落当作零成本的看护摄像头。这类需求用不着额外买 IPC 硬件手机自带的摄像头、Wi-Fi 和编解码能力就是全部零件源码包的价值在于把这几部分串成一条稳定能跑的流。下载这类 zip 工程的人通常有两类。一类是刚接触 Android 流媒体开发想从完整工程里看懂摄像头取帧、编码、网络传输的链路另一类是已有 Android 基础要在项目里快速加入监控能力。这两类人拿到手的都是同一个东西一个需要导入、编译、再装到手机上的 Android Studio 工程不是点开即用的 App 安装包。所以先别急着找 APK先把这个工程的路数看懂。标题里的「手机远程监控」落到代码层面可以拆成三个问题摄像头画面怎么取出来、取出来的帧怎么编码、编码后的数据怎么通过网络发给观看端。下面按源码包最常见的方案——CameraX 采集加内嵌 HTTP 服务推送 MJPEG 流——把选型、导入、关键代码、参数和排错依次讲透。2. 先选型Android 远程监控的 MJPEG、RTSP、WebRTC 怎么挑2.1 三条路线的本质采集、编码、传输的不同组合拿到远程监控需求第一件事不是写代码而是选路线。Android 端能落地的方案有三条区别在于画面怎么从摄像头走到观看端。这一层没想清楚后面写的代码大概率要推翻重来。MJPEG 路线是把摄像头每一帧独立编码成 JPEG通过 HTTP 连续推送。观看端用浏览器打开 URL 就能看画面实现最简单。帧与帧之间没有依赖关系网络丢一帧不影响下一帧渲染。代价是压缩率低静态画面下同样清晰度的流量是 H.264 的好几倍。RTSP 路线用 MediaCodec 做 H.264 硬编码通过 RTSP 协议外发VLC、ffplay 可以直接拉流。帧间参考编码让带宽和延迟都优于 MJPEG但代价是要自己处理 RTP 封包和 RTSP 信令。RTP 封包一旦出错播放端黑屏花屏新手很难判断是编码问题还是传输问题排查成本高出一大截。WebRTC 路线延迟最低能在几十毫秒级别但它依赖信令服务器做两端协商工程结构复杂。手机远程监控通常是单向查看上 WebRTC 属于杀鸡用牛刀除非未来要做双向音视频通话否则不建议作为源码包的第一版方案。2.2 为什么源码包工程里最常见的是 MJPEG 路线在「Android 应用源码」这个分类下以学习和二次开发为目的的工程绝大多数走 MJPEG。原因不在宣传册上而在工程实践里整条链路只需要 CameraX 加一个几百行的 HTTP 服务不依赖任何第三方流媒体库zip 工程解压之后没有额外配置就能编译。RTSP 路线的依赖选择反而尴尬可用的开源封装库要么年久失修要么要自己改源码对一份面向新手的源码来说门槛太陡。调试方式的差距也一样明显。MJPEG 的验证终点是「浏览器里能看到画面」——打开一个 URL 就知道通不通问题能快速定位到采集端还是传输端RTSP 得用专门的播放器拉流失败了还要抓包看 RTSP 信令和 RTP 序列号就为了验证一个「画面能不能出来」的问题。对比项MJPEGRTSPWebRTC端到端延迟300ms 起步100ms 左右30–80ms静态画面带宽高每帧独立压缩低帧间参考低观看端要求浏览器或 VLCVLC / ffplay需 Web 页面配合信令服务器不需要不需要需要代码量最少中等高源码包教学友好度最适合进阶选项不适合带宽这一行值得单独解释H.264 画面静止时一个 I 帧后面全是几 KB 的 P 帧MJPEG 每帧都是完整 JPEG静止画面下一帧也有 30–50KB。所以同一个 640x48010fps 的监控画面MJPEG 要 1.5–2MbpsRTSP 能压到 400–600Kbps。源码包先跑 MJPEG 完全没问题但心里要清楚将来要放到公网长期跑改造方向是把推流端换成 RTSP这个选型结论一开始定下来能省很多返工。两条路线在工程依赖上的差距一眼就能看出来// MJPEG 路线只需要一个内嵌 HTTP 服务 implementation org.nanohttpd:nanohttpd:2.3.1 // RTSP 路线没有统一官方的 RTP 封装库多数要自己处理 MediaCodec 输出 // 这也是源码包默认不选 RTSP 的实际原因之一3. 把源码跑起来zip 导入、权限配置与 CameraX 取帧链路3.1 Android Studio 导入 zip 工程的三个注意点zip 解压后第一件事是确认里面是不是完整的 Gradle 工程。标准结构是 settings.gradle、项目级 build.gradle、app 模块的 build.gradle 以及 gradle/wrapper 目录。如果缺了 gradle/wrapper不要手动下载 Gradle直接让 Android Studio 按 gradle-wrapper.properties 里的 distributionUrl 自动拉取网络慢就换成国内镜像的 distributionUrl。导入阶段有三个高频坑。第一个是 Gradle 与 JDK 版本不匹配旧工程用 Gradle 6.x 配 JDK 8 或 11而新版 Android Studio 默认 JDK 17编译时大概率报 Unsupported class file major version在 File Project Structure 里把 JDK 切到兼容版本即可。第二个是 compileSdk 版本高于本地已装的 SDKStudio 虽然会提示自动下载但网络差时容易卡在下载步骤提前用 sdkmanager 装好能省不少时间。第三个是依赖仓库拉取慢把阿里云镜像放到仓库列表最前面pluginManagement { repositories { // 镜像在前官方仓库兜底 maven { url https://maven.aliyun.com/repository/google } maven { url https://maven.aliyun.com/repository/public } maven { url https://maven.aliyun.com/repository/gradle-plugin } google() mavenCentral() gradlePluginPortal() } }这段配置写在 settings.gradle 的 pluginManagement 块里。Gradle 解析插件和依赖时按仓库声明顺序逐个尝试镜像在前能显著缩短首次 sync 时间。CameraX 和 NanoHTTPD 都带传递依赖这个改动通常能省下几分钟到十几分钟的等待是导入 zip 源码包性价比最高的一个操作。3.2 权限与 CameraX 绑定监控画面链路的第一段监控 App 的权限有两类。摄像头权限是硬性的没有它画面就是黑的网络权限用于 HTTP 服务接受连接。INTERNET 是普通权限但很常被忽略——Manifest 里漏了声明时socket 绑定能成功客户端却连不上Logcat 只报一个 Permission denied容易被误判成防火墙或路由器问题。运行时权限用标准写法请求即可String[] permissions { Manifest.permission.CAMERA, Manifest.permission.INTERNET }; if (Build.VERSION.SDK_INT Build.VERSION_CODES.M checkSelfPermission(permissions[0]) ! PackageManager.PERMISSION_GRANTED) { requestPermissions(permissions, 100); }CameraX 取帧链路的核心是把 Preview 和 ImageAnalysis 两个用例绑定到 Lifecycle 上。用 ProcessCameraProvider 绑定一次Activity 销毁时摄像头自动释放不需要像 Camera2 那样手写状态机ProcessCameraProvider.getInstance(this).addListener(() - { try { ProcessCameraProvider provider ProcessCameraProvider.getInstance(this).get(); provider.unbindAll(); provider.bindToLifecycle( this, CameraSelector.DEFAULT_BACK_CAMERA, preview, imageAnalysis ); } catch (ExecutionException | InterruptedException e) { e.printStackTrace(); } }, ContextCompat.getMainExecutor(this));bindToLifecycle 的三个关键参数分别是LifecycleOwner这里是 Activity 自身、摄像头方向默认后摄和要绑定的用例。源码包里如果把方向写死成 BACK_CAMERA前摄手机装上就只能看到黑屏改成 FRONT_CAMERA 即可。注意这里用到了 ContextCompat.getMainExecutorCameraX 初始化必须在主线程完成而 addListener 的回调线程由传入的 Executor 决定。3.3 ImageAnalysis 回调里拿帧YUV 到 JPEG 的转换与参数analyze 回调拿到的 ImageProxy 是摄像头输出的原始帧不能直接进 HTTP 推送链路得先转成 JPEG 字节数组。CameraX 1.1.0 之后 ImageProxy 自带 toBitmap()不用再手写 YUV 转换算法。关键是构造 ImageAnalysis 时把输出格式设成 RGBA_8888让 toBitmap() 直接包装缓冲区省掉一次 YUV 转 RGB 的开销ImageAnalysis imageAnalysis new ImageAnalysis.Builder() .setResolutionSelector(new ResolutionSelector.Builder() .setResolutionStrategy(ResolutionStrategy.CENTER_INSIDE, new Size(640, 480)) .build()) .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888) .build(); imageAnalysis.setAnalyzer(Executors.newSingleThreadExecutor(), imageProxy - { Bitmap bitmap imageProxy.toBitmap(); ByteArrayOutputStream baos new ByteArrayOutputStream(); bitmap.compress(Bitmap.CompressFormat.JPEG, 70, baos); byte[] jpegBytes baos.toByteArray(); mjpegServer.pushFrame(jpegBytes); imageProxy.close(); });这段代码最关键的坑在最后一行。imageProxy.close() 不调用的话CameraX 的缓冲池会很快耗尽analyze 回调永久停止表现为画面卡在启动后的第一帧App 不闪退也不报错排查起来相当费劲。这是远程监控源码包里新手遇到最多的幽灵问题。三个参数值得单独说明。setResolutionStrategy 里的 640x480 是局域网监控的常用值到手机观看端足够清晰又不会让 toBitmap 每次分配太多内存。JPEG 质量 70 是监控场景的经验折中低于 50 会出现明显色块高于 85 单帧体积涨三倍以上在手机小屏上视觉提升有限。STRATEGY_KEEP_ONLY_LATEST 表示处理不过来时丢弃旧帧只留最新对监控这种实时性优先的场景是对的换成人脸识别这类需要逐帧处理的场景才应该改成阻塞背压。4. 内嵌 HTTP 服务推送 MJPEG 流NanoHTTPD 实现与参数调优4.1 NanoHTTPD 的 MJPEG 长连接推送实现MJPEG 传输的本质是 HTTP 长连接配合 multipart/x-mixed-replace。服务器在一个响应流里不断写 JPEG 帧帧与帧之间用 boundary 分隔。观看端每解析到一个 boundary 就刷新一帧画面。连接始终不关闭客户端不需要反复重连画面延迟只取决于推流节奏。Android 端内嵌 HTTP 服务最常用的方案是 NanoHTTPD加一行依赖就能用implementation org.nanohttpd:nanohttpd:2.3.1核心实现是自定义 InputStream。NanoHTTPD 返回 chunked response 时会从这个 InputStream 持续读数据写 socket所以这个流不能返回 -1而是要在没有新帧时阻塞等待。配合一个容量为 1 的 BlockingQueue队列里永远只保留最新帧观看端拿到的永远是当前画面import java.io.*; import java.util.concurrent.*; public class MjpegServer extends NanoHTTPD { private static final String BOUNDARY frame-boundary; private final BlockingQueuebyte[] frameQueue new LinkedBlockingQueue(1); public MjpegServer(int port) { super(port); } public void pushFrame(byte[] jpegData) { frameQueue.poll(); // 丢弃旧的未消费帧 frameQueue.offer(jpegData); // 只保留最新帧 } Override public Response serve(IHTTPSession session) { if (/stream.equals(session.getUri())) { return newChunkedResponse(Response.Status.OK, multipart/x-mixed-replace; boundary BOUNDARY, new FrameInputStream()); } return newFixedLengthResponse(Response.Status.NOT_FOUND, text/plain, 404 not found); } private class FrameInputStream extends InputStream { private byte[] frameData; private int offset; Override public int read() throws IOException { try { if (frameData null) { frameData buildFrame(frameQueue.take()); // 没有新帧就阻塞 offset 0; } if (offset frameData.length) { frameData null; return read(); } return frameData[offset] 0xFF; } catch (InterruptedException e) { throw new IOException(stream interrupted, e); } } private byte[] buildFrame(byte[] jpeg) { String header -- BOUNDARY \r\n Content-Type: image/jpeg\r\n Content-Length: jpeg.length \r\n\r\n; ByteArrayOutputStream out new ByteArrayOutputStream(jpeg.length 64); try { out.write(header.getBytes()); out.write(jpeg); out.write(\r\n.getBytes()); } catch (IOException ignored) { } return out.toByteArray(); } } }提示read() 里的阻塞是刻意设计的。没有新帧时连接挂起不动有新帧就立刻写入 socket这样观看端永远显示最新画面不会因为旧帧积压而延迟越来越大。这正是 MJPEG 比 HLS 延迟稳定的根本原因。逻辑链路很简单CameraX 的 analyze 回调每来一帧调 pushFrame把队列里的旧帧换成新帧观看端连上后NanoHTTPD 从 FrameInputStream 读数据read() 从队列取帧把 boundary 头、JPEG 数据、结尾拼接成一个完整的 MJPEG 分片写进响应。两个细节要注意NanoHTTPD 的 serve 运行在它自己的线程池里不会阻塞主线程端口建议选 8090 或 10000 以上的高位端口避开 8080 这个容易被其他服务占用的地址。4.2 分辨率、帧率、JPEG 质量三个关键参数MJPEG 的画面效果由三个参数决定采集分辨率、推流帧率、JPEG 质量。三者互相制约调优时必须一起调只改其中一个往往看不到预期效果。参数取值范围对链路的影响推荐默认值采集分辨率640x480 到 1920x1080决定单帧体积和 CPU 转换耗时640x480推流帧率5 到 30fps决定画面流畅度和每秒流量10fpsJPEG 质量50 到 90决定单帧体积和画面细节70这三个参数的联动关系大致是分辨率翻四倍单帧体积翻四倍帧率翻倍每秒流量翻倍JPEG 质量从 70 提到 90单帧体积可能翻三倍。所以 640x48010fps 加质量 70 是综合平衡点总带宽约 1.5–2Mbps手机端渲染压力和流量消耗都可控。调参位置对应三处代码分辨率在 ImageAnalysis 的 setResolutionSelector帧率看 FrameInputStream 从队列取帧的节奏JPEG 质量在 Bitmap.compress 的第二个参数。改完重装 App 即生效不需要动协议层。如果要在 4G/5G 公网环境运行把分辨率降到 320x240、帧率 5fps仍然能得到可用画面月度流量能控制在几个 GB 内。4.3 四个高频问题生命周期、OOM、主线程卡顿、端口复用源码包改完跑起来崩溃和黑屏集中在四个地方。生命周期管理是最常见的一个。Activity 旋转或退到后台时CameraX 绑定关系失效ImageAnalysis 停止回调但 MjpegServer 的线程还在空转。正确做法是在 onDestroy 里停掉服务器Override protected void onDestroy() { super.onDestroy(); if (mjpegServer ! null) { mjpegServer.stop(); } // CameraX 会随 bindToLifecycle 传入的 LifecycleOwner 自动释放 }OOM 是第二个高频问题。CameraX 的 ImageProxy 实际 buffer 对齐尺寸可能大于请求的分辨率toBitmap() 在高分辨率下每次分配好几 MB 内存。观看端没人连接时analyze 回调仍在转换帧GC 跟不上就崩了。缓解方案有两个没有客户端连接时跳过转换或者用一个 volatile 标志控制 analyze 里是否执行 toBitmap。第三个是主线程卡顿。ImageAnalysis 默认在主线程回调如果在 analyze 里做 Bitmap 压缩之外的重活比如写磁盘UI 会明显掉帧。上面代码里 setAnalyzer 传入的 Executors.newSingleThreadExecutor() 就是把回调切到独立线程这个参数在源码包里经常被漏掉。第四个是端口复用。NanoHTTPD.stop() 后端口可能还在 TIME_WAIT 状态立刻重启服务会在 bind 时报 Address already in use。把端口做成配置项启动时尝试绑定失败则端口号加一重试是最省事的解法比在 stop 里强制关 ServerSocket 更安全。5. 验证与进阶延迟实测与按需推流改造5.1 用 VLC 或浏览器验证流并实测延迟部署完成后先在局域网验证。手机和观看端连同一个 Wi-Fi在系统设置里查到手机 IP。浏览器或 VLC 打开http://手机IP:8090/stream画面出来说明采集、编码、传输三级链路都通了。VLC 的打开路径是「媒体 打开网络串流」填入同一地址。VLC 的统计信息里有输入字节速率可以反推参数是否生效10fps、640x480、质量 70 时速率约 180–250KB/s。明显偏高说明 JPEG 质量参数没起作用偏低说明 analyze 回调频率低于预期。延迟用秒表法实测手机摄像头对准另一个屏幕上的秒表观看端截图与真实秒表对比差值就是端到端延迟。MJPEG 方案 300–500ms 是正常范围更关键的是波动要小。如果延迟忽高忽低超过 500ms优先降帧率而不是降分辨率因为波动的主因往往是网络拥塞而非编码耗时。这个流没有任何鉴权局域网里任何设备都能打开 URL 直接看画面需要远程访问时不要直接暴露端口至少要加一层带口令的访问控制。5.2 帧间差分按需推流省流量更省电的改造全时推流的功耗和流量不适合长时间监控640x480 的 MJPEG 流一小时约 700MB 流量。把「永远在推流」改成「有动静才推流」是源码包进阶改造里性价比最高的一步。最简单可靠的触发方案是帧间差分在 analyze 回调里把当前帧缩成 16x16 灰度图和上一帧逐像素比较平均灰度差超过阈值才推流private byte[] lastFrame null; private boolean hasMotion(Bitmap bitmap) { Bitmap small Bitmap.createScaledBitmap(bitmap, 16, 16, true); byte[] current new byte[256]; for (int i 0; i 256; i) { int pixel small.getPixel(i % 16, i / 16); int gray (((pixel 16) 0xFF) ((pixel 8) 0xFF) (pixel 0xFF)) / 3; current[i] (byte) gray; } if (lastFrame null) { lastFrame current; return false; } int diffSum 0; for (int i 0; i 256; i) { diffSum Math.abs((current[i] 0xFF) - (lastFrame[i] 0xFF)); } lastFrame current; return diffSum / 256 12; }16x16 灰度化加逐像素比较的成本在毫秒级不会拉高 CPU 占用。这里对字节做了 0xFF处理因为 Java 的 byte 是有符号的直接相减灰度值会出现负数干扰判断。阈值 12 表示平均每像素灰度变化约 4.7%是室内稳定光线的经验值灯光频闪明显的环境把它调到 20 以上或先对灰度图做一次简单中值滤波再比较。检测到运动后推流 30 秒再自动停止观察端画面在运动停止后约 30 秒冻结即说明按需推流生效。同样用 640x480 跑一晚上省流量和续航的效果对比全时推流会非常明显。最后留一个调试接口在 NanoHTTPD 的 serve 里加一个 /snapshot 路由返回队列里最新一帧 JPEG。远端黑屏时先访问这个地址有图说明采集和编码正常问题在传输或播放端没图说明取帧已经停了问题在 CameraX 链路。这一招能把排查范围瞬间缩小一半。本文还有配套的精品资源点击获取
返回列表