多點互聯(lián) (Multipeer Connectivity,即 MPC) 是在 2013 年的 WDCC 中提出的,期間做過不少宣傳,但是卻很少有案例能夠成功有效地使用它。接下來,就讓我們來看一看如何正確使用 MPC,尤其是在游戲中的應(yīng)用。
多點互聯(lián)是蘋果的一個傳輸無關(guān)的網(wǎng)絡(luò)框架,提供網(wǎng)絡(luò)的發(fā)現(xiàn)、創(chuàng)建和通信功能??梢哉f它是 Bonjour 的精神傳承者, Bonjour 可以在 LAN 和 Wi-Fi 的網(wǎng)絡(luò)下高效地識別設(shè)備。
MPC 的關(guān)鍵用途在于創(chuàng)建臨時網(wǎng)絡(luò)中的點對點連接,而不需要考慮天氣、無線、藍牙等各種因素,只需要有個人網(wǎng)絡(luò)就行。一旦創(chuàng)建之后,各個節(jié)點可以安全地共享消息、數(shù)據(jù)和文件資源。
絕大部分 MPC 的功能在更高層的 GameKit 框架中都可以找到。使用 GameKit 可以讓開發(fā)者接觸到有用的游戲概念,抽離底層的網(wǎng)絡(luò)協(xié)議。
大部分的游戲都更適合用 GameKit 開發(fā),它有很多直接使用 MPC 實現(xiàn)的游戲相關(guān)的封裝。不過作為 MPC 的進階手冊,本文主要涉及 MPC 的各種使用技巧。
當你的游戲或應(yīng)用需要在近距離的多臺設(shè)備中進行連接的時候, MPC 可以大幅提高用戶體驗。不論你是想要建立一個遠程控制還是多人游戲, MPC 都可以幫助你減少用戶使用過程中的阻力,減少服務(wù)器的開銷,甚至可以減少網(wǎng)絡(luò)延時等問題。
比如一個遠程控制的應(yīng)用,如果它不需要用戶進行任何設(shè)置,而是在安裝后立即自動連接到被控制端上,那么應(yīng)用的品質(zhì)會得到很大的提升。不論這個遠程控制針對的是游戲、軟件展示、音頻播放還是其他東西,都是這樣。DeckRocket 就是一個很好的開源的例子,它是一個用來遠程遙控 DeckSet 幻燈片的 iOS 應(yīng)用。
多用戶游戲也可以從 MPC 的零配置和離線連接特性中受益。比如一個包含游戲邏輯、規(guī)則和存檔功能的卡牌類游戲,可以在不聯(lián)網(wǎng)的狀態(tài)下讓任意兩名玩家進行即時對戰(zhàn)。在這篇文章里,我們將會從 CardsAgainst 這個真實的應(yīng)用中選取一些例子進行說明。 CardsAgainst 是著名游戲 Cards Against Humanity 的開源 iOS 版本,完整的項目源代碼可以在 Github 獲取。
本文中的其他示例則選自 PeerKit,一個 Github 上的開源框架,用來構(gòu)建事件驅(qū)動且無需配置的 MPC 應(yīng)用。
有很多種方法可以把 MPC 的設(shè)備偵測概念整合到應(yīng)用中。接下來我們將介紹三種廣泛使用的設(shè)計模式。
蘋果提供了一個內(nèi)置的 ViewController ,可以很方便地進行匹配和初始化連接。只需要設(shè)置好 serviceType
和 session
并且彈出一個 MCBrowserViewController 即可,MPC 會幫你做好剩下的事情。注意,serviceType
最多是 15 位 ASCII 字符。使用方法通常像逆向的 DNS 標記一樣 (例如: io-objc-mpc):
let session = MCSession(peer: MCPeerID(displayName: "Mary"))
let serviceType = "io-objc-mpc" // 最多 15 ASCII 字符
window!.rootViewController = MCBrowserViewController(serviceType: serviceType, session: session)
http://wiki.jikexueyuan.com/project/objc/images/18-9.png" alt="" />
不過,我們無法輕易地對 MCBrowserViewController
進行自定義,而你有可能想設(shè)置自己的匹配原則,那么請移步下面的章節(jié)。
如果你的游戲的匹配機制是先選取一個主節(jié)點來協(xié)調(diào)游戲邏輯,然后其他次節(jié)點和主節(jié)點進行連接,那么你應(yīng)該充分利用這些信息,只需要從主節(jié)點進行公示,然后次節(jié)點進行瀏覽即可:
http://wiki.jikexueyuan.com/project/objc/images/18-10.gif" alt="" />
// 主節(jié)點公示
advertiser = MCNearbyServiceAdvertiser(peer: myPeerID, discoveryInfo: discoveryInfo, serviceType: serviceType)
advertiser.delegate = self
advertiser.startAdvertisingPeer()
// 次節(jié)點瀏覽
mcBrowser = MCNearbyServiceBrowser(peer: myPeerID, serviceType: serviceType)
mcBrowser.delegate = self
mcBrowser.startBrowsingForPeers()
但是,總是有那么一些情況,最好能在應(yīng)用運行之前就建立好連接,而不用用戶進行任何操作。下面的章節(jié)就展示了如何實現(xiàn)這樣的功能。
MPC 能夠極大地減少用戶體驗的阻力。當你以正確的方式把它整合到應(yīng)用中時,你的用戶可以在安裝應(yīng)用之后立即開始通信,而不用任何配置。這會是一件大快所有人心的大好事。
http://wiki.jikexueyuan.com/project/objc/images/18-11.gif" alt="" />
為了實現(xiàn)這個功能,我們需要同時對會話進行公示和查看,我們把這種行為稱之為 收發(fā) (transceiving = transmitting and receiving)。
在多節(jié)點進行收發(fā)的時候,競爭問題是一個重大的挑戰(zhàn),因為可能會有很多節(jié)點同時嘗試連接彼此。這便是領(lǐng)袖選舉 (Leader Election) 問題,這個問題已經(jīng)被深入地討論和研究,并且有一些很好地解決方案。
下面介紹一種簡單而有效的方法。在邀請其他節(jié)點加入會話的時候,將每個節(jié)點的運行時間包含到元數(shù)據(jù) (metadata) 里,公示的節(jié)點總是加入到最早的會話中:
// 瀏覽者的委托代碼
func browser(browser: MCNearbyServiceBrowser!, foundPeer peerID: MCPeerID!, withDiscoveryInfo info: [NSObject : AnyObject]!) {
var runningTime = -timeStarted.timeIntervalSinceNow
let context = NSData(bytes: &runningTime, length: sizeof(NSTimeInterval))
browser.invitePeer(peerID, toSession: mcSession, withContext: context, timeout: 30)
}
// 公示者的委托代碼
func advertiser(advertiser: MCNearbyServiceAdvertiser!, didReceiveInvitationFromPeer peerID: MCPeerID!, withContext context: NSData!, invitationHandler: ((Bool, MCSession!) -> Void)!) {
var runningTime = -timeStarted.timeIntervalSinceNow
var peerRunningTime = NSTimeInterval()
context.getBytes(&peerRunningTime)
let isPeerOlder = (peerRunningTime > runningTime)
invitationHandler(isPeerOlder, mcSession)
if isPeerOlder {
advertiser.stopAdvertisingPeer()
}
}
MPC 提供了幾種發(fā)送和接收數(shù)據(jù)的方式,每種方式都有自己獨有的特點和取舍。
當發(fā)送少量事件驅(qū)動的數(shù)據(jù) (最多幾 kb) 的時候,比如游戲事件 (開始/暫停/退出),使用這個方法:sendData(_:toPeers:withMode:error:)
。
為了封裝傳輸?shù)臄?shù)據(jù),CardsAgainst 定義了一個游戲事件的枚舉類型,在接下來對隨行的數(shù)據(jù)進行序列化和反序列化的時候也會用到:
// 所有的游戲事件
enum Event: String {
case StartGame = "StartGame",
Answer = "Answer",
CancelAnswer = "CancelAnswer",
Vote = "Vote",
NextCard = "NextCard",
EndGame = "EndGame"
}
// 可靠地 (使用 .Reliable 模式) 向節(jié)點發(fā)送事件,有可能有隨行數(shù)據(jù)
func sendEvent(event: Event, object: AnyObject? = nil, toPeers peers: [MCPeerID] = session.connectedPeers as [MCPeerID]) {
if peers.count == 0 {
return
}
var rootObject: [String: AnyObject] = ["event": event.rawValue]
if let object = object {
rootObject["object"] = object
}
let data = NSKeyedArchiver.archivedDataWithRootObject(rootObject)
session.sendData(data, toPeers: peers, withMode: .Reliable, error: nil)
}
// 使用例
sendEvent(.StartGame, ["initialData": "hello objc.io!"])
具體內(nèi)容可以參考 ConnectionManager.swift 的源代碼。
就像是 TCP/UDP 一樣,MPC 有可靠傳輸和不可靠傳輸兩種模式。MCSessionSendDataMode 包含了這兩種模式。
如果要在可靠模式 (.Reliable) 下發(fā)送數(shù)據(jù):
let message = "Hello objc.io!"
let data = message.dataUsingEncoding(NSUTF8StringEncoding)!
var error: NSError? = nil
if !session.sendData(data, toPeers: peers, withMode: .Reliable, error: &error) {
println("error: \(error!)")
}
如果你發(fā)送的數(shù)據(jù)十分關(guān)鍵,直接關(guān)系到你的游戲能否正常運行,比如開始或者暫停游戲,使用可靠模式 (.Reliable):
如果與準確性和有序性相比,速度的優(yōu)先級更高,比如發(fā)送傳感器的數(shù)據(jù),那么不可靠模式 (.Unreliable) 可能更適合。務(wù)必權(quán)衡利弊,在考慮好流的情況下,選擇最適合你的方案。
當你發(fā)送大量數(shù)據(jù) (幾百 kB 甚至幾 MB) 的時候,比如文件,應(yīng)該使用 sendResourceAtURL(_:withName:toPeer:withCompletionHandler:)
方法。它可以通過 NSProgress
對象讓發(fā)送方和接收方同時監(jiān)控傳輸進度。
這是 DeckRocket 中的例子:
pdfProgress = session!.sendResourceAtURL(url, withName: filePath.lastPathComponent, toPeer: peer) { error in
dispatch_async(dispatch_get_main_queue()) {
self.pdfProgress!.removeObserver(self, forKeyPath: "fractionCompleted", context: &ProgressContext)
if error != nil {
HUDView.show("Error!\n\(error.localizedDescription)")
} else {
HUDView.show("Success!")
}
}
}
pdfProgress!.addObserver(self, forKeyPath: "fractionCompleted", options: .New, context: &ProgressContext)
對于流數(shù)據(jù),比如傳感器的讀數(shù)或者持續(xù)更新的用戶坐標信息等等,可以使用 startStreamWithName(_:toPeer:error:)
方法把數(shù)據(jù)寫到 NSOutputStream
中。接收者則通過 NSInputStream
讀取數(shù)據(jù)流:
// 接收者
public func session(session: MCSession!, didReceiveStream stream: NSInputStream!, withName streamName: String!, fromPeer peerID: MCPeerID!) {
// 假設(shè)是一個 UInt8 的流
var buffer = [UInt8](count: 8, repeatedValue: 0)
stream.open()
// 讀取單個字節(jié)
if stream.hasBytesAvailable {
let result: Int = stream.read(&buffer, maxLength: buffer.count)
println("result: \(result)")
}
}
雖然 MPC 很強大,但同時也面臨不少挑戰(zhàn)。下面列舉一下你可能會遇到的問題。
MPC 只能用于 iOS 7、iOS 8 和 OS X 10.10 ,所以如果不是蘋果的設(shè)備,或者不是最新的 OS X 發(fā)行版的話,那么請忘了 MPC 吧。跨平臺的應(yīng)用或者游戲需要依賴別的替代品。
盡管在 iOS 7 之后蘋果對 MPC 的可靠性做了很大的提升,可靠性依舊是 MPC 的痛處。不得不考慮到連接失敗的情況,而且為了盡可能覆蓋很多邊界情況,還需要做不少額外的功課。
撇開因無線連接的損耗所導(dǎo)致的網(wǎng)絡(luò)延時不談,編寫即時型網(wǎng)絡(luò)的代碼有點像是寫本地的多線程代碼。在假設(shè)事件發(fā)送成功之前,務(wù)必在合適的位置對關(guān)鍵傳輸加鎖,從而確保所有節(jié)點確認接收關(guān)鍵事件。
游戲常常需要共享狀態(tài),比如游戲是否開始或暫停,玩家是否退出等等。如果玩家在對手即將發(fā)動致命一擊的時候暫停游戲了會怎么樣? MPC 將異步的游戲邏輯競爭留給開發(fā)者來決定。使用 GameKit 這樣的框架對集中邏輯很有幫助,但是同時也犧牲了一些靈活性作為代價。
用 MPC 來寫一個復(fù)雜的游戲無疑充滿了挑戰(zhàn)性。你可以了解一下其他選擇再做決定。
蘋果在 GameKit 中投入了很多想法。盡管它強制要求使用指定的模型和結(jié)構(gòu)模式,并且還需要放棄會話連接過程中的一些控制,但是它確實抽離了很多底層的工作,減輕了工作量。
用 GameKit 開發(fā)游戲可以同時滿足點對點模式和傳統(tǒng)網(wǎng)絡(luò)連接模式的需求。
WebSocket 協(xié)議 (RFC 6455) 允許服務(wù)器端和客戶端之間進行雙向通信。每個節(jié)點需要建立一個新的 websocket 連接。該協(xié)議建立在 TCP 的基礎(chǔ)上,所以不提供類似 MPC 的 .Unreliable
信息發(fā)送模式。不像 MPC,websocket 不提供任何網(wǎng)絡(luò)創(chuàng)建或者設(shè)備檢測功能,所以服務(wù)器端和客戶端都必須連接在同一個網(wǎng)絡(luò)上。它常用于和 Bonjour 關(guān)聯(lián)使用。
如果是構(gòu)建跨平臺游戲或應(yīng)用,那么 WebSocket 可以說是極具吸引力的,不過它需要一個有自定義后臺的連接。
目前Swift (starscream) 和 Objective-C (SocketRocket、jetfire) 都有不少現(xiàn)成的 WebSocket 類庫可供使用。
把 MPC 整合到你的游戲或者應(yīng)用中的過程不會很復(fù)雜,但是卻能極大的提升用戶體驗,希望讀完本文你也認同此觀點。
如果想了解關(guān)于 MPC 的更多內(nèi)容,下面的資料可能會有所幫助。
Multipeer Connectivity Reference
Multipeer Connectivity WWDC 2013 Session
NSHipster Article on Multipeer Connectivity
PeerKit: An open-source Swift framework for building event-driven, zero-config MPC apps
CardsAgainst: An open-source iOS game built with MPC
DeckRocket: An open-source presentation remote control app for iOS/OSX built with MPC