【Xcode 14】星座聊天室 - WebSocket
『只有遠傳,沒有距離』。話說,人類最早的通訊方法應該就是面對面的溝通,當然啦,也許還有心電感應吧?不過距離有限;後來電話的發明 (安東尼奧・穆齊),讓溝通的距離越來越遠,讓近距離戀愛的人們能定時聯絡,建立信任,真是個造福世界的發明啊;後來,BB.Call的問世,讓通訊距離的範圍更延長了一步;而後,又發明了行動電話 - 2G / 3G,造就了Nokia / Motorola王朝,這可是無線電話的民用化的開始,而簡訊正式取代了BB.Call;最後就是,無線電話數位化的開始 - 3G / 4G / 5G,iPhone / 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語言來實現Websocket
- 選用的套件是:github.com/gorilla/websocket
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端的實現
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
- 是使用URLSession的webSocketTask - iOS 13+來做連線…
- 傳送訊息有兩種模式:String / Data;收接訊息就比較單純,收到什麼,就回傳什麼…
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
}
}
範例程式碼下載
後記
其實,人說『江湖一點訣,講破無價值』,意思就是他所教的東西沒什麼了不起,都只是些小技巧,但是這些小技巧如果不說破,就是個有價值的大學問。之前看過鎖匠開鎖,只見師父拿出一把像鐵尺一樣的工具往車窗縫插下去,然後手拉了幾下,車子就開門投降了,前後不到三十秒鐘,費用是兩百元,『聞道有先後,術業有專攻』,你不會的就是專業,各行各業都有它的專業,請互相尊重…