Post

Swift(스위프트): 멀티피어 통신 (Multipeer Connectivity) [기초]

Swift(스위프트): 멀티피어 통신 (Multipeer Connectivity) [기초]

출처

 

소개

멀티피어 통신 (Multipeer Connectivity)는 모바일 기기간에 Wi-Fi 네트워크, P2P Wi-Fi 및 Bluetooth 등의 개인 영역 네트워크를 통해 통신할 수 있도록 하는 프레임워크입니다. 연결된 피어는 중간 웹 서비스를 거치지 않고 메시지, 스트림 또는 파일 리소스를 다른 장치로 안전하게 전송할 수 있습니다.

 

사전 작업

스토리보드(Interface Builder) 기준으로 설명합니다. iOS App 프로젝트를 생성합니다.

 

뷰 컨트롤러 파일에서 MultipeerConnectivity를 import 합니다.

1
import MultipeerConnectivity

 

뷰 컨트롤러(ViewController.swift)에 아래 멤버 변수를 추가합니다.

1
2
3
4
private var session: MCSession!
private var advertiser: MCNearbyServiceAdvertiser!
// private var browser: MCNearbyServiceBrowser!
private var browserVC: MCBrowserViewController!
  • MCSession - 피어 연결에 대한 세션입니다.
  • MCNearbyServiceBrowser - 브라우저입니다. (이 포스트에서는 다루지 않습니다.)
  • MCNearbyServiceAdvertiser - 선전 서비스입니다.
  • MCBrowserViewController - 뷰 컨투롤러 형태로 시각화된 브라우저입니다. 피어 연결에 대한 표준 UI를 제공합니다.

 

info.plist에 아래 항목들을 추가합니다. 아래 두 항목은 필수로 입력해야 멀티피어 통신이 가능합니다.

  • Privacy - Local Network Usage Description
    • NSLocalNetworkUsageDescription
    • 로컬 기기에 연결할 때 표시할 메시지입니다.
  • Bonjour services
    • NSBonjourServices
    • _[서비스유형이름]._tcp 의 형태로 입력합니다.
    • 서비스 유형은 밑에서 설명하며, 서비스 유형이 여러 개인 경우 아이템을 추가 후 전부 입력합니다.
    • 예제에서 사용하는 서비스 유형 이름은 Chat-service 입니다.

 

선전(Advertising) 및 발견(Discovering)

멀티피어 통신의 첫 번째 단계는 동료(기기)가 서로를 알게 하는 것입니다. 이는 선전(Advertising) 및 발견(Discovering) 서비스를 통해 이루어집니다.

선전은 다른 피어에게 서비스를 알리는 반면 발견은 다른 피어가 광고하는 서비스를 클라이언트가 인식하는 반대 방향의 프로세스입니다. 많은 경우에 클라이언트는 동일한 서비스를 발견 및 선전하므로 특히 클라이언트-서버 패러다임에 뿌리를 둔 모든 사용자에게 초기 혼란이 발생할 수 있습니다.

각 서비스는 최대 15자 길이의 ASCII 문자, 숫자 및 하이픈(-)로 구성된 짧은 텍스트 문자열인 유형(type)으로 식별됩니다. 규칙에 따라 서비스 이름은 앱 이름으로 시작하고 그 뒤에 대시와 해당 서비스에 대한 고유한 설명자가 와야 합니다(간단한 com.apple.*-esque 형식의 역방향 DNS 표기법으로 생각하세요).

이번 예제는 Chat-service라는 이름으로 타입을 지정하겠습니다. ChatServiceType를 뷰 컨트롤러의 멤버 변수로 추가합니다.

1
private let ChatServiceType = "Chat-service"

 

피어는 표시 이름(display name)으로 초기화되는 MCPeerID 인스턴스로 고유하게 식별됩니다. 이것은 사용자가 지정한 별명 또는 단순히 현재 장치 이름일 수 있습니다.

장치의 이름을 표시 이름으로 지정합니다. localPeerId를 뷰 컨트롤러의 멤버 변수로 추가합니다.

1
private let localPeerID = MCPeerID(displayName: UIDevice.current.name)

 

NSNetService 또는 Bonjour C API를 사용하여 피어를 수동으로 선전하거나 발견할 수도 있지만 이는 다소 심화된 특정 문제입니다. 수동 피어 관리에 대한 추가 정보는 MCSession 설명서에서 찾을 수 있습니다.

 

선전 (Advertising)

서비스는 로컬 피어, 서비스 유형 및 서비스를 검색하는 피어와 통신할 선택적 정보로 초기화되는 MCNearbyServiceAdvertiser에 의해 선전(advertise)됩니다.

발견 정보(Discovery information)는 RFC 6763에 따라 인코딩된 Bonjour TXT 레코드로 전송됩니다.

 

아래 코드를 viewDidLoad(:_)에 추가합니다.

1
2
3
4
5
6
7
self.advertiser = MCNearbyServiceAdvertiser(
    peer: localPeerID,
    discoveryInfo: nil,
    serviceType: ChatServiceType)

advertiser.delegate = self
advertiser.startAdvertisingPeer()
  • MCNearbyServiceAdvertiser
    • peer: 현재 기기(나의 기기)의 Peer ID를 지정합니다.
    • discoveryInfo: MCNearbyServiceBrowser에서 이용할 수 있는 정보들을 담은 사전(dictionary)입니다.
    • serviceType: 서비스 유형을 지정합니다.
  • advertiser.delegate
    • 딜리게이트(위임자)를 현재 뷰 컨트롤러로 지정합니다.
  • advertiser.startAdvertisingPeer()
    • 이 메서드가 실행되는 시점부터 선전 활동을 시작합니다.
    • 선전 활동을 중지하려면 .stopAdvertisingPeer()를 사용합니다.

 

다음 MCNearbyServiceAdvertiserDelegate를 준수하는 뷰 컨트롤러의 extension을 추가합니다.

1
2
3
4
5
extension ViewController: MCNearbyServiceAdvertiserDelegate {
    func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) {
        // ... 잠시 후 작성 ... //
    }
}
  • …didReceiveInvitationFromPeer…
    • 초청(invitation)을 받았을 경우 해야 할 작업을 작성합니다.
    • 수락 여부는 invitationHandler의 Bool 타입의 첫 번째 파라미터를 사용합니다. (잠시 후 설명)

 

아래 코드는 AlertController 경고창을 사용하여 수락 여부를 결정합니다. 위 // ... 잠시 후 작성 ... // 부분을 아래 코드로 대체합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let title = "Accept \(peerID.displayName)'s Request"
let message = "Would you like to accept from: \(peerID.displayName)"

let alertController = UIAlertController(
    title: title,
    message: message,
    preferredStyle: .alert)

alertController.addAction(UIAlertAction(
    title: "No",
    style: .cancel
) { _ in
    invitationHandler(false, nil)
})

alertController.addAction(UIAlertAction(
    title: "Yes",
    style: .default
) { _ in
    invitationHandler(true, self.session)
})

present(alertController, animated: true)
  • invitationHandler
    • 첫 번째 파라미터: 수락 여부를 true/false로 결정합니다.
    • 두 번째 파라미터: 세션(MCSession)을 전송하는 부분입니다. 세션은 밑에서 설명합니다.
      • 수락하였다면 session 인스턴스를 보내고, 거부하였다면 nil로 지정합니다.

 

세션 생성

위의 예와 같이 선전하는 자(advertiser)가 세션을 생성하고 연결 초대를 수락하면 피어에게 전달됩니다. MCSession 개체는 로컬 피어 식별자 peer와 securityIdentity 및 encryptionPreference 매개 변수를 사용하여 초기화됩니다.

viewDidLoad()에 아래 부분을 추가합니다.

1
2
3
4
5
6
self.session = MCSession(
    peer: localPeerID,
    securityIdentity: nil,
    encryptionPreference: .none)

session.delegate = self
  • peer는 현재 기기(나의 기기)의 Peer ID입니다.
  • [심화] securityIdentity는 피어가 X.509 인증서로 피어를 안전하게 식별할 수 있도록 하는 선택 사항의 파라미터입니다.
    • 지정된 경우 첫 번째 개체는 클라이언트를 식별하는 SecIdentityRef여야 하며, 로컬 피어의 ID를 확인하는 데 사용할 수 있는 하나 이상의 SecCertificateRef 개체가 뒤따라야 합니다.
  • encryptionPreference 매개변수는 피어 간의 통신을 암호화할지 여부를 지정합니다. MCEncryptionPreference enum은 세 가지 가능한 값을 제공합니다.
    • MCEncryptionOptional: 세션은 암호화 사용을 선호하지만 암호화되지 않은 연결을 허용합니다.
    • MCEncryptionRequired: 세션에 암호화가 필요합니다.
    • MCEncryptionNone: 세션을 암호화하지 않습니다.

암호화를 활성화하면 전송 속도가 크게 줄어들 수 있으므로 응용 프로그램이 사용자에게 민감한 정보를 특별히 처리하지 않는 한 MCEncryptionNone을 사용하는 것이 좋습니다.

 

다음 MCNearbyServiceAdvertiserDelegate를 준수하는 뷰 컨트롤러의 extension을 추가합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
extension ViewController: MCSessionDelegate {
    func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) {
        switch state {
        case MCSessionState.connected:
            print("Connected: \(peerID.displayName)")
        case MCSessionState.connecting:
            print("Connecting: \(peerID.displayName)")
        case MCSessionState.notConnected:
            print("Not Connected: \(peerID.displayName)")
        @unknown default:
            break
        }
    }
    
    func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {
        print(#function, peerID, data)
    }
    
    func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) {
        print(#function, peerID)
    }
    
    func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) {
        print(#function, peerID)
    }
    
    func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Error?) {
        print(#function, peerID)
    }
}

 

 

발견 (Discovery)

클라이언트는 MCNearbyServiceAdvertiser와 마찬가지로 로컬 피어 식별자 및 서비스 유형으로 초기화되는 MCNearbyServiceBrowser를 사용하여 선전된 서비스를 검색할 수 있습니다.

1
2
let browser = MCNearbyServiceBrowser(peer: localPeerID, serviceType: ChatServiceType)
browser.delegate = self

[참고] 이번 포스트에서는 브라우징 기능을 편리하게 이용할 수 있는 MCBrowserViewController에 대해서만 다루며 MCNearbyServiceBrowser에 대해선 다루지 않습니다. 간략한 사용 예제는 아래 코드를 참조하세요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class ViewController: UIViewController {
    // ... //

    private var browser: MCNearbyServiceBrowser!

    // ... //

    override func viewDidLoad() {
        super.viewDidLoad()

        // ... //

        browser = MCNearbyServiceBrowser(peer: localPeerID, serviceType: MultiPeerServiceType)
        browser.delegate = self
        browser.startBrowsingForPeers()

        // ... //
    }
    
    func invitePeer(_ peerID: MCPeerID) {
        browser.invitePeer(peerID, to: session, withContext: nil, timeout: TimeInterval(120))
        print("Invited: \(peerID.displayName)")
    }

    // ... //
}

// ... //

extension ViewController: MCNearbyServiceBrowserDelegate {
    func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String : String]?) {
        print(#function, peerID)
        if peerID.displayName == "ABC's iPhone" {
            invitePeer(peerID)
        }
    }
    
    func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) {
        print(#function, peerID)
    }
}

// ... //

 

특정 서비스를 광고하는 많은 피어가 있을 수 있으므로 사용자(및 개발자)의 편의를 위해 MCBrowserViewController는 광고 피어를 표시하고 연결하는 기본 제공 표준 방법을 제공합니다.

1
2
3
4
5
6
let browserViewController = MCBrowserViewController(
    serviceType: ChatServiceType,
    session: session)

browserViewController.delegate = self
present(browserViewController, animated: true)

 

브라우저가 피어에 대한 연결을 마치고 난 뒤 UI 작업을 처리해야 하는데, 해당 부분은 다음과 같이 MCBrowserViewControllerDelegate를 준수하는 extension으로 추가합니다.

1
2
3
4
5
6
7
8
9
extension ViewController: MCBrowserViewControllerDelegate {
    func browserViewControllerDidFinish(_ browserViewController: MCBrowserViewController) {
        browserViewController.dismiss(animated: true)
    }
    
    func browserViewControllerWasCancelled(_ browserViewController: MCBrowserViewController) {
        browserViewController.dismiss(animated: true)
    }
}

 

실제 UI에 적용

스토리보드에 버튼(UIButton)과 스위치(UISwitch)를 추가하고, @IBAction으로 연결한 뒤 아래 코드를 작성합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@IBAction  func btnActShowBrowserVC(_ sender: Any) {
    browserVC = MCBrowserViewController(serviceType: ChatServiceType, session: session)
    browserVC.delegate = self
    present(browserVC, animated: true)
}

@IBAction func btnActStartAdvertising(_ sender: UISwitch) {
    if sender.isOn {
        advertiser.startAdvertisingPeer()
    } else {
        advertiser.stopAdvertisingPeer()
    }
}
  • 버튼을 누르면 피어를 찾는 뷰 컨트롤러인 MCBroswerVC를 띄웁니다.
  • 스위치를 토글하면 선전을 on/off 할 수 있습니다.

 

시뮬레이터와 실제 기기에서 각각 빌드 및 실행합니다. 아이패드(시뮬레이터)에서 선전 모드를 켠 뒤 아이폰(실제 기기)에서 피어를 발견하고 연결합니다.

YES를 누르면 연결됩니다.
YES를 누르면 연결됩니다.

 

정보 송수신

피어가 서로 연결되면 피어들 사이에 정보를 보낼 수 있습니다. Multipeer Connectivity 프레임워크는 세 가지 데이터 전송 형식을 구분합니다.

  • 메시지(Message) - 짧은 텍스트 또는 작은 직렬화된 개체와 같이 경계가 잘 정의된 정보입니다.
  • 스트림(Stream) - 오디오, 비디오 또는 실시간 센서 이벤트와 같은 데이터를 지속적으로 전송하는 데 사용되는 열린정보 채널입니다.
  • 리소스(Resource) - 이미지, 동영상 또는 문서와 같은 파일입니다.

 

메시지

메시지는 session.send(...)로 전송합니다.

1
2
3
4
5
6
7
8
9
10
11
12
let message = "Hello, World!"
let data = message.data(using: .utf8)
do {
    if let data {
        try session.send(
            data,
            toPeers: peers,
            with: .reliable)
    }
} catch {
    print("[Error] \(error)")
}

 

메시지는 MCSessionDelegate 메소드의 session( ...didReceiveData...fromPeer...)를 통해 수신됩니다. 이전 코드 예제에서 보낸 메시지를 디코딩하는 방법은 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
// MARK: - MCSessionDelegate
func session(
    _ session: MCSession,
    didReceive data: Data,
    fromPeer peerID: MCPeerID
) {
    let message = String(
        data: data,
        encoding: .utf8)
    print("\(message ?? "")")
}

 

[심화] 또 다른 접근 방식은 NSKeyedArchiver 인코딩 객체를 보내는 것입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let object: NSSecureCoding = []
var data: Data? = nil

if let object {
    data = NSKeyedArchiver.archivedData(withRootObject: object)
}

do {
    if let data {
        try session.send(
            data,
            toPeers: peers,
            with: .reliable)
    }
} catch {
    print("[Error] \(error)")
}

 

메시지를 실제 UI에 적용

아주 간단한 채팅 앱 예제를 만들어 보겠습니다.

뷰 컨트롤러의 멤버 변수로 아래를 추가합니다.

1
2
3
4
5
6
7
8
private var peers: [MCPeerID] = []
private var chatList: [String] = [] {
    didSet {
        DispatchQueue.main.async { [unowned self] in
            txvChatList.text = chatList.joined(separator: "\n")
        }
    }
}
  • peers - 채팅 메시지를 보낼 피어 목록입니다.
  • chatList - 수신된 메시지를 텍스트 형태로 저장하는 배열입니다.
    • 해당 배열이 업데이트되면 joined를 이용해 라인 브레이킹된 텍스트로 출력합니다.

 

스토리보드에 텍스트 뷰, 텍스트 필드 및 버튼을 추가하고, @IBAction으로 연결한 뒤 아래 코드를 작성합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@IBOutlet weak var txfMessage: UITextField!
@IBOutlet weak var txvChatList: UITextView!

private var peers: [MCPeerID] = []
private var chatList: [String] = [] {
    didSet {
        DispatchQueue.main.async { [unowned self] in
            
            txvChatList.text = chatList.joined(separator: "\n")
        }
    }
}

// ... //

@IBAction func btnActSendMessage(_ sender: UIButton) {
    do {
        if let data = (txfMessage.text ?? "" ).data(using: .utf8) {
            try session.send(
                data,
                toPeers: peers,
                with: .reliable)
            txfMessage.text = ""
        }
    } catch {
        print("[Error] \(error)")
    }
}

MCSessionDelegate 메소드의 session( ...didReceiveData...fromPeer...) 부분을 다음과 같이 작성합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) {
    switch state {
    case MCSessionState.connected:
        print("Connected: \(peerID.displayName)")
        // peer 추가
        peers.append(peerID)
    case MCSessionState.connecting:
        print("Connecting: \(peerID.displayName)")
    case MCSessionState.notConnected:
        print("Not Connected: \(peerID.displayName)")
    @unknown default:
        break
    }
}
  • 연결이 성공하면 연결 상대 피어를 피어 목록에 추가합니다. 이 목록은 session.send(...)에서 사용됩니다.

 

1
2
3
4
func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {
    let message = String( data: data, encoding: .utf8) ?? ""
    chatList.append("\(peerID.displayName): \(message)")
}
  • session.send를 통해 받은 메시지를 채팅창(txvChatList)에 출력합니다.

 

 

 

스트림

[심화] 스트림은 -startStreamWithName:toPeer:로 생성됩니다.

1
2
3
4
5
6
7
8
9
let outputStream = session.startStream(
    withName: name,
    toPeer: peer)

stream.delegate = self
stream.schedule(
    in: .main,
    forMode: .default)
stream.open()

 

스트림은 -session:didReceiveStream:withName:fromPeer:를 사용하여 MCSessionDelegate에 의해 수신됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// MARK: - MCSessionDelegate
func session(
    _ session: MCSession,
    didReceive stream: InputStream,
    withName streamName: String,
    fromPeer peerID: MCPeerID
) {
    stream.delegate = self
    stream.schedule(
        in: .main,
        forMode: .default)
    stream.open()
}

입력 및 출력 스트림을 모두 사용하려면 먼저 예약하고 열어야 합니다. 완료되면 다른 바인딩된 쌍과 마찬가지로 스트림을 읽고 쓸 수 있습니다.

 

리소스

리소스는 session.sendResource(...)를 통해 전송됩니다.

1
2
3
4
5
6
7
8
9
10
let fileURL = URL(fileURLWithPath: "path/to/resource")
let progress = session.sendResource(
    at: fileURL,
    withName: fileURL.lastPathComponent,
    toPeer: peer,
    withCompletionHandler: { error in
        if let error {
            print("[Error] \(error)")
        }
    })

반환된 NSProgress 인스턴스(변수 progress)는 전송 상태(진행률)를 조회하거나 전송을 취소하는 데 사용할 수 있는 NSProgress 개체입니다.

 

리소스 수신은

  • session(...didStartReceivingResourceWithName...),
  • session(...didFinishReceivingResourceWithName...)

의 두 메서드에서 발생합니다.

1
2
3
4
5
6
7
8
9
// MARK: - MCSessionDelegate
func session(
    _ session: MCSession,
    didStartReceivingResourceWithName resourceName: String,
    fromPeer peerID: MCPeerID,
    with progress: Progress
) {
    // 진행률(progress)에 따른 작업
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func session(
    _ session: MCSession,
    didFinishReceivingResourceWithName resourceName: String,
    fromPeer peerID: MCPeerID,
    at localURL: URL?,
    withError error: Error?
) {
    let destinationURL = URL(fileURLWithPath: "/path/to/destination")
    let error: Error? = nil
    do {
        try FileManager.default.moveItem(
            at: localURL,
            to: destinationURL)
    } catch {
        print("[Error] \(error)")
    }
}
  • session(…didStartReceivingResourceWithName…)
    • NSProgress 매개변수를 사용하면 수신 피어가 파일 전송 진행 상황을 모니터링할 수 있습니다.
  • session(…didFinishReceivingResourceWithName…)
    • FileManager.default.moveItem(...)을 통해 임시 파일인 localURL을 영구 위치(a permanent location)로 이동시킬 수 있습니다.

 

리소스를 실제 UI에 적용

메시지를 실제 UI에 적용 섹션으로부터 이어집니다.

채팅 히스토리를 저장 후 피어가 연결되면 그 피어에게 채팅 내용이 저장된 히스토리 파일을 전송하도록 할 수 있습니다. 먼저 host를 특정해야 합니다. 초대하는 사람이 방장(host)이며, 방장만이 이전 채팅 목록을 보낼 수 있도록 해야 합니다.

뷰 컨트롤러의 멤버 변수로 isHosting을 추가합니다.

1
2
/// 내가 방장(host)인지 여부 판별
private var isHosting = false

 

누군가를 초대하는 작업을 마친 경우 내가 방장이 되므로 ‘MCBrowser를 선택하는 작업을 마치는 메서드’에서 이를 알려줍니다.

1
2
3
4
5
6
7
8
extension ViewController: MCBrowserViewControllerDelegate {
    func browserViewControllerDidFinish(_ browserViewController: MCBrowserViewController) {
        browserViewController.dismiss(animated: true)
        isHosting = true
    }
    
    // ... //
}

 

방장으로부터 초대를 받은 경우 나는 방장이 아니므로 _‘초대를 받은 후 작업을 처리하는 메소드’_에서 이를 알려줍니다.

1
2
3
4
5
6
7
8
extension ViewController: MCNearbyServiceAdvertiserDelegate {
    func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) {
        // ... //
        
        present(alertController, animated: true)
        isHosting = false
    }
}

 

다음 채팅 목록을 리소스 형태로 전송하는 메서드를 추가합니다. 뷰 컨트롤러 안에 sendHIstory 함수를 작성합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func sendHistory(to peer: MCPeerID) {
    // 1. Create the URL in your temporary directory.
    let tempFile = URL(fileURLWithPath: NSTemporaryDirectory())
        .appendingPathComponent("messages.data")
    guard let historyData = try? JSONEncoder().encode(chatList) else { return }
    
    // 2. Write the chat history to a file at this URL.
    try? historyData.write(to: tempFile)
    
    // 3. Use MCSession to send a resource instead of Data.
    session?.sendResource(
        at: tempFile,
        withName: "Chat_History",
        toPeer: peer
    ) { error in
        if let error = error {
            print(error.localizedDescription)
        }
    }
}
  1. 채팅 목록(chatList)을 데이터로 저장할 임시 파일 위치를 지정합니다.
  2. chatList 전부 JSON 형태로 내보낸 뒤 임시 파일 저장소 NSTemporaryDirectory()에 저장합니다.
  3. Chat_History라는 이름으로 피어에게 리소스를 전송합니다.

 

MCSessionDelegate 부분의 func session(...didChange...) 부분에 아래 코드를 추가합니다. 피어와 연결에 성공하였을 때 내가 방장인 경우, 채팅 히스토리를 피어에게 보냅니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) {
    switch state {
    case MCSessionState.connected:
        // peer 추가
        peers.append(peerID)
        
        // 채팅 히스토리 보냄
        if isHosting{ 
            sendHistory(to: peerID)
        }
    case MCSessionState.connecting:
        print("Connecting: \(peerID.displayName)")
    case MCSessionState.notConnected:
        print("Not Connected: \(peerID.displayName)")
    @unknown default:
        break
    }
}

 

리소스를 전송받으면 해당 리소스 파일을 다시 채팅 목록(chatList 배열)으로 변환해야 합니다. MCSessionDelegate의 확장 부분에 있는 session(...didFinishReceivingResourceWithName...at localURL...) 메서드에 아래 내용을 추가합니다.

1
2
3
4
5
6
7
8
9
10
11
func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: Error?) {
    guard let localURL = localURL,
          let data = try? Data(contentsOf: localURL),
          let chatList = try? JSONDecoder().decode([String].self, from: data) else {
        return
    }
    
    DispatchQueue.main.async {
        self.chatList.insert(contentsOf: chatList, at: 0)
    }
}
  1. localURL로부터 data를 가져온 뒤, 해당 데이터를 chatList로 디코딩합니다.
  2. 디코딩된 chatList를 self.chatList 배열의 맨 첫 부분에 삽입합니다. chatList 변수에는 didSet이 있기 때문에 배열의 내용이 변경되면 채팅창 내용도 자동으로 업데이트됩니다.

 

빌드 및 실행합니다. 채팅방에 새로운 유저(오른쪽 아이폰 화면)가 입장하면 이전의 대화 내용이 그대로 전달되는 것을 볼 수 있습니다.

 

이와 같은 원리로 리소스 전송을 이용해 피어간 사진도 전송할 수 있습니다. 사진 전송에 관한 내용은 추후 별도의 포스트로 다루도록 하겠습니다.

 

전체 코드

This post is licensed under CC BY 4.0 by the author.