1. 程式人生 > >實戰Android Wifi P2p

實戰Android Wifi P2p

在我們的應用設計中,有這麼一個需求,將一臺已連線無人機的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,然後 回撥搜尋監聽 拿到拓展引數值,這是什麼原因造成的呢?最主要的原因是在我們第一次建立連線的時候,服務端和搜尋端就已經完成了連線的操作,在第二次搜尋時廣播監聽到 WifiP2pManagernetworkInfo,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)

Wi-Fi NSD官方介紹

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

最後給出一份WifiManager原始碼