起源
晚上在家里洗澡的时候,突然想听听歌,自high一把,拿起洗漱柜上的手机放音乐,不过因为手上的水,导致屏幕按钮点击特别烦,结果它掉地上了。这时候突然有一种想法,我用Android能不能实现类似"天猫精灵"这些东西呢?
正文
概述
其实现流程共分3步,分别为寻址授权,局域网通信,外网通信。其总体架构图如下所示:
如上所示,整体架构分2个模块,分别为家庭局域网模式和互联网模式,初始状态时,手机通过家庭局域网获取到Android设备的IP信息以及相应的授权任务,从而获得设备操作权限,之后通过局域网通讯的方式进行业务操作,同时该设备会与服务端进行任务同步。当该通过授权的手机在互联网模式下时,可进行任务下发,此时家庭局域网中的Android设备会同步到来自服务端下发的任务,进行相应的业务操作。
寻址授权
该流程为架构实现的第一步,也是实现局域网通讯的前提。因为设备处于无线模式下,可能会导致IP前后出现变化,所以每次局域网通讯之前需要先获取到设备的IP地址,实现该功能的方案有3种,一种是IP轮询检索,其次是蓝牙配对,最后一种是UDP广播。
IP轮询检索:即从1~255进行一个个的socket连接测试,Android设备端进行accept,手机端进行连接尝试,当手机端获取到来自设备端的返回时,说明当前的IP为该设备的IP地址。但是该方法耗时且对性能不足的特点,此案中不引入。
蓝牙配对:通过手机设备的蓝牙进行检索附近的蓝牙设备,然后进行配对授权,因为考虑到该功能的实现需要蓝牙服务,提升了设备成本,其次蓝牙服务只能一对一进行交互服务,当存在多部手机设备时,无法满足该功能。最后因为蓝牙服务差不多为10~15m的覆盖范围,考虑家庭中存在墙面等情况,该方案并不合适。
UDP广播:使用UDP协议进行信息的传输之前不需要建立连接。换句话说就是客户端向服务器发送信息,客户端只需要给出服务器的ip地址和端口号,然后将信息封装到一个待发送的报文中并且发送出去。至于服务器端是否存在,或者能否收到该报文,客户端根本不用管。其中广播UDP与单播UDP的区别就是IP地址不同,广播使用广播地址255.255.255.255,将消息发送到在同一广播网络上的每个主机。该方案也是本案所采用的方案。
ps:androidSDK中在android.net.nsd目录下存在NsdManager一个类,该类能够实现局域网下面的android设备通讯,并且SDK已经提供了相应的封装,使用起来非常方便,实现原理是通过网络服务的发现服务NsdService,其基于苹果的Bonjour服务发现协议,支持远程服务的发现和零配置,相对考虑到IOS的实现,怕出现兼容性问题,所以该方案暂时不进行考虑。
实现流程:
实现代码:
手机设备请求方:
public abstract class DeviceSearchWorker extends Thread {
private static final String TAG = "DeviceSearchWorker";
private static final int RECEIVE_TIME_OUT = 15000; // 接收超时时间
private static final byte PACKET_TYPE_FIND_DEVICE_REQ_13 = 0x13; // 搜索请求
private static final byte PACKET_TYPE_FIND_DEVICE_RSP_14 = 0x14; // 搜索响应
private static final byte PACKET_TYPE_FIND_DEVICE_CHK_15 = 0x15; // 搜索确认
private static final int DEVICE_FIND_PORT = 10000;
private static final int RESPONSE_DEVICE_MAX = 200; //接收消息的最大次数
private static final byte PACKET_DATA_TYPE_DEVICE_NAME_20 = 0x20;
private static final byte PACKET_DATA_TYPE_DEVICE_ROOM_21 = 0x21;
private String deviceIP; //发送广播之后,设备返回来的设备ip地址
DatagramSocket socket = null;
private Set deviceSet;
public DeviceSearchWorker(){
deviceSet = new HashSet<>();
}
private Handler myHandler = new Handler(Looper.getMainLooper());
@Override
public void run() {
super.run();
try{
onPushDeviceSearchStartMsg();
socket = new DatagramSocket();
socket.setSoTimeout(RECEIVE_TIME_OUT);
byte[] sendData = new byte[1024];
InetAddress broadIp = InetAddress.getByName("255.255.255.255"); //来一个广播
DatagramPacket packet = new DatagramPacket(sendData, sendData.length, broadIp, DEVICE_FIND_PORT);
for (int i = 0; i < 3; i++){
packet.setData(packetData(i + 1, PACKET_TYPE_FIND_DEVICE_REQ_13));
//发送广播
socket.send(packet);
// 监听来信
byte[] receData = new byte[1024];
DatagramPacket recePacket = new DatagramPacket(receData, receData.length);
int rspCount = RESPONSE_DEVICE_MAX;
while (rspCount-- > 0) {
LogUtils.i(TAG, "DatagramPacket >>> " + rspCount);
recePacket.setData(receData);
socket.receive(recePacket);
if (recePacket.getLength() > 0) {
deviceIP = recePacket.getAddress().getHostAddress();
if (parsePack(recePacket)) {
LogUtils.i(TAG, "设备上线:" + deviceIP);
// 发送一对一的确认信息。使用接收报,因为接收报中有对方的实际IP,发送报时广播IP
recePacket.setData(packetData(rspCount, PACKET_TYPE_FIND_DEVICE_CHK_15)); // 注意:设置数据的同时,把recePack.getLength()也改变了
socket.send(recePacket);
onPushDeviceSearchFinishedMsg();
}
}
}
}
}catch (SocketException e){
e.printStackTrace();
onPushDeviceSearchFailedMsg();
} catch (UnknownHostException e) {
e.printStackTrace();
onPushDeviceSearchFailedMsg();
} catch (IOException e) {
e.printStackTrace();
onPushDeviceSearchFailedMsg();
} finally {
if(socket != null){
socket.close();
}
}
}
private void onPushDeviceSearchFailedMsg() {
myHandler.post(new Runnable() {
@Override
public void run() {
onPushDeviceSearchFailedMsg();
}
});
}
private void onPushDeviceSearchFinishedMsg() {
myHandler.post(new Runnable() {
@Override
public void run() {
onDeviceSearchFinished(deviceSet);
}
});
}
private void onPushDeviceSearchStartMsg() {
myHandler.post(new Runnable() {
@Override
public void run() {
onDeviceSearchStart();
}
});
}
/**
* 解析报文
* 协议:$ + packType(1) + data(n)
* data: 由n组数据,每组的组成结构type(1) + length(4) + data(length)
* type类型中包含name、room类型,但name必须在最前面
*/
private boolean parsePack(DatagramPacket pack) {
if (pack == null || pack.getAddress() == null) {
return false;
}
String ip = pack.getAddress().getHostAddress();
int port = pack.getPort();
for (DeviceBean d : deviceSet) {
if (d.getIp().equals(ip)) {
return false;
}
}
int dataLen = pack.getLength();
int offset = 0;
byte packType;
byte type;
int len;
DeviceBean device = null;
if (dataLen < 2) {
return false;
}
byte[] data = new byte[dataLen];
System.arraycopy(pack.getData(), pack.getOffset(), data, 0, dataLen);
if (data[offset++] != '$') {
return false;
}
packType = data[offset++];
if (packType != PACKET_TYPE_FIND_DEVICE_RSP_14) {
return false;
}
while (offset + 5 < dataLen) {
type = data[offset++];
len = data[offset++] & 0xFF;
len |= (data[offset++] << 8);
len |= (data[offset++] << 16);
len |= (data[offset++] << 24);
if (offset + len > dataLen) {
break;
}
switch (type) {
case PACKET_DATA_TYPE_DEVICE_NAME_20:
String name = new String(data, offset, len, Charset.forName("UTF-8"));
device = new DeviceBean();
device.setName(name);
device.setIp(ip);
device.setPort(port);
break;
case PACKET_DATA_TYPE_DEVICE_ROOM_21:
String room = new String(data, offset, len, Charset.forName("UTF-8"));
if (device != null) {
device.setRoom(room);
}
break;
default: break;
}
offset += len;
}
if (device != null) {
deviceSet.add(device);
return true;
}
return false;
}
/**
* 协议:$ + packType(1) + sendSeq(4) + [deviceIP(n<=15)]
* @param seq 发送序列
* @param packetType 报文类型
* @return
*/
private byte[] packetData(int seq, byte packetType) {
byte[] data = new byte[1024];
int offset = 0;
data[offset++] = '$';
data[offset++] = packetType;
seq = seq == 3 ? 1 : ++seq; // can't use findSeq++
data[offset++] = (byte) seq;
data[offset++] = (byte) (seq >> 8 );
data[offset++] = (byte) (seq >> 16);
data[offset++] = (byte) (seq >> 24);
if (packetType == PACKET_TYPE_FIND_DEVICE_CHK_15) {
byte[] ips = deviceIP.getBytes(Charset.forName("UTF-8"));
System.arraycopy(ips, 0, data, offset, ips.length);
offset += ips.length;
}
byte[] result = new byte[offset];
System.arraycopy(data, 0, result, 0, offset);
return result;
}
public abstract void onDeviceSearchStart();
public abstract void onDeviceSearchFinished(Set deviceSet);
public abstract void onDeviceSearchFailed();
public void close(){
try{
if(socket != null){
socket.close();
}
}catch (Exception e){
e.printStackTrace();
}
this.interrupt();
}
}
Android设备接收方:
public abstract class DeviceClientWorker extends Thread {
/**
* 设备对应的port
*/
private static final int DEVICE_FIND_PORT = 10000;
private static final String TAG = "DeviceClientWorker";
private static final int RECEIVE_TIME_OUT = 10000; // 接收超时时间,应小于等于主机的超时时间10000
private static final byte PACKET_TYPE_FIND_DEVICE_REQ_13 = 0x13; // 搜索请求
private static final byte PACKET_TYPE_FIND_DEVICE_RSP_14 = 0x14; // 搜索响应
private static final byte PACKET_TYPE_FIND_DEVICE_CHK_15 = 0x15; // 搜索确认
private static final byte PACKET_DATA_TYPE_DEVICE_NAME_20 = 0x20; //设备名称
private static final byte PACKET_DATA_TYPE_DEVICE_ROOM_21 = 0x21; //设备所处的房间名
private static final int RESPONSE_DEVICE_MAX = 200; // 响应设备的最大个数,防止UDP广播攻击
private static Handler workHandler = new Handler(Looper.getMainLooper());
/**
* 设备名称
*/
private String deviceName;
/**
* 房间名称
*/
private String room;
private boolean isRunning;
DatagramSocket socket = null;
public DeviceClientWorker(String deviceName, String room){
this.deviceName = deviceName;
this.room = room;
isRunning = true;
}
@Override
public void run() {
super.run();
try {
socket = new DatagramSocket(DEVICE_FIND_PORT);
byte[] data = new byte[1024];
DatagramPacket packet = new DatagramPacket(data, data.length);
while (isRunning){
LogUtils.i(TAG, "waitting receive data");
socket.receive(packet); //等待接收数据
LogUtils.i(TAG, "data received");
if(verifySearchData(packet)){
byte[] backData = packData();
LogUtils.i(TAG, "back device info");
DatagramPacket sendPacket = new DatagramPacket(backData, backData.length, packet.getAddress(), packet.getPort());
socket.send(sendPacket);
socket.setSoTimeout(RECEIVE_TIME_OUT);
LogUtils.i(TAG, "waitting for server veritify again");
socket.receive(packet);
if(verifyCheckData(packet)){ //验证确认信息
pushDeviceClientSearchedMsg((InetSocketAddress)packet.getSocketAddress());
}
}
socket.setSoTimeout(0); // 连接超时还原成无穷大,阻塞式接收
}
} catch (SocketException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}finally {
if(socket != null){
socket.close();
}
}
}
private void pushDeviceClientSearchedMsg(final InetSocketAddress socketAddress) {
workHandler.post(new Runnable() {
@Override
public void run() {
onDeviceSearchedCallBack(socketAddress);
}
});
}
/**
* 当设备被发现时执行
*/
public abstract void onDeviceSearchedCallBack(InetSocketAddress socketAddr);
/**
* 验证再次确认信息
* 协议:$ + packType(1) + sendSeq(4) + deviceIP(n<=15)
* packType - 报文类型
* sendSeq - 发送序列
* deviceIP - 设备IP,仅确认时携带
* @param packet
* @return
*/
private boolean verifyCheckData(DatagramPacket packet) {
if(packet.getLength() < 6){
return false; //前面的$ + packType(1) + sendSeq(4) 共占6位
}
byte[] data = packet.getData();
int offset = packet.getOffset();
int sendSeq;
if (data[offset++] != '$' || data[offset++] != PACKET_TYPE_FIND_DEVICE_CHK_15) {
return false;
}
sendSeq = data[offset++] & 0xFF;
sendSeq |= (data[offset++] << 8 );
sendSeq |= (data[offset++] << 16);
sendSeq |= (data[offset++] << 24);
if (sendSeq < 1 || sendSeq > RESPONSE_DEVICE_MAX) {
return false;
}
String ip = new String(data, offset, packet.getLength() - offset, Charset.forName("UTF-8"));
LogUtils.i(TAG, "ip from host : " + ip);
return ip.equals(DeviceUtils.getOwnWifiIP());
}
/**
* 搜索响应
* 组装搜索反馈信息
* 协议:$ + packType(1) + data(n)
* data: 由n组数据,每组的组成结构type(1) + length(4) + data(length)
* type类型中包含name、room类型,但name必须在最前面
* @return
*/
private byte[] packData() {
byte[] data = new byte[1024];
int offset = 0;
data[offset++] = '$';
data[offset++] = PACKET_TYPE_FIND_DEVICE_RSP_14;
//追加设备名称信息
byte[] temp = getBytesFromType(PACKET_DATA_TYPE_DEVICE_NAME_20, deviceName);
System.arraycopy(temp, 0, data, offset, temp.length);
offset += temp.length;
temp = getBytesFromType(PACKET_DATA_TYPE_DEVICE_ROOM_21, room);
System.arraycopy(temp, 0, data, offset, temp.length);
offset += temp.length;
byte[] retVal = new byte[offset];
System.arraycopy(data, 0, retVal, 0, offset);
return retVal;
}
/**
* 根据类型 追加数据
* @param dataType
* @param data
* @return
*/
private byte[] getBytesFromType(byte dataType, String data) {
byte[] retVal = new byte[0];
if(data != null){
byte[] tmpData = data.getBytes(Charset.forName("utf-8"));
retVal = new byte[5 + tmpData.length]; //5来源于 type(1) + length(4)
retVal[0] = dataType;
retVal[1] = (byte) tmpData.length;
retVal[2] = (byte) (tmpData.length << 8 );
retVal[3] = (byte) (tmpData.length << 16);
retVal[4] = (byte) (tmpData.length << 24);
System.arraycopy(tmpData, 0, retVal, 5, tmpData.length);
}
return retVal;
}
/**
* 验证接收到的数据是否为约定的合法搜索数据
* 协议:$ + packType(1) + sendSeq(4)
* @param packet
* @return
*/
private boolean verifySearchData(DatagramPacket packet) {
if(packet.getLength() != 6){
return false;
}
byte[] data = packet.getData();
int offset = packet.getOffset();
int sendReq = 0;
//校验格式是否正确
if (data[offset++] != '$' || data[offset++] != PACKET_TYPE_FIND_DEVICE_REQ_13) {
return false;
}
sendReq = data[offset++] & 0xFF;
sendReq |= (data[offset++] << 8 );
sendReq |= (data[offset++] << 16);
sendReq |= (data[offset++] << 24);
return sendReq >= 1 && sendReq <= 3;
}
public void close() {
try{
if(socket != null){
socket.close();
}
}catch (Exception e){
e.printStackTrace();
}
isRunning = false;
this.interrupt();
}
}
考虑到安全性交互,可进行多个不同的协议进行设备认证,以上代码并不包含token验证机制,可后续进行追加
局域网通信
该环节需要将Android设备当做服务器,来处理接收客户端的业务请求。因为在寻址授权那一环节时,使用UDP来实现功能。当时想到的是后续的索性全部使用UDP来实现好了,全部使用自定义协议。但是这样一来,发现后续所有的业务需求的实现都无法借鉴我们平时通用的方法了,此时就想,是不是能模拟Http请求,使用Android设备实现后台服务?
Android设备服务端实现:
通常我们搭建后台Server所使用的是Tomcat容器,翻阅了一下资料,发现Apache官方提供了一个叫HttpCore这个包,可以用它来建立客户端、代理、服务端Http服务同时支持同步异步服务,相关链接地址,除此之外,还需要模拟HttpServlet,并对其进行Controller、Service、Dao三层模块划分,这里介绍一个Android的开源框架:AndServer
其实现的系统流程图如下所示:
应用层运行时流程图如下所示:
该框架模拟了SpringMVC的注解方式来实现,最后关于Dao层数据库的实现,使用LitePal数据库框架进行实现。
手机局域网客户端实现:
相对来说,客户端的实现非常简单,在通过UDP授权之后,将会获取到来自Android设备的token,以及IP地址,后续的业务请求只需要通过Http请求服务器的方式请求局域网中的Android设备,这里不再进行详细介绍
外网通信
该环节产生的场景来自于当我们身处在非局域网覆盖范围时,但又想要进行Android设备操作,如:迅雷下载
手机实现:
判断当前设备是否进行过授权操作,如存在多个,可进行Android设备的选择,同时进行业务请求操作给服务端
服务端实现:
接收来自手机设备的业务请求,每个请求中会包含需要操作的Android设备ID信息以及业务行为,并对该操作进行SyncKey的自增。
接收来自Android设备的每隔10s的轮询同步请求,返回该ID设备对应的业务行为。
Android设备实现:
当同步到新的SyncKey信息时,更新本地Job数据库,并对新的业务进行流程处理
扩展
- 将家庭电视机集成Android设备的应用,实现媒体资源下载,迅雷下载,室内视频遥控控制
- 在树莓派3中烧入AndroidThings,并在应用中集成科大讯飞语音功能,实现室内无线投屏,窗帘、灯泡开关控制、音乐播放等等
- Android设备集成相机功能,实现视频流RTSP实时传输远程查看家庭监控
- Android设备集成Face++,实现门锁开关报警
- AndroidThings集成一氧化碳传感器,实现家庭燃气检测报警
- 等等等等