2014/03/07

[iOS7] Background Fetch in iOS7

오늘 정리할 내용은, Background Fetch에 대한 것입니다.
이 기능은 iOS7에서 추가 되었고, Background Fetch 는 앱이 백그라운드에 있을 때, 간헐 적으로 iOS에서 호출해서, 앱이 실행 될 수 있도록 해주는 것입니다.

그럼 얼마나 자주 호출이 될까요?

- iOS의 문서에는 "the system uses available information to determine the best time to launch or wake apps. For example, it does so when networking conditions are good or when the device is already awake. "라고 되어 있습니다.
- iPhone 5s에서 테스트를 해보니, 대략 8~10분 정도에서 호출이 되기도 하고,
- 8~10분 정도 지난 후에 파워 키로 화면을 켜고, 패스워드를 입력해서, Main화면이 표시되면, Background Fetch가 호출 되기도 합니다.
- 일정한 시간으로 주기적으로 호출되지 않습니다. 간헐적으로 시스템에서 지금이 최적이다라고 생각하는 시간, 즉 배터리를 사용하기 좋은 시간이라고 생각할 때 호출이 됩니다.

호출 된 후에 얼마나 계속 실행이 될까요?

실행 될 때,  completionHandler를 파라미터로 받습니다.
모든 패치를 끝내면, 이 함수를 호출 해서, Fetch가 끝났음을 시스템에 알려줘야 합니다.
약 30초 안에, 모든 패치를 끝내고, handler를 호출해줘야 합니다. 만약 호출하지 않거나, 시간이 많이 걸리다던지 하는 것에 의해서, 다음 Fetch가 언제 호출 될지 영향을 받는 것 같습니다.
 간단하게 끝날 것 같으면, NSURLRequest를 사용하고, 시간이 걸리면, NSURLSession을 사용하라고 권고하고 있습니다.

Background Fetch를 사용하려면?


1. App에 Background Mode에 설정을 추가합니다.
- Helloworld-Info.plist 에 "Required background modes"항목을 추가하고, Item으로 "App downloads content from the network"를 추가하면 된다.

- 다른 방법으로 Project의 Capability에서 Background Mode를 On하고 background fetch를 체크합니다.

2. 앱이 시작될 때, 시스템에 background fetch시간을 설정합니다.
- 일정 간격을 설정할 수는 없고, Minimum과 Never를 선택할 수 있습니다.
source code
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    // Override point for customization after application launch.
    
    //Background Fetch 초기화 함. 가장 짧은 주기로 설정함.
    [[UIApplication sharedApplication] setMinimumBackgroundFetchInterval:UIApplicationBackgroundFetchIntervalMinimum];

    return YES;
}

3. Fetch가 실행되는 시간을 알아보기 위해서, Background로 들어갈 때와, Fetch가 실행될 때, Local Notification을 보내도록 합니다.
source code
- (void)applicationDidEnterBackground:(UIApplication *)application
{
    // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. 
    // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
    UILocalNotification *noti = [[UILocalNotification alloc] init];
    if(noti)
    {
        noti.repeatInterval = 0.0f;
        noti.alertBody = [NSString stringWithFormat:@"Backgournd:%@", [NSDate date]];
        NSLog(@"%@", noti.alertBody);
        [[UIApplication sharedApplication] presentLocalNotificationNow:noti];
    }
    [[UIApplication sharedApplication] setMinimumBackgroundFetchInterval:UIApplicationBackgroundFetchIntervalMinimum];
}

4. Fetch가 호출 되면, 웹사이트에 접속합니다.
source code
#pragma mark - Background Fetch
- (void)application:(UIApplication *)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler
{
    NSLog(@"Received Background Fetch");
    //데이터를 받아오거나 호출할 URL
    NSURL *url = [NSURL URLWithString:@"http://hidavidbae.blogspot.kr"];
    
    //URL객체가 잘 만들어졌을 경우
    if (url != nil) {
        // URL로 호출할 때 사용할 request객체임.
        NSURLRequest *request = [NSURLRequest requestWithURL:url];
        //데이터 로드 실패 시 주소를 받아올 에러 포인터
        NSError *error = nil;
        //해당 URL의 데이터를 가져옴
        NSData *data = [NSURLConnection sendSynchronousRequest:request
                                             returningResponse:nil error:&error];
        //데이터가 로딩이 되었을 경우
        if (data != nil) {
            //사용자에게 notification을 보냄.
            UILocalNotification *noti = [[UILocalNotification alloc] init];
            if(noti)
            {
                noti.repeatInterval = 0.0f;
                noti.alertBody = [NSString stringWithFormat:@"Fetch Success:(%d):%@",
                                  (int)data.length, [NSDate date]];
                [[UIApplication sharedApplication] presentLocalNotificationNow:noti];
            }            
            //성공했음을 알림
            completionHandler(UIBackgroundFetchResultNewData);
        }else{
            //에러를 로그로 찍음.
            NSLog(@"%@", error);
            //실패했음을 알림.
            completionHandler(UIBackgroundFetchResultFailed);
        }
    }else{
        //실패했음을 알림.
        completionHandler(UIBackgroundFetchResultFailed);
    }
}


실제 동작은 어떻게 될까요?


- Simulator에서 동작하는지 먼저 테스트를 할 수 있습니다.
 시뮬레이터에 앱을 설치해서 동작시키고, Home키로 앱을 Background로 보냅니다.
 이후, Xcode의 debug > Simulate background fetch 를 선택해서 동작을 바로바로 테스트해 볼 수 있습니다.

- 실제 iPhone 5s 디바이스에서는 아래와 같이, 8~10분 쯤에, 아이폰을 켜고, 비밀번호 력후 Main화면에 들어가면, Fetch가 실행이 되었습니다.
 이렇게 몇번 화면이 동작하고 Fetch된 이후 약 8분 정도에 자동으로 Fetch도 된 경우가 있었습니다.
 그리고는 2~3시간 후, 한번, 그리고 1시간 후 한번 정도 실행이 되었습니다.
 특별하게 화면이 꺼져 있는데, 동작한 적은 없는것 같고, 내가 화면을 켜고, Home화면에 들어가면, 위치 정보를 읽고 있음이 화면에 표시되고, Fetch가 실행되는 것으로 보입니다.


마치며..


- Fetch는 언제 일어날까요? 
  - 사용자가 iPhone을 어떤 형태로 사용하는 가에 따라서 달라지는 것 같습니다.
  - 밤에는 Fetch를 하지 않고, 낮에 사용자가 자주 iPhone을 켜면, Fetch도 일어나는 것 같습니다.

- Fetch는 사용자의 편의를 위해서 만들어졌습니다.
   패치가 필요한 이유는 사용자가 iPhone을 켜고, 앱을 실행하기 전에, 데이터가 딱!!! 들어와 있기를 위해서 만들어진 것입니다.
  즉, 필요 없는 시간, 혹은 사용자가 많이 안쓰는 앱에 대해서 불 필요하게 자주 Fetch를 하지 않습니다.
 그리고, 패치하는데 시간이 오래 걸리거나, 응답이 잘 없는 앱에 대해서는 Fetch 스케쥴을 뒤로 미뤄버리는 것 같습니다.

- 어떻게 사용하면 좋을까요?
  실시간은 아니지만, 사용자가 앱을 실행하기 전에, 데이터가 미리 다운로드 받으면, 사용자가 편리하게 이용할 수 있도록 장치를 마련해 놓는 용도로 사용하면 되겠습니다.

[소스코드]

https://github.com/davidbae/iOS7-background-fetch

[추가 분석 자료]

3월 9일 새벽부터, 오후 9시쯤 까지 Fetch가 실행 된 시간을 분석해 보았습니다.
제 iPhone 5s에서는 오전 3시~ 오후1시까지 9시간 동안은 Fetch가 진행이 되지 않았고, 그외의 시간에는 약 8~20분 사이를 오락가락 하며 Background Fetch가 실행이 된 것을 볼 수 있습니다.
 아마도 제가 오후에 iPhone을 많이 사용해서, 점심먹고부터 Fetch가 주기적으로 실행이 되는 것 같습니다.

 2014-03-09 01:42:47 (Background Fetch Start)
 2014-03-09 01:43:12 (00:00:25)
 2014-03-09 01:43:42 (00:00:29)
 2014-03-09 01:44:11 (00:00:29)
 2014-03-09 01:54:26 (00:10:14)
 2014-03-09 02:16:57 (00:22:31)
 2014-03-09 02:39:17 (00:22:20)
 2014-03-09 02:49:21 (00:10:04)
 2014-03-09 03:01:36 (00:12:14)
 2014-03-09 03:31:21 (00:29:44)
 2014-03-09 13:09:56 (09:38:34
      ==>오전에는 한번도 실행이 안되었습니다.
      ==>제가 오전에 사용을 잘 안해서 그런지, 아니면, 중간에 내가 앱을 종료했는지 잘 모르겠네요.
      ==> 며칠 더 자료를 모아보면 알 수 있을 것 같습니다.
 2014-03-09 13:19:40 (00:09:44)
 2014-03-09 13:30:16 (00:10:35)
 2014-03-09 13:40:01 (00:09:45)
 2014-03-09 13:49:46 (00:09:45)
 2014-03-09 13:58:52 (00:09:05)
 2014-03-09 14:07:52 (00:09:00)
 2014-03-09 14:21:36 (00:13:43)
 2014-03-09 14:29:17 (00:07:41)
 2014-03-09 14:30:02 (Background Fetch Start)
 2014-03-09 14:30:22 (Background Fetch Start)
 2014-03-09 14:54:38 (00:24:16)
 2014-03-09 15:02:17 (00:07:38)
 2014-03-09 15:12:01 (00:09:44)
 2014-03-09 15:26:43 (00:14:42)
 2014-03-09 15:41:27 (00:14:43)
 2014-03-09 15:50:51 (00:09:24)
 2014-03-09 16:02:04 (00:11:12)
 2014-03-09 16:15:32 (00:13:28)
 2014-03-09 16:23:53 (00:08:21)
 2014-03-09 16:36:04 (00:12:11)
 2014-03-09 16:45:46 (00:09:41)
 2014-03-09 16:57:40 (00:11:54)
 2014-03-09 17:05:34 (00:07:54)
 2014-03-09 17:18:53 (00:13:19)
 2014-03-09 17:31:16 (00:12:22)
 2014-03-09 17:41:01 (00:09:44)
 2014-03-09 17:50:46 (00:09:45)
 2014-03-09 18:04:46 (00:13:59)
 2014-03-09 18:15:11 (00:10:24)
 2014-03-09 18:23:30 (00:08:19)
 2014-03-09 18:34:16 (00:10:45)
 2014-03-09 18:45:21 (00:11:04)
 2014-03-09 18:53:31 (00:08:10)
 2014-03-09 19:03:46 (00:10:15)
 2014-03-09 19:13:31 (00:09:44)
 2014-03-09 19:21:03 (00:07:32)
 2014-03-09 19:30:26 (00:09:22)
 2014-03-09 19:45:31 (00:15:05)
 2014-03-09 19:55:39 (00:10:07)
 2014-03-09 20:03:54 (00:08:14)
 2014-03-09 20:13:23 (00:09:29)
 2014-03-09 20:23:06 (00:09:42)
 2014-03-09 20:30:56 (00:07:49)
 2014-03-09 20:50:39 (00:19:42)
 2014-03-09 21:08:18 (00:17:38)
 2014-03-09 21:28:46 (00:13:37)
 2014-03-09 21:38:31 (00:09:45)
 2014-03-09 21:48:16 (00:09:44)
 2014-03-09 22:15:36 (00:27:20)

 2014-03-09 23:10:19 (00:54:43)

아래는 3월 10일 자료 입니다.
 2014-03-10 00:30:58 (01:20:38)
 2014-03-10 04:45:58 (04:15:00
 2014-03-10 12:33:41 (07:47:43) //오후 1시이후, 약10분단위로 실행
  ==> 이것으로 미뤄봐서, iOS7에서는 사용자의 사용 형태에 따라서, Fetch를 적당한 시간에 실행하는 것으로 보입니다.

 2014-03-10 12:45:00 (00:11:18)
 2014-03-10 12:53:26 (00:08:26)
 2014-03-10 13:05:01 (00:11:35)
 2014-03-10 13:14:45 (00:09:44)
 2014-03-10 13:31:21 (00:16:35)
 2014-03-10 13:40:11 (00:08:50)
 2014-03-10 13:55:26 (00:15:14)
 2014-03-10 14:05:11 (00:09:45)
 2014-03-10 14:14:56 (00:09:44)
 2014-03-10 14:27:24 (00:12:28)
 2014-03-10 14:35:57 (00:08:32)
 2014-03-10 14:46:11 (00:10:14)
 2014-03-10 14:55:56 (00:09:45)
 2014-03-10 15:05:27 (00:09:30)
 2014-03-10 15:14:24 (00:08:57)
 2014-03-10 15:22:26 (00:08:01)
 2014-03-10 15:32:12 (00:09:46)
 2014-03-10 15:41:46 (00:09:34)
 2014-03-10 15:51:31 (00:09:44)
 2014-03-10 16:01:16 (00:09:45)
 2014-03-10 16:11:01 (00:09:44)
 2014-03-10 16:22:00 (00:10:59)
 2014-03-10 16:33:16 (00:11:15)
 2014-03-10 16:48:01 (00:14:45)
 2014-03-10 16:57:46 (00:09:44)
 2014-03-10 17:07:31 (00:09:45)
 2014-03-10 17:16:40 (00:09:09)
 2014-03-10 17:26:26 (00:09:45)
 2014-03-10 17:36:11 (00:09:44)
 2014-03-10 17:44:43 (00:08:31)
 2014-03-10 17:54:56 (00:10:13)
 2014-03-10 18:02:50 (00:07:53)
 2014-03-10 18:12:31 (00:09:41)
 2014-03-10 18:22:16 (00:09:44)
 2014-03-10 18:31:23 (00:09:07)
 2014-03-10 18:40:01 (00:08:38)
 2014-03-10 18:51:46 (00:11:44)
 2014-03-10 19:01:30 (00:09:44)
 2014-03-10 19:12:47 (00:11:17)
 2014-03-10 19:20:18 (00:07:30)
 2014-03-10 19:30:20 (00:10:01)
 2014-03-10 19:39:49 (00:09:28)
 2014-03-10 19:49:01 (00:09:12)
 2014-03-10 19:56:45 (00:07:44)
 2014-03-10 20:13:16 (00:16:30)
 2014-03-10 20:24:36 (00:11:20)
 2014-03-10 20:33:41 (00:09:05)
 2014-03-10 20:41:34 (00:07:53)
 2014-03-10 20:49:46 (00:08:12)
 2014-03-10 21:02:32 (00:12:46)
 2014-03-10 21:12:22 (00:09:49)
 2014-03-10 21:20:39 (00:08:17)
 2014-03-10 21:30:46 (00:10:06)
 2014-03-10 21:38:47 (00:08:00)
 2014-03-10 21:48:56 (00:10:08)
 2014-03-10 22:00:01 (00:11:05)
 2014-03-10 22:08:20 (00:08:19)
 2014-03-10 22:15:57 (00:07:36)
 2014-03-10 22:28:51 (00:12:54)

 2014-03-10 22:43:56 (00:15:05)

[참고자료]

- iOS7 : What's new in iOS7
- [iOS] :iOS Programming Guide - App State and Multitasking
- [Double Encore]: http://www.doubleencore.com/2013/09/ios-7-background-fetch/
- [Hayageek] : http://hayageek.com/ios-background-fetch/
- [Tuts+] : http://code.tutsplus.com/tutorials/ios-7-sdk-working-with-background-fetch--mobile-20520

2014/01/17

[iOS] Keyboard가 표시될 때, Toolbar의 위치 이동하기.

 키보드가 표시될 때, 발생하는 이벤트를 받아오는 방법은 블로그 ([iOS] Keyboard가 표시될 때, 사라질 때 이벤트와 그 키보드의 위치는?)를 참고하시면 됩니다.
 이번에는 아래에 표시되는 toolbar를 키보드가 표시되는 Animation에 맞춰서 위로 이동하고, 아래로 이동하도록 정리합니다.

1. 관련한 정보는 어디에서 가져오나요?

keyboard가 표시될 경우, NSNotification 객체에 정보를 담아서 전달이 됩니다. 이 notification에서 해당 정보를 찾아서 가져오면 됩니다.

2. 어떤 정보가 필요할 까요?

Toolbar에 위치를 이동시키는데, 이동 시간과, 애니메이션 방법, 위치가 필요합니다.
키보드가 나타나는 시간동안 어떤 형태로 애니메이션으로 어디에 표시되는지를 알면, 그와 같은 방식으로 이동하면 같이 붙어서 이동하는 것처럼 표시가 됩니다.
source code
    NSTimeInterval animationDuration;
    UIViewAnimationCurve animationCurve;
    CGRect keyboardFrame;
    [[userInfo objectForKey:UIKeyboardAnimationCurveUserInfoKey] getValue:&animationCurve];
    [[userInfo objectForKey:UIKeyboardAnimationDurationUserInfoKey] getValue:&animationDuration];
    [[userInfo objectForKey:UIKeyboardFrameEndUserInfoKey] getValue:&keyboardFrame];

키보드가 표시될 때, 사라질 때, Toolbar에 대한 Animation을 아래 소스와 같이 설정해 두면 됩니다.

3. 소스

source code
#pragma mark - Toolbar animation helpers

// Helper method for moving the toolbar frame based on user action
- (void)moveToolBarUp:(BOOL)up 
forKeyboardNotification:(NSNotification *)notification
{
    NSDictionary *userInfo = [notification userInfo];
    
    // Get animation info from userInfo
    NSTimeInterval animationDuration;
    UIViewAnimationCurve animationCurve;
    CGRect keyboardFrame;
    [[userInfo objectForKey:UIKeyboardAnimationCurveUserInfoKey] 
                                      getValue:&animationCurve];
    [[userInfo objectForKey:UIKeyboardAnimationDurationUserInfoKey] 
                                      getValue:&animationDuration];
    [[userInfo objectForKey:UIKeyboardFrameEndUserInfoKey] 
                                      getValue:&keyboardFrame];
    
    // Animate up or down
    [UIView beginAnimations:nil context:nil];
    [UIView setAnimationDuration:animationDuration];
    [UIView setAnimationCurve:animationCurve];
    
    UIToolbar *toolbar = self.navigationController.toolbar;
    [toolbar setFrame:CGRectMake(toolbar.frame.origin.x,
                                 toolbar.frame.origin.y + 
        (keyboardFrame.size.height * (up ? -1 : 1)), toolbar.frame.size.width, toolbar.frame.size.height)];
    [UIView commitAnimations];
}

- (void)keyboardWillShow:(NSNotification *)notification {
    // move the toolbar frame up as keyboard animates into view
    [self moveToolBarUp:YES 
forKeyboardNotification:notification];
}

- (void)keyboardWillHide:(NSNotification *)notification {
    // move the toolbar frame down as keyboard animates into view
    [self moveToolBarUp:NO 
forKeyboardNotification:notification];
}


4. 결과 화면,

 


2014/01/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




2013/12/29

[Xcode] Custom Code Snippet 사용하여, 코딩 생산성을 올려 봅시다.

 Xcode에서 init를 치면, 관련추가 함수가 표시되는 Code Snippet이 동작하게 됩니다.
이 코드 스니펫에 내가 원하는 Code Snippet 즉, 내가 만든 '코드 정보'를 추가할 수 있습니다.

코드 스니펫은 뭘까요?

코딩할 때, init를 치면, 추가적으로 표시되는 부분이 있습니다. Code Completion이라고 하는데, 여기에 코드 묶음이나, 함수가 자동으로 추가되는 것이 있습니다.
init를 치면, - (id) init {...}가 자동으로 추가되는 것과 같은것입니다.

여기에 내가 만든 Custom code snippet 을 추가해 봅시다.

이전 블로그에서 Keyboard관련 함수가 있었는데, 이것을 코드 스니펫에 넣어 두고, 필요한 ViewController에서 바로 사용할 수 있도록 설정해 보겠습니다.
일단 Code Snippet Library가 화면에 표시되도록 합니다.
(오른쪽 아래에 표시되는 코드 스니펫 라이브러리)
(메뉴에서 코드 스니펫 라이브러리 보기)

그리고 추가할 코드를 블록으로 선택하고, 드래그해서 코드 스니펫에 넣습니다. 아래 이미지는 이전 블로그에서 추가한 Keyboard Notification관련 코드를 스니펫으로 추가하고 있습니다.
코드를 선택해서 Code Snippet Libarary에 Drag해서 넣기

추가된 것이 표시가 되면, 클릭해서, Edit를 해서, 정보를 수정합니다.

(추가된 스니펫을 수정하기, Edit Custom Code Snippet)

  • Title: 코드스니펫 라이브러리에 표시되는 이름
  • Summary: Title 밑에 설명으로 표시됩니다.
  • Completion Shortcut : 코딩할 때, 이 숏컷을 입력에 대해서 코드스니펫이 표시가 됩니다. init를 입력하면 함수가 나오는 것 처럼.
  • Completion Scopes : 함수 내에서 사용을 하거나, 클래스 메소드에 추가될지 범위를 설정하는 것입니다.
    • All: 모든 경우에 다 표시됩니다.
    • Class Implementation : 클래스의 메소드 입력할 때 표시됩니다. 
    • Code Expression : 메소드 내에서 코드 입력할 때 표시 됩니다.


그럼 코드에서 사용해 봅시다.

위에 Compeltion Scopes를 Class implementation으로 해두어서, 함수 입력 부분에서 DBU를 치면 입력이 나와야 합니다. 아래처럼..
(함수 입력 부분에서 DBU를 치면 내가 입력해둔 코드 스니펫이 표시가 된다. Enter하면 그 코드가 추가된다.)
위와 같이 표시되고, 엔터를 치면, 코드가 추가가 됩니다.

코드에서 토큰입력할 수 있는 코드를 넣어 봅시다.

token labeled Code는 탭키로 입력부분에 바로 이동할 수 있는 토큰 코드 입니다.
(forin 입력시 표시되는 token labeled code)
입력한 코드 스니펫에 token labeled code를 추가합니다.
추가하는 방법은 <# Code #>을 입력하면 됩니다. 아래 처럼.

(코드스니펫에 token labeled code 추가하기)
 이 코드를 직접 사용하게 되면 아래와 같이 표시가 됩니다.
잘 추가해 두면 아주 유용하게 사용할 수 있을 것 같습니다.
(실제 표시된 된것)

이 코드 스니펫은 백업이 될까요? 다른 팀원과 같이 사용할 수는 없을까요?

Xcode를 다시 설치하면, 이 코드 스니펫이 다 사라지지 않을까요? 그리고, 팀원들과 같이 동시에 같은 코스 스니펫을 관리하고 사용할 수는 없을까요?
이거 아시는 분 좀 알려주세요.
저장된 XML을 Backup하고 다시 copy하면 추가가 될까요? 흠...

Custom Code Snippet이 저장되는 위치는?

/Library/Develper/Xcode/UserData/CodeSnippets/...
XML로 저장이 됩니다. 텍스트에디터로 확인을 해보면 아래처럼 나옵니다.
(XML로 저장된 Custom Code Snippet)


참고자료:



[iOS] Keyboard가 표시될 때, 사라질 때 이벤트와 그 키보드의 위치는?

 iOS에서 키보드가 표시될 때, 입력부분이 아래에 있다면, 화면이 위로 밀려 올라가야 합니다.이 때, 키보드의 크기를 알아야, 현재 화면을 위로 밀어 올릴 수 있습니다.

 UITableViewController는 자동으로 내부적으로 크기를 줄이고, 입력하는 부분을 위로 올려주게 되어 있습니다.

만약 일반적인 ViewController를 사용해서 화면을 구성한 경우에는 위치를 변경해 주어야 합니다.

1. 키보드가 화면에 나타나거나, 사라지는 Event는 무엇이고, 어떻게 알아낼까요?

키보드가 표시될 때, 전달되는 이벤트는.. 
  • UIKeyboardWillShowNotification : 키보드 표시되기 전, 전달되는 이벤트
  • UIKeyboardDidShowNotification : 키보드 표시되고 난 후, 전달되는 이벤트
  • UIKeyboardWillHideNotification : 키보드 사라지기 전,
  • UIKeyboardDidHideNotification : 키보드 사라진 후, 이벤트
  • UIKeyboardWillChangeFrameNotification : 키보드 모양이 바뀌기 전 (iOS5 이상)
  • UIKeyboardDidChangeFrameNotification : 키보드 모양이 바뀐 후 (iOS5 이상)

필요한 이벤트를 알림센터에 등록해서, 각 이벤트에 해당하는 메시지 함수를 호출 할 수 있습니다.
source code

#pragma mark - Keyboard detect function
// Call this method somewhere in your view controller setup code.
- (void)registerForKeyboardNotifications
{
    NSNotificationCenter *defaultCenter = [NSNotificationCenter defaultCenter];
    [defaultCenter addObserver:self
                      selector:@selector(keyboardWillbeShown:) //표시되기 전
                          name:UIKeyboardWillShowNotification object:nil];
    [defaultCenter addObserver:self
                      selector:@selector(keyboardWasShown:)    //표시된 후
                          name:UIKeyboardDidShowNotification object:nil];
    [defaultCenter addObserver:self
                      selector:@selector(keyboardWillBeHidden:) //사라지기 전
                          name:UIKeyboardWillHideNotification object:nil];
    [defaultCenter addObserver:self
                      selector:@selector(keyboardWasHidden:)    //사라진 후
                          name:UIKeyboardDidHideNotification object:nil];
    
}

2. 호출된 키보드 이벤트 함수에서, 키보드의 크기를 알아야, 다른 컴포넌트의 크기를 조정할 수 있습니다.

 이벤트로 전달되는 NSNotification클래스의 userInfo에 해당 내용이 추가되어 있습니다.
키보드의 크기는 UIKeyboardFrameBeginUserInfoKey를 통해서 읽어 올 수 있는데, 다른 값들은 차이를 알수가 없었습니다.
  • UIKeyboardFrameBeginUserInfoKey : 키보드가 표시되기 시작할 때의 크기를 가지고 있습니다. NSValue형태로 CGRect 값을 가지고 있습니다.
  • UIKeyboardFrameEndUserInfoKey
  • UIKeyboardAnimationCurveUserInfoKey:
  • UIKeyboardCenterBeginUserInfoKey : CGPoint로 키보드 중심 위치?
  • UIKeyboardCenterEndUserInfoKey :   
  • UIKeyboardBoundsUserInfoKey
아래의 소스코드처럼, Begin과 End이 값을 각 이벤트에 따라서 읽어 봤지만 차이가 없네요.
source code
// Called when the UIKeyboardDidShowNotification is sent.
- (void)keyboardWillBeShown: (NSNotification *) aNotification
{
    // Get Keyboard Size
    NSDictionary* info = [aNotification userInfo];
    CGSize kbSize = [[info objectForKey:UIKeyboardFrameBeginUserInfoKey] CGRectValue].size;
    CGSize kbSize2 = [[info objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue].size;
    double anmationDuration = [[info objectForKey:UIKeyboardAnimationCurveUserInfoKey] doubleValue];
    NSLog(@"keyboard Will beShown: %@, %@, duration:%lf", NSStringFromCGSize(kbSize), NSStringFromCGSize(kbSize2), anmationDuration);
}

3. 결과로 나오는 값은 어떻게 될까요?

iPhone 3.5-inch와 4.0-inch의 height의 길이 차이가 나지만, iPad는 Scale이 같으므로 동일한 결과가 나왔습니다.
iPhone Retina 3.5-inch
세로 
 keyboard Will beShown:   {320, 216}, {320, 216}, duration:7.
 keyboard WasShown:       {320, 216}, {320, 216}
 keyboard Will beHidden:  {320, 216}, {320, 216}
 keyboard WasHidden:      {320, 216}, {320, 216} 

가로
 keyboard Will beShown:   {162, 480}, {162, 480}, duration:7.
 keyboard WasShown:       {162, 480}, {162, 480}
 keyboard Will beHidden:  {162, 480}, {162, 480}
 keyboard WasHidden:      {162, 480}, {162, 480}

iPhone Retina 4.0-inch
세로
 keyboard Will beShown:   {320, 216}, {320, 216}, duration:7.000000
 keyboard WasShown:       {320, 216}, {320, 216}
 keyboard Will beHidden:  {320, 216}, {320, 216}
 keyboard WasHidden:      {320, 216}, {320, 216}
가로
 keyboard Will beShown:   {162, 568}, {162, 568}, duration:7.000000
 keyboard WasShown:       {162, 568}, {162, 568} //너비의 차이..
 keyboard Will beHidden:  {162, 568}, {162, 568}
 keyboard WasHidden:      {162, 568}, {162, 568}

iPad
세로
 keyboard Will beShown:   {768, 264},  {768, 264}, duration:7.000000
 keyboard WasShown:       {768, 264},  {768, 264}
 keyboard Will beHidden:  {768, 264},  {768, 264}
 keyboard WasHidden:      {768, 264},  {768, 264}
가로
 keyboard Will beShown:   {352, 1024}, {352, 1024}, duration:7.000000
 keyboard WasShown:       {352, 1024}, {352, 1024}
 keyboard Will beHidden:  {352, 1024}, {352, 1024}
 keyboard WasHidden:      {352, 1024}, {352, 1024}

iPad Retina
세로
 keyboard Will beShown:   {768, 264},  {768, 264}, duration:7.000000
 keyboard WasShown:       {768, 264},  {768, 264} //iPad와 동일.
 keyboard Will beHidden:  {768, 264},  {768, 264}
 keyboard WasHidden:      {768, 264},  {768, 264}
가로
 keyboard Will beShown:   {352, 1024}, {352, 1024}, duration:7.000000
 keyboard WasShown:       {352, 1024}, {352, 1024}
 keyboard Will beHidden:  {352, 1024}, {352, 1024}
 keyboard WasHidden:      {352, 1024}, {352, 1024}


2013/12/26

[iOS] CoreData를 이용하여 데이터를 저장하자.

 iOS에서 SQLite 를 이용하여, 데이터를 저장하고 읽어 올 수 있는데, 그러기 위해서는 SQL문과 관련 지식이 필요합니다.
 하지만, CoreData를 이용하면, SQL에 대한 이해없이도, 비교적 간단하게 데이터를 저장할 수 있습니다. 
 SQL문을 직접사용하지 않더라도, 내부적으로 Wrapping이 되어서 SQLite에 저장하게 됩니다.

먼저 CoreData Framework을 사용하기 위해서는 해당 프로젝트에서 CoreData를 사용할 수 이도록 설정을 해줘야 합니다. 
 가장 간단한 방법은 프로젝트를 Empty Application으로 만들면서, CoreData사용을 체크하면, 저장을 수 있도록 기본 소스가 추가됩니다.
만약, Single Application으로 프로젝트를 이미 만들어서 사용하고 있다면, 해당 기능들만 추가하면 사용이 가능합니다. 
 추가하는 방법은 블로그의 "[iOS] CoreData를 사용하기 위해서 추가할 것들.."을 참고하여 주세요.

1. Data Model 추가하기.

 데이터베이스의 스키마와 같은 파일을 추가합니다. New File > Core Data > Data Model 을 선택해서 추가합니다.
 프로젝트에 Model.xcdatamodeld이 추가되었을 것입니다.
 이 파일에 저장하고자 하는 형태의 데이터 엔티티를 저장합니다.
 즉, 이 엔티티가 하나의 객체/단위로 저장이 될 것입니다.
 사용자가 앱을 실행한 시간을 저장을 한다고 생각하면, 현재 날짜를 가지는 Attribute를 아래와 같이 추가할 수 있습니다.




2. 추가한 데이터 모델을 저장소와 연결하기.

 AppDelegate에 추가된 managedObjectModel 함수에 1.에서 추가된 데이터 모델에 대한 파일 이름을 설정해 줍니다.
위에 데이터 모델이름이 프로젝트 생성할 때 "CoreData사용"으로 만들어지면 프로젝트 이름과 동일하게 만들어집니다.
그래서, 별도로 데이터모델 파일을 생성한 경우에는 그 파일을 이름을 위에 넣어야 변경이 됩니다.

3. 데이터 저장되는지 확인하기.

AppDelegate의 [-application didFinishLaunchingWithOptions:]함수에서 현재 시간을 저장하여 봅시다.
source code

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    // Override point for customization after application launch.
    
    NSManagedObjectContext *context = self.managedObjectContext;
    NSManagedObject *managedObject = [NSEntityDescription insertNewObjectForEntityForName:@"UsageStatisticDate" inManagedObjectContext:context];
    [managedObject setValue:@"2013-12-26" forKey:@"executedate"]; //날짜는 임의로 넣었습니다.
    NSError *error;
    [context save:&error];
    
    return YES;
}

4. SQLite에 저장되어 있는지 터미널로 확인해 봅시다.

위에서 저장한 것이 정확하게 저장이 되어 있는지 확인을 해보기 위해서, iPhoneSimulator에 해당 앱이 저장된 위치를 터미널로 찾아갑니다.
(Library/Application Suport/iPhone Simulator/'버전'/Applications/'.....'/Documents/)
여기에서 sqlite3로 해당 db를 열어서 내용을 확인합니다.
위에서 해당 디렉토리에서 DBUCoreDate.sqlite라는 이름의 파일이 존재합니다.
이 파일을 sqlist3 명령어로 열어서, 저장되어 있는 테이블(.tables)과 테이블 내의 데이터를 SQL문(select * from zusagestatisticdate)로 확인을 해보았습니다.
"2013-12-26"이 들어 있는 것을 볼 수 있습니다.

5. NSManagedObject를 추가해서 저장해 봅시다.

위에 4번에서는 NSManagedObject를 받아와서, setValue를 통해서 저장을 하였는데, 해당 클래스를 만들어서 저장할 수 있습니다.
Menu > Editor > Create NSManagedObject Subclass.. 를 선택하여 클래스 만듭니다.
파일이 두개가 추가됩니다.
3.에서 저장했던 방식을 아래와 같이 수정을 합니다.

source code
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    // Override point for customization after application launch.
    NSManagedObjectContext *context = self.managedObjectContext;
    
    UsageStatisticDate *usageDate = [NSEntityDescription insertNewObjectForEntityForName:@"UsageStatisticDate" inManagedObjectContext:context];
    usageDate.executedate = @"2013-12-26 using UsageStatisticDate class";
    
    NSError *error;
    [context save:&error];
    
    return YES;
}

위 파일에서는 Subclass를 이용해서 저장하게 됩니다.
저장한 것을 터미널에서 다시 확인을 해 봅니다.








[iOS] CoreData를 사용하기 위해서 추가할 것들..

내가 생성한 Single View Application에서 CoreData를 사용하기 위해서, 아래 사항들을 추가해 줍니다.
 아래에 추가되는 것은, Xcode에서 새로운 프로젝트를 만들 때, Empty Application에서 Use CoreData를 체크하였을 때 추가되는 부분입니다.

1. CoreData Framework 추가하기.

프로젝트 설정의 General에서 Linked Framework and Libraries에서 CoreData를 추가합니다.




2. Precompile Header에 CoreData.h 추가하기.

프로젝트에서 해당 클래스를 사용할 수 있도록 Prefix파일에 추가해 줍니다.
(파일이름이 '프로젝트이름-Prefix.pch'인 파일입니다.)


3. AppDelegate.h에 @Property 및 함수 추가하기

source code
@property (readonly, strong, nonatomic) NSManagedObjectContext *managedObjectContext;
@property (readonly, strong, nonatomic) NSManagedObjectModel *managedObjectModel;
@property (readonly, strong, nonatomic) NSPersistentStoreCoordinator *persistentStoreCoordinator;

- (void)saveContext;
- (NSURL *)applicationDocumentsDirectory;

4. AppDelegate.m에 관련 함수 추가하기.

source code
- (void)saveContext
{
    NSError *error = nil;
    NSManagedObjectContext *managedObjectContext = self.managedObjectContext;
    if (managedObjectContext != nil) {
        if ([managedObjectContext hasChanges] && ![managedObjectContext save:&error]) {
            // Replace this implementation with code to handle the error appropriately.
            // abort() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
            NSLog(@"Unresolved error %@, %@", error, [error userInfo]);
            abort();
        }
    }
}
#pragma mark - Core Data stack

// Returns the managed object context for the application.
// If the context doesn't already exist, it is created and bound to the persistent store coordinator for the application.
- (NSManagedObjectContext *)managedObjectContext
{
    if (_managedObjectContext != nil) {
        return _managedObjectContext;
    }
    
    NSPersistentStoreCoordinator *coordinator = [self persistentStoreCoordinator];
    if (coordinator != nil) {
        _managedObjectContext = [[NSManagedObjectContext alloc] init];
        [_managedObjectContext setPersistentStoreCoordinator:coordinator];
    }
    return _managedObjectContext;
}

// Returns the managed object model for the application.
// If the model doesn't already exist, it is created from the application's model.
- (NSManagedObjectModel *)managedObjectModel
{
    if (_managedObjectModel != nil) {
        return _managedObjectModel;
    }
    NSURL *modelURL = [[NSBundle mainBundle] URLForResource:@"CoreData" withExtension:@"momd"];
    _managedObjectModel = [[NSManagedObjectModel alloc] initWithContentsOfURL:modelURL];
    return _managedObjectModel;
}

// Returns the persistent store coordinator for the application.
// If the coordinator doesn't already exist, it is created and the application's store added to it.
- (NSPersistentStoreCoordinator *)persistentStoreCoordinator
{
    if (_persistentStoreCoordinator != nil) {
        return _persistentStoreCoordinator;
    }
    
    NSURL *storeURL = [[self applicationDocumentsDirectory] URLByAppendingPathComponent:@"DBUCoreData.sqlite"];
    
    NSError *error = nil;
    _persistentStoreCoordinator = [[NSPersistentStoreCoordinator alloc] initWithManagedObjectModel:[self managedObjectModel]];
    if (![_persistentStoreCoordinator addPersistentStoreWithType:NSSQLiteStoreType configuration:nil URL:storeURL options:nil error:&error]) {
        /*
         Replace this implementation with code to handle the error appropriately.
         
         abort() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
         
         Typical reasons for an error here include:
         * The persistent store is not accessible;
         * The schema for the persistent store is incompatible with current managed object model.
         Check the error message to determine what the actual problem was.
         
         
         If the persistent store is not accessible, there is typically something wrong with the file path. Often, a file URL is pointing into the application's resources directory instead of a writeable directory.
         
         If you encounter schema incompatibility errors during development, you can reduce their frequency by:
         * Simply deleting the existing store:
         [[NSFileManager defaultManager] removeItemAtURL:storeURL error:nil]
         
         * Performing automatic lightweight migration by passing the following dictionary as the options parameter:
         @{NSMigratePersistentStoresAutomaticallyOption:@YES, NSInferMappingModelAutomaticallyOption:@YES}
         
         Lightweight migration will only work for a limited set of schema changes; consult "Core Data Model Versioning and Data Migration Programming Guide" for details.
         
         */
        NSLog(@"Unresolved error %@, %@", error, [error userInfo]);
        abort();
    }
    
    return _persistentStoreCoordinator;
}

#pragma mark - Application's Documents directory

// Returns the URL to the application's Documents directory.
- (NSURL *)applicationDocumentsDirectory
{
    return [[[NSFileManager defaultManager] URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask] lastObject];
}


5. 사용하기.

Data Model을 추가하고, Entity를 설정한 다음 사용할 수 있습니다.
자세한 내용은  블로그([iOS] CoreData를 이용하여 데이터를 저장하자.)를 참고하여 주세요.



2013/12/07

[iOS] 두 App 사이에 데이터 교환하기, ( URL 및 Pasteboard 사용)

하나의 앱(SenderApp) 에서 다른 앱(ReceiverApp)으로 정보를 전달해야 될때,
방법1) URL을 이용해서, 앱을 호출하고, URL에 데이터를 base64로 인코딩해서 보낼 수 있습니다.


방법2) 다른 방법으로 Unique Pasteboard를 만들고, 거기에 데이터를 넣고, URL을 통해서 해당 앱을 호출하고, 그 앱에서 Unique Pasteboard에서 데이터를 읽어 올 수가 있습니다.


1. URL의 데이터부분으로 전달하기.

 Receiver App의 URL Type을 등록합니다.
 dbuurl의 URL Type을 등록된 Receiver App이 설치가 되면, openURL함수를 이용해서, 호출할 수 있습니다.

Sender App에서 정보를 아래와 같이 보낼 수 있습니다.
source code

- (IBAction)sendData:(id)sender
{
    NSString *encodedData = [NSString stringWithFormat:@"iamdavidbae@gmail.com:"];
    NSString *urlQuery = [NSString stringWithFormat:@"dbuurl://localhost/data?%@", encodedData];
    
    NSURL *url = [NSURL URLWithString:urlQuery];
    if( [[UIApplication sharedApplication] canOpenURL:url] == YES )
    {   
        //데이터를 보낸다.
        [[UIApplication sharedApplication] openURL:url];
    }else{
        // openURL을 할 수 없으므로, App이 설치되지 않았거나, URL이 잘못 되었다.
    }
}

ReceiverApp에서 openURL로 호출이 되면, 아래의 AppDelegate의 handleOpenURL openURL:sourceApplication:annotation함수가 호출이 됩니다.

source code

//- (BOOL)application:(UIApplication *)application handleOpenURL:(NSURL *)url //deprecated...
- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url sourceApplication:(NSString *)sourceApplication annotation:(id)annotation
    NSLog(@" openURL");
    if ([@"/data" isEqual:[url path]]) {
        NSString *urlData = [url query];
        NSLog(@" handleOpenURL: query:%@", urlData);
        _viewController.text = [NSString stringWithFormat:@" handleOpenURL: query:%@", urlData];
        //그리고 base64 스트링을 디코딩해야 한다.

        //받은 데이터를 화면에 표시합니다.
        _viewController.text = urlData;
        [_viewController.view setNeedsDisplay];
        
        return YES;
    }
    return NO;
}

주의 사항!
URL로 전달하는 것이므로, 데이터가 base64로 인코딩이 되어야 합니다. 받는 부분에서는 다시 디코딩을 해야 되고..

2. Pasteboard를 사용해서 데이터를 전달하자.

위에 방법은 URL에 데이터가 공유가 되므로, 민감한 데이터는 공유하기가 힘들어지고, 모든 것을 Base64로 만들어야 하는 번거로움이 있습니다.
그래서, Unique한 Pasteboard를 만들고 거기에 데이터를 넣어서 전달 할 수 있습니다.
일단 ReceiverApp에서 URL Type을 위와 동일하게 등록을 합니다.

SenderApp에서 URL을 호출하기 전에 특정 Pasteboard에 데이터를 저장합니다.
아래에서는 "kr.pe.davidbae.pasteboard_001"이라는 이름으로 만듭니다.
source code
- (IBAction)sendData:(id)sender
{    
    NSString *urlQuery = [NSString stringWithFormat:@"dbuurl://localhost/data?%@"];
    NSURL *url = [NSURL URLWithString:urlQuery];
    if( [[UIApplication sharedApplication] canOpenURL:url] == YES )
    {
        //받는 app이 설치되어 있다. Pasteboard를 만들고, 데이터를 넣자.
        UIPasteboard *pasteboard = [UIPasteboard pasteboardWithName:@"kr.pe.davidbae.dbuurl.pasteboard_001" create:YES];
        if (pasteboard != nil) {
            pasteboard.string = sendingData; //보내는 데이터를 넣는다.
        }else{
            NSLog(@"Can't create pasteboard");
        }
        [[UIApplication sharedApplication] openURL:url];
    }else{
        // openURL을 할 수 없으므로, App이 설치되지 않았거나, URL이 잘못 되었다.
    }
}

ReceiverApp에서 handleOpenURL openURL:sourceApplication:annotation함수에서 pasteboard를 읽어서 데이터를 읽어 옵니다.
source code
//- (BOOL)application:(UIApplication *)application handleOpenURL:(NSURL *)url //deprecated...
- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url sourceApplication:(NSString *)sourceApplication annotation:(id)annotation
{
    NSLog(@" openURL");
    if ([@"/data" isEqual:[url path]]) {
        NSLog(@" handleOpenURL: query:%@", urlData);
        //Pasteboard check
        UIPasteboard *pasteboard = [UIPasteboard pasteboardWithName:@"kr.pe.davidbae.dbuurl.pasteboard_001" create:NO];
        if (pasteboard != nil) {
            //정보를 읽어 온다. 읽어온 후에는 정보를 지운다.
            NSLog(@"pasteboard : %@", pasteboard.string);
            pasteboard.string = @"";
        }else{
            //Pasteboard가 없다!!
            NSLog(@"pasteboard is nil");
        }
        return YES;
    }

    
    return NO;
}


위 두가지 방법을 필요한 곳에 사용하면 되겠습니다.

3. 만들어진 Pasteboard는 언제 사라질 까요?

UIPasteboard의 설명에는, 페이스트보드를 만든 앱이 quit를 할 경우 사라진다고 되어 있는데, 직접 테스트를 해보니, 리부팅할 때까지 그대로 남아 있습니다.
Persistant속성을 넣으면, 기부팅할 때까지 계속 남아 있고, App을 uninstall하였을 경우에만 사라진다고 되어 있습니다.

[iOS] Pasteboard에 Copy한 데이터를 Background에서 읽어서 표시하자.

앱스토어에 등록된 Biscuit이라는 앱이, Background에 있으면서, 사용자가 Copy한 영어 단어를 받아서, 단어의 뜻을 Notification으로 알려주는 기능이 있습니다.
단어를 따로 copy해서 찾을 필요 없고, 바로 알림(Notification)으로 알려주니 정말 잘 만든 것 같습니다.
 그럼, App이 Backgound에서 어떻게 사용자가 Copy를 하였는지 알 수 있을까요?

1. 어디에 copy를 하느냐.

 사용자가 특정 String을 선택해서 copy를 하면, 시스템의 generalPasteboard에 추가가 됩니다. 이것은  UIPasteboard 를 참고하시면 됩니다.
 [UIPasteboard generalPasteboard]로 리턴되는 pasteboard는 시스템에서 공통적으로 사용하는 것으로, 리부팅해도 그대로 남아 있습니다. persist의 속성을 가지고 있습니다.
 하지만 공용으로 사용하는 것이므로, 다른 앱에서 모두다 copy를 하면 여기에 써지게 됩니다.
 그래서, 웹페이지에서 특정 단어를 Copy를 하면, 여기에 저장이 됩니다. 그래서 이 저장이 언제 변경이 되었는지 알아낼 수 있다면, Biscuit 앱처럼 기능을 구현할 수 있습니다.

2. 변경사항 알아내기 ( UIPasteboardChangedNotification )

앱이 Active되어 있는 상태에서는 UIPasteboardChangedNotification 이벤트를 NotiCenter에 등록하면, pasteboard가 변경되었을 때마다 해당 Event를 받을 수 있습니다.

source code
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(changeNotification) name:UIPasteboardChangedNotification object:nil];


하지만, 앱이 백그라운드로 들어간 상태에서는 이 이벤트를 받을 수 없게 됩니다.

3. 백그라운드에서 Pasteboard의 변경사항 알아내기.

(이 방식은 Stackoverflow의 Grabbing the UIPasteboard like Pastebot while running in the background 의 답변으로 되어 있는 부분을 참고하여 만들었습니다.)
앱이 백그라운드로 들어가는 함수에서, task를 만들어서 일정시간 동안 동작하게 하고, 1초 단위로 Pasteboard에 변경이 있는지 Polling방식으로 체크를 합니다.

source code
- (void)applicationDidEnterBackground:(UIApplication *)application
{
    // Create a background task identifier
    __block UIBackgroundTaskIdentifier task; 
    task = [application beginBackgroundTaskWithExpirationHandler:^{
        NSLog(@"System terminated background task early"); 
        [application endBackgroundTask:task];
    }];

    // If the system refuses to allow the task return
    if (task == UIBackgroundTaskInvalid)
    {
        NSLog(@"System refuses to allow background task");
        return;
    }

    // Do the task
    dispatch_async(dispatch_get_global_queue(0, 0), ^{

        NSString *pastboardContents = nil;

        for (int i = 0; i < 1000; i++) 
        {
            if (![pastboardContents isEqualToString:[UIPasteboard generalPasteboard].string]) 
            {
                //사용자에게 notification을 보냄.
                UILocalNotification *noti = [[UILocalNotification alloc] init];
                if(noti)
                {
                    noti.repeatInterval = 0.0f;
                    noti.alertBody = [NSString stringWithFormat:@"Pasteboard:%@ -> %@", 
                                                               pastboardContents, 
                                                               [UIPasteboard generalPasteboard].string];
                    [[UIApplication sharedApplication] presentLocalNotificationNow:noti];
                }
                //중복체크를 위해서, 데이터를 저장함.
                pastboardContents = [UIPasteboard generalPasteboard].string;
                NSLog(@"Pasteboard Contents: %@", pastboardContents);
            }

            // Wait some time before going to the beginning of the loop
            [NSThread sleepForTimeInterval:1];

            //앱이 다시 Active되면 task를 멈춰야 한다.
            if (_stopFlag) {
                break;
            }
        }

        // End the task
        [application endBackgroundTask:task];


    });


}
(원문: http://stackoverflow.com/a/10268252)

4. 동작 방식 보기




왼쪽이 Biscuit 앱을 실행하고, 'Retina'를 Copy한 상태이고, 오른쪽은 '디스플레이'를 copy해서, Pasteboard의 String이 'Retina'에서 '디스플레이'로 변경되었음을 알 수 있습니다.

5. 응용방법

가계부 등의 앱에서 문자로 들어온 카드 사용 내용을 사용자가 copy해서 붙여넣는 것이아니라, SMS에서 copy만 하면, 바로 바로 알아 내게 됩니다.


6. 그러나.. 문제점

  1) 백그라운드에서 무한정 1초마다 체크하는 것은 비효율적입니다.
  2) 애플에서 이런 방식의 Background 동작을 승인할 것인가?
  3) 10분이 지나면, 자동으로 Expired되어서 Task가 종료 됩니다.

10분이 지나면, 아래와 같이 Background에 있던 타스크가 종료됩니다. 거의 정확하게 10분이 지나면 종료가 되는 군요.


Biscuit에서는 어떻게 해결을 했는지 궁금합니다.


2013/12/04

[iOS] App이 몇번 실행되었는지, 처음 설치 된지 얼마나 지났는지 알아보자

App을 개발해서 배포하고 나면, 사용자에게 평가를 요청하고, 앱스토어에 좋은 평가가 많이 모여야, 더 많은 사람들이 앱을 설치하게 됩니다.
이 평가 요청을 언제하는 것이 좋을까요? 앱을 설치하고 바로 요청하면, 사용하지도 않은 상태에서 평가를 남기게 되니까. 뭔가 강요 당한 느낌이 들어서, 좋은 평가로 이어지지 않습니다.
그렇다면, 설치 후에 5일 정도 지났고, 10번정도(하루에 2번) 실행을 한 상태에서 사용자에게 평가를 요청하면, 고객 입장에서도 충분히 써봤으니 평가를 해줄 수 있을 것입니다.

App이 실행될 때, 현재 몇번 실행이 되었는지, 처음 실행한 후에 며칠이 지났는지 알 수 있어야 합니다.

NSUserDefault에 처음 실행한 시간과, 실행한 횟수를 저장해 두면, 다음 실행할 때, 알수가 있게 됩니다.

아래와 같이 viewDidLoad 함수에서, 해당 값을 읽어서 사용하면 됩니다.

Source Code
- (void)viewDidLoad
{
    [super viewDidLoad];
 // Do any additional setup after loading the view, typically from a nib.

    NSUserDefaults *userDefault = [NSUserDefaults standardUserDefaults];
    if ([userDefault valueForKey:@"DBUAppInfo"] == nil)
    {   //앱을 처음 실행한 상태로, 상태 값이 없습니다.
        //오늘 날짜와, 앱 실행 횟수를 저장합니다.
        NSDictionary *dic = [[NSDictionary alloc] initWithObjectsAndKeys:[NSDate date], @"firstRunDate", [NSNumber numberWithInt:1], @"numberOfRuns", nil];
        [userDefault setObject:dic forKey:@"DBUAppInfo"];
    }else{
        //실행한 적이 있으므로, 저장된 dictionary를 가져옵니다.
        NSDictionary *dic = [userDefault valueForKey:@"DBUAppInfo"];
        NSNumber *numberOfRuns = [dic objectForKey:@"numberOfRuns"];      //실행 횟수
        NSLog(@"running Count: %d", numberOfRuns.intValue+1);
        NSDate *firstDate = [dic objectForKey:@"firstRunDate"];          //첫 실행 날짜
        NSLog(@"firstDate: %@", firstDate);
        
        //첫 실행 후, 며칠이 지났는지 확인
        NSDate *today = [NSDate date];
        NSCalendar *calendar = [NSCalendar currentCalendar];
        NSUInteger unitFlag = NSMonthCalendarUnit | NSDayCalendarUnit | NSHourCalendarUnit | NSSecondCalendarUnit;
        NSDateComponents *components = [calendar components:unitFlag
                                                   fromDate:firstDate
                                                     toDate:today
                                                    options:0];
        NSLog(@"month: %ld ,day: %ld, hour:%ld, second:%ld", (long)[components month], (long)[components day], (long)[components hour], (long)[components second]);
        
        //정보를 업데이트하여, 다시 저장.
        NSDictionary *dic2 = [[NSDictionary alloc] initWithObjectsAndKeys:firstDate, @"firstRunDate", [NSNumber numberWithInt:(numberOfRuns.intValue +1)], @"numberOfRuns", nil];
        [userDefault setObject:dic2 forKey:@"DBUAppInfo"];
    }
    [userDefault synchronize]; //NSUserDefault를 동기화
    
}

위에서 "DBUAppInfo" 는 NSUserDefault에 저장하는 dictionary의 키(key)입니다.
"numberOfRuns"와 "firstRunDate"는 횟수와 처음 실행한 날짜를 dictionary에 저장하는 키(key)입니다.