iOS流式即時通訊教程

桃紅宿雨發表於2018-03-09

前言

本文翻譯自Real-Time Communication with Streams Tutorial for iOS
翻譯的不對的地方還請多多包涵指正,謝謝~

iOS流式即時通訊教程

從時間初始,人們就已開始夢想著更好地跟遙遠的兄弟通訊的方式。從信鴿到無線電波,我們一直在努力將通訊變得更清晰更高效。

在現代中,一種技術已成為我們尋求相互理解的重要的工具:簡易網路套接字。

現代網路基礎結構的第四層,套接字是任何從文字編輯到遊戲線上通訊的核心。

為何是套接字

你可能會奇怪,“為什麼不優先使用URLSession而選擇低階API?”。如果你沒覺得奇怪,可以假裝你覺得......

好問題^_^ URLSession通訊是基於HTTP網路協議。使用HTTP,通訊是以【請求-響應】方式進行。這意味著在大部分App大多數網路程式碼都遵循以下模式:

  1. server端請求JSON資料
  2. 在代理方法內接收並使用JSON

但當你希望server告訴App一些事情是怎麼辦嘞?對於這種事情HTTP確實處理的不太好。誠然,你可以通過不斷請求server看是否有更新來實現,也叫輪詢,或者你可以更狡猾點使用長輪詢,但這些技術都感覺不那麼自然且都有自己的缺陷。最後,為什麼要限制自己一定要使用請求-響應的正規化如果它不是一個合適的工具嘞?

注:長輪詢 ---- 原文沒有

長輪詢是傳統輪旋技術的變種,可以模擬資訊從服務端推送到客戶端。使用長輪詢,客戶端像普通的輪詢一樣請求服務端。但當服務端沒有任何資訊可以給到服務端時,server會持有這個請求等待可用的資訊而不是傳送一個空資訊給客戶端。一旦server有可傳送的資訊(或者超時),就傳送一個響應給客戶端。客戶端通常會收到資訊後立即在請求server,這樣服務基本會一致有一個等待中的用於響應客戶端的請求。在web/AJAX中,長連線被叫做Comet

長輪詢本身並不是一個推送技術,但可以用於在長連線不可能實現的情況下使用。

在這篇流式教程中,你將會學習如何使用套接字直接建立一個實時的聊天應用。

iOS流式即時通訊教程

程式中不是每個客戶端都去檢查服務端是否有更新,而是使用在聊天期間持續存在的輸入輸出流。

開始~

開始前,下載這個啟動包,包含了聊天App和用Go語言寫的server程式碼。你不用擔心自己需要寫Go程式碼,只需啟動server用來跟客戶端互動。

啟動並執行server

server程式碼是使用Go寫完的並且已幫你編譯好。假如你不相信從網上下載的已編譯好的可執行檔案,資料夾中有原始碼,你可以自己編譯。

為了執行已編譯好的server,開啟你的終端,切到下載的資料夾並輸入以下命令,並接下來輸入你的開機密碼:

sudo ./server
複製程式碼

在你輸入完密碼後,應該能看到 Listening on 127.0.0.1:80。聊天server開始執行啦~ 現在你可以調到下個章節了。

假如你想自己編譯Go程式碼,需要用Homebrew安裝Go

沒有Homebrew工具的話,需要先安裝它。開啟終端,複製如下命令貼到終端。

/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)
複製程式碼

然後,使用如下命令安裝Go

brew install go
複製程式碼

一旦完成安裝,切到下載的程式碼位置並在終端使用如下編譯命令:

go build server.go
複製程式碼

最終,你可以啟動server,使用上述啟動伺服器的程式碼。

瞅瞅現有的App

下一步,開啟DogeChat工程,編譯並執行,你會看到已經幫你寫好的介面:

iOS流式即時通訊教程

如上圖所示,DogeChat已經寫好可以允許使用者輸入名字後進入到聊天室。不幸的是,前一個工程師不知道怎麼寫聊天App因此他寫完了所有的介面和基本的跳轉,留下了網路層部分給你。

建立聊天室

在開始編碼前,切到 ChatRoomViewController.swift 檔案。你可以看到你有了一個介面處理器,它能接收來自輸入欄的資訊,也可以通過使用Message物件配置cell的TableView來展示訊息。

既然你已經有了ViewController,那麼你只需要建立一個ChatRoom來處理繁重的工作。

開始寫新類前,我想快速列舉下新類的功能。對於它,我們希望能處理這些事情:

  1. 開啟聊天室伺服器的連線
  2. 允許通過提供名字來進入聊天室
  3. 使用者能夠收發資訊
  4. 當時完成時關閉連線

現在你知道你該做什麼啦,點選Command+N建立新的檔案。選擇Cocoa Touch Class並將它命名為ChatRoom

建立輸入輸出流

現在,繼續並替換在檔案內的內容如下:

import UIKit

class ChatRoom: NSObject {
  //1
  var inputStream: InputStream!
  var outputStream: OutputStream!
  
  //2
  var username = ""
  
  //3
  let maxReadLength = 4096
  
}
複製程式碼

這裡,你定義了ChatRoom類,並宣告瞭為使溝通更高效的屬性。

  1. 首先,你有了輸入輸出流。使用這對類可以讓你建立基於app和server的套接字。自然地,你會通過輸出流來傳送訊息,輸出流接收訊息。
  2. 下一步,你定義了username變數用於儲存當前使用者的名字
  3. 最後定義了maxReadLength。該變數限制你單次傳送資訊的資料量

然後,切到ChatRoomViewController.swift並在類的內部商法新增ChatRoom屬性:

let chatRoom = ChatRoom()
複製程式碼

目前你已經構建了類的基礎結構,是時候開始你之前列舉類功能的第一項了---開啟server與App間的連線。

開啟連線

返回到ChatRoom.swift檔案在屬性定義的下方,加入以下程式碼:

func setupNetworkCommunication() {
  // 1
  var readStream: Unmanaged<CFReadStream>?
  var writeStream: Unmanaged<CFWriteStream>?

  // 2
  CFStreamCreatePairWithSocketToHost(kCFAllocatorDefault,
                                     "localhost" as CFString,
                                     80,
                                     &readStream,
                                     &writeStream)
}
複製程式碼

這裡發生了:

  1. 第一段,建立了兩個未初始化的且不會自動記憶體管理的套接字流
  2. 將讀寫套接字聯絡起來並將其連上主機的套接字,這裡的埠號是80。
    這個函式傳入四個引數,第一個是你要用來初始化流的分配型別。儘可能地使用kCFAllocatorDefault,但如果遇到你希望它有不同表現的時候有其他的選項。

下一步,你指定了hostname。此時你只需要連線本地機器,但如果你有遠端服務得指定IP,你可以在此使用它。

然後,你指定了連線通過80埠,這是在server端設定的一個埠號。

最後,你傳入了讀寫的流指標,這個方法能使用已連線的內部的讀寫流來初始化它們。

現在你已獲得了出事後的流,你可以通過新增以下兩行程式碼儲存它們的引用:

inputStream = readStream!.takeRetainedValue()
outputStream = writeStream!.takeRetainedValue()
複製程式碼

在不受管理的物件上呼叫takeRetainedValue()可以讓你同步獲得一個保留的引用並且消除不平衡的保留(an unbalanced retain),因此之後記憶體不會洩露。現在當你需要流時你可以使用它們啦。

下一步,為了讓app能夠合理地響應網路事件,這些流需要新增進runloop內。在setupNetworkCommunication函式內部最後新增以下兩行程式碼:

inputStream.schedule(in: .current, forMode: .commonModes)
outputStream.schedule(in: .current, forMode: .commonModes)
複製程式碼

你已經準備好開啟“洪流之門”了~ 開始吧,新增以下程式碼(還在setupNetworkCommunication函式內部最後):

inputStream.open()
outputStream.open()
複製程式碼

這就是全部啦。我們回到ChatRoomViewController.swift類,在viewWillAppear函式內新增如下程式碼:

chatRoom.setupNetworkCommunication()
複製程式碼

在本地伺服器上,現在你已開啟了客戶端和服務端連線。再次編譯執行程式碼,將會看到跟你寫程式碼之前一模一樣的介面。

iOS流式即時通訊教程

參與聊天

現在你已連上了服務端,是時候發一些訊息了~ 第一件事情你可能會說我到底是誰。之後,你也希望開始傳送資訊給其他人了。

這裡提出了一個重要的問題:因為你有兩種訊息,需要想個辦法來區分他們。

通訊協議

降到TCP層好處之一是你可以定義自己的協議來決定一個資訊的有效與否。對於HTTP,你需要想到這些煩人的動作:GetPUTPATCH。需要構造URL並使用合適的頭部和各種各樣的事情。

這裡我們之後兩種資訊,你可以傳送:

iam:Luke
複製程式碼

來進入聊天室並通知世界你的名字。你可以說:

msg:Hey, how goes it mang?
複製程式碼

來傳送一個訊息給任何一個在聊天室的人。

這樣純粹且簡單。

這樣顯然不安全,因此不要在工作中使用它。

你知道了伺服器的期望格式,可以在ChatRoom寫一個方法來進入聊天室了。僅有的引數就是名字了。

為實現它,新增如下方法到剛新增的方法後面:


funcfunc  joinChatjoinChat(username: String)(username: String) {
   {   //1//1
     letlet data =  data = "iam:"iam:\(username)\(username)"".data(using: .ascii)!
  .data(using: .ascii)!   //2//2
     selfself.username = username
  
  .username = username      //3//3
     __ = data.withUnsafeBytes { outputStream.write($ = data.withUnsafeBytes { outputStream.write($00, maxLength: data., maxLength: data.countcount) }
}) } }
複製程式碼
  1. 首先,使用簡單的聊天協議構造了訊息
  2. 然後,儲存了剛傳進來的名字,之後可以在傳送訊息的時候使用它
  3. 最後,將訊息寫入輸出流。這比你預想的要複雜一些,write(_:maxLength:)方法將一個不安全的指標引用作為第一個引數。withUnsafeBytes(of:_:)方法提供一個非常便利的方式在閉包的安全範圍內處理一些資料的不安全指標。

方法已就緒,回到ChatRoomViewController.swift並在viewWillAppear(_:)方法內最後新增進入聊天室的方法呼叫。

chatRoom.joinChat(username: username)
複製程式碼

現在編譯並執行,輸入名字進入介面看看:

iOS流式即時通訊教程

同樣什麼也沒發生?

iOS流式即時通訊教程

稍等,我來解釋下~ 去看看終端程式。就在 Listening on 127.0.0.1:80 下方,你會看到 Luke has joined,或如果你的名字不是Luke的話就是其他的內容。

這是個好訊息,但你肯定更希望看到在手機螢幕上成功的跡象。

響應即將來臨的訊息

幸運的是,伺服器接收的訊息就像你剛剛傳送的一樣,並且傳送給在聊天的每個人,包括你自己。更幸運的是,app本就已可在ChatRoomViewController的表格介面上展示即將要來的訊息。

所有你要做的就是使用inputStream來捕捉這些訊息,將其轉換成Message物件,並將它傳出去讓表格做顯示。

為響應訊息,第一個需要做的事情是讓ChatRoom成為輸入流的代理。首先,到ChatRoom.swift最底部新增以下擴充套件:

extension ChatRoom: StreamDelegate {

}
複製程式碼

現在ChatRoom已經採用了StreamDelegate協議,可以申明為inputStream的代理了。

新增以下程式碼到setupNetworkCommunication()方法內,並且剛好在schedule(_:forMode:)方法之前。

inputStream.delegate = self
複製程式碼

下一步,在擴充套件中新增stream(_:handle:)的實現:

func stream(_ aStream: Stream, handle eventCode: Stream.Event) {
    switch eventCode {
    case Stream.Event.hasBytesAvailable:
      print("new message received")
    case Stream.Event.endEncountered:
      print("new message received")
    case Stream.Event.errorOccurred:
      print("error occurred")
    case Stream.Event.hasSpaceAvailable:
      print("has space available")
    default:
      print("some other event...")
      break
    }
}
複製程式碼

這裡你處理了即將來的可能在流上會發生的事件。你最感興趣的一個應該是Stream.Event.hasBytesAvailable,因為這意味著有訊息需要你讀~

下一步,寫一個處理即將來的訊息的方法。在下面方法下新增:

private func readAvailableBytes(stream: InputStream) {
  //1
  let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: maxReadLength)
  
  //2
  while stream.hasBytesAvailable {
    //3
    let numberOfBytesRead = inputStream.read(buffer, maxLength: maxReadLength)
    
    //4
    if numberOfBytesRead < 0 {
      if let _ = stream.streamError {
        break
      }
    }

    //Construct the Message object
    
  }
}
複製程式碼
  1. 首先,建立一個緩衝區,可以用來讀取訊息位元組
  2. 下一步,一直迴圈到輸入流沒有位元組讀取了為止
  3. 在每一步迴圈中,呼叫read(_:maxLength:)方法讀取流中的位元組並將它放入傳進來的緩衝區中
  4. 如果讀取的位元組數小於0,說明錯誤發生並退出

該方法需要在輸入流有位元組可用的時候呼叫,因此在stream(_:handle:)內的Stream.Event.hasBytesAvailable中呼叫這個方法:

readAvailableBytes(stream: aStream as! InputStream)
複製程式碼

此時,你獲得了一個充滿位元組的緩衝區!在完成這個方法前,你需要寫另一個輔助方法將緩衝區程式設計Message物件。

將如下程式碼放到readAvailableBytes(_:)後面:

private func processedMessageString(buffer: UnsafeMutablePointer<UInt8>,
                                    length: Int) -> Message? {
  //1
  guard let stringArray = String(bytesNoCopy: buffer,
                                 length: length,
                                 encoding: .ascii,
                                 freeWhenDone: true)?.components(separatedBy: ":"),
    let name = stringArray.first,
    let message = stringArray.last else {
      return nil
  }
  //2
  let messageSender:MessageSender = (name == self.username) ? .ourself : .someoneElse
  //3
  return Message(message: message, messageSender: messageSender, username: name)
}
複製程式碼
  1. 首先,使用緩衝區和長度初始化一個String物件。設定該物件是ASCII編碼,並告訴物件在使用完緩衝區的時候釋放它,並使用:符號來分割訊息,因此你就可以分別獲得名字和訊息。
  2. 下一步,你知道你或者其他人基於名字傳送了一個訊息。在真是的app中,可能會希望用一個獨特的令牌來區分不同的人,但在這裡這樣就可以了。
  3. 最後,使用剛才獲得的字串構造Message物件並返回

readAvailableBytes(_:)方法的最後新增以下if-let程式碼來使用構造Message的方法:

if let message = processedMessageString(buffer: buffer, length: numberOfBytesRead) {
  //Notify interested parties
  
}
複製程式碼

此時,你已準備將Message傳送給某人了,但是誰呢?

建立ChatRoomDelegate協議

OK,你肯定希望告訴ChatRoomViewController.swift新的訊息來了,但你並沒有它的引用。因為它持有了ChatRoom的強引用,你不希望顯示地申明一個ChatRoomViewController屬性來建立引用迴圈。

這是使用代理協議的絕佳時刻。ChatRoom不關係哪個物件想知道新訊息,它就是負責告訴某人就好。

ChatRoom.swift的頂部,新增下面簡單的協議定義:

protocol ChatRoomDelegate: class {
  func receivedMessage(message: Message)
}
複製程式碼

下一步,新增weak可選屬性來保留一個任何想成為ChatRoom代理的物件引用。

weak var delegate: ChatRoomDelegate?
複製程式碼

現在,回到readAvailableBytes(_:)方法並在if-let內新增下面的程式碼:

delegate?.receivedMessage(message: message)
複製程式碼

為完成它,回到ChatRoomViewController.swift並在MessageInputDelegate代理擴充套件下面新增對ChatRoomDelegate的擴充套件

extension ChatRoomViewController: ChatRoomDelegate {
  func receivedMessage(message: Message) {
    insertNewMessageCell(message)
  }
}
複製程式碼

就像我之前說的,其餘的工作都已經幫你做好了,insertNewMessageCell(_:)方法會接收你的訊息並妥善地新增合適的cell到表格上。

現在,在viewWillAppear(_:)內呼叫它的super程式碼後將介面控制器設定為ChatRoom的代理。

chatRoom.delegate = self
複製程式碼

再一次編譯執行,輸入你的名字進入到聊天頁面:

iOS流式即時通訊教程

聊天室現在成功展示了一個表明你進入聊天室的cell。你正式地傳送了一條訊息並接收了來自基於套接字TCP伺服器的訊息。

傳送訊息

是時候允許使用者傳送真正的文字訊息啦~

回到ChatRoom.swift並在類定義的底部新增如下程式碼:

func sendMessage(message: String) {
  let data = "msg:\(message)".data(using: .ascii)!
  
  _ = data.withUnsafeBytes { outputStream.write($0, maxLength: data.count) }
}
複製程式碼

該方法就像之前寫的joinChat(_:)方法,將你傳送的msg轉成作為真正訊息的文字。

因為你希望在inputBar告訴ChatRoomViewController使用者已點選Send按鈕時傳送訊息,回到ChatRoomViewController.swift並找到MessageInputDelegate的擴充套件。

這裡,你會找到一個叫sendWasTapped(_:)的空方法。為了真正來傳送訊息,直接就將它傳給chatRoom

chatRoom.sendMessage(message: message)
複製程式碼

這就是傳送功能的全部啦~ server將會收到訊息並將其轉發給任何人,ChatRoom將會與以加入房間的方式被通知到訊息。

再次執行併傳送訊息:

iOS流式即時通訊教程

若你想看到別人在這裡聊天,開啟一個新的終端,並輸入:

telnet localhost 80
複製程式碼

這樣允許你用命令列的方式連線到TCP伺服器。現在那裡可以傳送跟app相同的命令:

iam:gregg
複製程式碼

然後,傳送一條訊息:

msg:Ay mang, wut's good?
複製程式碼

iOS流式即時通訊教程

恭喜你,已成功建立了聊天客戶端~

清理工作

如果你之前有寫過任何關於檔案的程式設計,你應該知道當檔案使用完時的良好習慣。事實證明,像在Unix中的任何其他事情一樣,開著的套接字連線是使用檔案控制程式碼來表示的,這意味著像其他檔案一樣,在使用完畢後,你需要關閉它。

sendMessage(_:)方法後面新增如下方法

func stopChatSession() {
  inputStream.close()
  outputStream.close()
}
複製程式碼

你可能已猜到,該方法會關閉流並使得訊息不能被接收或者傳送出去。這也會將流從之前新增的runloop中移除掉。

為最終完成它,在Stream.Event.endEncountered程式碼分支下新增呼叫該方法的程式碼:

stopChatSession()
複製程式碼

然後,回到ChatRoomViewController.swift並在viewWillDisappear(_:)內也新增上述程式碼。

這樣,就大功告成了~

何去何從

想下完整程式碼,請點選這裡

目前你已經掌握(至少是看過一個簡單的例子)關於套接字網路的基礎,還有幾種方法來擴充套件你的眼界。

UDP 套接字

本教程是關於TCP通訊的例子,TCP會建立一個連線並儘可能保證資料包可達。作為選擇,你可以使用UDP,或者資料包套接字通訊。這些套接字並沒有如此的傳輸保證,這意味著他們更加快速且更小的開銷。在遊戲領域他們很實用。體驗過延遲嗎?那樣意味著你遇到了糟糕的連線,許多應該收到的包被丟棄了。

WebSockets

另一種想這樣給應用使用HTTP的技術叫WebSockets。不像傳統的TCP套接字,WebSockets至少保持與HTTP的關係,並且可以用於實現與傳統套接字相同的實時通訊目標,所有這一切都來自瀏覽器的舒適性和安全性。當然WebSockets也可以在iOS上使用,我們剛好有這篇教程如果你想學習更多內容的話。

Beej的網路程式設計指南

最後,如果你真的想深入瞭解網路,看看免費的線上書籍--Beej的網路程式設計指南。拋開奇怪的暱稱,這本書提供了非常詳盡且寫的很好的套接字程式設計。如果你害怕C語言,那麼這本書確實有點“恐怖”,但說不定今天是你面對恐懼的時候呢:]

希望你能享受這篇流教程,像往常一樣,如果你有任何問題請毫無顧忌的讓我知道或者在下方留言~

相關文章