【Xcode 14】星座聊天室 - WebSocket

『只有遠傳,沒有距離』。話說,人類最早的通訊方法應該就是面對面的溝通,當然啦,也許還有心電感應吧?不過距離有限;後來電話的發明 (安東尼奧・穆齊),讓溝通的距離越來越遠,讓近距離戀愛的人們能定時聯絡,建立信任,真是個造福世界的發明啊;後來,BB.Call的問世,讓通訊距離的範圍更延長了一步;而後,又發明了行動電話 - 2G / 3G,造就了Nokia / Motorola王朝,這可是無線電話的民用化的開始,而簡訊正式取代了BB.Call;最後就是,無線電話數位化的開始 - 3G / 4G / 5GiPhone / Android手機的問世,拍照 / 通話 / 上網三合一的方式,取代了相機 / 行動電話 / PDA,可謂是跨時代的發明,而發進到了即時通訊 - IM(Instant Messaging),完完全全取代了通話與簡訊的功能,而且能傳圖片 / 影音,真的是太方便了,但為什麼能這麼即時呢?這就要來談談WebSocket通訊協定 (其實後面還有一個叫SSE - Server Sent Events的協定,只是沒它那麼紅…)

做一個長得像這樣的東西

作業環境

項目 版本
CPU Apple M1
macOS Ventura 13.3.1 arm64
Golang 1.19.3 arm64
Visual Studio Code 1.78.0 arm64
Xcode 14.3 arm64

背景

早期,很多網站為了實現推播技術,所用的技術都是輪詢 - Polling。輪詢是指由瀏覽器每隔一段時間(如每秒)向伺服器發出HTTP請求,然後伺服器返回最新的資料給客戶端。這種傳統的模式帶來很明顯的缺點,即瀏覽器需要不斷的向伺服器發出請求,然而HTTP請求與回覆可能會包含較長的header,其中真正有效的資料可能只是很小的一部分,所以這樣會消耗很多頻寬資源。 比較新的輪詢技術是Comet。這種技術雖然可以實現雙向通訊,但仍然需要反覆發出請求。而且在Comet中普遍採用的HTTP長連接也會消耗伺服器資源。

在這種情況下,HTML5定義了WebSocket協定,能更好的節省伺服器資源和頻寬,並且能夠更即時地進行通訊。 Websocket使用ws或wss的統一資源標誌符(URI)。其中wss表示使用了TLS的Websocket。如:

ws://example.com/wsapi
wss://secure.example.com/wsapi

Websocket與HTTP和HTTPS使用相同的TCP埠,可以繞過大多數防火牆的限制。預設情況下,Websocket協定使用80埠;執行在TLS之上時,預設使用443埠。

在這裡引用一下菜鳥教程的圖片…

  • 輪詢情境介紹 (一直問)
客人 (客戶端):請問MOS還有位子嗎?
店家 (伺服器):還沒有位子喲…🔥
客人 (客戶端):請問MOS還有位子嗎?
店家 (伺服器):還沒有位子喲…🔥🔥
客人 (客戶端):請問MOS還有位子嗎?
店家 (伺服器):還沒有位子喲…🔥🔥🔥
.
.
.
客人 (客戶端):請問MOS還有位子嗎?
店家 (伺服器):有位子了喲…
  • WebSocket情境介紹 (問一次就好)
客人 (客戶端):請問MOS還有位子嗎?
店家 (伺服器):還沒有位子喲,有位子會馬上通知您…🥳
.
.
.
店家 (伺服器):有位子了喲…

由此可以看出,輪詢是伺服器端『被動』回覆,而Websocket是伺服器端『主動』回覆…

Server端的實現

go get github.com/gorilla/websocket
  • 這裡是選用的是http的8899埠,連線的URL就長這個樣子…
ws://192.168.1.110/echo/?id=William
ws://<localhost>/echo/?id=<id>
  • 存Websocket連線的方式如下,斷線就清掉…
[
  <連線1>: <代號1>,
  <連線2>: <代號2>
]
  • Go的程式碼如下
package main

import (
	"fmt"
	"log"
	"net/http"
	"william/utility"

	"github.com/gorilla/websocket"
)

type Client struct {
	conn *websocket.Conn
	name string
}

const (
	port     = "8899"
	path     = "/echo/"
	queryKey = "id"
)

var clientName string = ""
var clients = make(map[**websocket.Conn]Client)

func main() {
	webSocketSetting()
}

// WebSocket功能設定
func webSocketSetting() {

	upgrader := webSocketUpgraderMaker(true)

	http.HandleFunc(path, func(writer http.ResponseWriter, request *http.Request) {

		connect, error := upgrader.Upgrade(writer, request, nil)
		clientName = request.URL.Query()["id"][0]

		if len(clientName) == 0 {
			utility.Println("id => null")
			return
		}

		if error != nil {
			utility.Println("connect => ", error)
			return
		}

		defer func() {

			message := fmt.Sprintf("%p is disconnect !!!.", &connect)

			utility.Println(message)
			connect.Close()
			delete(clients, &connect)
			utility.Println(clients)
		}()

		client := Client{
			conn: connect,
			name: clientName,
		}

		clients[&connect] = client
		utility.Println(&connect)

		for {
			messageType, message, error := connect.ReadMessage()

			if error != nil {
				utility.Println("read:", error)
				break
			}

			broadcastMessage(messageType, string(message))
		}
	})

	url := fmt.Sprintf(":%s", port)
	serverStart := fmt.Sprintf("server start at :%s", port)

	utility.Println(serverStart)
	log.Fatal(http.ListenAndServe(url, nil))
}

// 產生WebSocketUpgrader元件
func webSocketUpgraderMaker(notCheckCORS bool) *websocket.Upgrader {

	upgrader := &websocket.Upgrader{
		CheckOrigin: func(request *http.Request) bool { return notCheckCORS },
	}

	return upgrader
}

// 廣播訊息
func broadcastMessage(messageType int, message string) {

	utility.Println(message)

	for _, client := range clients {

		error := client.conn.WriteMessage(messageType, []byte(message))

		if error != nil {
			utility.Println("Write Error => ", error)
			break
		}
	}
}
<html>
<head></head>
<body>
    <script type="text/javascript">
        
        let sock = null;
        let wsuri = "ws://192.168.1.110:8899/echo/?id=HTML";

        window.onload = function() {

            console.log("onload");

            sock = new WebSocket(wsuri);

            sock.onopen = function() { console.log("connected to " + wsuri); }
            sock.onclose = function(e) { console.log("connection closed (" + e.code + ")"); }
            sock.onmessage = function(e) { console.log("message received: " + e.data); }
        };

        function send() {
            let msg = document.getElementById('message').value;
            sock.send(msg);
        };
    </script>
    <h1>WebSocket Echo Test</h1>
    <form>
        <p>
            Message: <input id="message" type="text" value="WebSocket測試">
        </p>
    </form>
    <button onclick="send();">Send Message</button>
</body>
</html>

Client端的實現

  • 在這裡呢,就用本行的iOS Swift做一個簡單的星座聊天室
  • 首先先選擇星座 (使用者),記下該資訊,前往聊天室頁面…

import UIKit
import WWPrint

// MARK: - 角色選擇頁
final class StarViewController: UIViewController {

    @IBOutlet weak var titleLabel: UILabel!
    @IBOutlet weak var starName: UIImageView!
    @IBOutlet var starSignList: [UIImageView]!
    
    private let segueId = "ChatViewControllerSegue"
    private var selectedStarSign: Constant.StarSign?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        initSetting()
    }
    
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        
        guard let viewController = segue.destination as? ChatViewController else { return }
        viewController.starSign = selectedStarSign
    }
}

// MARK: - @objc
extension StarViewController {
    
    /// 選擇星座
    /// - Parameter tap: UITapGestureRecognizer
    @objc func starSignTapAction(_ tap: UITapGestureRecognizer) {
        
        guard let imageView = tap.view as? UIImageView else { return }
        
        starName.image = imageView.image
        selectedStarSign = Constant.StarSign(rawValue: imageView.tag)
        titleLabel.text = selectedStarSign?.name()
    }
    
    /// 前往下一頁
    /// - Parameter tap: UITapGestureRecognizer
    @objc func starNameTapAction(_ tap: UITapGestureRecognizer) {
        guard selectedStarSign != nil else { return }
        performSegue(withIdentifier: segueId, sender: self)
    }
}

// MARK: - 小工具
extension StarViewController {
    
    /// 初始化設定
    func initSetting() {
        starNameSetting()
        starSignListSetting()
    }
    
    /// 星座點擊功能設定
    func starSignListSetting() {
        
        var index = 0
        
        starSignList.forEach { starSign in
            
            let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(starSignTapAction(_:)))
            
            starSign.isUserInteractionEnabled = true
            starSign.addGestureRecognizer(tapGestureRecognizer)
            starSign.tag = index
            
            index += 1
        }
    }
    
    /// 被選擇的星座點擊功能設定
    func starNameSetting() {
        
        let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(starNameTapAction(_:)))
        
        starName.isUserInteractionEnabled = true
        starName.addGestureRecognizer(tapGestureRecognizer)
    }
}

主核心 - WWWebSocket

import UIKit
import WWPrint

// MARK: - Utility (單例)
final class WWWebSocket: NSObject {
    
    static let shared = WWWebSocket()
    
    private var task: URLSessionWebSocketTask?
    
    private var didOpenWithProtocolBlock: ((String?) -> Void)?                                      // 已開啟連接
    private var didCloseWithCodeBlock: ((URLSessionWebSocketTask.CloseCode, Data?) -> Void)?        // 已關閉連接
    
    private override init() {}
}

// MARK: - URLSessionWebSocketDelegate
extension WWWebSocket: URLSessionWebSocketDelegate {
    
    func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
        didOpenWithProtocolBlock?(`protocol`)
    }
    
    func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
        didCloseWithCodeBlock?(closeCode, reason)
    }
}

// MARK: - 主要工具
extension WWWebSocket {
    
    /// [WebSocket連線](https://medium.com/@jqkqq7895/websocket-swift-71ed0104ab81)
    /// - Parameters:
    ///   - socketUrl: [連線URL - ws://](https://medium.com/彼得潘的-swift-ios-app-開發教室/websocket-swift-84c47e90bb49)
    ///   - configuration: URLSessionConfiguration
    ///   - queue: OperationQueue?
    ///   - didOpenWithProtocol: [連線開啟](https://www.appcoda.com.tw/swiftui-websocket/)
    ///   - didCloseWithCode: [連線關閉](https://ithelp.ithome.com.tw/articles/10208531)
    ///   - receiveResult: Result<URLSessionWebSocketTask.Message, Error>
    func connent(with socketUrl: String, configuration: URLSessionConfiguration = .default, delegateQueue queue: OperationQueue? = .main, didOpenWithProtocol: ((String?) -> Void)?, didCloseWithCode: ((URLSessionWebSocketTask.CloseCode, Data?) -> Void)?, receiveResult: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
        
        guard let url = URL(string: socketUrl) else { receiveResult(.failure(Constant.MyError.notUrlFormat)); return }
        
        let urlSession = URLSession(configuration: configuration, delegate: self, delegateQueue: queue)
        let request = URLRequest(url: url)
        
        didOpenWithProtocolBlock = didOpenWithProtocol
        didCloseWithCodeBlock = didCloseWithCode
        
        task = urlSession.webSocketTask(with: request)
        
        receiveMessage(with: task) { result in
            switch result {
            case .failure(let error): receiveResult(.failure(error))
            case .success(let message): receiveResult(.success(message))
            }
        }
        
        task?.resume()
    }
    
    /// [傳送訊息](https://creativecoding.in/2020/03/25/用-socket-io-做一個即時聊天室吧!(直播筆記)/)
    /// - Parameters:
    ///   - message: URLSessionWebSocketTask.Message
    ///   - result: Error?
    func sendMessage(_ message: URLSessionWebSocketTask.Message, result: @escaping (Error?) -> Void) {
        
        let taskMessage: URLSessionWebSocketTask.Message
        
        switch message {
        case .data(let data): taskMessage = URLSessionWebSocketTask.Message.data(data)
        case .string(let string): taskMessage = URLSessionWebSocketTask.Message.string(string)
        @unknown default: fatalError()
        }
        
        task?.send(taskMessage) { result($0) }
    }
    
    /// 關閉連線
    /// - Parameters:
    ///   - closeCode: URLSessionWebSocketTask.CloseCode
    ///   - reason: Data?
    func cancel(with closeCode: URLSessionWebSocketTask.CloseCode = .goingAway, reason: Data? = nil) {
        task?.cancel(with: closeCode, reason: reason)
    }
}

// MARK: - 小工具
private extension WWWebSocket {
    
    /// 收接訊息
    /// - Parameters:
    ///   - task: URLSessionWebSocketTask?
    ///   - result: Result<URLSessionWebSocketTask.Message, Error>
    func receiveMessage(with task: URLSessionWebSocketTask? ,result: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
        
        task?.receive(completionHandler: { _result in
            switch _result {
            case .failure(let error): result(.failure(error))
            case .success(let message): result(.success(message))
                self.receiveMessage(with: task, result: result)
            }
        })
    }
}

聊天室

  • 在這裡比較要注意的是傳送 / 接收的資料型態
  • 細節的部分就不多說了,相信大家都是Swift高高手
typealias ChatMessage = (tag: Int?, name: String?, text: String?, type: MessageType)    // (星座編號 / 星座名稱 / 傳送文字 / 類型)
  • 因為只能傳送文字的關係,圖片會轉成base64做傳送 (當然,實務上不會這麼做的,因為有大小的限制,而且太浪費傳輸量了…)
  • 收到該JSON文字之後再解回來處理,名字是自己的訊息就是MasterTableViewCell,名字是別人的就是SlaveTableViewCell
  • 還有一個滿特別的事是談話框的實現,是利用像Android的9-Patch的功能來實現,雖然它也有Code的版本,但是使用UI設定還是方便許多…
  • 再來就是因為鍵盤可能擋到輸入框,所以要做一個跟鍵盤一樣高的View去移動輸入框
import UIKit
import WWPrint
import PhotosUI

// MARK: - 對話功能頁
final class ChatViewController: UIViewController {
            
    @IBOutlet weak var myTableView: UITableView!
    @IBOutlet weak var myTextField: UITextField!
    @IBOutlet weak var starImageView: UIImageView!
    @IBOutlet weak var connentView: UIView!
    @IBOutlet weak var keyboardConstraintHeight: NSLayoutConstraint!
    
    static var chatMessageList: [Constant.ChatMessage] = []
    
    var starSign: Constant.StarSign?
    
    private let ip = "192.168.1.110"
    private let port = "8899"
    
    override func viewDidLoad() {
        super.viewDidLoad()
        initSetting()
        keyboardNotification()
    }
    
    @objc func keyboardWillShow(_ notification: Notification) { keyboardNotification(notification) }
    @objc func dimissKeyboard() { view.endEditing(true) }
    
    @IBAction func connent(_ sender: UIBarButtonItem) {
                
        guard let id = starSign?.id() else { return }
        
        let socketUrl = "ws://\(ip):\(port)/echo/?id=\(id)"
        webSocketConnent(with: socketUrl)
    }
    
    @IBAction func sendMessage(_ sender: UIButton) {
        sendWebSocketString(myTextField.text)
    }
    
    @IBAction func sendImage(_ sender: UIButton) {
        
        let picker = PHPickerViewController._photoLibrary(delegate: self)
        present(picker, animated: true)
    }
    
    deinit {
        WWWebSocket.shared.cancel()
        wwPrint("deinit => \(self)")
    }
}

// MARK: - UITableViewDelegate, UITableViewDataSource
extension ChatViewController: UITableViewDelegate, UITableViewDataSource {
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return Self.chatMessageList.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = chatCellMaker(tableView, cellForRowAt: indexPath) else { fatalError() }
        return cell
    }
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        dimissKeyboard()
    }
    
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
                
        guard let chatMessage = Self.chatMessageList[safe: indexPath.row] else { return UITableView.automaticDimension }
        
        switch chatMessage.type {
        case .text: return UITableView.automaticDimension
        case .image: return view.frame.width * 0.5
        case .video: return view.frame.width * 0.5
        }
    }
}

// MARK: - UITextFieldDelegate
extension ChatViewController: UITextFieldDelegate {
    
    func textFieldShouldReturn(_ textField: UITextField) -> Bool {
        textField.resignFirstResponder()
        return true
    }
}

// MARK: - PHPickerViewControllerDelegate
extension ChatViewController: PHPickerViewControllerDelegate {
    
    func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
        
        picker.dismiss(animated: true)
        
        guard let itemProvider = results.map(\.itemProvider).first else { return }
        
        itemProvider._data(forType: UIImage.self) { result in
            switch result {
            case .failure(let error): wwPrint(error)
            case .success(let image): self.sendWebSocketImage(image)
            }
        }
    }
}

// MARK: - 小工具
private extension ChatViewController {
    
    /// 初始化設定
    func initSetting() {
        
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(Self.dimissKeyboard))
        
        myTableView.delegate = self
        myTableView.dataSource = self
        myTextField.delegate = self
        myTableView.addGestureRecognizer(tapGesture)
        
        title = starSign?.name()
        starImageView.image = starSign?.image()
        keyboardConstraintHeight.constant = 0
    }
    
    /// 鍵盤顯示 / 隱藏通知設定
    func keyboardNotification() {
        NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil)
        NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow), name: UIResponder.keyboardWillHideNotification, object: nil)
    }
    
    /// WebSocket連線 (自動重新連線) => ws:// + wss://
    /// - Parameters:
    ///   - url: String
    ///   - isAutoConnent: isAutoConnent
    func webSocketConnent(with url: String, isAutoConnent: Bool = true) {
        
        WWWebSocket.shared.cancel()
        
        WWWebSocket.shared.connent(with: url) { [weak self] `protocol` in
            self?.connentView.backgroundColor = .systemBlue
        } didCloseWithCode: { [weak self] closeCode, data in
            self?.connentView.backgroundColor = .lightGray
        } receiveResult: { [weak self] result in
            switch result {
            case .failure(let error):
                if (isAutoConnent) { self?.webSocketConnent(with: url, isAutoConnent: isAutoConnent) }
                self?.connentView.backgroundColor = .lightGray
                wwPrint(error)
            case .success(let message):
                let isSuccess = self?.phaseMessage(message)
                wwPrint("isSuccess => \(isSuccess ?? false)")
            }
        }
    }
    
    /// 鍵盤事件通知處理
    /// - Parameter notification: Notification
    func keyboardNotification(_ notification: Notification) {
        
        guard let info = UIDevice._keyboardInfomation(notification: notification),
              let curveType = UIView.AnimationCurve(rawValue: Int(info.curve))
        else {
            return
        }
        
        keyboardConstraintHeight.constant = view.frame.height - info.frame.origin.y
        
        let animator = UIViewPropertyAnimator(duration: info.duration, curve: curveType) { [weak self] in
            guard let this = self else { return }
            this.view.layoutIfNeeded()
        }
        
        animator.startAnimation()
    }
    
    /// Cell的長相設定 (名字是自己的 => MasterTableViewCell / 名字是其它人的 => SlaveTableViewCell)
    /// - Parameters:
    ///   - tableView: UITableView
    ///   - indexPath: IndexPath
    /// - Returns: UITableViewCell?
    func chatCellMaker(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell? {
        
        let message = ChatViewController.chatMessageList[indexPath.row]
        let name = starSign?.name()
        
        if (message.name != name) {
            let cell = tableView.dequeueReusableCell(withIdentifier: "SlaveTableViewCell") as? SlaveTableViewCell
            cell?.config(with: indexPath)
            return cell
        }
        
        let cell = tableView.dequeueReusableCell(withIdentifier: "MasterTableViewCell") as? MasterTableViewCell
        cell?.config(with: indexPath)
        return cell
    }
    
    /// 傳送文字訊息
    /// - Parameter message: String?
    func sendWebSocketString(_ message: String?) {
        
        guard let message = message,
              let starSign = starSign,
              !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
        else {
            return
        }
        
        let chatMessage = """
        {"tag":\(starSign.rawValue),"name":"\(starSign.name())","text":"\(message)","type":\(Constant.MessageType.text.rawValue)}
        """
        
        WWWebSocket.shared.sendMessage(.string(chatMessage)) { error in
            if let error = error { wwPrint(error) }
        }
    }
    
    /// 傳送圖片訊息 (Base64String)
    /// - Parameter image: UIImage?
    func sendWebSocketImage(_ image: UIImage?) {
        
        guard let image = image,
              let data = image.pngData(),
              let starSign = starSign
        else {
            return
        }
        
        let chatMessage = """
        {"tag":\(starSign.rawValue),"name":"\(starSign.name())","text":"\(data._base64String())","type":\(Constant.MessageType.image.rawValue)}
        """
        
        if let data = chatMessage._data() {
            WWWebSocket.shared.sendMessage(.data(data)) { error in
                if let error = error { wwPrint(error) }
            }
        }
    }
    
    /// 解析WebSocket傳來的Message
    /// - Parameter message: URLSessionWebSocketTask.Message
    /// - Returns: Bool
    func phaseMessage(_ message: URLSessionWebSocketTask.Message) -> Bool {
        
        let jsonObject: Any?
        
        switch message {
        case .string(let string): jsonObject = string._jsonObject()
        case .data(let data): jsonObject = data._jsonObject()
        @unknown default: jsonObject = nil
        }
        
        guard let jsonObject = jsonObject,
              let dictionary = jsonObject as? [String: Any],
              let rawValue = dictionary["type"] as? Int,
              let type = Constant.MessageType(rawValue: rawValue)
        else {
            return false
        }
        
        let chatMessage = Constant.ChatMessage(tag: dictionary["tag"] as? Int, name: dictionary["name"] as? String, text: dictionary["text"] as? String, type: type)
        Self.chatMessageList.append(chatMessage)
        
        let indexPath = IndexPath(row: Self.chatMessageList.count - 1, section: 0)
        myTableView.insertRows(at: [indexPath], with: .none)
        myTableView.selectRow(at: indexPath, animated: true, scrollPosition: .bottom)
        myTextField.text = ""
        
        return true
    }
}

範例程式碼下載

後記

其實,人說『江湖一點訣,講破無價值』,意思就是他所教的東西沒什麼了不起,都只是些小技巧,但是這些小技巧如果不說破,就是個有價值的大學問。之前看過鎖匠開鎖,只見師父拿出一把像鐵尺一樣的工具往車窗縫插下去,然後手拉了幾下,車子就開門投降了,前後不到三十秒鐘,費用是兩百元,『聞道有先後,術業有專攻』,你不會的就是專業,各行各業都有它的專業,請互相尊重…