Ray가 알림: 본 튜토리얼은 iOS 6 진수성찬의 세 번째 튜토리얼입니다. 우리는 iOS 6에 대한 이전 튜토리얼들을 업데이트하고 있습니다. 따라서 본 튜토리얼은 ARC, 스토리보드 그리고 iOS 6의 새로운 API등과 같은 최종 기능으로 업데이트 되었습니다.
- 단지 앱에 책정된 가격보다 더욱 많은 수익을 올릴 수 있다. 어떤 사용자들은 추가 컨첸츠에 많은 지불을 한다.
- 앱을 대부분의 사람들이 망설임 없이 다운로드 받도록 무료로 올릴 수 있다. 그리고 그 앱에 만족하면 그들은 추가 컨테츠를 구매할 수 있다.
- 앱을 처음 등록한 이후, 추후 추가 수익을 더 내기 위해 새로운 앱을 만들기 보다 그 앱에 추가 컨텐츠를 계속 등록할 수 있다.
![inapp01.jpg](https://www.hooni.net/xe/files/attach/images/207/258/057/4f8b5f626699a6461a30d28ffd29a0ca.jpg)
![inapp02.png](https://www.hooni.net/xe/files/attach/images/207/258/057/19e81504187479a7d75cc797414fa4c0.png)
![inapp03.png](https://www.hooni.net/xe/files/attach/images/207/258/057/d31c02157cd3229bb883e85ffb6fe97e.png)
![inapp04.jpg](https://www.hooni.net/xe/files/attach/images/207/258/057/71d7e34ef92de690ce0bfcd8663cb255.jpg)
![inapp05.png](https://www.hooni.net/xe/files/attach/images/207/258/057/63796284bf5d5982ee00575096b97aa4.png)
![inapp06.png](https://www.hooni.net/xe/files/attach/images/207/258/057/e981508e984af33e2eb7147778de1b37.png)
- Consumables(소비형). 게임에서 흔히 볼 수 있는 생명력 보충, 게임머니, 임시강화제등과 같은 하나 이상을 구매하여 사용할 수 있는 것들이다.
- Non-Consumables(비소비형). 한 번의 구매로 계속적으로 이용할 수 있다. 추가 레벨이나 컨텐츠등이 이에 속한다.
![inapp07.png](https://www.hooni.net/xe/files/attach/images/207/258/057/f1c5339fb9fca4edf932e8787cf636f2.png)
주의:모든 비소비형 구매는 사용자의 모든 기기에서 이용이 가능해야 한다. 예컨데 두 대의 기기를 가진 사용자가 있다면 동일한 인앱구매에 대해 두 번의 비용 결재가 되면 안된다는 것이다. 우리는 나중에 트랜잭션 복원에 대해 논의할 때 다른 디바이스에서 구매한 비소비형 컨텐츠를 사용자에게 제공하는 방법에 대해 다루게 될 것이다. 소비형의 경우에는 다르다. 소비형은 사용자가 구매한 기기에서만 사용될 수 있도록 하는 것이다. 디바이스들간의 소비형 구매 아이템 공유를 원한다면, 아이클라우드나 다른 기술을 이용해 직접 구현해야 한다.
![inapp08.png](https://www.hooni.net/xe/files/attach/images/207/258/057/f13bcc760d34f021496960833a0fa4f8.png)
- Reference Name: iTunes Connect에서 보여질 인앱구매 항목의 이름이다. 이것은 앱에서는 노출되지 않기 때문에 자유롭게 입력하면 된다.
- Product ID: 애플 문서에서는 “product identifier”로 표현되고 있는데, 인앱구매 항목을 식별할 수 있는 유일한 고유 문자열이다. 일반적으로 이 항목을 사용할 앱의 번들아이디로 시작하는 것이 가장 좋으며, 항목의 고유이름을 끝에 추가한다.
- Cleared for Sale: 앱이 앱스토어에 활성화 되자마자 구매가 가능한지 여부를 지정한다.
- Price Tier: 인앱구매 항목의 가격이다.
![inapp09.png](https://www.hooni.net/xe/files/attach/images/207/258/057/d289126c1ad96e0106d39f63e02312a2.png)
![inapp10.png](https://www.hooni.net/xe/files/attach/images/207/258/057/2e514a59bea0f6c121b920e9493c8556.png)
![inapp11.png](https://www.hooni.net/xe/files/attach/images/207/258/057/10c696c5f5dd89be57a23f6ba9cedd19.png)
![inapp12.png](https://www.hooni.net/xe/files/attach/images/207/258/057/e912980cfae108ada10f177b2479ab8d.png)
![inapp13.png](https://www.hooni.net/xe/files/attach/images/207/258/057/c34b57f06fc142962ce8c2312e0aacbe.png)
typedef void (^RequestProductsCompletionHandler) (BOOL success, NSArray * products); @interface IAPHelper : NSObject - (id)initWithProductIdentifiers:(NSSet *)productIdentifiers; - (void)requestProductsWithCompletionHandler: (RequestProductsCompletionHandler)completionHandler; @end
참고: 아직 블럭을 잘 모르겠는가? iOS 5 튜토리얼 시리즈에서 블럭을 사용하는 방법을 참고하자.
// 1 #import "IAPHelper.h" #import <StoreKit/StoreKit.h> // 2 @interface IAPHelper () @end @implementation IAPHelper { // 3 SKProductsRequest * _productsRequest; // 4 RequestProductsCompletionHandler _completionHandler; NSSet * _productIdentifiers; NSMutableSet * _purchasedProductIdentifiers; } @end
- In-App Purchase API를 사용하기 위해서는 StoreKit을 이용해야 하기 때문에 StoreKit 헤더를 포함한다.
- StoreKit으로부터 프로덕트 목록을 가져오려면 SKProductsRequestDelegate protocol을 구현해야 한다. 여기서 클래스 확장으로 이 프로토콜을 명시한다.
- 클래스가 유지되는 동안 프로덕트 목록을 가져오기 위해 발행하는 SKProductsRequest를 저장하는 인스턴스 변수를 선언한다. 클래스가 이미 활성화되었는지 판단하거나 클래스가 유지되는 동안 메모리에서의 보증을 요구하는 참조로 유지된다.
- 미해걸된 프로덕트 요청을 위한 completion handler, 전달 받은 프로덕트 식별자 목록 그리고 이전에 구매된 프로덕트 식별자 목록도 기억해야 한다.
- (id)initWithProductIdentifiers:(NSSet *)productIdentifiers { if ((self = [super init])) { // 프로덕트 식별자 목록 저장 _productIdentifiers = productIdentifiers; // 이전에 구매하였는지 확인 _purchasedProductIdentifiers = [NSMutableSet set]; for (NSString * productIdentifier in _productIdentifiers) { BOOL productPurchased = [[NSUserDefaults standardUserDefaults] boolForKey:productIdentifier]; if (productPurchased) { [_purchasedProductIdentifiers addObject:productIdentifier]; NSLog(@"Previously purchased: %@", productIdentifier); } else { NSLog(@"Not purchased: %@", productIdentifier); } } } return self; }
- (void)requestProductsWithCompletionHandler: (RequestProductsCompletionHandler)completionHandler { // 1 _completionHandler = [completionHandler copy]; // 2 _productsRequest = [[SKProductsRequest alloc] initWithProductIdentifiers:_productIdentifiers]; _productsRequest.delegate = self; [_productsRequest start]; }
#pragma mark - SKProductsRequestDelegate - (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response { NSLog(@"Loaded list of products..."); _productsRequest = nil; NSArray * skProducts = response.products; for (SKProduct * skProduct in skProducts) { NSLog(@"Found product: %@ %@ %0.2f", skProduct.productIdentifier, skProduct.localizedTitle, skProduct.price.floatValue); } _completionHandler(YES, skProducts); _completionHandler = nil; } - (void)request:(SKRequest *)request didFailWithError:(NSError *)error { NSLog(@"Failed to load list of products."); _productsRequest = nil; _completionHandler(NO, nil); _completionHandler = nil; }
#import "IAPHelper.h" @interface RageIAPHelper : IAPHelper + (RageIAPHelper *)sharedInstance; @end
#import "RageIAPHelper.h" @implementation RageIAPHelper + (RageIAPHelper *)sharedInstance { static dispatch_once_t once; static RageIAPHelper * sharedInstance; dispatch_once(&once, ^{ NSSet * productIdentifiers = [NSSet setWithObjects: @"com.razeware.inapprage.drummerrage", @"com.razeware.inapprage.itunesconnectrage", @"com.razeware.inapprage.nightlyrage", @"com.razeware.inapprage.studylikeaboss", @"com.razeware.inapprage.updogsadness", nil]; sharedInstance = [[self alloc] initWithProductIdentifiers:productIdentifiers]; }); return sharedInstance; } @end
#import "MasterViewController.h" #import "DetailViewController.h" // 1 #import "RageIAPHelper.h" #import <StoreKit/StoreKit.h> // 2 @interface MasterViewController () { NSArray *_products; } @end @implementation MasterViewController // 3 - (void)viewDidLoad { [super viewDidLoad]; self.title = @"In App Rage"; self.refreshControl = [[UIRefreshControl alloc] init]; [self.refreshControl addTarget:self action:@selector(reload) forControlEvents:UIControlEventValueChanged]; [self reload]; [self.refreshControl beginRefreshing]; } // 4 - (void)reload { _products = nil; [self.tableView reloadData]; [[RageIAPHelper sharedInstance] requestProductsWithCompletionHandler:^(BOOL success, NSArray *products) { if (success) { _products = products; [self.tableView reloadData]; } [self.refreshControl endRefreshing]; }]; } #pragma mark - Table View - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { return 1; } // 5 - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return _products.count; } - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell" forIndexPath:indexPath]; SKProduct * product = (SKProduct *) _products[indexPath.row]; cell.textLabel.text = product.localizedTitle; return cell; } @end
- iTunes Connect에서 받은 SKProduct 정보를 사용하기 위해 StoreKit 헤더와 함께 앞서 작성한 RageIAPHelper 클래스를 임포트하였다.
- iTunes Connect에서 받은 SKProduct를 저장하기 위한 인스턴스 변수를 추가하였다. 테이블뷰의 각 행에는 프로덕트 제목이 보여질 것이다.
- 이것은 굉장히 편리한 iOS 6의 새로운 잡아당겨 새로고침 테이블뷰 컨트롤의 예제이다. 위에서 보듯 굉장히 사용하기 쉽다. UIRefreshControl 인스턴스를 생성하고 UITableViewController의 멤버변수 refreshControl에 대입만 해주면 된다. 그런 다음 사용자가 새로고침을 위해 테이블을 잡아 당겼을 때 호출될 타겟을 등록해주면 된다. 여기서는 reload 메소드가 호출되도록 하였다. 기본적으로 이것은 테이블뷰가 처음으로 보여질 때 발생하지 않는다. 따라서 여기서는 처음으로 보여질 때 reload 메소드를 호출하고 리프레쉬 컨트롤에 첫 시작임을 코드로 직접 알리도록 하였다.
- 첫 시작, 또는 사용자가 새로고침을 위해 화면을 밑으로 당겨서 reload 메소드가 호출되었을 때, iTunes Connect로 부터 인앱구매 프로덕트 정보를 받기 위해 앞서 구현하였던 RageIAPHelper의 requestProductsWithCompletionHandler 메소드가 호출되도록 하였다. 이 요청이 완료되면 블럭이 호출될 것이다. 블럭안에서는 프로덕트 목록을 인스턴스 변수에 대입하고, 테이블뷰를 갱신하고, 리프레쉬 컨트롤의 애니메이션을 중지시키도록 하였다.
- SKProduct의 지역화된 제목을 각 행에 표시하기 위한 테이블뷰의 일반적인 구현이다.
![inapp14.png](https://www.hooni.net/xe/files/attach/images/207/258/057/a20b5d297f5ce0d9f363b9c6d4e97709.png)
- 기기의 “설정” -> “iTunes & App Stores”로 가서 모든 계정을 로그아웃하고 샌드박스 계정을 이용해 다시 시도해 보자.
- 여기를 확인해 보자. 응답이 없으면 iTunes sandbox가 다운되었을 수 있다.
- 해당 App ID의 인앱구매가 활성화되었는가?
- 해당 프로젝트 .plist의 Bundle ID가 App ID와 동일한가?
- SKProductRequest을 생성할 때 올바른 Product ID를 넘겼는가?
- iTunes Connect에 프로덕트를 추가한 이후 몇 시간정도 기다려 보고 다시 시도해 보자.
- iTunes Connect에 은행 계좌관련 정보를 정확히 입력하고 활성화 되었는가?
- 기기에서 앱을 삭제하고 재설치 해보자.
- SKPayment 오브젝트를 만들고 사용자가 구매를 원하는 프로덕트 식별자를 전달해야 한다. 그리고 그것을 지불 큐에 추가해야 한다.
- StoreKit은 “정말로 구매하겠습니까?”라고 사용자에게 물어볼 것이고, 계정과 비밀번호 입력을 요구할 것이며, 요금청구를 생성하고, 성공 혹은 실패를 보낼 것이다. 또한 재다운로드를 위한 이미 구매된 것인 경우를 파악하여 그에 대한 메시지를 보낼 것이다.
- 여러분은 구매통보를 받기 위한 특별한 오브젝트를 지정해야 한다. 이 오브젝트는 컨텐츠 다운로드를 시작하고(여기서는 하드코딩되어 있기 때문에 필요가 없다.), 컨텐츠를 사용가능하도록 unlock할 것이다. (여기서는 NSUserDefaults와 purchasedProducts 배열에 구매정보를 저장할 것이다.)
// 파일의 첫 부분에 추가하자 #import <StoreKit/StoreKit.h> UIKIT_EXTERN NSString *const IAPHelperProductPurchasedNotification; // 2개의 새로운 메소드 선언 - (void)buyProduct:(SKProduct *)product; - (BOOL)productPurchased:(NSString *)productIdentifier;
프로덕트가 구매되었을 때 통보를 받기 위한 통보식별자를 선언하였고, 프로덕트 구매를 시작하는 메소드와 구매가 완료되었는지를 판단하는 메소드를 선언하였다.
다음으로, IAPHelper.m 파일에 아래의 코드를 추가하자.
- (BOOL)productPurchased:(NSString *)productIdentifier { return [_purchasedProductIdentifiers containsObject:productIdentifier]; } - (void)buyProduct:(SKProduct *)product { NSLog(@"Buying %@...", product.productIdentifier); SKPayment * payment = [SKPayment paymentWithProduct:product]; [[SKPaymentQueue defaultQueue] addPayment:payment]; }
@interface IAPHelper ()
[[SKPaymentQueue defaultQueue] addTransactionObserver:self];
#import "RageIAPHelper.h"
[RageIAPHelper sharedInstance];
- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions { for (SKPaymentTransaction * transaction in transactions) { switch (transaction.transactionState) { case SKPaymentTransactionStatePurchased: [self completeTransaction:transaction]; break; case SKPaymentTransactionStateFailed: [self failedTransaction:transaction]; break; case SKPaymentTransactionStateRestored: [self restoreTransaction:transaction]; default: break; } }; }
- (void)completeTransaction:(SKPaymentTransaction *)transaction { NSLog(@"completeTransaction..."); [self provideContentForProductIdentifier: transaction.payment.productIdentifier]; [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; } - (void)restoreTransaction:(SKPaymentTransaction *)transaction { NSLog(@"restoreTransaction..."); [self provideContentForProductIdentifier: transaction.originalTransaction.payment.productIdentifier]; [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; } - (void)failedTransaction:(SKPaymentTransaction *)transaction { NSLog(@"failedTransaction..."); if (transaction.error.code != SKErrorPaymentCancelled) { NSLog(@"Transaction error: %@", transaction.error.localizedDescription); } [[SKPaymentQueue defaultQueue] finishTransaction: transaction]; }
주의: finishTransaction 호출은 아주 중요하다. StoreKit이 처리가 완료되었읍을 알게 하는 것인데, 이를 호출하지 않으면 앱이 실행될 때마다 트랜잭션 전달이 계속될 것이다.
// 파일의 윗 부분에 추가하자. NSString *const IAPHelperProductPurchasedNotification = @"IAPHelperProductPurchasedNotification"; // 새로운 메소드 구현을 추가하자. - (void)provideContentForProductIdentifier:(NSString *)productIdentifier { [_purchasedProductIdentifiers addObject:productIdentifier]; [[NSUserDefaults standardUserDefaults] setBool:YES forKey:productIdentifier]; [[NSUserDefaults standardUserDefaults] synchronize]; [[NSNotificationCenter defaultCenter] postNotificationName:IAPHelperProductPurchasedNotification object:productIdentifier userInfo:nil]; }
![inapp15.png](https://www.hooni.net/xe/files/attach/images/207/258/057/a22e81d6df78c2b71ea59fd22bc0ad96.png)
// 클래스 익스텐션에 추가하자 NSNumberFormatter * _priceFormatter; // viewDidLoad 하단에 추가하자 _priceFormatter = [[NSNumberFormatter alloc] init]; [_priceFormatter setFormatterBehavior:NSNumberFormatterBehavior10_4]; [_priceFormatter setNumberStyle:NSNumberFormatterCurrencyStyle]; // tableView:cellForRowAtIndexPath 하단에 추가하자 (return cell 위에) [_priceFormatter setLocale:product.priceLocale]; cell.detailTextLabel.text = [_priceFormatter stringFromNumber:product.price]; if ([[RageIAPHelper sharedInstance] productPurchased:product.productIdentifier]) { cell.accessoryType = UITableViewCellAccessoryCheckmark; cell.accessoryView = nil; } else { UIButton *buyButton = [UIButton buttonWithType:UIButtonTypeRoundedRect]; buyButton.frame = CGRectMake(0, 0, 72, 37); [buyButton setTitle:@"Buy" forState:UIControlStateNormal]; buyButton.tag = indexPath.row; [buyButton addTarget:self action:@selector(buyButtonTapped:) forControlEvents:UIControlEventTouchUpInside]; cell.accessoryType = UITableViewCellAccessoryNone; cell.accessoryView = buyButton; }
- (void)buyButtonTapped:(id)sender { UIButton *buyButton = (UIButton *)sender; SKProduct *product = _products[buyButton.tag]; NSLog(@"Buying %@...", product.productIdentifier); [[RageIAPHelper sharedInstance] buyProduct:product]; }
- (void)viewWillAppear:(BOOL)animated { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(productPurchased:) name:IAPHelperProductPurchasedNotification object:nil]; } - (void)viewWillDisappear:(BOOL)animated { [[NSNotificationCenter defaultCenter] removeObserver:self]; } - (void)productPurchased:(NSNotification *)notification { NSString * productIdentifier = notification.object; [_products enumerateObjectsUsingBlock: ^(SKProduct * product, NSUInteger idx, BOOL *stop) { if ([product.productIdentifier isEqualToString:productIdentifier]) { [self.tableView reloadRowsAtIndexPaths: @[[NSIndexPath indexPathForRow:idx inSection:0]] withRowAnimation:UITableViewRowAnimationFade]; *stop = YES; } }]; }
![inapp16.png](https://www.hooni.net/xe/files/attach/images/207/258/057/cd857e255ba9f5b6ea30a4777c9c906a.png)
- (void)restoreCompletedTransactions;
- (void)restoreCompletedTransactions { [[SKPaymentQueue defaultQueue] restoreCompletedTransactions]; }
// viewDidLoad 하단에 추가 self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"Restore" style:UIBarButtonItemStyleBordered target:self action:@selector(restoreTapped:)]; // 새로운 메소드 추가 - (void)restoreTapped:(id)sender { [[RageIAPHelper sharedInstance] restoreCompletedTransactions]; }
![inapp17.png](https://www.hooni.net/xe/files/attach/images/207/258/057/7d82776fdf640d6efc1deb5d066c2861.png)
- iOS 6 Hosted Downloads: 애플서버를 이용하여 다운로드를 제공하는 방법을 배운다.
- 서버기반 시스템: 앱 업데이트없이 여러분의 서버를 통해 인앱구매항목을 추가하는 법을 배운다.
- 영수증 확인: 서버나 앱에서 영수증 유효성을 확인하는 방법을 배운다.
- SKStoreProductViewController: iOS 6의 새로운 메소드를 이용해 앱안의 아이튠즈스토어에서 아이템을 파는 법을 배운다.
- iTunes Search API: iTunes Search API를 이용하여 스토어에 표시되는 아이템을 동적으로 검색하는 방법을 배운다.
- 다운로드 진행 표시: 다운로드 진행상황과 상태 표시와 사용자를 위한 좋은 구조의 구매 흐름에 대해 배운다.
![inapp18.jpg](https://www.hooni.net/xe/files/attach/images/207/258/057/e2e82d38045797a41cb48d563ce0438d.jpg)