Android收發UDP報文詳解 及 優雅解決接收不到問題
前段時間專案組接了一個研究所專案,移動端這邊需要做一個UDP接收報文的程式APP,其中還涉及到多頁面之間收發報文、動態修改地址、埠號等等。原本編寫這個收發程式並不難,步驟也比較固定,在網上找了相關例子進行二次開發,可是發現UDP報文接收不到,這其中還是隱藏著某些坑,僅以此篇文章來總結其奧妙精髓。
一. DatagramSocket收發報文相關知識點
此節將講解資料報通訊的原理,即不需要面向連線的通訊方式,資料報通訊方式採用的是UDP(User Datagram Protocol)協議,這裡同TCP作比較再次介紹。(對相關知識不熟悉者,可先看此篇部落格稍作了解: http://blog.csdn.net/itermeng/article/details/72970099
1. UDP與TCP學習
UDP(User Datagram Protocol)
非面向連線的提供不可靠的資料包式的資料傳輸協議。類似於從郵局傳送信件的過程,傳送信件是通過郵局系統一站一站進行傳遞,中間也有可能丟失。Java中有些類是基於UDP協議來進行網路通訊的,有DatagramPacket、DatagramSocket、MulticastSocket等類。TCP(Transport Control Protocol)
面向連線的能夠提供可靠的流式資料傳輸的協議。類似於打電話的過程,在撥完電話後兩者之間會先建立連線,為了更好的通話,確保通話連線後兩者開始互相傳輸資訊。相對應的類有URL、URLConnection Socket、ServerSocket等UDP 與 TCP的區別
TCP有建立時間,UDP無
- UDP傳輸有大小限制,每一個數據報需在64K以內
- TCP常見應用:Telnet遠端登入、Ftp檔案傳輸
- UDP常見應用:ping指令,用來檢測網路上某臺伺服器是否還在提供服務。
2. DatagramSocket學習
(1)定義概念
此類表示用來發送和接收資料報包的套接字。
資料報套接字是包投遞服務的傳送或接收點。每個在資料報套接字上傳送或接收的包都是單獨編址和路由的。從一臺機器傳送到另一臺機器的多個包可能選擇不同的路由,也可能按不同的順序到達。
在 DatagramSocket 上總是啟用 UDP 廣播發送。為了接收廣播包,應該將 DatagramSocket 繫結到萬用字元地址。在某些實現中,將 DatagramSocket 繫結到一個更加具體的地址時廣播包也可以被接收。
(2)建構函式總結
建構函式名稱 | 含義 |
---|---|
DatagramSocket() | 構造資料報套接字並將其繫結到本地主機上任何可用的埠。 |
DatagramSocket(int port) | 建立資料報套接字並將其繫結到本地主機上的指定埠。 |
DatagramSocket(int port, InetAddress laddr) | 建立資料報套接字,將其繫結到指定的本地地址。 |
(3)重要方法摘要
方法名稱 | 含義 |
---|---|
void close() | 關閉此資料報套接字。 |
void connect(InetAddress address, int port) | 將套接字連線到此套接字的遠端地址。 |
boolean isClosed() | 返回是否關閉了套接字。 |
void receive(DatagramPacket p) | 從此套接字接收資料報包。 |
void send(DatagramPacket p) | 從此套接字傳送資料報包。 |
DatagramSocket(int port, InetAddress laddr) | 建立資料報套接字,將其繫結到指定的本地地址。 |
3. InetAddress學習
(1)定義概念
此類表示網際網路協議 (IP) 地址。
IP 地址是 IP 使用的 32 位或 128 位無符號數字,它是一種低階協議,UDP 和 TCP 協議都是在它的基礎上構建的。InetAddress 的例項包含 IP 地址,還可能包含相應的主機名(取決於它是否用主機名構造或者是否已執行反向主機名解析)。
(2)建立方法
注意,建立此類事通過類方法而獲取,並非構造方法。
方法名稱 | 含義 |
---|---|
static InetAddress getByAddress(byte[] addr) | 在給定原始 IP 地址的情況下,返回 InetAddress 物件。 |
static InetAddress getByAddress(String host, byte[] addr) | 根據提供的主機名和 IP 地址建立 InetAddress。 |
static InetAddress getByName(String host) | 在給定主機名的情況下確定主機的 IP 地址。 |
4. DatagramPacket學習
(1)定義概念
此類表示資料報包。
資料報包用來實現無連線包投遞服務。每條報文僅根據該包中包含的資訊從一臺機器路由到另一臺機器。從一臺機器傳送到另一臺機器的多個包可能選擇不同的路由,也可能按不同的順序到達。不對包投遞做出保證。
(2)構造方法
建構函式名稱 | 含義 |
---|---|
DatagramPacket(byte[] buf, int length) | 用來接收長度為 length 的資料包 |
DatagramPacket(byte[] buf, int length, InetAddress address, int port) | 構造資料報包,用來將長度為 length 的包傳送到指定主機上的指定埠號 |
二. 傳送、接收UDP報文步驟講解
在此程式中,將UDP報文的傳送、接收操作分別寫入到兩個執行緒中,根據具體需求進行呼叫。
1. 資料報傳送 解析
以下步驟都在傳送執行緒內,需要注意的是傳送UDP報文邏輯單一,順序執行完畢執行緒即可結束,不涉及到後臺等待的需求,所以執行完後即可關閉套接字連線。
傳送步驟
- 構造DatagramSocket物件
- 根據傳送IP 來建立InetAddress物件
- 根據InetAddress物件、傳送埠號、傳送資料 來建立傳送的DatagramPacket資料包物件
- 呼叫DatagramSocket物件的
send(datagramPacket)
方法,傳送UDP報文 - 呼叫DatagramSocket物件的
close()
關閉套接字連線
對應以上步驟,程式碼展示(僅為部分重要程式碼):
Byte[] buf="hello android! ".getBytes();
DatagramSocket sendSocket = new DatagramSocket();
InetAddress serverAddr = InetAddress.getByName(SEND_IP);
DatagramPacket outPacket = new DatagramPacket(buf, buf.length,serverAddr, SEND_PORT);
sendSocket.send(outPacket);
sendSocket.close();
2. 資料報接收 解析
原理
以下步驟都在接收執行緒內,同傳送執行緒大致相同,但需要注意這兩者本質的區別:傳送執行緒裡的邏輯執行一遍即可結束,但是接收執行緒需要在後臺待定等待接收UDP報文,不可執行一遍就結束!相當於在一個介面中,可多次建立傳送執行緒用來發送報文,但是接收執行緒只需在介面初始化時建立,從而一直監聽報文接收(若重新進入介面,邏輯如上)。
所以,在接收執行緒內部需要用到迴圈,在迴圈內部呼叫套接字物件的receive()
方法來接收UDP報文。注意套接字物件的連線關閉,傳送執行緒中單一的邏輯,執行完傳送過程即可關閉連線,但是在接收執行緒中使用了迴圈,所以需要用一個全域性標識量來控制迴圈,若介面退出或銷燬則將標示值置為false,這樣接收執行緒即可結束,再關閉套接字連線。
接收步驟
- 需要根據接收埠號 構造DatagramSocket物件
- 建立傳送的DatagramPacket資料包物件
- 呼叫DatagramSocket物件的
receive(datagramPacket)
方法,接收UDP報文
對應以上步驟,程式碼展示(僅為部分重要程式碼):
DatagramSocket receiveSocket = new DatagramSocket(RECEIVE_PORT);
while(listenStatus){
byte[] inBuf= new byte[1024];
DatagramPacket inPacket=new DatagramPacket(inBuf,inBuf.length);
receiveSocket.receive(inPacket); i if(!inPacket.getAddress().equals(serverAddr)){
throw new IOException("未知名的報文");
}
receiveInfo = inPacket.getData();
receiveHandler.sendEmptyMessage(1);
}
三. 介面收發UDP報文完整程式碼
public class TextActivity extends AppCompatActivity{
/*
* Data
* */
private final static String SEND_IP = "27.18.140.100"; //傳送IP
private final static int SEND_PORT = 8989; //傳送埠號
private final static int RECEIVE_PORT = 8080; //接收埠號
private boolean listenStatus = true; //接收執行緒的迴圈標識
private byte[] receiveInfo; //接收報文資訊
private byte[] buf;
private DatagramSocket receiveSocket;
private DatagramSocket sendSocket;
private InetAddress serverAddr;
private SendHandler sendHandler = new SendHandler();
private ReceiveHandler receiveHandler = new ReceiveHandler();
/*
* UI
* */
private TextView tvMessage;
private Button btnSendUDP;
class ReceiveHandler extends Handler{
@Override
public void handleMessage(Message msg) {
super.handleMessage(msg);
tvMessage.setText("接收到資料了" + receiveInfo.toString());
Toast.makeText(TextActivity.this, "接收到資料了", Toast.LENGTH_SHORT).show();
}
}
class SendHandler extends Handler{
@Override
public void handleMessage(Message msg) {
super.handleMessage(msg);
tvMessage.setText("UDP報文傳送成功");
Toast.makeText(TextActivity.this, "成功傳送", Toast.LENGTH_SHORT).show();
}
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_text);
btnSendUDP = (Button) findViewById(R.id.btn_send);
tvMessage = (TextView) findViewById(R.id.tv_show);
btnSendUDP.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
//點選按鈕則傳送UDP報文
new UdpSendThread().start();
}
});
//進入Activity時開啟接收報文執行緒
new UdpReceiveThread().start();
}
@Override
protected void onDestroy() {
super.onDestroy();
//停止接收執行緒,關閉套接字連線
listenStatus = false;
receiveSocket.close();
}
/*
* UDP資料傳送執行緒
* */
public class UdpSendThread extends Thread
{
@Override
public void run()
{
try
{
buf="i am an android developer, hello android! ".getBytes();
// 建立DatagramSocket物件,使用隨機埠
sendSocket = new DatagramSocket();
serverAddr = InetAddress.getByName(SEND_IP);
DatagramPacket outPacket = new DatagramPacket(buf, buf.length,serverAddr, SEND_PORT);
sendSocket.send(outPacket);
sendSocket.close();
sendHandler.sendEmptyMessage(1);
} catch (Exception e)
{
e.printStackTrace();
}
}
}
/*
* UDP資料接收執行緒
* */
public class UdpReceiveThread extends Thread
{
@Override
public void run()
{
try
{
sendSocket = new DatagramSocket(RECEIVE_PORT);
serverAddr = InetAddress.getByName(SEND_IP);
while(listenStatus)
{
byte[] inBuf= new byte[1024];
DatagramPacket inPacket=new DatagramPacket(inBuf,inBuf.length);
sendSocket.receive(inPacket);
if(!inPacket.getAddress().equals(serverAddr)){
throw new IOException("未知名的報文");
}
receiveInfo = inPacket.getData();
receiveHandler.sendEmptyMessage(1);
}
} catch (Exception e)
{
e.printStackTrace();
}
}
}
}
四. 優雅解決UDP報文接收不到問題
以上程式碼已經可以實現傳送、接收UDP報文了,至少我自測是沒有問題的,是修改好的版本。一開始我實在網上找的版本進行二次修改的,只是那程式碼有些小缺陷,導致有時候接收UDP報文有問題,前2個原因是根據此次工作發現並解決了此問題,如果前兩點未解決,第三點是另一種嘗試。
1. 傳送與接收DatagramSocket需不同
此點很重要,之前在網上找的例子就將接收、傳送執行緒中的DatagramSocket共用同一個全域性變數,這會直接導致應用程式無法接收到UDP報文!
首先接收、傳送UDP的邏輯分別在兩個不同的執行緒中的run()
方法裡,根據執行緒的啟動去呼叫它們。注意兩個執行緒存在的生命週期:
接收執行緒 在一個介面中(不退出介面的情況下)只會被建立並啟動一次,即執行緒一直存在於後臺等待接收UDP報文,此時它的DatagramSocket物件也是要一直存在的;
傳送執行緒 在需要的情況下會多次被重複建立並啟動,它的每次啟動都會去建立DatagramSocket物件,傳送完報文後會立即關閉掉DatagramSocket的連線。
若兩個執行緒共用一個DatagramSocket物件,接收執行緒開啟後,DatagramSocket物件存在於後臺,此時傳送一次報文後,DatagramSocket物件會被關閉連線,這樣應用程式就無法再接收到UDP報文了。所以,這兩個執行緒的DatagramSocket物件需獨立不相同!
2. 退出介面需關閉接收Socket連線
在理解了上一點後,在不退出介面的情況下應用程式可以很好的收發UDP報文,再思考全面一點。考慮使用者在使用中退出介面又重新回到介面,重點還是放在DatagramSocket物件上,一般出錯大多是出在這個上面。
傳送執行緒執行完邏輯後會關閉DatagramSocket連線,執行緒結束。
可是接收執行緒是一直執行在後臺,除非迴圈標識量置為False,接收執行緒才會結束。
如果不對接收執行緒中的DatagramSocket物件進行處理,在退出介面又重新回到介面時,接收執行緒會被重新建立,之前建立的DatagramSocket物件連線未關閉,此時再重新建立,便會出現異常,導致應用程式無法接收到UDP報文。異常如下:
java.net.BindException: bind failed: EADDRINUSE (Address already in use)
很明顯,顯示地址被佔用。所以介面退出時應對接收執行緒的回收及Socket物件的連線關閉,修改程式碼如下:
@Override
protected void onDestroy() {
super.onDestroy();
//停止接收執行緒,關閉套接字連線
listenStatus = false;
receiveSocket.close();
}
3. Rom關閉了手機接收UDP報功能
我的程式在修改完以上後基本沒有出什麼問題了,但是這點原因在網上出現的概率很大,遂一起寫進來,為讀者拓展思維。有的手機不能直接接收UDP包,可能是手機廠商在定製Rom的時候把這個功能給關掉了,解決辦法如下:
(1)可先在oncreate()方法裡面例項化一個WifiManager.MulticastLock 物件lock;具體如下:
WifiManager manager = (WifiManager) this
.getSystemService(Context.WIFI_SERVICE);
WifiManager.MulticastLock lock= manager.createMulticastLock("test wifi");
(2)在呼叫廣播發送、接收報文之前先呼叫lock.acquire()方法;
(3)用完之後及時呼叫lock.release()釋放資源,否決多次呼叫lock.acquire()方法,程式可能會崩,詳情請見
Caused by: java.lang.UnsupportedOperationException: Exceeded maximum number of wifi locks
注:記得在配置檔案裡面新增如下許可權:
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
經過這樣處理後,多數手機都能正常傳送接收到廣播報文。
五. 測試環境建議
關於這個測試環境,我是藉助手機和電腦來完成的,可能大多數人用的是 Wireshark 軟體來測試UDP報文收發,但是對於新手而言需要學習一會,這裡推薦一個上手難度為0的測試軟體:網路除錯助手NetAsssist,資源在最後。
用法
將手機和電腦連線在同一區域網下,我這裡是將兩者全部連線校園Wifi,然後開啟電腦軟體,上面自動顯示電腦分配的IP,檢視手機連線Wifi高階設定獲取手機分配IP。選擇好網路除錯助手軟體的協議型別和埠號,手機可傳送報文到電腦上,在電腦網路除錯助手軟體上填寫好“手機IP:接收埠號”,點擊發送按鈕,手機即可接收到電腦傳送的UDP報文。
軟體使用截圖:
程式應用測試截圖:
最後說明一下,關於所做的專案需求略微複雜,但是應用程式的骨幹精華就是第三小節的那些程式碼,而一些坑及需要注意的問題在第四點提及,這次接觸UDP報文,著實是邊學邊摸索,總算從坑裡爬了出來,可能我所記錄的並不詳細或者有誤,虛心請教~
希望對你們有幫助:)