SwiftUI 与 MultipeerConnectivity 使用教程
本文代码执行环境说明:
- 编译环境 MacOS 12.2.1、XCode 13.2.1
- 运行环境 IOS 15
前言
Multipeer Connectivity (下文将简称为 "MPC")是苹果提供的一个近距离设备发现和通信的能力。设备间通过 MPC 连接后,可以安全的传输文本信息、流信息、文件等内容。本文将介绍 MPC 搭配 SwiftUI 实现一个简单的聊天 APP,下面是最终版的演示:
项目搭建
- 在 XCode 里新建一个 SwiftUI 项目
- 更新
Info.plist
,添加下述内容,其中[a]
替换为自定义文案,告知用户使用本地网络的原因,[b]
替换为自定义的服务名称,该名称需要和后面代码里保持一致,本文使用的是myServiceType
:
若不添加上述配置,在真机运行时会报错<key>NSLocalNetworkUsageDescription</key> <string>[a]</string> <key>NSBonjourServices</key> <array> <string>_[b]._tcp</string> </array>
[MCNearbyServiceAdvertiser] Server did not publish ...
ViewModel 实现
基于 MVVM 模式,首先新建一个 MCVM.swift
文件(MC == MultipeerConnectivity,VM == ViewModel),具体解释请参见下述代码注释:
import Foundation
import MultipeerConnectivity
import SwiftUI
import os
// 封装一下原生的 MCPeerID,添加一些自定义属性,更方便的用于 View 层展示
struct MyMCPeerID: Identifiable {
var id: String {
get{ self.peerID.displayName}
}
let peerID: MCPeerID
var state : MCSessionState = .notConnected
init(peerID id: MCPeerID) {
peerID = id
}
}
// 消息来源方
enum MessageType {
case
sent, // 我发送的消息
received // 我接收到的消息
}
// 消息体
struct Message: Identifiable {
let id = UUID()
let content: String // 消息内容
let date: Date // 发送/接收时间
let type: MessageType // 消息来源方
let from: String // 消息是谁发送的
var dateString: String { // 格式化后的日期,用于展示
get {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
return formatter.string(from: self.date)
}
}
}
class MCVM: NSObject, ObservableObject {
private let log = Logger()
private var peerID: MCPeerID
private var session: MCSession
private var advertiser: MCNearbyServiceAdvertiser
private var browser: MCNearbyServiceBrowser
@Published var peerIDs: [MyMCPeerID] = []
// Tips: 这里不能使用也无须使用@Published修饰,会编译报错 ”Property wrapper cannot be applied to a computed property“,
// This computed value depends on published value, so it will recalculated whenever published value changed.
var connectedPeerIDs: [MyMCPeerID] {
get {
self.peerIDs.filter{$0.state == .connected}
}
}
// 存放接收和发送到的消息
@Published var messages: [Message] = []
override init() {
let type = "myServiceType"
// 初始化当前设备对应的 PeerID 对象,以设备名作为'昵称'
peerID = MCPeerID(displayName: UIDevice.current.name);
// 初始化'会话对象'
session = MCSession(peer: peerID)
// '广播对象'用于让周边设备发现自己
advertiser = MCNearbyServiceAdvertiser(peer: peerID, discoveryInfo: nil, serviceType: type);
// '浏览对象'用于发现周边设备
browser = MCNearbyServiceBrowser(peer: peerID, serviceType: type)
// Tips: In swift, 'super()' should put after child class initialized all of his properties and before 'self' is used
super.init()
// delegate
advertiser.delegate = self
browser.delegate = self
session.delegate = self
}
// 实例销毁后自动停止广播和扫描
deinit {
advertiser.stopAdvertisingPeer()
browser.stopBrowsingForPeers()
}
// 封装扫描函数用于手动触发
func browse() {
browser.startBrowsingForPeers()
}
// 封装广播函数用于手动触发
func advertize() {
advertiser.startAdvertisingPeer()
}
// 连接到指定设备
func connect(peerId id: MCPeerID) {
browser.invitePeer(id, to: session, withContext: nil, timeout: 10)
}
// 向所有已连接设备发送消息
func sendMsg(_ msg: String) {
do {
let msg = Message(content: msg, date: Date(), type: .sent, from: peerID.displayName)
try session.send(msg.content.data(using: .utf8)! , toPeers: peerIDs.filter{$0.state == .connected}.map{$0.peerID}, with: .reliable)
messages.append(msg)
} catch {
print("send error:", error)
}
}
}
AdvertiserDelegate
注册广播相关的回调函数,广播失败或是收到了连接请求会触发对应函数:
extension MCVM: MCNearbyServiceAdvertiserDelegate {
func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didNotStartAdvertisingPeer error: Error) {
log.error("ServiceAdvertiser didNotStartAdvertisingPeer: \(String(describing: error))")
}
func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) {
log.info("didReceiveInvitationFromPeer \(peerID)")
invitationHandler(true, session)
peerIDs.append(MyMCPeerID(peerID: peerID))
}
}
BrowserDelegate
注册扫描相关的回调函数,扫描失败或是扫描到了周边设备时会触发相关函数:
extension MCVM: MCNearbyServiceBrowserDelegate {
func browser(_ browser: MCNearbyServiceBrowser, didNotStartBrowsingForPeers error: Error) {
log.error("ServiceBrowser didNotStartBrowsingForPeers: \(String(describing: error))")
}
func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String: String]?) {
log.info("ServiceBrowser found peer: \(peerID)")
peerIDs.append(MyMCPeerID(peerID: peerID))
}
func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) {
log.info("ServiceBrowser lost peer: \(peerID)")
let idx = peerIDs.firstIndex(where: { i in
return i.peerID == peerID
})
if let idx = idx {
peerIDs.remove(at:idx)
}
}
}
SessionDelegate
注册会话相关的回调,当连接的设备状态变更、收到消息等情况时会触发对应函数:
extension MCVM: MCSessionDelegate {
func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) {
log.info("peer \(peerID) didChangeState: \(state.rawValue)")
let targetIdx = peerIDs.firstIndex(where: { i in
return i.peerID == peerID
})
if let targetIdx = targetIdx {
// 因为我们这个delegate的回调函数执行不是在主线程
// 这么写会有警告 “[SwiftUI] Publishing changes from background threads is not allowed ..."
// target.state = state
DispatchQueue.main.async{ [self] in
var p = MyMCPeerID(peerID: peerID)
p.state = state
peerIDs[targetIdx] = p
}
}
}
func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {
log.info("didReceive data bytes \(data.count) bytes")
DispatchQueue.main.async{ [self] in
messages.append(Message(content: String(data: data, encoding: .utf8) ?? "不支持的文本格式", date: Date(), type: .received, from: peerID.displayName))
}
}
func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) {
log.error("didReceive not supported")
}
func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) {
log.error("didStartReceivingResourceWithName not supported")
}
func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Error?) {
log.error("didFinishReceivingResourceWithName not supported")
}
}
View 实现
编写 UI,在 ContentView.swift
写入下述内容:
import SwiftUI
import MultipeerConnectivity
struct ContentView: View {
@EnvironmentObject var mcvm: MCVM
@State var msg: String = ""
var body: some View {
NavigationView {
List {
Group {
Button("发送广播") {
mcvm.advertize()
}
Button("扫描Peers") {
mcvm.browse()
}
}
Section {
ForEach(mcvm.peerIDs) { p in
HStack {
Button(p.peerID.displayName) {
mcvm.connect(peerId: p.peerID)
}
Text(String(p.state == .connecting ?"连接中" : p.state == .connected ? "已连接": "未连接"))
}
}
} header: {
Text("searched peers")
}
if mcvm.connectedPeerIDs.count > 0 {
TextField("消息", text: $msg)
Button("发送") {
mcvm.sendMsg(msg)
}
}
Section {
ForEach(mcvm.messages) { m in
HStack {
if m.type == .sent {
Spacer()
}
VStack {
HStack {
Text(m.content)
if m.type == .received {
Text("from \(m.from)")
}
}
Text(m.dateString)
}
}
}
} header: {
Text("received messages")
}
}.navigationTitle("Peers")
}
// https://stackoverflow.com/questions/65316497/swiftui-navigationview-navigationbartitle-layoutconstraints-issue
.navigationViewStyle(StackNavigationViewStyle())
}
}
最后记得在 NearbyDemoApp.swift
里添加上 environmentObject
:
ContentView().environmentObject(MCVM())
代码运行
可以选择启动两个模拟机来运行,或是使用两台真机运行,或是模拟机+真机进行运行。 若是在真机运行,系统会自动请求相关权,请确保权限都允许:
测试中会发现当 APP 后台运行后连接便会断掉,故在实际开发时,需要完善对连接失败、连接断开等异常情况的处理,确保良好的用户体验。
总结
本例中仅测试了普通消息的收发,但 MultipeerConnectivity 的功能远不至此,它还可以用作设备发现、实时互动、文件传输等等,且无须启动蓝牙和 WiFi,用户操作性上面也更加便捷,不过是否能和安卓系统进行交互,鄙人并未做进一步探索。
除了对上层 API 的使用,该技术涉及到的一些底层能力比如 mDNS
、Bonjour
等也大概了解了下,但在此不多赘述,仅做提醒。