简介:安卓Android源码——基于手机的远程视频监控系统,是一份面向Android初中级开发者的完整工程,演示如何利用手机摄像头实现远程画面的实时采集与查看,可用于毕业设计、课程项目或移动监控方向的技术练手。压缩包共41个文件,以5个Java源文件(核心类ImageServer.java、CameraTest等)和14个class编译文件为核心,另含6个xml配置与布局、8张png图片资源、可直接安装的apk包及项目配置文件,整体仅168KB,轻量易阅。工程目录涵盖src源码、res资源、gen自动生成文件及bin编译输出等标准Android结构,便于按模块研读。已有289人学习。代码覆盖Android四大组件、Camera API调用、Socket/HTTP网络通信、后台Service保活、多线程图像压缩与传输、权限声明和UI布局等关键点,并附有AndroidManifest.xml与工程配置,便于对照源码理解视频流采集、压缩、传输到显示的完整链路,可在此基础上快速二次开发。
1. 老旧Android工程里挖出的远程监控:ImageServer与CameraTest不是摆设
一个zip压缩包,里面躺着一个Eclipse时代的老旧Android工程。第一眼会误以为它只是个普通的相机demo,实际上,这台手机被设计成同时承担摄像头采集和Socket服务端的监控设备。用户在同一局域网内用电脑或另一台手机连接手机显示的IP和端口,就能实时看到摄像头画面。它的架构并不复杂,却能把Camera API、JPEG图像编码、多线程队列和TCP网络通信整合成一个可以跑通的闭环。这个源码适合有一定Android基础、想从零搭建轻量监控端的人,也适合想复习老项目迁移到Android Studio的工程师。我会从工程结构、采集链路、网络传输、权限与生命周期,一直讲到用Python客户端验证推帧结果。
2. 源码工程结构拆解:从zip解压到Eclipse项目的可执行路径
2.1 src下只有两个Java文件,却能跑通监控链路?
解压zip后,你会看到典型的Eclipse Android项目布局:src目录里是核心源码,gen目录是R.java和构建产物,bin里留着上一次打包的CameraTest.apk和classes.dex。真正值得读的是src下的ImageServer.java和CameraTest.java,前者承担网络服务,后者负责摄像头采集和预览。assets、res、proguard-project.txt、project.properties这些目录和配置文件,决定了老工程能否在新环境下继续构建。
先给出一张文件定位表,方便后续按图索骥:
| 路径/文件 | 作用 | 是否需要重点关注 |
|---|---|---|
AndroidManifest.xml | 声明权限、Activity、Service,是应用入口配置 | 必须改,尤其是权限与targetSdk |
src/org/.../ImageServer.java | Socket服务端,接收采集到的JPEG帧并发送给客户端 | 核心,决定视频流能不能出去 |
src/org/.../CameraTest.java | Activity类,打开摄像头,预览画面并回调帧数据 | 核心,决定图像采得清不清楚 |
res/layout/ | 页面布局,通常放置SurfaceView和启动按钮 | 次要,但要先能显示预览 |
project.properties | 声明Eclipse编译目标SDK版本 | 迁移到Android Studio前要改 |
proguard-project.txt | 混淆规则 | 普通调试可以不理会 |
bin/CameraTest.apk | 预编译安装包 | 只供参考,不作为源码依据 |
gen/R.java | 资源索引 | 自动生成,不要手动编辑 |
这个表把“老工程都有哪些文件”交代清楚了。很多人一看到bin里的APK就急着反编译,其实先读源码、再构建一次,远比逆向classes.dex容易理解。
2.2 AndroidManifest.xml里的权限声明与组件注册
监控应用需要摄像头和网络,第一件事就是在清单文件里声明权限。老工程通常长这样:
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.cameratest"> <uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <application android:label="CameraTest"> <activity android:name="CameraTest" android:screenOrientation="landscape"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> <service android:name="ImageServer" /> </application> </manifest>这个清单的逻辑很直接:CAMERA和INTERNET是远程视频监控的一对基础权限,少了任何一个都会在真机上直接失败。screenOrientation="landscape"强制横屏,可以省去预览画面旋转的换算,摄像头传感器的原始方向与横屏更匹配。这里把ImageServer注册成service,表示它可能运行在后台线程里,不依赖界面显示。
要注意的是,Android 6.0以后CAMERA被归为危险权限,仅靠<uses-permission>不够,运行时还要弹窗申请。老工程把target=android-19写在project.properties里,并没有运行时权限代码,所以源码搬到新手机上必须先补requestPermissions,否则必定闪退。
2.3 project.properties与targetSdk对构建的影响
老项目没有build.gradle,project.properties是Eclipse的构建配置入口:
# This file is automatically generated. target=android-19target=android-19表示这个工程编译时面向Android 4.4。当迁移到Android Studio时,只要把这句话翻译成compileSdkVersion和minSdkVersion即可。实际操作中,我一般会创建一个新Gradle模块,把这几个配置文件保留在仓库里做历史参考,而不是直接依赖Eclipse生成的项目结构。
除了target,还有.classpath和.project这两个Eclipse专用文件,Android Studio不需要它们。如果直接用Android Studio打开旧工程,经常会出现“Invalid project description”或找不到SDK的错误。最简单的处理方式是新建Android项目,把src、res、AndroidManifest.xml拷贝进去,再手工调整包名和依赖。
2.4 一个容易踩的坑:直接把bin目录里的APK当权威
bin里的APK虽然是上一次构建的产物,但它的classes.dex很可能是一份旧代码。读源码时不要被bin里的编译时间误导,例如resources.ap_和jarlist.cache都只是Eclipse的中间文件。真正能反映设计意图的只有src和res。如果发现ImageServer.java里监听的端口和APK实际端口不一致,以源码为准,因为APK可能来自更早的版本。
3. CameraTest的采集链路:从Camera.open到onPreviewFrame的每一帧
3.1 老Camera API为什么够用
监控场景只需要持续输出预览帧,不需要复杂的对焦和HDR,所以老Camera API反而比Camera2更容易上手。CameraTest.java里大概率会出现Camera.open(0)来获取后置摄像头,然后通过setPreviewCallback把每一帧YUV数据交给ImageServer处理。与Camera2的CaptureRequest管线相比,老Camera API省略了会话、缓冲区排队等概念,代码量少,适合快速验证视频传输链路。
一个典型的预览回调骨架如下:
public class CameraTest extends Activity implements Camera.PreviewCallback { private Camera mCamera; private ImageServer mServer; @Override protected void onResume() { super.onResume(); mCamera = Camera.open(); Camera.Parameters params = mCamera.getParameters(); params.setPreviewSize(640, 480); params.setPreviewFormat(ImageFormat.NV21); mCamera.setParameters(params); mCamera.setPreviewCallback(this); mCamera.startPreview(); } @Override public void onPreviewFrame(byte[] data, Camera camera) { if (mServer != null && mServer.isConnected()) { mServer.offerFrame(data); } } @Override protected void onPause() { super.onPause(); if (mCamera != null) { mCamera.stopPreview(); mCamera.release(); mCamera = null; } } }onPreviewFrame里的data是NV21格式的裸帧,分辨率640x480时,一帧数据大约640 * 480 * 3 / 2 = 460800字节。如果直接把这样的裸数据通过Socket发送,每秒15帧就是6.9MB左右,WiFi下勉强能传,但公网或弱网环境下会非常卡。正确的做法是先把NV21压缩成JPEG,再把体积砍到原来的三分之一甚至更小。
3.2 NV21转JPEG的两种常用方案
把NV21转成JPEG有很多种写法,最常见的两种:
| 转换方式 | 优点 | 缺点 |
|---|---|---|
YuvImage.compressToJpeg | 简单直接,不依赖Bitmap | 分辨率大时耗时明显,需要控制质量 |
Bitmap+compress | 可以做缩放、水印、画框 | 内存开销大,Bitmap需要手动recycle |
如果只是做监控画面传输,我一般选第一种:
public static byte[] nv21ToJpeg(byte[] nv21, int width, int height, int quality) { YuvImage image = new YuvImage(nv21, ImageFormat.NV21, width, height, null); ByteArrayOutputStream out = new ByteArrayOutputStream(); image.compressToJpeg(new Rect(0, 0, width, height), quality, out); return out.toByteArray(); }这里quality取值范围0到100,监控场景设在65到75比较合适。太高的话单帧会有50KB以上,太低会出现明显色块。compressToJpeg内部做的是YUV到RGB再到JPEG的转换,过程中没有额外申请Bitmap,适合放在后台线程执行。
需要注意一点:YuvImage生成的JPEG不包含EXIF信息,客户端显示时不会自动旋转。这也是为什么清单文件里要强制横屏,横屏下摄像头传感器方向和屏幕方向一致,能省掉旋转角度计算。
3.3 分辨率、帧率与带宽的取舍
远程监控是典型的带宽敏感型应用。如果接收端是同一WiFi下的电脑,640x480@15fps还能接受;如果走4G公网,建议把分辨率降到320x240,帧率降到10fps。下面是一个粗略的带宽估算表:
| 分辨率 | 单帧JPEG体积(质量70) | 15fps所需带宽 | 25fps所需带宽 |
|---|---|---|---|
| 320x240 | 10-18KB | 1.2-2.2Mbps | 2-3.6Mbps |
| 640x480 | 25-45KB | 3-5.4Mbps | 5-9Mbps |
| 1280x720 | 60-110KB | 7.2-13.2Mbps | 12-22Mbps |
这张表只是经验值,具体体积取决于画面复杂度和JPEG编码器。画面里静态场景多,体积会小很多;如果对着一个满是树叶抖动的地方,同样的质量数值体积会迅速翻倍。
在旧Camera API中设置帧率,可以用:
Camera.Parameters params = mCamera.getParameters(); params.setPreviewFpsRange(15000, 15000); // 表示15fps mCamera.setParameters(params);不是所有设备都支持任意的FPS范围,setPreviewFpsRange之前最好遍历getSupportedPreviewFpsRange()。硬编码一个不支持的区间会导致setParameters抛异常,这也是老机器上常见的崩溃点。
4. ImageServer的网络循环:用原始Socket推送JPEG,而不是HTTP服务器
4.1 Socket长连接比HTTP更适合视频流
从名字看,ImageServer容易让人误以为它要开一个Web服务,其实它更可能是用ServerSocket监听端口,把采集到的JPEG帧推送给连接进来的客户端。HTTP请求-响应模型适合单张图片获取,但实时视频流需要连续推帧,每帧都建立一个TCP连接会产生大量握手开销。Socket长连接一旦建立,服务端可以持续往同一个通道写入帧数据,延迟和资源占用都更小。
一个简化的服务端循环如下:
public class ImageServer { private ServerSocket serverSocket; private volatile boolean running; private BlockingQueue<byte[]> frameQueue = new LinkedBlockingQueue<>(3); public void start(int port) throws IOException { serverSocket = new ServerSocket(port); running = true; new Thread(this::acceptLoop).start(); new Thread(this::sendLoop).start(); } private void acceptLoop() { while (running) { try { Socket client = serverSocket.accept(); client.setTcpNoDelay(true); handleClient(client); } catch (IOException ignored) {} } } private void handleClient(Socket socket) { try (DataOutputStream out = new DataOutputStream(socket.getOutputStream())) { while (running) { byte[] frame = frameQueue.poll(1000, TimeUnit.MILLISECONDS); if (frame != null) { out.writeInt(frame.length); out.write(frame); out.flush(); } } } catch (IOException e) { // 客户端断开连接 } } public void offerFrame(byte[] jpeg) { if (!frameQueue.offer(jpeg)) { frameQueue.poll(); frameQueue.offer(jpeg); } } }这里的数据帧格式很微小,却决定了客户端能不能正确解析。协议可以定义为:
| 字段 | 类型 | 说明 |
|---|---|---|
| 帧长度 | int(大端) | 表示后面跟着的JPEG字节数 |
| JPEG数据 | byte[] | 一帧完整图像 |
DataOutputStream.writeInt默认按大端序写入,客户端解析时必须使用struct.unpack(">I", ...)解包。代码里的frameQueue容量设为3,意思是最多缓存3帧。当采集速度大于网络发送速度时,新帧会顶掉最旧帧,避免延迟越堆越大。
4.2 TCP_NODELAY与队列大小的设置
视频帧通常大于一个MSS(约1460字节),即使不设置TCP_NODELAY,大包也会立即拆包发送。真正需要setTcpNoDelay(true)的场景是控制信令和视频帧混发时,小包可能被Nagle算法缓冲到200ms后再发,视觉上就是画面突然卡一下。所以对Socket的写通道,我总会先设置TCP_NODELAY再开始循环。
队列容量为什么是3,而不是10或者无限?因为监控要的是“新画面”,不是“完整历史”。如果队列无限增大,客户端看到的是越来越迟的画面,这不是监控而是录像回放。当网络恢复后,队列里的旧帧很快被清空,新帧被发送,延迟自然回到正常水平。
4.3 连接不上的排错顺序
从真机调试到局域网环境,最常见的失败是端口不可达。先用下面命令验证端口:
# 查看手机当前的wlan IP adb shell ip addr show wlan0 # 从电脑测试端口连通性 nc -vz 192.168.1.10 8080如果nc命令提示Connection refused,先确认ImageServer有没有真正启动,再确认防火墙是否拦了入站端口。很多公共WiFi启用了AP隔离,手机和电脑虽然在同一局域网但互相不可见。此时可以用adb reverse把手机端口映射到电脑本地,绕开网络隔离:
adb reverse tcp:8080 tcp:8080这条命令的语义是:电脑访问自己的8080端口时,数据通过USB转发给手机的8080端口。它只对电脑本机连接有效,但非常适合开发阶段验证ImageServer的推帧逻辑是否正常。
5. Android权限、多线程与生命周期:让Camera和Socket不互相拖垮
5.1 权限声明之外,还要处理运行时权限
老工程默认跑在Android 4.4上,所以AndroidManifest.xml里只写<uses-permission>就够了。但现在随便一台真机都是Android 10以上,CAMERA、RECORD_AUDIO这类危险权限必须动态申请。补齐运行时权限的模板代码并不复杂:
if (checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { requestPermissions(new String[]{Manifest.permission.CAMERA}, 1); } else { openCamera(); }这里checkSelfPermission用来判断是否已经授权,requestPermissions弹出系统授权对话框。用户拒绝后,openCamera()不会被调用,所以不会出现Camera.open()抛SecurityException的情况。INTERNET和ACCESS_NETWORK_STATE是普通权限,不需要动态申请。
还有一点容易被忽略:当targetSdkVersion >= 28时,Android默认禁止明文Socket通信。如果你直接用ServerSocket收发明文JPEG,系统会抛出Cleartext communication not permitted。解决办法是在AndroidManifest.xml的<application>标签里加:
<application android:usesCleartextTraffic="true">这行配置只适合局域网调试,正式产品建议用SSLSocket或至少做端到端加密,否则视频流裸奔在公网上非常危险。
5.2 相机回调线程不能做网络IO
onPreviewFrame运行在系统Binder线程,不是主线程,但也不适合直接做网络写操作。我见过很多改造者把out.write直接写在回调里,看起来能跑,可一旦网络抖动,写阻塞会把相机回调全部卡住,画面变成“冻住”的状态。正确的做法是把byte[]先放进有界队列,再由独立网络线程消费。下面是一个用HandlerThread的例子:
HandlerThread senderThread = new HandlerThread("frame-sender"); senderThread.start(); Handler sender = new Handler(senderThread.getLooper()); sender.post(() -> { byte[] frame = imageServer.blockingPoll(); imageServer.send(frame); });这个方案的边界要清楚:Handler.post的任务会排队,如果网络发送一直慢于采集,任务队列还是会增长。更好的方案是像第4章那样用BlockingQueue,队列满时丢弃旧帧。采集线程永远不阻塞,网络线程永远在低延迟状态下等待新帧。
5.3 Activity生命周期管理:不要在onPause里忘记释放Camera
栗子:
| 生命周期 | 相机操作 | 网络操作 |
|---|---|---|
onCreate | 初始化UI,申请权限 | 创建ImageServer对象 |
onResume | Camera.open,startPreview | 绑定端口,开启线程 |
onPause | stopPreview,release | 断开客户端连接 |
onDestroy | 释放所有资源 | 关闭ServerSocket |
Camera是独占资源,不释放的话,其他应用甚至自己再次打开时都会失败。onPause不一定伴随应用退出,来电、锁屏、切后台都会触发,所以在onPause里释放相机是底线。
5.4 让监控端屏幕常亮
老式监控应用通常希望手机放在某个角落长时间运行。屏幕灭掉后系统可能进入休眠,相机预览虽然还在,但应用线程调度会受影响。最简单的方法是保持屏幕常亮:
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);这个方法不需要任何权限。如果还想让Service在后台不被回收,就需要把ImageServer放进前台Service并绑定通知栏。这个工程如果只是局域网测试,Activity常亮加WAKE_LOCK就够了。
6. 真机验证:用Python和adb forward确认ImageServer在正常推帧
6.1 先写一个最简Python接收端
验证监控链路不需要Android客户端,直接写一个Python脚本在电脑上解析TCP流即可。先用adb reverse把手机端口映射到电脑,再连接127.0.0.1:8080:
import socket import struct import numpy as np import cv2 s = socket.socket() s.connect(("127.0.0.1", 8080)) while True: raw_len = s.recv(4) if len(raw_len) < 4: break frame_len = struct.unpack(">I", raw_len)[0] data = b'' while len(data) < frame_len: chunk = s.recv(frame_len - len(data)) if not chunk: break data += chunk image = cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_COLOR) cv2.imshow("remote_monitor", image) if cv2.waitKey(1) & 0xFF == ord('q'): break这段代码把TCP流按“长度+内容”拆包,recv循环处理半包。如果一帧JPEG比较大,TCP不会一次性把整帧数据送过来,必须循环读取直到拿满frame_len字节。cv2.imdecode直接解码JPEG缓冲区,省去写临时文件再读回的过程。
验证步骤是:手机开启ImageServer,PC执行adb reverse tcp:8080 tcp:8080,再运行上面的Python脚本。如果画面一帧帧出现,说明摄像头采集、JPEG编码、Socket推帧、接收端解析整条链路都是通的。
6.2 迁移到Android Studio时最容易踩的三个坑
第一,不要直接导入Eclipse的.classpath,Android Studio不认识它。正确做法是在IDE里选择“Import Project”,让它自动创建Gradle骨架。第二,project.properties里的target=android-19要转换成compileSdkVersion和minSdkVersion,否则系统会默认用很老的SDK编译,部分API出现兼容问题。第三,如果运行在Android 9以上手机,注意usesCleartextTraffic="true"和requestPermissions有没有补上。
如果Python端一直提示连接失败,先确认adb reverse是否成功,再确认App里的ImageServer有没有在onCreate或onResume中启动。画面出现但颜色不对,多数是预览格式不是NV21,或者YuvImage转换时宽高参数与setPreviewSize不一致。对于这类老Camera项目,把日志写在onPreviewFrame入口处,观察data.length是否等于width * height * 3 / 2,基本上几分钟就能定位问题。
本文还有配套的精品资源,点击获取