SwiftUI 与 MultipeerConnectivity 使用教程

本文代码执行环境说明:

  • 编译环境 MacOS 12.2.1XCode 13.2.1
  • 运行环境 IOS 15

前言

Multipeer Connectivity (下文将简称为 "MPC")是苹果提供的一个近距离设备发现和通信的能力。设备间通过 MPC 连接后,可以安全的传输文本信息、流信息、文件等内容。本文将介绍 MPC 搭配 SwiftUI 实现一个简单的聊天 APP,下面是最终版的演示:

项目搭建

  1. 在 XCode 里新建一个 SwiftUI 项目
  2. 更新 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 的使用,该技术涉及到的一些底层能力比如 mDNSBonjour 等也大概了解了下,但在此不多赘述,仅做提醒。