在我们的应用设计中,有这么一个需求,将一台已连接无人机的Android手机(主机)的图传发送给另一台手机(从机),并且从机也可以控制主机的一些操作,以此达到无人机协作的目的。发送数据我们可以通过socket来实现,但前提是从机或是主机如何知道对方的IP和端口呢?
Wifi P2P
Android有一种连接方式叫 Wi-Fi点对点(P2P),他不需要组织局域网环境,在手机两端打开wifi就可以搜索到对方,主机通过注册服务的方式,将自己的IP和端口以参数携带的方式暴露出去,从机通过搜索服务的方式搜索周边的服务,将搜索到的服务进行解析对比取出IP和端口值,从机最终通过socket往这个解析成功的IP和端口发送数据。
目的
在接下来进行的一切操作中,我们要达到的目的有两个:
- 获取拓展参数
- 解析拿到IP
注册服务
wifip2p服务注册需要几个主要的参数:
- serviceName : 服务的名称
- serviceType : 服务类型,命名格式为
_<protocol>._<transportlayer>
- txtMap : 拓展参数 服务名称是我们在从机搜索时匹配对方的依据;serviceType是服务的一种类型,比如我们接触最多的有打印机服务
_ipp._tcp
;txtMap是一个字典型的数据,他可以随注册服务一块暴露出去,比如主机开启了三个socket server,我们需要将这三个socket server的端口告知从机,就可以采用拓展参数的方式。
构建服务
mManager = (WifiP2pManager) context.getSystemService(Context.WIFI_P2P_SERVICE); mChannel = mManager.initialize(context, context.getMainLooper(), null); //模拟主机的图传端口是11021 map.put("image_port","11021"); p2pDnsSdServiceInfo = WifiP2pDnsSdServiceInfo.newInstance(serviceName, serviceType, map); 复制代码
启动服务
//添加服务 mManager.addLocalService(mChannel, p2pDnsSdServiceInfo,listener); //启动服务 mManager.discoverPeers(mChannel, null); 复制代码
搜索服务
搜索服务的逻辑会比较有点复杂,他需要配合BroadCastReceiver一块来实现
初始化广播监听
mManager = (WifiP2pManager) context.getSystemService(Context.WIFI_P2P_SERVICE); mChannel = mManager.initialize(context, context.getMainLooper(), null); broadCastReceiver = new WifiBroadCastReceiver(mManager, mChannel, this); context.registerReceiver(broadCastReceiver, intentFilter); 复制代码
广播会实时监听当前的WifiP2p网络状态是否已连接,如果是连接状态的话,则直接返回连接的结果信息,也就是返回搜索到的服务的IP,这个地方有一个注意点,后面再说
WifiBroadCastReceiver.java
class WifiBroadCastReceiver extends BroadcastReceiver { WifiP2pManager mManager; WifiP2pManager.Channel mChannel; WifiP2pManager.ConnectionInfoListener listener; public WifiBroadCastReceiver(WifiP2pManager mManager, WifiP2pManager.Channel mChannel, WifiP2pManager.ConnectionInfoListener listener) { this.listener = listener; this.mChannel = mChannel; this.mManager = mManager; } @Override public void onReceive(Context context, Intent intent) { if (WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION.equals(intent.getAction())) { if (mManager == null) { return; } NetworkInfo networkInfo = intent.getParcelableExtra(WifiP2pManager.EXTRA_NETWORK_INFO); if (networkInfo.isConnected()) { //注意此请求,后面再讲解 mManager.requestConnectionInfo(mChannel, new WifiP2pManager.ConnectionInfoListener); } } } } 复制代码
添加搜索监听,获取拓展参数
WifiP2pManager.DnsSdTxtRecordListener txtListener = new WifiP2pManager.DnsSdTxtRecordListener() { @Override public void onDnsSdTxtRecordAvailable(String fullDomain, Map record, WifiP2pDevice device) { //record 就是服务端发送出去的拓展参数 //fullDomain 服务端的serviceName+".local" //device 拿到服务的一些device信息,可以拿到mac地址 device.deviceAddress } }; //设置监听 mManager.setDnsSdResponseListeners(mChannel, null, txtListener); //添加到服务 serviceRequest = WifiP2pDnsSdServiceRequest.newInstance(); mManager.addServiceRequest(mChannel, serviceRequest, new WifiP2pManager.ActionListener()); //开启搜索 mManager.discoverServices(mChannel, new WifiP2pManager.ActionListener()); 复制代码
在wifip2p发起搜索的时候,如果搜索到对方会触发 WifiP2pManager.DnsSdTxtRecordListener
监听,但这仅仅只是一个搜索到对方的过程,并且在该回调中是拿不到真正的服务端IP值的,此回调只能拿到拓展参数和服务端的物理设备信息
请求服务端连接
在搜索端发现服务的时候,接下来就是一个请求的过程,在 WifiP2pManager.DnsSdTxtRecordListener
监听中发起connect连接,这个过程就是请求希望自己与服务端建立连接,服务端会收到一个由系统弹出的dialog,是否同意客户端连接
//将搜索到的服务的mac地址添加到配置里面,以备后面对该地址发起连接操作 WifiP2pConfig config = new WifiP2pConfig(); config.deviceAddress = device.deviceAddress; config.groupOwnerIntent = 0; if (serviceRequest != null){ //移除服务 mManager.removeServiceRequest(mChannel, serviceRequest,null); } //请求建立连接 mManager.connect(mChannel, config, new WifiP2pManager.ActionListener() { @Override public void onSuccess() { LogUtils.log("P2PManager connect onSuccess "); } @Override public void onFailure(int errorCode) { LogUtils.log("P2PManager connect onFailure errorCode=" + errorCode); } }); 复制代码
解析IP
当服务端选择同意的时候,相当于是激活了WifiP2pManager的连接,会触发在上面注册的广播,networkInfo.isConnected
就会返回 true
,然后开启 mManager.requestConnectionInfo(mChannel,new WifiP2pManager.ConnectionInfoListener);
的请求,触发 onConnectInfoAvailable
方法
WifiP2pManager.ConnectionInfoListener.java
@Override public void onConnectionInfoAvailable(WifiP2pInfo info) { try { //todo 获取服务端IP地址 InetAddress.getByName(info.groupOwnerAddress.getHostAddress()) } catch (Exception e) { e.printStackTrace(); } } 复制代码
结论
从上面的一系列过程中,我们可以整理出一个流程出来,服务端的注册还是比较简单的,我们来整理下搜索端:
- 初始化搜索监听
- 开启搜索
- 回调搜索监听
- 请求建立连接
- 解析服务IP
看似流程明白了,但在我们实践的过程中,这个流程是会发生微妙的变化的,在我们的讲解中,是以一个完全没有建立过连接的设备来阐述的,假设一种情况,在我进行了上面的一波操作后,我们又进行了一次搜索对方的操作,大家会觉得这样的流程会是怎样的呢?他就会发生:
- 初始化搜索监听
- 开启搜索
- 解析服务IP
- 回调搜索监听
有没有发现 请求建立连接
的过程没有了,而且在开启搜索之后,先返回的解析服务IP,然后 回调搜索监听
拿到拓展参数值,这是什么原因造成的呢?最主要的原因是在我们第一次建立连接的时候,服务端和搜索端就已经完成了连接的操作,在第二次搜索时广播监听到 WifiP2pManager
的 networkInfo,isConnected()
为 true
,所以就先发起了 解析服务IP
的操作,所以,回调搜索监听
就会晚一点达到。
在我们之前的业务中,最先是在 回调搜索监听
中先拿到拓展参数,然后设置到全局,最后在 解析服务IP
中拿到IP地址,并且将这个全局的拓展参数一并返回,然后再实践中发现了上面阐述的问题,后来,我们是这么解决的:
最终回调
InetAddress inetAddress; public void callbackSuccess(InetAddress inetAddress) { //存储有效地址到全局 if (inetAddress != null) { this.inetAddress = inetAddress; } //判断拓展参数和地址是否都有值 if (p2pServices != null && p2pServices.size() > 0 && inetAddress != null) { //返回结果 WifiP2pClient.this.clientCallBack.onSuccess(inetAddress, p2pServices); } } 复制代码
回调搜索监听
WifiP2pManager.DnsSdTxtRecordListener txtListener = new WifiP2pManager.DnsSdTxtRecordListener() { @Override public void onDnsSdTxtRecordAvailable(String fullDomain, Map record, WifiP2pDevice device) { p2pServices.clear(); p2pServices.addAll(record); callbackSuccess(null); } } 复制代码
解析服务IP
@Override public void onConnectionInfoAvailable(WifiP2pInfo info) { callbackSuccess(InetAddress.getByName(info.groupOwnerAddress.getHostAddress())); } 复制代码
由于 回调搜索监听
和 解析服务IP
两个操作都是不固定的,所以,采用了全局设置有效参数来解决问题。
注意
- wifi p2p 获取\设置拓展参数必须在API 21以上
- wifi p2p 的serviceName不能为中文
- wifi p2p 的serviceType 格式为
_<protocol>._<transportlayer>
,千万不要在最后加.
- wifi p2p 二次连接先返回的解析IP,后触发参数解析
你以为就这么结束了吗?No,业务场景继续升级,我们需要实现跨平台操作,实现Android与iOS的互通,接下来,又要进入另一个话题 NsdManager
Nsd(network service discovery)
Network service discovery (NSD) gives your app access to services that other devices provide on a local network 复制代码
正如官往介绍,NSD要想实现两端手机的通信必须是在一个局域网环境下才能搜索到对方。NSD方式显然没有wifip2p那么便捷,需要自己去构建一个局域网,局域网环境可以通过一台设备开启热点,让另一台设备连接。NSD还有一个过人之处,那就是跨平台,它可以搜索到iOS设备
暴露出去服务,拿到对方的IP和端口,github有一份示例 demo,可以先从它入手学习。
目的
在接下来进行的一切操作中,我们要达到的目的有两个:
- 获取拓展参数
- 解析拿到IP
- 解析拿到port
注册服务
Nsd注册服务和wifiP2p差不多:
- serviceName
- serviceType
- setPort 设置端口
- setAttribute 设置拓展参数 Nsd参数设置会比wifiP2p多一个设置端口的功能,我们在上面讲解wifiP2p将socket server的端口暴露出去时,采用的是拓展参数的形式,但这个地方是有限制的,就是在API 21以下,拓展参数的获取和设置是没有用的,在Nsd上面也是如此,所以,Nsd在系统兼容方面多了一个选择和保障。
构建服务
mNsdManager = (NsdManager) context.getSystemService(Context.NSD_SERVICE); serviceInfo = new NsdServiceInfo(); serviceInfo.setServiceName(serviceName); serviceInfo.setServiceType(serviceType); //如果要设置端口的话,该值必须大于0 serviceInfo.setPort(port);//port must >0 //设置拓展参数 if (map != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { for (Map.Entry<String, String> m : map.entrySet()) { serviceInfo.setAttribute(m.getKey(), m.getValue()); } } 复制代码
启动服务
mRegistrationListener = new NsdManager.RegistrationListener() { @Override public void onServiceRegistered(NsdServiceInfo NsdServiceInfo) {} @Override public void onRegistrationFailed(NsdServiceInfo serviceInfo, int errorCode) {} @Override public void onServiceUnregistered(NsdServiceInfo arg0) {} @Override public void onUnregistrationFailed(NsdServiceInfo serviceInfo, int errorCode) {} }; //注册服务 mNsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, mRegistrationListener); 复制代码
搜索服务
Nsd的搜索相对于wifiP2p来说十分的简单,他不需要wifip2p建立连接的过程,对方在暴露出服务时,搜索端搜索到对方时可以直接拿到对方的IP、端口和拓展参数,所以十分的方便
开启搜索监听
private NsdManager.DiscoveryListener nsDicListener = new NsdManager.DiscoveryListener() { @Override public void onDiscoveryStarted(String serviceType) {} @Override public void onStopDiscoveryFailed(String serviceType, int errorCode) {} @Override public void onStartDiscoveryFailed(String serviceType, int errorCode) {} @Override public void onServiceLost(NsdServiceInfo serviceInfo) { } @Override public void onServiceFound(NsdServiceInfo serviceInfo) { //判断搜索到的服务名称是否匹配服务端配置的名称 if (serviceName.equals(serviceInfo.getServiceName())) { //开启解析服务 resolveNsd(serviceInfo); } } @Override public void onDiscoveryStopped(String serviceType) {} }; mNsdManager = (NsdManager) context.getSystemService(Context.NSD_SERVICE); mNsdManager.discoverServices(serviceType, NsdManager.PROTOCOL_DNS_SD, nsDicListener); 复制代码
解析服务
private void resolveNsd(NsdServiceInfo serviceInfo) { mNsdManager.resolveService(serviceInfo, new NsdManager.ResolveListener() { @Override public void onResolveFailed(NsdServiceInfo serviceInfo, int errorCode) {} @Override public void onServiceResolved(NsdServiceInfo nsdServiceInfo) { HashMap<String, String> serviceMap = new HashMap<>(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { //获取拓展参数 Map<String, byte[]> map = nsdServiceInfo.getAttributes(); for (Map.Entry<String, byte[]> m : map.entrySet()) { serviceMap.put(m.getKey(), new String(m.getValue(), 0, m.getValue().length)); } } //成功回调结果 WifiNsdClient.this.clientCallBack.onSuccess(nsdServiceInfo.getServiceName(), nsdServiceInfo.getHost(), nsdServiceInfo.getPort(), serviceMap); } }); } 复制代码
结论
Nsd的整个过程并不难,过程也非常的简单,他没有wifiP2p混乱的步骤,也没有广播参与,也没有建立连接的过程,唯一缺点就是需要自建局域网,Nsd搜索流程为:
- 开启搜索
- 解析服务拿到端口、ip、拓展参数
当然,在实践过程中,也发现了Nsd的弊端,在我们的业务中,有可能会有两个飞手,他们都在一个局域网中,并且他们都开启了两个服务等待从机进行连接,从机在搜索的时候肯定会发现两个服务,然后对这两个服务进行解析,但是,我们发现,在第一个服务解析时返回的都是成功的,第二次解析时永远都是失败的,然后我们根据返回的 errorCode
进行源码跟踪,跟踪到返回的内容是 Indicates that the operation failed beacause it is already active
, 意思就是当前Nsd解析时处于激活的状态,所以操作失败。根据这段内容我们找到了源码的出错位置
NsdService
... case NsdManager.RESOLVE_SERVICE: if (DBG) Slog.d(TAG, "Resolve service"); servInfo = (NsdServiceInfo) msg.obj; clientInfo = mClients.get(msg.replyTo); //如果mResolvedService不为空,则直接抛出错误 if (clientInfo.mResolvedService != null) { replyToMessage(msg, NsdManager.RESOLVE_SERVICE_FAILED, NsdManager.FAILURE_ALREADY_ACTIVE); break; } id = getUniqueId(); //解析服务操作 if (resolveService(id, servInfo)) { //创建mResolvedService clientInfo.mResolvedService = new NsdServiceInfo(); storeRequestMap(msg.arg2, id, clientInfo, msg.what); } else { replyToMessage(msg, NsdManager.RESOLVE_SERVICE_FAILED, NsdManager.FAILURE_INTERNAL_ERROR); } ... 复制代码
从源码中可以看到,在第一次解析服务时,clientInfo.mResolveService
为空,所以后面就会开始创建 mResolvedService
,然后进行解析,如果这时候第二个服务进来了,clientInfo.mResolveService
肯定是不为空的,所以,就会调用 replyToMessage
方法,触发我们刚刚接收到的错误信息。
但也不是说Nsd不能解析多个服务,只是在解析一个服务时是一个耗时的任务,但搜索服务是非常快速的,我们必须要等一个服务解析完成时,才可以进行下一个解析,源码如下:
case NativeResponseCode.SERVICE_GET_ADDR_SUCCESS: /* NNN resolveId hostname ttl addr */ try { clientInfo.mResolvedService.setHost(InetAddress.getByName(cooked[4])); clientInfo.mChannel.sendMessage(NsdManager.RESOLVE_SERVICE_SUCCEEDED, 0, clientId, clientInfo.mResolvedService); } catch (java.net.UnknownHostException e) { clientInfo.mChannel.sendMessage(NsdManager.RESOLVE_SERVICE_FAILED, NsdManager.FAILURE_INTERNAL_ERROR, clientId); } stopGetAddrInfo(id); removeRequestMap(clientId, id, clientInfo); //重置为null clientInfo.mResolvedService = null; break; 复制代码
在解析成功的回调中,最后会把 mResolveService
重置为null,这样再次解析的话,就不会抛出错误信息。
由于多次解析服务会产生问题,所以,我们要保证搜索端搜索到的服务是唯一确定的,这样就可以避免多服务解析的问题,最终我们给的解决方案是从serviceName中入手,在Nsd中,serviceName的作用并没有那么大,我们完全可以利用他来达到传参的目的,我们产品设计是主机展示二维码内容,从机扫码进行连接,二维码内容是一串随机码加平台信息,随机码的主要目的是为了区别不同Master服务,然后Master将这个二维码内容设置到Nsd的serviceName中,然后暴露服务,从机扫码拿到这个二维码内容,然后比对Nsd搜索到的serviceName是否与从机扫到的二维码内容一致,是的话,就直接解析。
注意
- Nsd 不能搜索多个满足条件的服务,Nsd服务解析一次只允许解析一个服务,下个服务的解析必须等当前解析完成才能解析
- Nsd设置端口必须大于0
- Nsd 获取\设置拓展参数必须在API 21以上
总结
无人机开发是有趣的,但也是充满各种挑战的,比如主机同步视频给从机,如何给一帧数据分段,怎么分稳定,从机接收时如何拼接完整的一帧数据显示。最后,也可以体验下我们的产品 Mesh Lite。