2014년 1월 14일 화요일

[iOS7] Multipeer Connectivity Framework를 이용하여 iOS 기기를 연결하여 봅시다.

(소스 추가: 2014.03.08)

iOS7에서 처음 소개되는 MultipeerConnectivity Framework에 대해서 테스트해 보도록 합니다.
 관련한 자료로는 Apple Developer Site에 여러 기기에서 각각 같은 Room Name으로 접속을 하면, 서로 연결이되어서, 메시지와 이미지를 보낼 수 있도록 하는 예제가 있고, WWDC 2013에 관련 설명이 있습니다. (링크는 아래 참조)
위 예제에서는 MCBrowserViewController와 MCAdvertiserAssistant를 사용하고 있어서, iOS에서 제공하는 기본 UI를 사용해서 만들어진 것입니다.
이 블로그에서는 기본 제공 UI 없이 자체 UI를 가지고 연결하는 방법을 예제로 만들어보겠습니다.

About Multipeer Connectivity Framework

멀티피어 연결 프레임워크는 iOS 3.0 에서 지원되어 오던 GKSession이 Deprecated되면서, 별도의 프레임워크로 등장한 것입니다. 대부분 비슷하지만, 기능이 많이 축소되었고, 서로간에 연결이 가능하면서, Wi-Fi, BT등의 여러 경로에 있는 Peer to Peer를 연결할 수 있도록 지원하고 있습니다.
 즉, A-B가 연결하고, B-C가 연결을 하면, A-C가 자동으로 같은 세션으로 연동이 되게 됩니다.
그렇다는 말은, A-B는 Wi-Fi로 연결되어 있고, B-C가 BT로 연결이 되면, A-C는 B를 중간 연결로 서로 연결이 되는 것입니다.
 iOS6가지 GKSession으로 개발해오던 저는 이 기능으로 소스를 많이 고쳐야 되기에, 고치기 전에 어디까지 지원하는 지 알아보고자 합니다.

1. iOS7 기기를 서로 어떻게 찾을까요?
- 이전에는 GKSession을 통해서, Available한 Peer를 찾고, 거기에 연결을 요구해서, 연결을 했었는데, iOS7에서는 이것을 다른 클래스에서 별도로 제공해 줍니다.

- "나 연결이 가능해요" 알리기
  : MCNearbyServiceAdvertiser 클래스를 통해서, 특정 서비스 타입을 광고하게 할 수 있습니다.
  : MCAdvertiserAssistant 표준 UI를 이용해서 연결할 수도 있습니다.

- "누구 연결 할 수 있는 사람 있나요?" 찾기
  : MCNearbyServiceBrowser 클래스를 통해서, 특정 서비스 타입을 지원하는 기기를 찾을 수 있습니다.
  : MCBrowserViewController 표준 UI를 이용해서, 찾고, 선택해서 연결할 수 있습니다.

위에서 Browser부분은 능동적으로 연결을 요청하는 쪽이 되고, Advertiser를 수동적으로 연결을 기다리게 됩니다.
 Peer-to-Peer로 연결이 가능하기 때문에, Advertiser를 실행해서 광고를 하고, 필요하면, 내가 Browser를 이용해서 지원하는 디바이스를 찾아서 연결할 수도 있습니다.

2. 특정 서비스를 지원하는지 어떻게 알고 연결을 하나요?
- Advertiser와 Browser를 생성하고, 시작할 때, 아래와 같이 특정 서비스 이름을 넣게 됩니다.

MCAdvertiserAssistant의 시작 함수
- (instancetype)initWithServiceType:(NSString *)serviceType discoveryInfo:(NSDictionary *)info session:(MCSession *)session
MCNearbyServiceAdvertiser 의 시작 함수
- (instancetype)initWithPeer:(MCPeerID *)myPeerID discoveryInfo:(NSDictionary *)info serviceType:(NSString *)serviceType
MCNearbyServiceBrowser 의 시작 함수
- (instancetype)initWithPeer:(MCPeerID *)myPeerID serviceType:(NSString*)serviceType
MCBrowserViewController 의 시작 함수
- (instancetype)initWithServiceType:(NSString *)serviceType session:(MCSession*)session

이 serviceType이 동일한 것을 Wi-Fi, BT를 통해서 찾아주게 됩니다. 이 이름이 다르면 전혀 다른 서비스로 인식을 해서, 연결해 주지 않습니다.

3. 연결 이후에 데이터는 어떻게 주고 받나요?
- Browser에서 찾으면, Invitation을 보내는데, 거기에 생성한 MCSession객체를 넘겨주어서, 해당 PeerID와 Session이 연결되도록 하고,
- Advertiser에서는 invitation을 받고, accept하는 handler를 호출할 때, MCSession객체는 넘겨서, 해당 PeerID와 연결하게 됩니다.
- 연결이 완료가 되면, MCSessionDelegate의 didChangeState함수가 호출이되고, 거기에서 Connected로 상태가 넘어오게 됩니다. 그러면 연결이 완료가 된 것입니다.
- 데이터는 MCSession의 sendData 함수를 통해서 NSData 타입의 데이터를 전송할 수 있게 됩니다.
- NSData를 보낼 수 있으므로, Binary데이터도 Serialize해서 전송할 수 있겠습니다.

4. 연결하고 데이터를 송/수신하기까지 전체 과정은 어떻게 되나요?

- Peer를 찾는 경우..
 위 그림의 Peer1의 경우로, MCNearbyServiceBrowser 클래스를 이용해서 특정 서비스 타입/이름으로 등록한 Peer를 찾게 됩니다.
 browser가 찾게 되면, foundPeer:withDiscoveryInfo 함수를 통해서 찾은 피어를 알 수 있게 됩니다. 이 때 같이 오는 info를 통해서, 어떤 Peer인지 구분할 수 있습니다.
 한번 찾은 Peer는 계속 유지를 해야 하고, lostPeer를 통해서, 망에서 피어가 나갔는지 인식할 수 있게 됩니다. lostPeer를 받으면 찾은 피어리스트에서 제거해 주면 됩니다.
 찾은 피어에 대해서 초대를 하기 위해서는 invitePeer:forSession:withContext:timeout 함수를 통해서 초대를 합니다. 파라미터로 넘기는 MCSession객체를 통해서 연결되었는지 알 수 있고, 연결된 후 데이터를 주고 받고, 연결을 끊을 수도 있습니다.
 위 그림은 연결될 때까지만 표현하고 있습니다.

- 연결 받을 경우...
 MCNearbyServiceAdvertiser를 생성하고, 내가 지원하는 서비스 타입(이름)으로 advertising을 시작합니다. 그러면, Wi-Fi, BT에서 해당 서비스를 지원하는 Peer로 등록이 됩니다.
 서비스브라우저가 찾고 난 후, 연결을 위해서, invite를 하면, advertiser에서는 didReceiveInvitationFromPeer:withContext:inviteationHandler이 호출되어서 초대를 수락할 것인지 아닌지를 결정하게 된다.
 invitationHandler함수를 실행하면서, MCSession을 전달하게되고, 그 세션을 통해서, 연결이 되었는지 알 수 있고, 연결된 후 데이터를 주고 받고, 연결을 끊을 수도 있게 됩니다.


연결하는 테스트 Single Application을 만들어 봅니다.

1. XCode를 이용해서 예제를 만듭니다.
 - Single Application
 - Storyboard에
    - Create/Destory/Start/Stop Advertiser
    - Create/Destory/Start/Stop Browser, Invite Peers
    - Session Disconnect/SendData, Create/Destroy Session
    - Info
    버튼과 UITextView를 추가합니다.
 - 각 버튼들에 대한 관련 함수를 만듭니다.
 - Session Container 클래스를 추가합니다. (NSObject 상속)

2. DBUSessionContainer클래스의 함수 추가합니다.

1) 해당 클래스에 필요한 변수와 함수를 정의 합니다.
.h file source code
@protocol DBUSessionContainerDelegate;

@interface DBUSessionContainer : NSObject 

@property (readonly, nonatomic) MCSession *session;
@property (assign, nonatomic) id delegate;

// Designated initializer
- (id)initWithDisplayName:(NSString *)displayName serviceType:(NSString *)serviceType;
// Method for sending text messages to all connected remote peers.  Returna a message type transcript

- (MCPeerID *)peerID;

- (NSData *)sendMessage:(NSString *)message;
// Method for sending image resources to all connected remote peers.  Returns an progress type transcript for monitoring tranfer
- (NSData *)sendImage:(NSURL *)imageUrl;

- (void) startBrowser;
- (void) stopBrowser;
- (void) startBrowsingForPeers;
- (void) stopBrowsingForPeers;
- (void) inviteFoundPeers;

- (void) startAdvertiser;
- (void) stopAdvertiser;
- (void) startAdvertisingPeer;
- (void) stopAdvertisingPeer;

- (void) startSession;
- (void) stopSession;
- (void) disconnect;

- (void) info;
@end

 내부 함수에는 ServiceBrowser, ServiceAdvertiser, Session을 생성/종료, 시작/중지 등의 함수를 호출 할 수 있도록 추가하고, Delegate도 추가합니다.

.m file source code
@interface DBUSessionContainer()
{
    MCPeerID *_myPeerID;
    NSString *_serviceType;
    
    NSMutableDictionary *_foundPeersDictionary;
    NSArray *_invitationArray;
    
    void (^_invitationHandler)(BOOL, MCSession *);
}
//
@property (retain, nonatomic) MCNearbyServiceBrowser *browser;

// Framework UI class for handling incoming invitations
@property (retain, nonatomic) MCAdvertiserAssistant *advertiserAssistant;
@property (retain, nonatomic) MCNearbyServiceAdvertiser *advertiser;
@end


@implementation DBUSessionContainer


- (id)initWithDisplayName:(NSString *)displayName serviceType:(NSString *)serviceType
{
    if (self = [super init]) {
        // Create the peer ID with user input display name.  This display name will be seen by other browsing peers
        _myPeerID = [[MCPeerID alloc] initWithDisplayName:displayName];
        // Create the session that peers will be invited/join into.  You can provide an optinal security identity for custom authentication.  Also you can set the encryption preference for the session.
        _serviceType = serviceType;
        
        [self startSession];
        
        _foundPeersDictionary = [[NSMutableDictionary alloc] init];
    }
    return self;
}
// On dealloc we should clean up the session by disconnecting from it.
- (void)dealloc
{
    [self stopAdvertisingPeer];
    [self stopSession];
}
 생성자에서 MCPeerID를 생성하고 MCSession을 Create합니다.

2) MCNearbyServiceBrowser와 Delegate를 추가합니다.

.m file source code
#pragma mark - MCNearbyServiceBrowserDelegate
- (void)browser:(MCNearbyServiceBrowser *)browser
      foundPeer:(MCPeerID *)peerID withDiscoveryInfo:(NSDictionary *)info
{
    NSString *log;
    if ([[_foundPeersDictionary allKeys] containsObject:peerID])
    {
        log = [NSString stringWithFormat:@"found PeerID(%@) but already found", peerID.displayName];
    }else{
        [_foundPeersDictionary setObject:info forKey:peerID];
        log = [NSString stringWithFormat:@"found PeerID(%@)", peerID.displayName];
    }
    LOGMESSAGE(log);
    
    [self.delegate updateFoundPeers:[_foundPeersDictionary allKeys]];
}

- (void)browser:(MCNearbyServiceBrowser *)browser lostPeer:(MCPeerID *)peerID
{   //Advertiser가 stopAdvertisingForPeer를 호출하여 광고를 중단했을 경우 호출되며,
    //연결하려는 client가 더 연결을 하기 어려울 때 호출됨. 추가 적으로 연결하면 안됨.
    NSString *log;
    if([[_foundPeersDictionary allKeys] containsObject:peerID] ){
        log = [NSString stringWithFormat:@"browser lostPeer: %@", peerID.displayName];
        [_foundPeersDictionary removeObjectForKey:peerID];
    }else{
        log = [NSString stringWithFormat:@"browser lostPeer: %@ but not in list", peerID.displayName];
    }
    LOGMESSAGE(log);
    [self.delegate updateFoundPeers:[_foundPeersDictionary allKeys]];
}

- (void)browser:(MCNearbyServiceBrowser *)browser didNotStartBrowsingForPeers:(NSError *)error
{
    NSLog(@"didNotStartBrowsingForPeers:%@", error.localizedDescription);
}


3) MCNearbyServiceAdvertiser와 Delegate를 추가합니다.

.m file source code
#pragma mark - MCNearbyServiceAdvertiserDelegate
- (void)advertiser:(MCNearbyServiceAdvertiser *)advertiser didNotStartAdvertisingPeer:(NSError *)error
{
    NSLog(@"didNotStartAdvertisingPeer: %@", [error description]);
}
- (void)advertiser:(MCNearbyServiceAdvertiser *)advertiser didReceiveInvitationFromPeer:(MCPeerID *)peerID withContext:(NSData *)context invitationHandler:(void (^)(BOOL, MCSession *))invitationHandler
{
    NSLog(@"didReceiveInvitationFromPeer:%@", peerID.displayName);
    if( context )
    {
        NSLog(@"                 context:%@", [NSString stringWithCString:[context bytes] encoding:NSUTF8StringEncoding]);
    }
    if(_session){
        [self.delegate logMessage:[NSString stringWithFormat:@"Invitation received from %@", peerID.displayName]];
        
        if (!_invitationArray) {
            _invitationArray = [NSArray arrayWithObject:[invitationHandler copy]];
        }
        _invitationHandler = invitationHandler;
        UIAlertView *alertView = [[UIAlertView alloc]
                                  initWithTitle:@"Invitation"
                                  message:[NSString stringWithFormat:@"from %@", peerID.displayName]
                                  delegate:self
                                  cancelButtonTitle:@"NO"
                                  otherButtonTitles:@"YES", nil];
        [alertView show];
        alertView.tag = 2;
        
        NSLog(@"                     accepts: YES");
    }else{
        invitationHandler(NO, _session);
        NSLog(@"                     accepts: NO");
    }
    NSLog(@"                     session:%@", _session);
    
    //[self stopAdvertisingPeer]; //연결 된 후에 중단한다.
}

4) MCSession과 delegate를 추가합니다.

.m file source code
#pragma mark - MCSessionDelegate

- (NSString *)stringForPeerConnectionState:(MCSessionState)state
{
    switch (state) {
        case MCSessionStateConnected:
            return @"MCSessionStateConnected";
        case MCSessionStateConnecting:
            return @"MCSessionStateConnecting";
        case MCSessionStateNotConnected:
            return @"MCSessionStateNotConnected";
        default:
            break;
    }
    return @"NoneState";
}
- (void)session:(MCSession *)session
           peer:(MCPeerID *)peerID
 didChangeState:(MCSessionState)state
{
    NSLog(@"Peer [%@] changed state to %@", peerID.displayName, [self stringForPeerConnectionState:state]);
    NSLog(@"       Session: %@", session);
    
    switch (state) {
        case MCSessionStateConnected:{
            
            NSArray *peers = _session.connectedPeers;
            if(![peers containsObject:peerID]){
                [_session connectPeer:peerID withNearbyConnectionData:nil];
                [self.delegate logMessage:[NSString stringWithFormat:@"Peer(%@) is connected but not in connectedPeers(%@)\n connectPeer", peerID.displayName, peers]];
            }else{
                //[self stopAdvertisingPeer];
                [self.delegate logMessage:[NSString stringWithFormat:@"Peer(%@) is connected", peerID.displayName]];
            }
            break;
        }
        case MCSessionStateConnecting:
            break;
        case MCSessionStateNotConnected:
            //self.sendDataButton.enabled = NO;
            [self.delegate logMessage:[NSString stringWithFormat:@"Peer(%@) is Not connected", peerID.displayName]];
            break;
        default:
            break;
    }
}

- (void)session:(MCSession *)session
 didReceiveData:(NSData *)data
       fromPeer:(MCPeerID *)peerID
{
    NSLog(@"didReceiveData: %@ from peerID:%@", [data description], peerID.displayName);
    
    // Decode the incoming data to a UTF8 encoded string
    NSString *receivedMessage = [[NSString alloc] initWithData:data encoding: NSUTF8StringEncoding];
    [self.delegate receivedMessage:[NSString stringWithFormat:@">>>[%@]%@",peerID.displayName,receivedMessage]];
    
    NSArray *peers = _session.connectedPeers;
    if(![peers containsObject:peerID])
    {
        [_session connectPeer:peerID withNearbyConnectionData:nil];
    }
}

- (void)session:(MCSession *)session didStartReceivingResourceWithName:(NSString *)resourceName
       fromPeer:(MCPeerID *)peerID
   withProgress:(NSProgress *)progress
{
    NSLog(@"didStartReceivingResourceWithName: %@ fromPeer:%@", resourceName, peerID.displayName);
}
- (void)session:(MCSession *)session didReceiveStream:(NSInputStream *)stream
       withName:(NSString *)streamName
       fromPeer:(MCPeerID *)peerID
{
    NSLog(@"didREceiveStream: %@, fromPeer:%@", streamName, peerID.displayName);
}
- (void)session:(MCSession *)session didFinishReceivingResourceWithName:(NSString *)resourceName fromPeer:(MCPeerID *)peerID atURL:(NSURL *)localURL withError:(NSError *)error
{
    NSLog(@"didFinishReceivingResourceWithName: %@, %@", resourceName, peerID.displayName);
}


- (void) session:(MCSession*)session didReceiveCertificate:(NSArray*)certificate fromPeer:(MCPeerID*)peerID certificateHandler:(void (^)(BOOL accept))certificateHandler
{
    if (certificateHandler != nil) {
        certificateHandler(YES);
        NSLog(@"certificateHandler called: %@", certificateHandler);
        [self.delegate logMessage:@"CertificateHandler called"];
    }
}



3. 화면 구성을 아래와 같이 구성합니다.

iPad Test UI
iPhone Test UI 

위 화면에서처럼 각 ServiceBrowser, ServiceAdvertiser, Session에 대한 기능들을 내가 컨트롤 할 수 있게 만들면 여러 가지 내용을 이해하는데 도움이 됩니다.

테스트 결과

1. Server/Client의 전형적인 구조를 만든다면...
  - 서버와 클라이언트를 Host Game, Join Game의 버튼으로 한번에 하나의 모드로 동작할 수 있도록 만들고, 각 기능이 종료하거나, 끝나면, Browser, Advertiser, Session을 종료하고 다시 시작하도록 하는 것이 좋습니다.
 - 서버에 클라이언트가 찾아서 접속하는 방식이라면, 서버는 Advertiser가 되고, 클라이언트는 Browser로 동작을 하도록 해서, 클라이언트에서 서버로 접속을 요청하도록 해야 합니다. (물론 반대로도 구현이 가능합니다.)

2. 클라이언트를 실행도중에 home 키로 종료를 하게 되면...
 - Background로 들어가기 전에, Browser와 Session을 종료하여야, 다시 접속할 경우, 다른 이름으로 접속이 용이 합니다.
 - 세션을 그대로 남겨두어로 무관한데, 브라우저가 보낸 초대를 수락하지 못하는 경우가 종종 발생하고 있습니다.

3. Browser에서 찾은 advertiser를 기록하고 있는 것이 중요함..
 - foundPeer로 찾은 Peer에 대해서 따로 저장하고 있어야, advertiser가 종료하여도 다시 invitation을 보낼 수 있습니다.
 - 그렇지 않으면, Advertiser를 stop, destroy하고 다시 생성하여야, 신규 Peer로 browser에서 foundPeer가 호출이 됩니다.


몇가지 더 있는 것은 실험하면서, 정리하도록 하겠습니다.


[소스 코드]

- 기존 소스에서 메시지를 입력할 수 있는 기능을 추가하였습니다.
- 소스: https://github.com/davidbae/MultipeerConnectivityTest