iOS流式即時通信教程

前言

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

iOS流式即時通信教程

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

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

現代網絡基礎結構的第四層,套接字是任何從文本編輯到遊戲在線通信的核心。github

爲什麼是套接字

你可能會奇怪,「爲何不優先使用URLSession而選擇低級API?」。若是你沒以爲奇怪,能夠僞裝你以爲......web

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

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

但當你但願server告訴App一些事情是怎麼辦嘞?對於這種事情HTTP確實處理的不太好。誠然,你能夠經過不斷請求server看是否有更新來實現,也叫輪詢,或者你能夠更狡猾點使用長輪詢,但這些技術都感受不那麼天然且都有本身的缺陷。最後,爲何要限制本身必定要使用請求-響應的範式若是它不是一個合適的工具嘞?編程

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

長輪詢是傳統輪旋技術的變種,能夠模擬信息從服務端推送到客戶端。使用長輪詢,客戶端像普通的輪詢同樣請求服務端。但當服務端沒有任何信息能夠給到服務端時,server會持有這個請求等待可用的信息而不是發送一個空信息給客戶端。一旦server有可發送的信息(或者超時),就發送一個響應給客戶端。客戶端一般會收到信息後當即在請求server,這樣服務基本會一致有一個等待中的用於響應客戶端的請求。在web/AJAX中,長鏈接被叫作Comet瀏覽器

長輪詢自己並非一個推送技術,但能夠用於在長鏈接不可能實現的狀況下使用。安全

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

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

開始~

開始前,下載這個啓動包,包含了聊天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工程,編譯並運行,你會看到已經幫你寫好的界面:

如上圖所示,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()
複製代碼

在本地服務器上,如今你已打開了客戶端和服務端鏈接。再次編譯運行代碼,將會看到跟你寫代碼以前如出一轍的界面。

參與聊天

如今你已連上了服務端,是時候發一些消息了~ 第一件事情你可能會說我究竟是誰。以後,你也但願開始發送信息給其餘人了。

這裏提出了一個重要的問題:由於你有兩種消息,須要想個辦法來區分他們。

通訊協議

降到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)
複製代碼

如今編譯並運行,輸入名字進入界面看看:

一樣什麼也沒發生?

稍等,我來解釋下~ 去看看終端程序。就在 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
複製代碼

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

聊天室如今成功展現了一個代表你進入聊天室的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將會與以加入房間的方式被通知到消息。

再次運行併發送消息:

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

telnet localhost 80
複製代碼

這樣容許你用命令行的方式鏈接到TCP服務器。如今那裏能夠發送跟app相同的命令:

iam:gregg
複製代碼

而後,發送一條消息:

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

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

清理工做

若是你以前有寫過任何關於文件的編程,你應該知道當文件使用完時的良好習慣。事實證實,像在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語言,那麼這本書確實有點「恐怖」,但說不定今天是你面對恐懼的時候呢:]

但願你能享受這篇流教程,像往常同樣,若是你有任何問題請毫無顧忌的讓我知道或者在下方留言~

相關文章
相關標籤/搜索