前言
本文翻譯自Real-Time Communication with Streams Tutorial for iOS
翻譯的不對的地方還請多多包涵指正,謝謝~
iOS流式即時(shí)通訊教程
從時(shí)間初始,人們就已開始夢想著更好地跟遙遠(yuǎn)的兄弟通訊的方式。從信鴿到無線電波,我們一直在努力將通訊變得更清晰更高效。
在現(xiàn)代中,一種技術(shù)已成為我們尋求相互理解的重要的工具:簡易網(wǎng)絡(luò)套接字。
現(xiàn)代網(wǎng)絡(luò)基礎(chǔ)結(jié)構(gòu)的第四層,套接字是任何從文本編輯到游戲在線通訊的核心。
為何是套接字
你可能會(huì)奇怪,“為什么不優(yōu)先使用URLSession而選擇低級API?”。如果你沒覺得奇怪,可以假裝你覺得......
好問題_ URLSession通訊是基于HTTP網(wǎng)絡(luò)協(xié)議。使用HTTP,通訊是以【請求-響應(yīng)】方式進(jìn)行。這意味著在大部分App大多數(shù)網(wǎng)絡(luò)代碼都遵循以下模式:
- 從
server端請求JSON數(shù)據(jù) - 在代理方法內(nèi)接收并使用
JSON
但當(dāng)你希望server告訴App一些事情是怎么辦嘞?對于這種事情HTTP確實(shí)處理的不太好。誠然,你可以通過不斷請求server看是否有更新來實(shí)現(xiàn),也叫輪詢,或者你可以更狡猾點(diǎn)使用長輪詢,但這些技術(shù)都感覺不那么自然且都有自己的缺陷。最后,為什么要限制自己一定要使用請求-響應(yīng)的范式如果它不是一個(gè)合適的工具嘞?
注:長輪詢 ---- 原文沒有
長輪詢是傳統(tǒng)輪旋技術(shù)的變種,可以模擬信息從服務(wù)端推送到客戶端。使用長輪詢,客戶端像普通的輪詢一樣請求服務(wù)端。但當(dāng)服務(wù)端沒有任何信息可以給到服務(wù)端時(shí),
server會(huì)持有這個(gè)請求等待可用的信息而不是發(fā)送一個(gè)空信息給客戶端。一旦server有可發(fā)送的信息(或者超時(shí)),就發(fā)送一個(gè)響應(yīng)給客戶端??蛻舳送ǔ?huì)收到信息后立即在請求server,這樣服務(wù)基本會(huì)一致有一個(gè)等待中的用于響應(yīng)客戶端的請求。在web/AJAX中,長連接被叫做Comet。長輪詢本身并不是一個(gè)推送技術(shù),但可以用于在長連接不可能實(shí)現(xiàn)的情況下使用。
在這篇流式教程中,你將會(huì)學(xué)習(xí)如何使用套接字直接創(chuàng)建一個(gè)實(shí)時(shí)的聊天應(yīng)用。

程序中不是每個(gè)客戶端都去檢查服務(wù)端是否有更新,而是使用在聊天期間持續(xù)存在的輸入輸出流。
開始~
開始前,下載這個(gè)啟動(dòng)包,包含了聊天App和用Go語言寫的server代碼。你不用擔(dān)心自己需要寫Go代碼,只需啟動(dòng)server用來跟客戶端交互。
啟動(dòng)并運(yùn)行server
server代碼是使用Go寫完的并且已幫你編譯好。假如你不相信從網(wǎng)上下載的已編譯好的可執(zhí)行文件,文件夾中有源代碼,你可以自己編譯。
為了運(yùn)行已編譯好的server,打開你的終端,切到下載的文件夾并輸入以下命令,并接下來輸入你的開機(jī)密碼:
sudo ./server
在你輸入完密碼后,應(yīng)該能看到 Listening on 127.0.0.1:80。聊天server開始運(yùn)行啦~ 現(xiàn)在你可以調(diào)到下個(gè)章節(jié)了。
假如你想自己編譯Go代碼,需要用Homebrew安裝Go。
沒有Homebrew工具的話,需要先安裝它。打開終端,復(fù)制如下命令貼到終端。
/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)
然后,使用如下命令安裝Go:
brew install go
一旦完成安裝,切到下載的代碼位置并在終端使用如下編譯命令:
go build server.go
最終,你可以啟動(dòng)server,使用上述啟動(dòng)服務(wù)器的代碼。
瞅瞅現(xiàn)有的App
下一步,打開DogeChat工程,編譯并運(yùn)行,你會(huì)看到已經(jīng)幫你寫好的界面:

如上圖所示,DogeChat已經(jīng)寫好可以允許用戶輸入名字后進(jìn)入到聊天室。不幸的是,前一個(gè)工程師不知道怎么寫聊天App因此他寫完了所有的界面和基本的跳轉(zhuǎn),留下了網(wǎng)絡(luò)層部分給你。
創(chuàng)建聊天室
在開始編碼前,切到 ChatRoomViewController.swift 文件。你可以看到你有了一個(gè)界面處理器,它能接收來自輸入欄的信息,也可以通過使用Message對象配置cell的TableView來展示消息。
既然你已經(jīng)有了ViewController,那么你只需要?jiǎng)?chuàng)建一個(gè)ChatRoom來處理繁重的工作。
開始寫新類前,我想快速列舉下新類的功能。對于它,我們希望能處理這些事情:
- 打開聊天室服務(wù)器的連接
- 允許通過提供名字來進(jìn)入聊天室
- 用戶能夠收發(fā)信息
- 當(dāng)時(shí)完成時(shí)關(guān)閉連接
現(xiàn)在你知道你該做什么啦,點(diǎn)擊Command+N創(chuàng)建新的文件。選擇Cocoa Touch Class并將它命名為ChatRoom。
創(chuàng)建輸入輸出流
現(xiàn)在,繼續(xù)并替換在文件內(nèi)的內(nèi)容如下:
import UIKit
class ChatRoom: NSObject {
//1
var inputStream: InputStream!
var outputStream: OutputStream!
//2
var username = ""
//3
let maxReadLength = 4096
}
這里,你定義了ChatRoom類,并聲明了為使溝通更高效的屬性。
- 首先,你有了輸入輸出流。使用這對類可以讓你創(chuàng)建基于app和
server的套接字。自然地,你會(huì)通過輸出流來發(fā)送消息,輸出流接收消息。 - 下一步,你定義了
username變量用于存儲當(dāng)前用戶的名字 - 最后定義了
maxReadLength。該變量限制你單次發(fā)送信息的數(shù)據(jù)量
然后,切到ChatRoomViewController.swift并在類的內(nèi)部商法添加ChatRoom屬性:
let chatRoom = ChatRoom()
目前你已經(jīng)構(gòu)建了類的基礎(chǔ)結(jié)構(gòu),是時(shí)候開始你之前列舉類功能的第一項(xiàng)了---打開server與App間的連接。
開啟連接
返回到ChatRoom.swift文件在屬性定義的下方,加入以下代碼:
func setupNetworkCommunication() {
// 1
var readStream: Unmanaged<CFReadStream>?
var writeStream: Unmanaged<CFWriteStream>?
// 2
CFStreamCreatePairWithSocketToHost(kCFAllocatorDefault,
"localhost" as CFString,
80,
&readStream,
&writeStream)
}
這里發(fā)生了:
- 第一段,創(chuàng)建了兩個(gè)未初始化的且不會(huì)自動(dòng)內(nèi)存管理的套接字流
- 將讀寫套接字聯(lián)系起來并將其連上主機(jī)的套接字,這里的端口號是80。
這個(gè)函數(shù)傳入四個(gè)參數(shù),第一個(gè)是你要用來初始化流的分配類型。盡可能地使用kCFAllocatorDefault,但如果遇到你希望它有不同表現(xiàn)的時(shí)候有其他的選項(xiàng)。
下一步,你指定了hostname。此時(shí)你只需要連接本地機(jī)器,但如果你有遠(yuǎn)程服務(wù)得指定IP,你可以在此使用它。
然后,你指定了連接通過80端口,這是在server端設(shè)定的一個(gè)端口號。
最后,你傳入了讀寫的流指針,這個(gè)方法能使用已連接的內(nèi)部的讀寫流來初始化它們。
現(xiàn)在你已獲得了出事后的流,你可以通過添加以下兩行代碼存儲它們的引用:
inputStream = readStream!.takeRetainedValue()
outputStream = writeStream!.takeRetainedValue()
在不受管理的對象上調(diào)用takeRetainedValue()可以讓你同步獲得一個(gè)保留的引用并且消除不平衡的保留(an unbalanced retain),因此之后內(nèi)存不會(huì)泄露?,F(xiàn)在當(dāng)你需要流時(shí)你可以使用它們啦。
下一步,為了讓app能夠合理地響應(yīng)網(wǎng)絡(luò)事件,這些流需要添加進(jìn)runloop內(nèi)。在setupNetworkCommunication函數(shù)內(nèi)部最后添加以下兩行代碼:
inputStream.schedule(in: .current, forMode: .commonModes)
outputStream.schedule(in: .current, forMode: .commonModes)
你已經(jīng)準(zhǔn)備好打開“洪流之門”了~ 開始吧,添加以下代碼(還在setupNetworkCommunication函數(shù)內(nèi)部最后):
inputStream.open()
outputStream.open()
這就是全部啦。我們回到ChatRoomViewController.swift類,在viewWillAppear函數(shù)內(nèi)添加如下代碼:
chatRoom.setupNetworkCommunication()
在本地服務(wù)器上,現(xiàn)在你已打開了客戶端和服務(wù)端連接。再次編譯運(yùn)行代碼,將會(huì)看到跟你寫代碼之前一模一樣的界面。

參與聊天
現(xiàn)在你已連上了服務(wù)端,是時(shí)候發(fā)一些消息了~ 第一件事情你可能會(huì)說我到底是誰。之后,你也希望開始發(fā)送信息給其他人了。
這里提出了一個(gè)重要的問題:因?yàn)槟阌袃煞N消息,需要想個(gè)辦法來區(qū)分他們。
通信協(xié)議
降到TCP層好處之一是你可以定義自己的協(xié)議來決定一個(gè)信息的有效與否。對于HTTP,你需要想到這些煩人的動(dòng)作:Get,PUT和PATCH。需要構(gòu)造URL并使用合適的頭部和各種各樣的事情。
這里我們之后兩種信息,你可以發(fā)送:
iam:Luke
來進(jìn)入聊天室并通知世界你的名字。你可以說:
msg:Hey, how goes it mang?
來發(fā)送一個(gè)消息給任何一個(gè)在聊天室的人。
這樣純粹且簡單。
這樣顯然不安全,因此不要在工作中使用它。
你知道了服務(wù)器的期望格式,可以在ChatRoom寫一個(gè)方法來進(jìn)入聊天室了。僅有的參數(shù)就是名字了。
為實(shí)現(xiàn)它,添加如下方法到剛添加的方法后面:
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) }
}) } }
- 首先,使用簡單的聊天協(xié)議構(gòu)造了消息
- 然后,保存了剛傳進(jìn)來的名字,之后可以在發(fā)送消息的時(shí)候使用它
- 最后,將消息寫入輸出流。這比你預(yù)想的要復(fù)雜一些,
write(_:maxLength:)方法將一個(gè)不安全的指針引用作為第一個(gè)參數(shù)。withUnsafeBytes(of:_:)方法提供一個(gè)非常便利的方式在閉包的安全范圍內(nèi)處理一些數(shù)據(jù)的不安全指針。
方法已就緒,回到ChatRoomViewController.swift并在viewWillAppear(_:)方法內(nèi)最后添加進(jìn)入聊天室的方法調(diào)用。
chatRoom.joinChat(username: username)
現(xiàn)在編譯并運(yùn)行,輸入名字進(jìn)入界面看看:

同樣什么也沒發(fā)生?

稍等,我來解釋下~ 去看看終端程序。就在 Listening on 127.0.0.1:80
下方,你會(huì)看到 Luke has joined,或如果你的名字不是Luke的話就是其他的內(nèi)容。
這是個(gè)好消息,但你肯定更希望看到在手機(jī)屏幕上成功的跡象。
響應(yīng)即將來臨的消息
幸運(yùn)的是,服務(wù)器接收的消息就像你剛剛發(fā)送的一樣,并且發(fā)送給在聊天的每個(gè)人,包括你自己。更幸運(yùn)的是,app本就已可在ChatRoomViewController的表格界面上展示即將要來的消息。
所有你要做的就是使用inputStream來捕捉這些消息,將其轉(zhuǎn)換成Message對象,并將它傳出去讓表格做顯示。
為響應(yīng)消息,第一個(gè)需要做的事情是讓ChatRoom成為輸入流的代理。首先,到ChatRoom.swift最底部添加以下擴(kuò)展:
extension ChatRoom: StreamDelegate {
}
現(xiàn)在ChatRoom已經(jīng)采用了StreamDelegate協(xié)議,可以申明為inputStream的代理了。
添加以下代碼到setupNetworkCommunication()方法內(nèi),并且剛好在schedule(_:forMode:)方法之前。
inputStream.delegate = self
下一步,在擴(kuò)展中添加stream(_:handle:)的實(shí)現(xiàn):
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
}
}
這里你處理了即將來的可能在流上會(huì)發(fā)生的事件。你最感興趣的一個(gè)應(yīng)該是Stream.Event.hasBytesAvailable,因?yàn)檫@意味著有消息需要你讀~
下一步,寫一個(gè)處理即將來的消息的方法。在下面方法下添加:
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
}
}
- 首先,創(chuàng)建一個(gè)緩沖區(qū),可以用來讀取消息字節(jié)
- 下一步,一直循環(huán)到輸入流沒有字節(jié)讀取了為止
- 在每一步循環(huán)中,調(diào)用
read(_:maxLength:)方法讀取流中的字節(jié)并將它放入傳進(jìn)來的緩沖區(qū)中 - 如果讀取的字節(jié)數(shù)小于0,說明錯(cuò)誤發(fā)生并退出
該方法需要在輸入流有字節(jié)可用的時(shí)候調(diào)用,因此在stream(_:handle:)內(nèi)的Stream.Event.hasBytesAvailable中調(diào)用這個(gè)方法:
readAvailableBytes(stream: aStream as! InputStream)
此時(shí),你獲得了一個(gè)充滿字節(jié)的緩沖區(qū)!在完成這個(gè)方法前,你需要寫另一個(gè)輔助方法將緩沖區(qū)編程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)
}
- 首先,使用緩沖區(qū)和長度初始化一個(gè)
String對象。設(shè)置該對象是ASCII編碼,并告訴對象在使用完緩沖區(qū)的時(shí)候釋放它,并使用:符號來分割消息,因此你就可以分別獲得名字和消息。 - 下一步,你知道你或者其他人基于名字發(fā)送了一個(gè)消息。在真是的app中,可能會(huì)希望用一個(gè)獨(dú)特的令牌來區(qū)分不同的人,但在這里這樣就可以了。
- 最后,使用剛才獲得的字符串構(gòu)造
Message對象并返回
在readAvailableBytes(_:)方法的最后添加以下if-let代碼來使用構(gòu)造Message的方法:
if let message = processedMessageString(buffer: buffer, length: numberOfBytesRead) {
//Notify interested parties
}
此時(shí),你已準(zhǔn)備將Message發(fā)送給某人了,但是誰呢?
創(chuàng)建ChatRoomDelegate協(xié)議
OK,你肯定希望告訴ChatRoomViewController.swift新的消息來了,但你并沒有它的引用。因?yàn)樗钟辛?code>ChatRoom的強(qiáng)引用,你不希望顯示地申明一個(gè)ChatRoomViewController屬性來創(chuàng)建引用循環(huán)。
這是使用代理協(xié)議的絕佳時(shí)刻。ChatRoom不關(guān)系哪個(gè)對象想知道新消息,它就是負(fù)責(zé)告訴某人就好。
在ChatRoom.swift的頂部,添加下面簡單的協(xié)議定義:
protocol ChatRoomDelegate: class {
func receivedMessage(message: Message)
}
下一步,添加weak可選屬性來保留一個(gè)任何想成為ChatRoom代理的對象引用。
weak var delegate: ChatRoomDelegate?
現(xiàn)在,回到readAvailableBytes(_:)方法并在if-let內(nèi)添加下面的代碼:
delegate?.receivedMessage(message: message)
為完成它,回到ChatRoomViewController.swift并在MessageInputDelegate代理擴(kuò)展下面添加對ChatRoomDelegate的擴(kuò)展
extension ChatRoomViewController: ChatRoomDelegate {
func receivedMessage(message: Message) {
insertNewMessageCell(message)
}
}
就像我之前說的,其余的工作都已經(jīng)幫你做好了,insertNewMessageCell(_:)方法會(huì)接收你的消息并妥善地添加合適的cell到表格上。
現(xiàn)在,在viewWillAppear(_:)內(nèi)調(diào)用它的super代碼后將界面控制器設(shè)置為ChatRoom的代理。
chatRoom.delegate = self
再一次編譯運(yùn)行,輸入你的名字進(jìn)入到聊天頁面:

聊天室現(xiàn)在成功展示了一個(gè)表明你進(jìn)入聊天室的cell。你正式地發(fā)送了一條消息并接收了來自基于套接字TCP服務(wù)器的消息。
發(fā)送消息
是時(shí)候允許用戶發(fā)送真正的文本消息啦~
回到ChatRoom.swift并在類定義的底部添加如下代碼:
func sendMessage(message: String) {
let data = "msg:\(message)".data(using: .ascii)!
_ = data.withUnsafeBytes { outputStream.write($0, maxLength: data.count) }
}
該方法就像之前寫的joinChat(_:)方法,將你發(fā)送的msg轉(zhuǎn)成作為真正消息的文本。
因?yàn)槟阆M?code>inputBar告訴ChatRoomViewController用戶已點(diǎn)擊Send按鈕時(shí)發(fā)送消息,回到ChatRoomViewController.swift并找到MessageInputDelegate的擴(kuò)展。
這里,你會(huì)找到一個(gè)叫sendWasTapped(_:)的空方法。為了真正來發(fā)送消息,直接就將它傳給chatRoom。
chatRoom.sendMessage(message: message)
這就是發(fā)送功能的全部啦~ server將會(huì)收到消息并將其轉(zhuǎn)發(fā)給任何人,ChatRoom將會(huì)與以加入房間的方式被通知到消息。
再次運(yùn)行并發(fā)送消息:

若你想看到別人在這里聊天,打開一個(gè)新的終端,并輸入:
telnet localhost 80
這樣允許你用命令行的方式連接到TCP服務(wù)器?,F(xiàn)在那里可以發(fā)送跟app相同的命令:
iam:gregg
然后,發(fā)送一條消息:
msg:Ay mang, wut's good?

恭喜你,已成功創(chuàng)建了聊天客戶端~
清理工作
如果你之前有寫過任何關(guān)于文件的編程,你應(yīng)該知道當(dāng)文件使用完時(shí)的良好習(xí)慣。事實(shí)證明,像在Unix中的任何其他事情一樣,開著的套接字連接是使用文件句柄來表示的,這意味著像其他文件一樣,在使用完畢后,你需要關(guān)閉它。
在sendMessage(_:)方法后面添加如下方法
func stopChatSession() {
inputStream.close()
outputStream.close()
}
你可能已猜到,該方法會(huì)關(guān)閉流并使得消息不能被接收或者發(fā)送出去。這也會(huì)將流從之前添加的runloop中移除掉。
為最終完成它,在Stream.Event.endEncountered代碼分支下添加調(diào)用該方法的代碼:
stopChatSession()
然后,回到ChatRoomViewController.swift并在viewWillDisappear(_:)內(nèi)也添加上述代碼。
這樣,就大功告成了~
何去何從
想下完整代碼,請點(diǎn)擊這里
目前你已經(jīng)掌握(至少是看過一個(gè)簡單的例子)關(guān)于套接字網(wǎng)絡(luò)的基礎(chǔ),還有幾種方法來擴(kuò)展你的眼界。
UDP 套接字
本教程是關(guān)于TCP通訊的例子,TCP會(huì)建立一個(gè)連接并盡可能保證數(shù)據(jù)包可達(dá)。作為選擇,你可以使用UDP,或者數(shù)據(jù)包套接字通訊。這些套接字并沒有如此的傳輸保證,這意味著他們更加快速且更小的開銷。在游戲領(lǐng)域他們很實(shí)用。體驗(yàn)過延遲嗎?那樣意味著你遇到了糟糕的連接,許多應(yīng)該收到的包被丟棄了。
WebSockets
另一種想這樣給應(yīng)用使用HTTP的技術(shù)叫WebSockets。不像傳統(tǒng)的TCP套接字,WebSockets至少保持與HTTP的關(guān)系,并且可以用于實(shí)現(xiàn)與傳統(tǒng)套接字相同的實(shí)時(shí)通信目標(biāo),所有這一切都來自瀏覽器的舒適性和安全性。當(dāng)然WebSockets也可以在iOS上使用,我們剛好有這篇教程如果你想學(xué)習(xí)更多內(nèi)容的話。
Beej的網(wǎng)絡(luò)編程指南
最后,如果你真的想深入了解網(wǎng)絡(luò),看看免費(fèi)的在線書籍--Beej的網(wǎng)絡(luò)編程指南。拋開奇怪的昵稱,這本書提供了非常詳盡且寫的很好的套接字編程。如果你害怕C語言,那么這本書確實(shí)有點(diǎn)“恐怖”,但說不定今天是你面對恐懼的時(shí)候呢:]
希望你能享受這篇流教程,像往常一樣,如果你有任何問題請毫無顧忌的讓我知道或者在下方留言~