闲置在家不用的Android手机有一两个都蒙尘了,想要把它们充分利用起来,可知道,现有的智能手机是可以充当Wifi摄像头来使用的,这就需要装一个App就能实现了,如果是用别的下载来APP安装用来会不会不放心呢,如果自己有能力,那就可以通过开发Android App项目过程来实现视频监控,有兴趣的来看看接下来的实现方案,
要完成整个过程,至少需要两部手机,一个手机用来充当WIFI摄像头(可以开启WIFI热点),另一个手机当视频监控用的,还是建议用WIFI路由器,就看中它信号强,网络又稳定
关于能看懂此文章的条件
- 会使用Android Studio开发工具
- 熟悉Java编程语言,开发过Android App
- 对WIFI路由器设置和网络信息收发报文
TCP
,UDP
原理有过了解
1.首先,打开Android Studio开发工具,选择新建Android 项目,使用Java语言,模板就选择 Emtpy Activity,在activity_main.xml文件中做好布局,具体布局内容太多这里就不贴了,自己布局就好,拖放组件是很简单的操作,只需要放三个按钮组件即可,分别是扫描摄像头,开启摄像头,退出APP,其它的不重要
2. 然后,在MainActivity.class上写代码,实现能打开按钮对应的页面即可,请看如下代码,其中用到的一些类,例如DeviceInfo.class, Common.class, BaseBackActivity.class这些就不贴了,看注释,具体的请等在后面提供的项目源码里看
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//...这里省略了,只是处理了对标题栏的隐藏
setContentView(R.layout.activity_main);
//获取布局中的按钮组件
Button btnScan = findViewById(R.id.button_scan);
Button btnPreview = findViewById(R.id.button_preview);
Button btnExit = findViewById(R.id.buttonExit);
final Context context = MainActivity.this;
//设置点击事件
btnExit.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
MainActivity.this.finish();//退出
}
});
btnScan.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
//初始化设备信息,包括了手机的摄像头相关属性,如名称,IP,数量
DeviceInfo di = DeviceInfo.init(context);
//...省略了一些判断细节,如判断IP是否正确,判断摄像头的网络状态
//打开扫描局域网内的摄像头页面
BaseBackActivity.navigateTo(MainActivity.this, ScanActivity.class, di);
}
});
btnPreview.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
//初始化设备信息
DeviceInfo di = DeviceInfo.init(context);
//...省略了一些判断细节,这一步是判断摄像头的授权
if(Common.requestCameraPermission(MainActivity.this)){
//打开WIFI摄像头的预览页面
BaseBackActivity.navigateTo(MainActivity.this, PreviewActivity.class, di);
}
}
});
}
}
接下来,做一个扫描摄像头页面的布局,文件是activity_scan.xml,大致布局如下图所示,运行后的效果图,就一个ListView
展示列表的组件,还有标题栏上的搜索图标,那是扫描按钮
接着,创建一个对应页面的类ScanActivity.class 文件后,写上代码,如下
/**
* 扫描摄像头窗口
* */
public class ScanActivity extends BaseBackActivity {
//定义扫描线程
private ScanThread thread;
//定义列表组件
private ListView list;
//定义对初始化扫描的判断值
private boolean isFirstScan = false;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_scan);
//省略了,处理初始化标题栏的
//获取上一页传来的设备信息对象, getSerializable()是来自父类BaseBackActivity的方法
DeviceInfo di = (DeviceInfo) getSerializable();
//创建线程时,传入设备信息对象
thread = new ScanThread(this, di, new Handler(){
@Override
public void handleMessage(@NonNull Message msg) {
super.handleMessage(msg);
//处理线程传来的消息
switch (msg.what) {
//扫描完成通知
case BaseThread.MESSAGE_SUCCESS:
{
//获取扫描后的局域网内所有可用的摄像头
ArrayList<RemoteCamera> cameras = thread.getCameras();
if(cameras.isEmpty()) {
//showToast方法来自父类,弹出提示
showToast(ScanActivity.this, "找不到可用的摄像头!");
}else{
//更新摄像头列表显示的
CamerasAdapter adapter = new CamerasAdapter(ScanActivity.this, cameras);
list.setAdapter(adapter);
list.invalidate();
showToast(ScanActivity.this, "扫描完成!");
}
}
break;
//扫描失败,或更新状态
case ScanThread.MESSAGE_FAIL:
case ScanThread.MESSAGE_LOADING:
showToast(ScanActivity.this, (String) msg.obj);
break;
default:
}
}
});
list = findViewById(R.id.listview);
//列表的点击事件
list.setOnItemClickListener(new AdapterView.OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> adapterView, View view, int i, long l) {
RemoteCamera camera = thread.getCameras().get(i);
//打开远程摄像头连接页面,传递一个摄像头信息camera
navigateTo(ScanActivity.this, RemoteActivity.class, camera);
}
});
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
//...此处省略,加载菜单布局的
return super.onCreateOptionsMenu(menu);
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
//监听菜单按钮
switch (item.getItemId()) {
//扫描图标按钮被点击
case R.id.app_bar_search:
thread.startScanCamera();
return true;
default:
}
return super.onOptionsItemSelected(item);
}
@Override
protected void onResume() {
super.onResume();
//第一次打开页面就扫描
if (!isFirstScan) {
thread.startScanCamera();
isFirstScan = true;
}
}
}
public class ScanThread extends BaseThread {
private ArrayList<RemoteCamera> cameras;
private DeviceInfo info;
//定义一个扫描线程
private Thread scanThread = null;
public ScanThread(Activity context, DeviceInfo info, Handler handler) {
//传参给父类BaseThread的构造方法,初始化
super(context, handler);
this.info = info;
this.cameras = new ArrayList<RemoteCamera>();
//来自父类的线程,用于处理接收的
thread = new Thread(new Runnable() {
@Override
public void run() {
// TODO Auto-generated method stub
try {
//初始化端口
mSocket = new DatagramSocket(null);
//...
mSocket.bind(new InetSocketAddress(SenderThread.FIND_CAMERA_PORT));
while(!Thread.interrupted()) {
//定一个空的数据报文
DatagramPacket pack = new DatagramPacket(new byte[1028], 1028);
//用空数据报文来接收数据,这时会一直等待,阻塞
mSocket.receive(pack);
//收到时,将报文里的数据转换成字符串
String s = new String(pack.getData(), 0, pack.getLength());
//在把字符串转成字符串数组,将接收到数据按照约定的协议转换一下
String[] datas = Common.getDeviceData(s);
//...此处省略,处理拿到count, 是摄像头数量,添加到cameras中
cameras.add(new RemoteCamera(cameras.size(), datas[0], count, datas[2]));
//发完成提示消息
showToast("扫到一个摄像头", MESSAGE_SUCCESS);
}
} catch (Exception e) {
showToast(e.getMessage());//遇到错误!
} finally {
cancelScan(true);
}
}
});
thread.start();
}
public void startScanCamera() {
//...次数省略判断的细节,下一步提示用户扫描中,建一个线程处理
showToast("扫描中...", MESSAGE_LOADING);
scanThread = new Thread(new Runnable() {
@Override
public void run() {
try {
//...此处省略一些细节,定义data数据
// 定义局域网的广播地址,这样表示 *.*.*.255,
InetAddress cameraAddress = InetAddress.getByName(Common.getWanIP(info.getLocalIp())+"255");
// 将data数据封装到报文中,还有IP地址,FIND_CAMERA_PORT 是 30000
DatagramPacket pack = new DatagramPacket(data, data.length, cameraAddress, FIND_CAMERA_PORT);
//将数据报文发送到广播地址,只要是连接到此局域网内的所有设备开放的30000端口都会收到该广播报文
mSocket.send(pack);
} catch (Exception e) {
showToast(e.getMessage());//遇到错误!
} finally {
//处理完后取消操作
cancelScan(false);
}
}
});
scanThread.start();
}
//判断是否在扫描
public boolean isScaning() {
return scanThread!=null;
}
public ArrayList<RemoteCamera> getCameras() {
return cameras;
}
//取消扫描
public void cancelScan(boolean isCancelAll) {
if(isCancelAll) {
//处理来自父类的方法
cancelThread();
}
if (isScaning()) {
scanThread.interrupt();
scanThread = null;
}
}
}
小提示
注意到创建的页面都有继承BaseBackActivity.class类,创建的线程都有继承类BaseThread.class,具体怎么写的,这里不详细讲了,那说下它的作用,它是相当于一个可以复用的类吧,类似模板,可以这样理解,稍微能明白,实现不会复杂
<manifest xmlns:android="...">
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
manifest>
TextView
组件,还有一个预览画面的SurfaceView
组件放在中间,宽高分别是固定的320dp,240dp小提示
有没有注意到,看视频监控上的状态栏,网络保持在23.3K/s每秒,这已经是一帧一帧的传输图像了,图像是320x240分辨率的,传输量会不会低了,可能有点卡吧,跟网络传输延迟有关的
public class RemoteActivity extends BaseBackActivity {
//定义一个网络接收的线程
private ReceiveThread thread;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_remote);
//...省略了,处理初始化标题栏的
//获取上一页传来的设备信息对象, getSerializable()是来自父类BaseBackActivity的方法
RemoteCamera remote = (RemoteCamera) getSerializable();
//从布局中获取组件
SurfaceView view = findViewById(R.id.surfaceView2);
final TextView showState = findViewById(R.id.textView_state2);
//将远程设备信息设置到标题栏上
setTitle("远程摄像头:"+remote.toString());
//先获取焦点,然后设置屏幕长亮
view.setFocusable(true);
view.setKeepScreenOn(true);
//建立一个接收线程,传一个远程设备信息对象,还有预览组件的holder用于更新画面
thread = new ReceiveThread(this, new Handler(){
@Override
public void handleMessage(@NonNull Message msg) {
super.handleMessage(msg);
//...处理线程发来的消息提示
}
}, remote, view.getHolder());
}
@Override
protected void onPostResume() {
super.onPostResume();
//让线程开始接收工作
thread.startReceive();
}
@Override
protected void onDestroy() {
//当前页面关闭时,让线程结束工作
thread.cancelReceive();
super.onDestroy();
}
}
public class ReceiveThread extends BaseThread {
private RemoteCamera remote;
private SurfaceHolder holder;
public ReceiveThread(Activity context, Handler handler, RemoteCamera remote, SurfaceHolder holder) {
super(context, handler);
this.remote = remote;
this.holder = holder;
}
public void startReceive() {
if (thread!=null) {
return;
}
thread = new Thread(new Runnable() {
@Override
public void run() {
String errMsg = "未知错误";
//...
try {
//...
if (mSocket==null) {
mSocket = new DatagramSocket(null);
//用开放30000端口来接收 FIND_CAMERA_PORT
mSocket.bind(new InetSocketAddress(FIND_CAMERA_PORT));
//...
showToast("连接中...", MESSAGE_UPDATE_STATE);
//发送请求接收下一帧图片
sendRet(mSocket, baos);
//...
showToast("等待接收...", MESSAGE_UPDATE_STATE);
while(mSocket!=null) {
//...定义空的数据报文packet
try {
//接收中,等待,此处阻塞
mSocket.receive(packet);
}catch (SocketTimeoutException te) {
showToast("连接超时..."+getLocalDateTime(), MESSAGE_UPDATE_STATE);
//再次发送请求
sendRet(mSocket, baos);
//...继续循环,重新接收
continue;
}
//判断一帧图片baos数据是否接收完成
if(packet.getLength() == endlen) {
String end = new String(packet.getData(), 0, endlen);
if(end.startsWith(PACKET_END)) {
//...获取time时间数据,下一步更新显示
updateViewDisplay(baos, time);
//设置接收下一帧等待时长,至少每100ms接收下一帧,可以设置更小,让视频看着更流畅
Thread.sleep(100);
baos.flush();
//再次发送请求
sendRet(mSocket, baos);
//...
showToast("接收中..."+getLocalDateTime(), MESSAGE_UPDATE_STATE);
continue;
}
}
//接收一帧图片数据流
baos.write(packet.getData(), 0, packet.getLength());
}
}
} catch (Exception e) {
errMsg = e.getMessage();//断开连接!;
} finally {
//...
cancelThread(errMsg);
}
}
});
thread.start();
}
private void sendRet(DatagramSocket dSocket, ByteArrayOutputStream baos) throws IOException, Exception {
//省略...处理发送接收下一帧图片请求
InetAddress address = InetAddress.getByName(remote.getIp());
DatagramPacket pack = new DatagramPacket(data, data.length, address, FIND_CAMERA_PORT);
dSocket.send(pack);
//...
}
private void updateViewDisplay(final ByteArrayOutputStream baos, final String time) {
//省略...处理转换图片
context.runOnUiThread(new Runnable() {
@Override
public void run() {
// TODO Auto-generated method stub
//...锁定中,从组件中获取画布Canvas
Canvas canvas = holder.lockCanvas(null);
//...将图片画组件中,让用户可以看到
canvas.drawBitmap(bitmap2, 0, 0, null);
//...画上时间
canvas.drawText(time, 20, 30, p);
//解除锁定
holder.unlockCanvasAndPost(canvas);
//...
}
});
}
public void cancelReceive() {
cancelThread();
}
}
接下来,做一个开启摄像头页面的布局,文件是activity_preview.xml,大致布局如下图所示,是运行后的效果图,同上面讲过,跟远程摄像头页面布局那个是一样的,现在是有多放了一个选择摄像头的下拉框组件Spinner
接着,创建一个对应的页面类PreviewActivity.class文件,写上代码,参考如下
public class PreviewActivity extends BaseBackActivity {
private Camera camera = null;
private SurfaceHolder holder;
private Spinner seletep;
private int selectCameraId = 0;
//定义发送图片的线程
private SenderThread thread;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_preview);
//...
final DeviceInfo info = (DeviceInfo) getSerializable();
//...获取布局中的组件,SurfaceView是绘制组件
final SurfaceView view = findViewById(R.id.surfaceView);
seletep = findViewById(R.id.spinner);
final TextView stateView = findViewById(R.id.textView_state);
//创建一个发送图片的线程,传入设备信息对象
thread = new SenderThread(this, info, new Handler(){
@Override
public void handleMessage(@NonNull Message msg) {
super.handleMessage(msg);
//...处理线程发来的消息
}
});
String localIp = thread.getLocalIp();
String name = thread.getDeviceName();
setTitle("设备名:"+name+ ", 局域网IP:"+localIp);
seletep.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> adapterView, View view, int i, long l) {
//...切换摄像头
}
});
//先获取焦点
view.setFocusable(true);
//然后设置屏幕长亮
view.setKeepScreenOn(true);
holder = view.getHolder();
holder.setType(SurfaceHolder.SURFACE_TYPE_PUSH_BUFFERS);
holder.addCallback(new SurfaceHolder.Callback() {
@Override
public void surfaceCreated(@NonNull SurfaceHolder surfaceHolder) {
//绘制组件创建,准备摄像头
int cameraCount = Camera.getNumberOfCameras();
//...
thread.setCameraCount(cameraCount);
}
@Override
public void surfaceChanged(@NonNull SurfaceHolder surfaceHolder, int i, int i1, int i2) {
//绘制组件大小改变,重置摄像头
//...
openCamera();
}
@Override
public void surfaceDestroyed(@NonNull SurfaceHolder surfaceHolder) {
thread.cancelThread();
//绘制组件销毁,释放摄像头资源
closeCamera();
}
});
}
@Override
protected void onPostResume() {
super.onPostResume();
//可被局域网内发现摄像头
thread.canFind(true);
}
private void openCamera() {
//...
try{
camera = Camera.open(selectCameraId);
Camera.Parameters params = camera.getParameters();
List<Camera.Size> sizes = params.getSupportedPictureSizes();
//图像大小
final int PICTURE_WIDTH = 320, PICTURE_HEIGHT = 240;
Camera.Size size = null;
//省略细节...查找摄像头配置参数,赋值图像大小
params.setPreviewSize(size.width, size.height);
params.setPreviewFrameRate(20);
params.setPictureFormat(PixelFormat.YCbCr_420_SP);
camera.setParameters(params);
//讲摄像头的图像设置到绘制组件中
camera.setPreviewDisplay(holder);
camera.setPreviewCallback(new Camera.PreviewCallback(){
@Override
public void onPreviewFrame(byte[] bytes, Camera camera) {
//...省略细节...处理摄像头传来的图片,将bytes转换成image,当然可以不转换,直接发送更高效吧
//交给线程去发送
thread.setSendCameraImage(image);
}
});
camera.startPreview();
} catch (Exception e) {
showToast(this, "开启摄像头遇到了错误!");
}
}
void closeCamera() {
//...
camera.stopPreview();
camera.setPreviewCallback(null);
camera.release();
camera = null;
}
}
public class SenderThread extends BaseThread {
private int cameraCount = 0;
public boolean isSending = false;
private DeviceInfo info;
private YuvImage image = null;
public SenderThread(Activity context, DeviceInfo info, Handler handler){
super(context, handler);
this.info = info;
}
//...
public void setCameraCount(int cameraCount) {
this.cameraCount = cameraCount;
}
public void canFind(boolean isFind) {
if(isFind==true && thread==null) {
this.thread = new Thread(new Runnable() {
@Override
public void run() {
// TODO Auto-generated method stub
String errMsg = "未知错误";
try {
mSocket = new DatagramSocket(null);
//...绑定开放的30000端口
mSocket.bind(new InetSocketAddress(FIND_CAMERA_PORT));
do {
//...
DatagramPacket pack = new DatagramPacket(new byte[1028], 1028);
try {
//接收数据,等待中,会阻塞
mSocket.receive(pack);
}catch (Exception e){
e.printStackTrace();
throw e;
}
//获取发来请求的设备地址
SocketAddress sendAddress = pack.getSocketAddress();
//...
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(pack.getData()));
Integer code = (Integer) ois.readObject();
//...
switch (code){
case GET_CAMERA_IP:
{
//...判断请求1,将摄像头的数据包装成data,封装在报文中回发过去
DatagramPacket packet = new DatagramPacket(data, data.length, sendAddress);
mSocket.send(packet);
}
break;
case RET_CAMERA_IP:
{
isSending = true;
//...判断请求2,处理一帧图片回发过去
sendImage(image, sendAddress, sendTime);
isSending = false;
}
break;
default:
}
}while (!thread.isInterrupted() && mSocket!=null);
} catch (Exception e) {
errMsg = e.getMessage();
} finally {
cancelThread(errMsg);
}
}
});
this.thread.start();
}else{
cancelThread();
}
}
private void sendImage(YuvImage image, SocketAddress sendAddress, String sendTime) throws Exception {
ByteArrayOutputStream outStream = new ByteArrayOutputStream();
//将图片转换成数据流,压缩了图片就变小,减少传输量
image.compressToJpeg(new Rect(0,0, image.getWidth(), image.getHeight()), 80, outStream);
//...定义缓存大小,转换图片数据流
byte[] buffer = new byte[1024];
ByteArrayInputStream bais = new ByteArrayInputStream(outStream.toByteArray());
try{
int len;
DatagramPacket pack;
//...读取图片数据流,并拆分几次分发出去
while((len = bais.read(buffer, 0, buffer.length)) != -1) {
pack = new DatagramPacket(buffer, len, sendAddress);
mSocket.send(pack);
}
//分发完成后,最后发一个结束信息,告诉接收方这一帧图片已发完
byte[] end = (PACKET_END+sendTime).getBytes();
pack = new DatagramPacket(end, end.length, sendAddress);
mSocket.send(pack);
}catch (Exception e){
e.printStackTrace();
}finally {
bais.close();
}
}
public void setSendCameraImage(YuvImage image) {
if (isSending()) {
return;
}
this.image = image;
}
public boolean isSending() {
return isSending;
}
}
开启AP隔离
,再点保存就可以了,取消AP隔离这样能让局域网的各种设备可互相连通,不需要连接到互联网小提示
- 为了安全起见,路由器中不建议对访客开放的WIFI网络中禁用AP隔离哦,
- 有些路由器中有访客WIFI开关,这个是没有AP隔离可禁用的
- 没有WIFI路由器的话,可用其中的一个手机开启WIFI热点功能代替,然后安装上面开发的APP,点击开启摄像头按钮就可以了,其它的手机都能扫描到这个摄像头的