起源
晚上在家里洗澡的時候锦担,突然想聽聽歌俭识,自high一把,拿起洗漱柜上的手機放音樂洞渔,不過因為手上的水套媚,導致屏幕按鈕點擊特別煩,結果它掉地上了磁椒。這時候突然有一種想法堤瘤,我用Android能不能實現(xiàn)類似"天貓精靈"這些東西呢?
正文
概述
其實現(xiàn)流程共分3步浆熔,分別為尋址授權本辐,局域網(wǎng)通信,外網(wǎng)通信。其總體架構圖如下所示:
如上所示慎皱,整體架構分2個模塊老虫,分別為家庭局域網(wǎng)模式和互聯(lián)網(wǎng)模式,初始狀態(tài)時茫多,手機通過家庭局域網(wǎng)獲取到Android設備的IP信息以及相應的授權任務祈匙,從而獲得設備操作權限,之后通過局域網(wǎng)通訊的方式進行業(yè)務操作天揖,同時該設備會與服務端進行任務同步夺欲。當該通過授權的手機在互聯(lián)網(wǎng)模式下時,可進行任務下發(fā)今膊,此時家庭局域網(wǎng)中的Android設備會同步到來自服務端下發(fā)的任務些阅,進行相應的業(yè)務操作。
尋址授權
該流程為架構實現(xiàn)的第一步万细,也是實現(xiàn)局域網(wǎng)通訊的前提扑眉。因為設備處于無線模式下,可能會導致IP前后出現(xiàn)變化赖钞,所以每次局域網(wǎng)通訊之前需要先獲取到設備的IP地址腰素,實現(xiàn)該功能的方案有3種,一種是IP輪詢檢索雪营,其次是藍牙配對弓千,最后一種是UDP廣播。
IP輪詢檢索:即從1~255進行一個個的socket連接測試献起,Android設備端進行accept洋访,手機端進行連接嘗試,當手機端獲取到來自設備端的返回時谴餐,說明當前的IP為該設備的IP地址姻政。但是該方法耗時且對性能不足的特點,此案中不引入岂嗓。
藍牙配對:通過手機設備的藍牙進行檢索附近的藍牙設備汁展,然后進行配對授權,因為考慮到該功能的實現(xiàn)需要藍牙服務厌殉,提升了設備成本食绿,其次藍牙服務只能一對一進行交互服務,當存在多部手機設備時公罕,無法滿足該功能器紧。最后因為藍牙服務差不多為10~15m的覆蓋范圍,考慮家庭中存在墻面等情況楼眷,該方案并不合適铲汪。
UDP廣播:使用UDP協(xié)議進行信息的傳輸之前不需要建立連接熊尉。換句話說就是客戶端向服務器發(fā)送信息,客戶端只需要給出服務器的ip地址和端口號桥状,然后將信息封裝到一個待發(fā)送的報文中并且發(fā)送出去帽揪。至于服務器端是否存在硝清,或者能否收到該報文辅斟,客戶端根本不用管。其中廣播UDP與單播UDP的區(qū)別就是IP地址不同芦拿,廣播使用廣播地址255.255.255.255士飒,將消息發(fā)送到在同一廣播網(wǎng)絡上的每個主機。該方案也是本案所采用的方案蔗崎。
ps:androidSDK中在android.net.nsd目錄下存在NsdManager一個類酵幕,該類能夠實現(xiàn)局域網(wǎng)下面的android設備通訊,并且SDK已經(jīng)提供了相應的封裝缓苛,使用起來非常方便芳撒,實現(xiàn)原理是通過網(wǎng)絡服務的發(fā)現(xiàn)服務NsdService,其基于蘋果的Bonjour服務發(fā)現(xiàn)協(xié)議,支持遠程服務的發(fā)現(xiàn)和零配置未桥,相對考慮到IOS的實現(xiàn)笔刹,怕出現(xiàn)兼容性問題,所以該方案暫時不進行考慮冬耿。
實現(xiàn)流程:
實現(xiàn)代碼:
手機設備請求方:
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; //接收消息的最大次數(shù)
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; //發(fā)送廣播之后舌菜,設備返回來的設備ip地址
DatagramSocket socket = null;
private Set<DeviceBean> 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));
//發(fā)送廣播
socket.send(packet);
// 監(jiān)聽來信
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);
// 發(fā)送一對一的確認信息。使用接收報亦镶,因為接收報中有對方的實際IP日月,發(fā)送報時廣播IP
recePacket.setData(packetData(rspCount, PACKET_TYPE_FIND_DEVICE_CHK_15)); // 注意:設置數(shù)據(jù)的同時,把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();
}
});
}
/**
* 解析報文
* 協(xié)議:$ + packType(1) + data(n)
* data: 由n組數(shù)據(jù)缤骨,每組的組成結構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;
}
/**
* 協(xié)議:$ + packType(1) + sendSeq(4) + [deviceIP(n<=15)]
* @param seq 發(fā)送序列
* @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<DeviceBean> 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; // 響應設備的最大個數(shù)精拟,防止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); //等待接收數(shù)據(jù)
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);
}
});
}
/**
* 當設備被發(fā)現(xiàn)時執(zhí)行
*/
public abstract void onDeviceSearchedCallBack(InetSocketAddress socketAddr);
/**
* 驗證再次確認信息
* 協(xié)議:$ + packType(1) + sendSeq(4) + deviceIP(n<=15)
* packType - 報文類型
* sendSeq - 發(fā)送序列
* 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());
}
/**
* 搜索響應
* 組裝搜索反饋信息
* 協(xié)議:$ + packType(1) + data(n)
* data: 由n組數(shù)據(jù)串前,每組的組成結構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;
}
/**
* 根據(jù)類型 追加數(shù)據(jù)
* @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;
}
/**
* 驗證接收到的數(shù)據(jù)是否為約定的合法搜索數(shù)據(jù)
* 協(xié)議:$ + 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();
}
}
考慮到安全性交互荡碾,可進行多個不同的協(xié)議進行設備認證,以上代碼并不包含token驗證機制局装,可后續(xù)進行追加
局域網(wǎng)通信
該環(huán)節(jié)需要將Android設備當做服務器坛吁,來處理接收客戶端的業(yè)務請求劳殖。因為在尋址授權那一環(huán)節(jié)時,使用UDP來實現(xiàn)功能拨脉。當時想到的是后續(xù)的索性全部使用UDP來實現(xiàn)好了哆姻,全部使用自定義協(xié)議。但是這樣一來玫膀,發(fā)現(xiàn)后續(xù)所有的業(yè)務需求的實現(xiàn)都無法借鑒我們平時通用的方法了矛缨,此時就想,是不是能模擬Http請求帖旨,使用Android設備實現(xiàn)后臺服務箕昭?
Android設備服務端實現(xiàn):
通常我們搭建后臺Server所使用的是Tomcat容器,翻閱了一下資料解阅,發(fā)現(xiàn)Apache官方提供了一個叫HttpCore這個包落竹,可以用它來建立客戶端、代理货抄、服務端Http服務同時支持同步異步服務述召,相關鏈接地址,除此之外蟹地,還需要模擬HttpServlet积暖,并對其進行Controller、Service锈津、Dao三層模塊劃分呀酸,這里介紹一個Android的開源框架:AndServer
其實現(xiàn)的系統(tǒng)流程圖如下所示:
應用層運行時流程圖如下所示:
該框架模擬了SpringMVC的注解方式來實現(xiàn),最后關于Dao層數(shù)據(jù)庫的實現(xiàn)琼梆,使用LitePal數(shù)據(jù)庫框架進行實現(xiàn)性誉。
手機局域網(wǎng)客戶端實現(xiàn):
相對來說,客戶端的實現(xiàn)非常簡單茎杂,在通過UDP授權之后错览,將會獲取到來自Android設備的token,以及IP地址煌往,后續(xù)的業(yè)務請求只需要通過Http請求服務器的方式請求局域網(wǎng)中的Android設備倾哺,這里不再進行詳細介紹
外網(wǎng)通信
該環(huán)節(jié)產(chǎn)生的場景來自于當我們身處在非局域網(wǎng)覆蓋范圍時,但又想要進行Android設備操作刽脖,如:迅雷下載
手機實現(xiàn):
判斷當前設備是否進行過授權操作羞海,如存在多個,可進行Android設備的選擇曲管,同時進行業(yè)務請求操作給服務端
服務端實現(xiàn):
接收來自手機設備的業(yè)務請求却邓,每個請求中會包含需要操作的Android設備ID信息以及業(yè)務行為,并對該操作進行SyncKey的自增院水。
接收來自Android設備的每隔10s的輪詢同步請求腊徙,返回該ID設備對應的業(yè)務行為简十。
Android設備實現(xiàn):
當同步到新的SyncKey信息時,更新本地Job數(shù)據(jù)庫撬腾,并對新的業(yè)務進行流程處理
擴展
- 將家庭電視機集成Android設備的應用螟蝙,實現(xiàn)媒體資源下載,迅雷下載民傻,室內視頻遙控控制
- 在樹莓派3中燒入AndroidThings胰默,并在應用中集成科大訊飛語音功能,實現(xiàn)室內無線投屏饰潜,窗簾初坠、燈泡開關控制、音樂播放等等
- Android設備集成相機功能彭雾,實現(xiàn)視頻流RTSP實時傳輸遠程查看家庭監(jiān)控
- Android設備集成Face++,實現(xiàn)門鎖開關報警
- AndroidThings集成一氧化碳傳感器锁保,實現(xiàn)家庭燃氣檢測報警
- 等等等等