iOS 實作 UDP Connectionless

春麗 S.T.E.M.
12 min readDec 30, 2023

--

目錄
1. Socket
1-1 TCP
1-2 UDP
2. CocoaAsyncSocket
2-1 程式碼
2-2 Read
2-3 Write
3. 成果

⦿ Socket
⦿ TCP
⦿ UDP
⦿ CocoaAsyncSocket
⦿ 程式碼
⦿ Read
⦿ Write
⦿ 成果

Socket

在現代網路 TCP/IP 的架構下,Socket 代表著使用 TCP、UDP 等傳輸層協議做網路溝通時的介面,是基於 BSD(Berkeley Software Distribution)中的 UNIX Socket 發展出的 API。

Socket 在 TCP/IP 架構下分兩類型:

  1. Datagram:Connectionless,基於非連接的資料傳輸(UDP)
  2. Stream:Connection oriented,基於連接的資料傳輸(TCP)

TCP

以 TCP 來說,Socket 分為 Server 與 Client,參照之前兩篇文章。

這兩篇文章採用 TCP Socket 做溝通,以 ESP32 當作 Server,iOS Device 當作 Client。在 Server 端必須監聽自己 Port 的動態,在 Client 端必須綁定 Server IP 的 Port,接著是 Connect、Read、Write、Close 來做到雙向溝通的流程。

Connect 即是連接,Close 是關閉連接,Read 為當 Soket 連接後,接收對方傳來的訊息,Write 為當 Socket 連接後,傳送給對方的訊息,這表示使用 TCP 連接是一對一的,讀寫僅在於兩方接收與傳遞。

UDP

UDP Socket 是基於非連接的,這表示它並沒一般意義上的 Server、Client 的區分,也就是說只要綁定特定 Port 能夠同時做到接收與傳送,即是廣播,如對 255.255.255.255,就是對內網所有 IP 位址的接收與傳送。

接下來,我們看看 iOS 如何實作 UDP Socket 吧!

回目錄

繼續閱讀|回目錄

CocoaAsyncSocket

在 iOS 中,我們可以使用 cocoapods 來管理套件,pod init => open Podfile => 加入 pod ‘CocoaAsyncSocket’ => pod install => 開啟 Project.xcworkspace

接著,在 Storyboard 加入下面的 UI。

由於 UDP Socket 是基於非連接,所以我們並不需要擔心其中一方的 IP,只需要綁定 Port 即可,接著要有一欄是用來傳送訊息,一欄是用來接收訊息,最後再加個清除訊息,基本功能就差不多了。

程式碼

首先在 VC 的 viewDidLoad 中建立 udpSocket 實例,在主線程使用,設置 delegate 為 self。

import CocoaAsyncSocket

var udpSocket: GCDAsyncUdpSocket!

override func viewDidLoad() {
super.viewDidLoad()

udpSocket = GCDAsyncUdpSocket(delegate: self,
delegateQueue: .main)
}

就可以在 extension 中使用 delegate function。

extension UDPClientViewController: GCDAsyncUdpSocketDelegate {
func udpSocket(_ sock: GCDAsyncUdpSocket, didNotConnect error: Error?) {
if let error = error {
print("didNotConnect, because of: \(error)")
}
}

func udpSocketDidClose(_ sock: GCDAsyncUdpSocket, withError error: Error?) {
if let error = error {
print("關閉連接,因為: \(error)")
}
}

func udpSocket(_ sock: GCDAsyncUdpSocket,
didReceive data: Data,
fromAddress address: Data,
withFilterContext filterContext: Any?) {
print("didReceiveData")

var host: NSString?
var port: UInt16 = 0

GCDAsyncUdpSocket.getHost(&host, port: &port, fromAddress: address)

let text = String(data: data,encoding: String.Encoding.utf8) ?? "Can't be identified"
showMessage(dateString() + "\n\"\(host! as String)\" sent: \(text)\n")
}

func udpSocket(_ sock: GCDAsyncUdpSocket, didSendDataWithTag tag: Int) {
print("didSendDataWithTag")
}

func udpSocket(_ sock: GCDAsyncUdpSocket,
didNotSendDataWithTag tag: Int,
dueToError error: Error?) {
print("didNotSendDataWithTag")
}


}

當綁定 Port 後即可開始廣播,所以只需要注意這幾個 function,didNotConnect 跟 didClose,沒辦法建立 Socket 以及 Socket 關閉。

didReceive、didSendDataWithTag、didNotSendDataWithTag,接收、傳送與傳送失敗,就可完成基本的除錯與透過 Socket 溝通。

其實在 GCDAsyncUdpSocketDelegate 中還有一個 function 如下:

    func udpSocket(_ sock: GCDAsyncUdpSocket, didConnectToAddress address: Data) {
print("didConnectToAddress \(address)")
}

不過這個例子中我們不會使用到它,暫且忽略吧。

這還沒完,我們還缺少綁定 Port 以及建立 Socket、關閉 Socket 的 function。

@IBAction func bindBtnTapped(_ sender: Any) {
bindBtn.isSelected = !bindBtn.isSelected
if bindBtn.isSelected {
bindBtn.setTitle("Unbind", for: .normal)
bindBtn.setTitleColor(.black, for: .selected)
bindSocket()
} else {
bindBtn.setTitle("Bind", for: .normal)
bindBtn.setTitleColor(.white, for: .selected)
unbindSocket()
}
}

當按下 bindBtn,我們要去改變 isSelected,而在之前的文章中,我們說過 isSelected 是一個很好辨識狀態的素材,因為如果使用預設的 Button 不對它操作,它將永遠是 false。

改變 isSelected 後,也跟著改變操作,因為在這個套件中我們並不能同時綁定兩個 Port,所以關於操作的生命週期需要相當注意,綁定後不得再綁定,所以綁定後,Button 變成需解綁的狀態。

以及如果 View Controller 要消失時,我們必須要解綁,所以在 viewWillDisappear 中加入如下:

override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
unbindSocket()
}

接著,我們先來看看 bindSocket()

        guard let text = portTextField.text,
let port = UInt16(text) else { return }

do {
try udpSocket.bind(toPort: port)
showMessage(dateString())
showMessage("已經綁定 Port: \(port)")
showMessage(separateLine)

} catch(let error) {
print(error)
}

do {
try udpSocket.enableBroadcast(true)
print("Broadcast")

} catch {
print("can't broadcast")
}

do {
try udpSocket.beginReceiving()
print("begin receiving...")
} catch {
print("receiving is not in process.")
}

view.endEditing(true)

確保 Port 欄位是能夠合法轉換成 UInt16 後,將 Socket 綁定這個 Port,接著讓 Socket 可以廣播,以及可以開始接收訊息。

然後是與 bind 相對的 unbindSocket()

func unbindSocket() {
if udpSocket != nil && udpSocket.isClosed() == false {
udpSocket.close()
}
}

如果 Socket 不是 nil,並且尚未 closed,我們才讓它 close。

幾乎快完成了!再加把勁。

Read

Read 我們在 delegate function 裡面做了。

Write

Write 我們必須在 Send Button 裡做。

@IBAction func sendBtnTapped(_ sender: Any) {
let data = messageTextField.text?.data(using: .utf8)
udpSocket.send(data!,
toHost: ip,
port: UInt16(portTextField.text!)!,
withTimeout: -1,
tag: 0)
view.endEditing(true)
}

因為是廣播,所以這個 ip 位址是 “255.255.255.255”,Port 當然是前面綁定的 Port,這裡直接 umwarp 沒關係,因為前面是 Port 合法才做綁定。

完成了!

回目錄

繼續閱讀|回目錄

成果

當我們綁定 8088 Port,就可以開始向內網綁定這個 Port 的其他人發送廣播訊息。

而實際上也會發給自己,這邊看到 IP 是 192.168.1.186,不過卻有兩則訊息,一個是 ::ffff:192.168.1.186,一個是 192.168.1.186,後者是 IPv4,前者是 IPv4 的映射,即是 IPv6(IPv4-mapped IPv6),這表示說這個廣播是 IPv4 與 IPv6 皆適用,並不能算是廣播兩次。

如果我們使用另外一個設備廣播,會接到如下訊息。

這是從我的 MBA(192.168.1.168)傳來的廣播。

不過實際把時間切分到 1/60 秒,其實可以看到 IPv4 的映射與 IPv4 的廣播其實接收時間有一點點差距,而真正調用 didReceive 也的確是兩次。

真的完成了!

其實以網路連接來說,使用 TCP 或 UDP 透過 Socket 溝通並不是那麼方便,在第一段提到 TCP/IP 就是一個網際網路套組,所以我們通常會搭配應用層協議(HTTP、CoAP⋯⋯),而不是直接使用傳輸層協議,OSI 是不是很有趣呢?

這次就分享到這,感謝您的閱讀。

回目錄

繼續閱讀|回目錄

Reference:

--

--

春麗 S.T.E.M.
春麗 S.T.E.M.

Written by 春麗 S.T.E.M.

Do not go gentle into that good night, Old age should burn and rave at close of day; Rage, rage, against the dying of the light.

No responses yet