iCloud와 UIDocument: 심화학습 파트 1/4

Ray Wenderlich

이 포스트는 영어 언어로도 제공됩니다.

Learn how to make a complete UIDocument + iCloud app!

UIDocument + iCloud의 완전한 앱을 만드는 방법

본 포스팅은 독립 소프트웨어 개발자이자 게이머이면서 사이트 관리자인 Ray Wenderlich에 의해 작성되었습니다.

아이클라우드 문서기반의 앱을 만드는 것은 복잡하다. 거기에는 생각해야 할 다른 것들이 굉장히 많고, 무언가 구현해야 할 것들을 잊어버리기 쉬우며, 그에 따라 실수를 하기도 한다.

애플의 세 번째 iOS 앱 튜토리얼과 아이클라우드 기초를 설명하는 우리의 훌륭한 iOS 5 튜토리얼등을 포함하여 아이클라우드 앱을 만드는 튜토리얼은 많이 있다.

우리가 진정으로 원하는 것은 기초를 지나 여러분이 사용할 수 있도록 완전한 앱에 모든 것을 구현하는 튜토리얼이다. 여기 여러분들을 위해 이러한것의 구현을 시도해 보고자 한다.

본 튜토리얼에서 우리는 PhotoKeeper라고 불리는 아이클라우드 문서 기반의 간단한 앱을 만들 것이다. 여기에서 다음의 구현 방법을 보게 될 것이다.

  • 아이클라우드와 기기에서 문서를 생성하고, 읽고, 수정하고, 삭제하기.
  • NSFileWrapper로 다중파일로 구성된 문서들 생성하기.
  • 마스터 뷰에서 효율적으로 문서 미리보기 구현하기.
  • 설정에서 사용자가 아이클라우를 활성화하거나 비활성으로 선택할 수 있도록 하기.
  • 사용자 조작에 의해 아이클라우드로(또는 아이클라우드로 부터) 파일을 옮기거나 복사하기.
  • 안전하게 파일이름을 변경하거나 삭제하기.
  • 문서의 불일치와 업데이트 다루기.

본 튜토리얼은 4개의 파트로 진행될 것이다. 첫 파트에서 우리는 로컬문서로만 작업하고 두 번째 파트에서 아이클라우드를 지원할 것이다.

아이클라우드의 모든 것을 익힐 준비가 되었는가? 그럼 시작하자.

아이클라우드 저장소를 고르는 방법

본 튜토리얼에서, 우리는 사용자가 좋아하는 사진을 아이클라우드와 기기에 저장하고 이름을 지정하는 PhotoKeeper라는 간단한 앱을 만들 것이다.

앱이 아이클라우드에 데이터를 저장할 수 있는 세 가지 주요 수단이 있다. 이 앱에서는 어떤 방법을 선택해야 할까?

  1. Key/Value 저장소 이용하기. 아이클라우드는 NSUserDefaults와 아주 유사한  NSUbiquitousKeyValueStore라고 불리는, 사용이 매우 편리한 헬퍼 클래스를 가지고 있다. 여러분의 앱이 1MB미만의 작은 데이터를 가진다면 이 방법이 일반적으로 가장 좋은 선택이다.
  2. Core Data 이용하기. 아이클라우드에 데이터를 저장하기 위해 코어데이터를 이용할 수 있다. 여러분의 앱이 문서나 파일의 개념을 가지지 않는 대용량 데이터를 가진다면 이 방법이 일반적으로 가장 좋은 선택이다.
  3. 문서 기반의 앱 만들기. 마지막 방법은 UIDocument라고 불리는 클래스를 서브클래싱하여 문서 기반의 앱을 만드는 것이다. 여러분의 앱이 사용자가 생성, 읽기, 수정, 삭제등을 할 수 있고 아이폰 설정에서 파일별로 나열되기를 원하는, 개별 문서 기반의 앱이라면 이 방법이 일반적으로 가장 좋은 선택이다.

PhotoKeeper에서는 세 번째의 선택이 가장 적합하다. 우리는 각 사진이 아이클라우드 저장소에서 구분되어 보여질 수 있도록 구분된 유닛/문서/파일로 다루어지길 원하기 때문이다.

만일 키/밸류 저장소가 더 적합하다고 생각된다면  iOS 5 튜토리얼에서 해당 기술에 대한 설명과 예제를 살펴보자.

아이클라우드 문서 기반 앱 둘러보기

아이클라우드 문서 기반 앱을 만들기 위해 수행해야 할 것들과 그에 따라 PhotoKeeper를 만들기 위한 디자인 선택에 대해 알아 보자. 다음 다섯 가지의 주제를 다룰 것이다.

  1. 어떻게 동작되는가?
  2. UIDocument의 서브클래싱
  3. 입/출력 형식
  4. 로컬 vs 아이클라우드
  5. 저장소 원리

1) 어떻게 동작되는가?

아주 고차원적으로 아이클라우드와 UIDocument가 동작되는 방법에 대해 알아보자.

아이클라우드가 활성화 되고 그것을 앱에서 사용할 수 있도록 설정되면, 해당 앱은 기기의 특별한 디렉토리를 액세스할 수 있다. 이 디렉토리의 모든 것은 시스템 데몬에 의해 자동으로 아이클라우드와 동기화될 것이다.

일반적인 API로는 이 디렉토리를 탐색할 수 없으며, 나중에 설명할 특별한 API를 이용해야 한다. 또한 아이클라우드 데몬이 파일들에 대해 동시에 작업할 수 있기 때문에 이 디렉토리 안의 파일 접근에 주의를 기울여야 한다.

이들 파일들을 조작하는 가장 쉬운 방법은 UIDocument로 불리는 클래스를 서브클래싱하는 것이다. 그럼으로써 아이클라우드 데몬과의 조정등과 같은 세밀한 부분을 가장 잘 돌보게 되며, 여러분에게 중요한 것(앱 데이터)에 우선 집중할 수 있도록 한다.

아이클라우드가 동작하는 방법에 대한 자세한 설명은 iOS 앱 프로그래밍 가이드: 아이클라우드 저장소를 살펴 보자.

Via Apple's iOS Programming Guide.

출처. 애플의 iOS 프로그래밍 가이드

2) UIDocument 서브클래싱

UIDocument를 서브클래싱할 때 아래의 두 개의 메소드를 오버라이드 할 필요가 있다.

  • loadFromContents:ofType:error: 문서를 읽는 것이라고 생각하자. 인풋 클래스를 생성하여 내부 데이터 모델로 디코드해야 한다.
  • contentsForType:error: 문서에 쓰는 것이라고 생각하자. 내부 데이터 모델을 아웃풋 클래스로 인코드해야 한다.

여러분의 UIDocument 클래스는 NSObject에서 계승받은 데이터 모델 참조자를 가지고 있는 것이 가장 좋다. 만일 여러분의 데이터 모델 오브젝트가 NSCoding(나중에 설명할 것이다.)을 지원한다면 이들 메소드의 구현은 중요하지 않다.

본 튜토리얼에서는 문서 데이터 모델을 아주 간단하게 유지하도록 했다. 실제로 그것은 사진일 뿐이기에. 그러나 여러분은 본 튜토리얼에서 보여지는 기술을 복잡하고 방대한 문서에 별 문제없이 이용할 수 있을 것이다.

3) 입/출력 형식

UIDocument는 입/출력을 위해 두 개의 다른 클래스를 지원한다.

  • NSData.간단한 데이터 버퍼를 나타낸다. 문서가 싱글 파일일 때 좋다.
  • NSFileWrapper. OS에서는 싱글파일로 다루는 파일패키기의 디랙토리를 나타낸다. 문서가 독립적으로 로드될 수 있도록 하는 다중파일을 포함하고 있을 때 좋다.

첫 눈에 봐도, 우리 문서는 단지 간단한 사진일 뿐이기 때문에  NSData의 사용이 가장 적합해 보인다.

그러나, PhotoKeeper의 디자인 목표 중 하나는 파일을 열기 전에 마스터 뷰 컨트롤러에서 사진의 섬네일을 표시하는 것이다. 만일 NSData를 사용한다면 섬네일을 위해서 저장소로 부터 모든 싱글 문서를 열어서 디코드해야만 한다. 이미지는 꽤 크기 때문에 퍼포먼스가 떨어지고  높은 메모리 오버헤더를 야기시킬 수 있다.

따라서 우리는 NSFileWrapper를 사용할 것이다. 그 File Wrapper 안에 두 개의 문서를 저장할 것이다.

  1. 주 데이터. 문서의 주 데이터로 PhotoKeeper에서는 전체 사이즈의 사진이다.
  2. 메타데이터. 마스터 뷰 컨트롤러에서 미리보기에 필요한 모든 것이 될 것이다. 빠르게 로드될 수 있는 적은 량의 데이터이다. PhotoKeeper에서는 리사이즈된 작은 섬네일 사진이다.

이 방법은 마스터 뷰 컨트롤러에서 용량이 큰 사진 전체의 데이터를 가져오는 대신 NSFileWrapper 안의 작은 용량의 메타데이타 서브 파일을 가지고 오는 것이다. 본 튜터리얼에서 사용되는 이 방법은 여러분들의 더욱 방대한 문서 데이터에서도 크게 다르지 않은 구현으로 유용하게 이용될 수 있을 것이다.

4) 로컬 vs. 아이클라우드

단지 아이클라우드를 사용가능하게 하는 것만으로 충분하지 않다. 아이클라우드 없이도 작동이 잘 되어야 한다.

사용자가 기기에서 아이클라우드를 사용하지 않도록 하거나 여러분 앱에 대해 별도로 아이클라우드 기능을 끄기를 원할 수 있기 때문이다.

UIDocument 사용으로 한 가지 좋은 점은 로컬 저장소와 아이클라우드 저장소를 동시에 사용할 수 있다는 것이다. 즉, 두 개의 매우 다른 방향의 코드를 가지지 않아도 된다는 것이다.

그러나, UIDocument를 지원하는 아이클라우드 기능 추가는 수 많은 과정과 미묘한 문제들을 가진다. 따라서 내 개인적으로 UIDocument로 로컬 기능이 작동되는 보다 쉬운 앱을 먼저 만들고 , 그 이후에 아이클라우드 지원을 추가하고자 한다. 바로 이것이 본 시리즈에서 진행하고자 하는 것이다.

5) 저장소 원리

사용자가 각 문서가 저장될 위치를 선택하도록 아이클라우드와 로컬, 두 개의 다른 탭을 가진 앱을 만드는 것이 이론적으로 가능하다.

그러나 이것은 애플이 원하는 원리가 아니다.  iOS에서의 문서기반 앱 프로그래밍 가이드에 따르면:

앱의 모든 문서는 로컬 샌드박스와 아이클라우드 컨테이너 디렉토리, 둘 중 하나에 저장된다. 사용자가 각 개별 문서에 대해 아이클라우드에 저장되도록 선택이 가능해서는 안된다.

또한, 사용자가 앱에 대해 아이클라우드 기능을 끌 수 있도록 하기를 추천한다. 좀처럼 변경될 필요도 없고, 앱의 혼란성을 줄이며, 한 번 설정 이후에는 사용자의 혼란을 줄이기 위해, 나는 이 기능의 가장 좋은 곳이 “설정(Setting)”이라고 생각한다.

모든 것을 구현해 집어 넣기

휴우!~ 배경 지식이 굉장히 많다. 지금 당장 모든 것을 이해하지 못한다 해도 걱정하지 말자. 튜토리얼을 진행하다 보면 보다 명확해 질 것이다.

위에 언급한 것을 요약하면, PhotoKeeper를 위해 아래의 내용으로 진행할 것이다.

  • 저장 수단으로 문서 기반 앱을 선택한다.
  • 대용량 사진의 저장소로 NSObject로 부터 데이터 모델 클래스를 생성하고  NSCoding을 구현한다.
  • 저용량 섬네일의 저장소로 NSObject로 부터 메타데이터 클래스를 생성하고  NSCoding을 구현한다.
  • UIDocument를 서브클래싱 한다. 데이터 모델과 메타데이터 클래스의 참조자를 가질 것이다.
  • 입/출력으로 NSFileWrapper를 이용한다. 우리의 UIDocument 클래스는 NSFileWrapper 디렉토리 내부의 구분된 파일로 데이터 모델과 메타데이터를 인코드할 것이다.
  • 로컬과 아이클라우드 모두에서 동작되도록 한다. 우선은 로컬에서만 동작되도록 하고 본 파트 시리즈의 나중에 아이클라우드 지원을 추가할 것이다.
  • 설정에 아이클라우드 지원 온/오프 기능을 추가한다. 모든 문서들은 로컬이나 아이클라우드, 둘 중 한 곳에 저장될 것이다.

여러분의 앱에서 위의 개발방향과 다르게 하기를 원할 수 있겠으나 본 튜토리얼의 UIDocument 기반이 대부분 적용될 것이다.

시작

드디어 코딩을 할 시간이다.

Xcode에서 iOSApplicationMaster-Detail Application 템플릿으로 새로운 프로젝트를 만들자. 프로덕트 이름으로 PhotoKeeper를 입력하고 Class Prefix로 PTK를 입력한 다음, Device Family로 아이폰을 선택하고, Use Storyboards와 Use Automatic Reference Counting을 체크하자. 나머지는 언체크이다.

Creating a new project in Xcode

다음으로 프로젝트 리소스를 다운로드하여 Xcode 프로젝트에 추가하자. 여기에는 문자열 포멧을 지원하고 이미지 크기를 변경해주는 두 개의 간단한 클래스와 이미지들 몇 개가 들어있다. 원한다면 들여다 보기 바란다.

프로젝트를 빌드하여 실행해 보자. 간단한 마스터/디테일 앱 골격으로 생성된 템플릿을 볼 수 있을 것이다. + 버튼을 탭하여 항목을 추가하거나 항목을 선택하여 디테일 상세 화면으로 이동할 수 있다.

The Master/Detail template in Xcode

본 시리즈의 첫 번째 파트에서의 목표는 로컬 UIDocument 목록이 마스터 뷰에 보여지도록 하고, + 버튼을 탭하면 새로운 UIDocument를 만드는 것이다.

UIDocuemnt의 서브클래스를 만들기 전에 데이터 모델 클래스를 먼저 만들 필요가 있다.

주 데이터 모델 클래스를 먼저 만들자. iOSCocoa TouchObjective-C class 템플릿으로 파일을 생성하여, 이름은 PTKData로 하고, NSObject에서 서브클래싱되도록 하자. 그리고 PTKData.h의 내용을 아래의 코드로 변경하자.

#import <Foundation/Foundation.h>
 
@interface PTKData : NSObject 
 
@property (strong) UIImage * photo;
 
@end

PTKData.m도 아래의 코드로 변경하자.

#import "PTKData.h"
 
@implementation PTKData
@synthesize photo = _photo;
 
- (id)initWithPhoto:(UIImage *)photo {
    if ((self = [super init])) {
        self.photo = photo;
    }
    return self;
}
 
- (id)init {
    return [self initWithPhoto:nil];
}
 
#pragma mark NSCoding
 
#define kVersionKey @"Version"
#define kPhotoKey @"Photo"
 
- (void)encodeWithCoder:(NSCoder *)encoder {
    [encoder encodeInt:1 forKey:kVersionKey];
    NSData * photoData = UIImagePNGRepresentation(self.photo);
    [encoder encodeObject:photoData forKey:kPhotoKey];
}
 
- (id)initWithCoder:(NSCoder *)decoder {
    [decoder decodeIntForKey:kVersionKey];
    NSData * photoData = [decoder decodeObjectForKey:kPhotoKey];
    UIImage * photo = [UIImage imageWithData:photoData];
    return [self initWithPhoto:photo];
}
 
@end

보는 것 처럼, 전체 크기의 사진을 기억시키기 위한 하나의 데이터만을 가지는 굉장히 간단한 모델 클래스이다. 데이터 버퍼를 인/디코딩하기 위해 NSCoding 프로토콜을 구현하였다.

사진과 함께 버전 넘버를 저장하는 것에 주의하자. 이것은 나중에 업그레이드시 구버전 파일 지원을 원할 경우 데이터 구조의 업데이트를 용이하게 해준다. 새로운 필드를 추가할 경우 버젼 넘버를 변경하면 디코딩에서 버전넘버를 확인하여 그 새로운 필드가 사용 가능한지 알 수 있다.

NSCoding의 더 많은 정보를 원한다면, 이 튜토리얼을 확인하자.

다음으로, 메타데이터 모델 클래스를 만들자. 다시 설명하지만, 이 클래스의 목적은 사진의 아주 작은 섬네일 버전이 저장될 수 있도록 하여 마스터 뷰 컨트롤러에서 빠르게 로드될 것이다.

iOSCocoa TouchObjective-C class 템플릿으로 새로운 파일을 만들어, 이름은 PTKMetadata로 하고, NSObject의 서브클래스가 되도록 하자. 그런 다음 PTKMetadata.h의 내용을 아래의 코드로 변경하자.

#import <Foundation/Foundation.h>
 
@interface PTKMetadata : NSObject 
 
@property (strong) UIImage * thumbnail;
 
@end

그리고 PTKMetadata.m의 내용도 아래의 코드로 변경하자.

#import "PTKMetadata.h"
 
@implementation PTKMetadata
@synthesize thumbnail = _thumbnail;
 
- (id)initWithThumbnail:(UIImage *)thumbnail {
    if ((self = [super init])) {
        self.thumbnail = thumbnail;
    }
    return self;
}
 
- (id)init {
    return [self initWithThumbnail:nil];
}
 
#pragma mark NSCoding
 
#define kVersionKey @"Version"
#define kThumbnailKey @"Thumbnail"
 
- (void)encodeWithCoder:(NSCoder *)encoder {
    [encoder encodeInt:1 forKey:kVersionKey];
    NSData * thumbnailData = UIImagePNGRepresentation(self.thumbnail);
    [encoder encodeObject:thumbnailData forKey:kThumbnailKey];
}
 
- (id)initWithCoder:(NSCoder *)decoder {
    [decoder decodeIntForKey:kVersionKey];
    NSData * thumbnailData = [decoder decodeObjectForKey:kThumbnailKey];
    UIImage * thumbnail = [UIImage imageWithData:thumbnailData];
    return [self initWithThumbnail:thumbnail];
}
 
@end

PTKData와 매우 유사하므로 더 이상의 설명은 하지 않겠다. 같은 클래스를 사용할 수도 있겠으나, 대부분의 앱에서 그렇듯, 나중에 데이터 필드의 추가가 필요한 경우를 위해 분리해 놓는 것이 좋다.

축하한다. 이제 PhotoKeeper의 데이터 모델 클래스를 가지게 되었다.

UIDocument 서브클래싱

다음으로 우리는 데이터와 메타데이터를 묶어서 하나의 NSFileWrapper로 집어 넣는 UIDocument의 서브클래스를 만들려고 한다.

iOSCocoa TouchObjective-C class 템플릿으로 파일을 생성하여 , 이름은 PTKDocument로 하고, UIDocument의 서브클래스로 지정하자. 그런 다음 PTKDocument.h의 내용을 아래의 코드로 변경하자.

#import <UIKit/UIKit.h>
 
@class PTKData;
@class PTKMetadata;
 
#define PTK_EXTENSION @"ptk"
 
@interface PTKDocument : UIDocument
 
// Data
- (UIImage *)photo;
- (void)setPhoto:(UIImage *)photo;
 
// Metadata
@property (nonatomic, strong) PTKMetadata * metadata;
- (NSString *) description;
 
@end

기본적으로 NSFileWrapper가 디렉토리이긴 하지만, 파일 확장자를 지정하여 해당 디렉토리가  앱이 어떻게 다루는지 아는 문서임을 확인되도록 할 필요가 있다. 우리는 파일 확장자로 ” ptk”를 사용할 것이며 나중에 쉽게 접근되도록 define 하였다.

PTKDocument에 개발자가 PTKData를 직접 접근하도록 하는 것 보다, 접근자를 추가하는 것이 최상의 방법이다. UIDocument는 실행취소(undo) 관리자를 가진 undo/redo 개념과 내용이 변경되었을 때 백그라운드로 자동 저장될 수 있는 개념에 기반하여 만들어 졌기 때문이다.

사용자가 메타데이터를 직접 접근한다 해도 앱은 수정될 것이 없기 때문에 괜찮다. 대신, 사용자가 사진을 지정했을 때 메타데이터는 자동으로 수정될 것이다.

다음으로 PTKDocument.m을 열자. 여기서는 추가해야 할 코드들이 많다. 차례로 진행해 보자.

#import "PTKDocument.h"
#import "PTKData.h"
#import "PTKMetadata.h"
#import "UIImageExtras.h"
 
#define METADATA_FILENAME   @"photo.metadata"
#define DATA_FILENAME       @"photo.data"
 
@interface PTKDocument ()
@property (nonatomic, strong) PTKData * data;
@property (nonatomic, strong) NSFileWrapper * fileWrapper;
@end
 
@implementation PTKDocument 
@synthesize data = _data;
@synthesize fileWrapper = _fileWrapper;
@synthesize metadata = _metadata;

먼저 필요한 헤더를 import하고 디렉토리의 NSFileWrapper안에 위치할 두 개의 서브파일을 위한 파일명을 정의한다.

그리고 두 개의 private 프로퍼티를 생성한다. 하나는 메인 문서 모델 클래스인 PTKData를 위한 것이고, 하나는 디렉토리를 하나의 싱글파일로 다루는 NSFileWrapper를 위한 것이다. 마지막으로 이들 프로퍼티를 synthseize 한다.

다음으로 UIDocument를 디스크로 쓰기 위해 아래의 코드를 추가한다.

- (void)encodeObject:(id)object toWrappers:(NSMutableDictionary *)wrappers preferredFilename:(NSString *)preferredFilename {
    @autoreleasepool {            
        NSMutableData * data = [NSMutableData data];
        NSKeyedArchiver * archiver = [[NSKeyedArchiver alloc] initForWritingWithMutableData:data];
        [archiver encodeObject:object forKey:@"data"];
        [archiver finishEncoding];
        NSFileWrapper * wrapper = [[NSFileWrapper alloc] initRegularFileWithContents:data];
        [wrappers setObject:wrapper forKey:preferredFilename];
    }
}
 
- (id)contentsForType:(NSString *)typeName error:(NSError *__autoreleasing *)outError {
 
    if (self.metadata == nil || self.data == nil) {        
        return nil;    
    }
 
    NSMutableDictionary * wrappers = [NSMutableDictionary dictionary];
    [self encodeObject:self.metadata toWrappers:wrappers preferredFilename:METADATA_FILENAME];
    [self encodeObject:self.data toWrappers:wrappers preferredFilename:DATA_FILENAME];   
    NSFileWrapper * fileWrapper = [[NSFileWrapper alloc] initDirectoryWithFileWrappers:wrappers];
 
    return fileWrapper;
 
}

contentsForType 메소드를 먼저 살펴 보자. NSFileWrapper 디렉토리를 생성하기 위해, initDirectoryWithFileWrapper 호출에서  NSFileWrapper 파일들을 오브젝트로 하고, 그 파일들의 이름을 키로하는 딕션너리를 구성하여 넘겼다. 데이터와 메타데이터를 위한 NSFileWrapper 파일을 만들기 위해 encodeObject:toWrappers:preferredFilename 메소드를 이용하였다.

encodeObject:toWrappers:preferredFilename 메소드는 아까 NSCoding을 이용하였다면 친숙해 보일 것이다. NSCoding을 구현한 객체를 데이터 버퍼로 변환하기 위해 NSKeyedArchiver 클래스를 사용하였다. 그런 다음 그 데이터 버퍼로 NSFileWrapper 파일을 만들고 그것을 딕셔너리에 추가하였다.

자, 이제 데이터 읽기를 구현해 보자. 아래의 코드를 추가 하자.

- (id)decodeObjectFromWrapperWithPreferredFilename:(NSString *)preferredFilename {
 
    NSFileWrapper * fileWrapper = [self.fileWrapper.fileWrappers objectForKey:preferredFilename];
    if (!fileWrapper) {
        NSLog(@"Unexpected error: Couldn't find %@ in file wrapper!", preferredFilename);
        return nil;
    }
 
    NSData * data = [fileWrapper regularFileContents];    
    NSKeyedUnarchiver * unarchiver = [[NSKeyedUnarchiver alloc] initForReadingWithData:data];
 
    return [unarchiver decodeObjectForKey:@"data"];
 
}
 
- (PTKMetadata *)metadata {    
    if (_metadata == nil) {
        if (self.fileWrapper != nil) {
            //NSLog(@"Loading metadata for %@...", self.fileURL);
            self.metadata = [self decodeObjectFromWrapperWithPreferredFilename:METADATA_FILENAME];
        } else {
            self.metadata = [[PTKMetadata alloc] init];
        }
    }    
    return _metadata;
}
 
- (PTKData *)data {    
    if (_data == nil) {
        if (self.fileWrapper != nil) {
            //NSLog(@"Loading photo for %@...", self.fileURL);
            self.data = [self decodeObjectFromWrapperWithPreferredFilename:DATA_FILENAME];
        } else {
            self.data = [[PTKData alloc] init];
        }
    }    
    return _data;
}
 
- (BOOL)loadFromContents:(id)contents ofType:(NSString *)typeName error:(NSError *__autoreleasing *)outError {
 
    self.fileWrapper = (NSFileWrapper *) contents;    
 
    // The rest will be lazy loaded...
    self.data = nil;
    self.metadata = nil;
 
    return YES;
 
}

아래 부분의 loadFromContents:ofType:error: 메소드 먼저 살펴 보다. 우리가 저장했던 NSFileWrapper 디렉토리, 넘겨받은 contents를 프로퍼티에 어떻게 감추는지 주의하자. 여기서 바로 읽어들이게 할 수는 있지만, 우리는 굼뜬 로딩을 원하기 때문에 실제로 지금 바로 데이터를 로드하지는 않는다.

굼뜬 로딩이란 “요청하기 전까지 로딩하지 말라”는 의미의 재밌는 표현이다. 이것은 마스터뷰 컨트롤러에서는 메타데이터만 로드할 필요가 있다는 점에서 좋은 방법이다. 전체 데이터는 상세 디테일 뷰 컨트롤러로 갈 때에만 필요하다.

데이터와 메타데이터의 접근자를 보면 NSFileWrapper 디렉토리가 지정되어 있는지 NSFileWrapper 디렉토리로 부터 적절한 파일을 읽었는지를 체크한다.

끝으로, decodeObjectFromWrapperWithPreferredFilename은 encodeObject:toWrappers:preferredFilename의 정반대이다. NSFileWrapper 디렉토리로 부터 적절한 파일을 읽고 NSCoding 프로토콜을 통해 NSData내용을 객체로 복원한다.

거의 다 되었다. 아래의 코드를 파일의 아래 부분에 추가하자.

- (NSString *) description {
    return [[self.fileURL lastPathComponent] stringByDeletingPathExtension];
}
 
#pragma mark Accessors
 
- (UIImage *)photo {
    return self.data.photo;
}
 
- (void)setPhoto:(UIImage *)photo {
 
    if ([self.data.photo isEqual:photo]) return;
 
    UIImage * oldPhoto = self.data.photo;
    self.data.photo = photo;
    self.metadata.thumbnail = [self.data.photo imageByScalingAndCroppingForSize:CGSizeMake(145, 145)];
 
    [self.undoManager setActionName:@"Image Change"];
    [self.undoManager registerUndoWithTarget:self selector:@selector(setPhoto:) object:oldPhoto];
}
 
@end

description는 경로와 확장자를 제외한 파일명을 리턴하고, photo는 그냥 간단히 getter이다.

중요한 부분은 setPhoto이다. data에 사진을 지정할 뿐만 아니라 작은 섬네일 이미지를 만들고 그것을 metadata안에 저장한다.

또한 아이폰을 흔들거나 버튼을 이용해 사용자가 변경내용을 취소할 수 있도록 실행취소 동작을 등록한다. 이렇게 함으로써 내부적으로 UIDocument가 수정되었는 지 알 수 있고 백그라운드에서 주기적으로 자동저장 될 것이다.

와우!~ 이제 데이터모델과 UIDocument가 동작된다. 이제 그것을 사용하는 마스터 뷰 컨트롤러를 손볼 차례다.

문서 생성하기

문서의 목록을 표시하기 전에, 그것이 보여질 수 있도록 적어도 하나를 추가하는 기능을 가직 필요가 있다. 새로운 문서를 생성하는 구현을 시작해 보자.

이 앱에서 새로운 문서를 생성하기 위해 아래 네 가지가 필요하다.

  1. 엔트리 저장
  2. 사용 가능한 URL 찾기
  3. 문서 생성
  4. 마지막 변경 작업

하나씩 진행해 보자.

엔트리 저장

PTKMasterViewController.m을 보면, viewDidLoad에서 사용자가 새로운 헝목울 추가할 수 있도록 툴바에 버튼을 추가하는 것이 보일 것이다.

UIBarButtonItem *addButton = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemAdd target:self action:@selector(insertNewObject:)];
self.navigationItem.rightBarButtonItem = addButton;

이 버튼이 눌러지면 insertNewObject가 호출된다. 현재는 NSDate를 배열에 추가하여 테이블 뷰에 보여지도록 구현되어 있다.

NSDate 표시 대신에, 우리는 문서의 정보가 보여지기를 원한다. 각 문서에 대해, 파일 URL, 섬네일 메타데이터, 최종 수정 일자, 문서의 상태 등을 알 필요가 있다.

우리가 원하는 이 모든 정보를 기억하기 위해, 아주 빠르게 클래스 하나를 만들자. iOSCocoa TouchObjective-C class 템플릿으로 파일을 하나 만들고, 이름을 PTKEntry로 하고, NSObject의 서브클래스로 지정하자. 그런 다음 PTKEntry.h의 내용을 아래의 코드로 변경하자.

#import <Foundation/Foundation.h>
 
@class PTKMetadata;
 
@interface PTKEntry : NSObject
 
@property (strong) NSURL * fileURL;
@property (strong) PTKMetadata * metadata;
@property (assign) UIDocumentState state;
@property (strong) NSFileVersion * version;
 
- (id)initWithFileURL:(NSURL *)fileURL metadata:(PTKMetadata *)metadata state:(UIDocumentState)state version:(NSFileVersion *)version;
- (NSString *) description;
 
@end

우리가 언급한 모든 항목들을 기록하기 위한 아주 간단한 클래스이다.

다음으로 PTKEntry.m에서 마찬가지로 간단하게 구현을 추가하자.

#import "PTKEntry.h"
 
@implementation PTKEntry
 
@synthesize fileURL = _fileURL;
@synthesize metadata = _metadata;
@synthesize state = _state;
@synthesize version = _version;
 
- (id)initWithFileURL:(NSURL *)fileURL metadata:(PTKMetadata *)metadata state:(UIDocumentState)state version:(NSFileVersion *)version {
 
    if ((self = [super init])) {
        self.fileURL = fileURL;
        self.metadata = metadata;
        self.state = state;
        self.version = version;
    }
    return self;
 
}
 
- (NSString *) description {
    return [[self.fileURL lastPathComponent] stringByDeletingPathExtension];
}
 
@end

사용가능한 URL 찾기

다음 과정은 문서생성을 위한 URL을 알아내는 것이다. 이것은 사용하지 않은 파일명으로 자동 생성해야 하기 때문에 말처럼 쉽지 않다. 그리고 첫 단계는 파일이 존재하는지 체크하는 것이다.

이를 해결하기 위해 _objects 배열이 디스크 상의 모든 PTKEntry들을 가지고 있다고 가정하자. 그러면 우리는 이 배열을 통해 파일명이 이미 사용되었는지 알아보면 된다.

코드에서 어떻게 보여지는지 알아 보자. PTKMasterViewController.m에서 아래의 내용대로 추가 또는 수정하자.

// 상단에 immport 추가
#import "PTKDocument.h"
#import "NSDate+FormattedStrings.h"
#import "PTKEntry.h"
#import "PTKMetadata.h"'
 
// 인터페이스를 위한 private 변수 추가
NSURL * _localRoot;
PTKDocument * _selDocument;
 
// @implementation 바로 아래에 메소드 구현 추가
#pragma mark Helpers
 
- (BOOL)iCloudOn {    
    return NO;
}
 
- (NSURL *)localRoot {
    if (_localRoot != nil) {
        return _localRoot;
    }
 
    NSArray * paths = [[NSFileManager defaultManager] URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask];
    _localRoot = [paths objectAtIndex:0];
    return _localRoot;    
}
 
- (NSURL *)getDocURL:(NSString *)filename {    
    if ([self iCloudOn]) {        
        // TODO
        return nil;
    } else {
        return [self.localRoot URLByAppendingPathComponent:filename];    
    }
}
 
- (BOOL)docNameExistsInObjects:(NSString *)docName {
    BOOL nameExists = NO;
    for (PTKEntry * entry in _objects) {
        if ([[entry.fileURL lastPathComponent] isEqualToString:docName]) {
            nameExists = YES;
            break;
        }
    }
    return nameExists;
}

윗 부분부터 살펴보자. 필요한 헤더를 임포트 하는 것으로 시작하였다. 그리고, Documents 디렉토리인 로컬 루트와 사용자가 탭을 함으로서 선택한 문서를 대입하기 위한 인스턴스 변수를 추가하였다. 그리고 아이클라우드가 켜져있는지 꺼져있는지 알려주는 메소드를 구현하였다. 여기서는 항상 꺼짐으로 리턴하지만 나중에 다시 이를 손 볼 것이다.

다음으로 굼뜬로딩을 위해 localRoot getter를 구현하였다. 만일 지정되지 않았다면 문서 디렉토리를 찾아서 인스턴스 변수로 전달하였다.

getDocURL 메소드는 파일명을 받아 그것의 전체 경로를 제공한다.아이클라우드가 꺼져 있으면, Documents 디렉토리 경로에 간단히 파일명망 추가한다. 나중에 아이클라우드를 위한 구현을 추가할 것이다.

마지막으로, docNameExistsInObjects 메소드는 PTKEntry 인스턴스 목록인 _objects 배열을 탐색하여 주어진 파일명이 있는지 체크한다. 만일 있다면 충돌이 발생할 것이고 아니면 괜찮을 것이다.

다음으로 파일에 사용되지 않은 이름을 얻기 위해 아래의 메소드를 추가하자.

- (NSString*)getDocFilename:(NSString *)prefix uniqueInObjects:(BOOL)uniqueInObjects {
    NSInteger docCount = 0;
    NSString* newDocName = nil;
 
    // At this point, the document list should be up-to-date.
    BOOL done = NO;
    BOOL first = YES;
    while (!done) {
        if (first) {
            first = NO;
            newDocName = [NSString stringWithFormat:@"%@.%@",
                          prefix, PTK_EXTENSION];
        } else {
            newDocName = [NSString stringWithFormat:@"%@ %d.%@",
                          prefix, docCount, PTK_EXTENSION];
        }
 
        // Look for an existing document with the same name. If one is
        // found, increment the docCount value and try again.
        BOOL nameExists;
        if (uniqueInObjects) {
            nameExists = [self docNameExistsInObjects:newDocName]; 
        } else {
            // TODO
            return nil;
        }
        if (!nameExists) {            
            break;
        } else {
            docCount++;            
        }
 
    }
 
    return newDocName;
}

여기서 파일명을 받아 그것이 사용가능한지 체크하는 것이다. 사용가능하지 않을 경우 (즉 동일한 이름의 파일이 있을 경우) 파일명 끝에 1을 추가해 붙혀서 다시 시도한다. 실패하면 숫자를 증가하여 사용가능한 이름을 찾을 때까지 계속한다.

문서 생성

PTKDocument 생성을 위해, 다음 두 개의 단계가 있다.

  1. PTKDocument를 파일 저장을 위한 URL로 alloc/init 한다.
  2. 생성된 파일 초기화를 위해 saveToURL 메소드를 호출한다.

saveToURL 호출 이후, 문서는 유효하게 열려지고 사용가능한 상태로 된다.

문서 생성 이후 우리는 그 문서를 저장하는 것으로 배열을 업데이트 해야하고 디테일 뷰 컨트롤러에 표시할 필요가 있다.

코드를 추가하자. 먼저 PKEntry를 _objects배열에 추가하거나 업데이트 하는 메소드를 추가하자. 아래의 코드를 awakeFromNib: 바로 위에 추가하자.

#pragma mark Entry management methods
 
- (int)indexOfEntryWithFileURL:(NSURL *)fileURL {
    __block int retval = -1;
    [_objects enumerateObjectsUsingBlock:^(PTKEntry * entry, NSUInteger idx, BOOL *stop) {
        if ([entry.fileURL isEqual:fileURL]) {
            retval = idx;
            *stop = YES;
        }
    }];
    return retval;    
}
 
- (void)addOrUpdateEntryWithURL:(NSURL *)fileURL metadata:(PTKMetadata *)metadata state:(UIDocumentState)state version:(NSFileVersion *)version {
 
    int index = [self indexOfEntryWithFileURL:fileURL];
 
    // Not found, so add
    if (index == -1) {    
 
        PTKEntry * entry = [[PTKEntry alloc] initWithFileURL:fileURL metadata:metadata state:state version:version];
 
        [_objects addObject:entry];
        [self.tableView insertRowsAtIndexPaths:[NSArray arrayWithObject:[NSIndexPath indexPathForRow:(_objects.count - 1) inSection:0]] withRowAnimation:UITableViewRowAnimationRight];
 
    } 
 
    // Found, so edit
    else {
 
        PTKEntry * entry = [_objects objectAtIndex:index];
        entry.metadata = metadata;    
        entry.state = state;
        entry.version = version;
 
        [self.tableView reloadRowsAtIndexPaths:[NSArray arrayWithObject:[NSIndexPath indexPathForRow:index inSection:0]] withRowAnimation:UITableViewRowAnimationNone];
 
    }
 
}
 
다음으로 insertNewObject의 구현을 아래의 내용으로 변경하자.
- (void)insertNewObject:(id)sender
{
    // Determine a unique filename to create
    NSURL * fileURL = [self getDocURL:[self getDocFilename:@"Photo" uniqueInObjects:YES]];
    NSLog(@"Want to create file at %@", fileURL);
 
    // Create new document and save to the filename
    PTKDocument * doc = [[PTKDocument alloc] initWithFileURL:fileURL];
    [doc saveToURL:fileURL forSaveOperation:UIDocumentSaveForCreating completionHandler:^(BOOL success) {
 
        if (!success) {
            NSLog(@"Failed to create file at %@", fileURL);
            return;
        } 
 
        NSLog(@"File created at %@", fileURL);        
        PTKMetadata * metadata = doc.metadata;
        NSURL * fileURL = doc.fileURL;
        UIDocumentState state = doc.documentState;
        NSFileVersion * version = [NSFileVersion currentVersionOfItemAtURL:fileURL];
 
        // Add on the main thread and perform the segue
        _selDocument = doc;
        dispatch_async(dispatch_get_main_queue(), ^{
            [self addOrUpdateEntryWithURL:fileURL metadata:metadata state:state version:version];
            [self performSegueWithIdentifier:@"showDetail" sender:self];
        });
 
    }]; 
}

아래의 내용으로 메소드들을 구현하였다.

  • 로컬 문서 디렉토리를 찾기 위해 getDocURL 메소드를 사용한다.
  • 위 디렉토리에서 사용가능한 파일명을 얻기 위해 getDocFilename 메소드를 사용한다.
  • PTKDocument 인스턴스를 생성하고 초기화한다.
  • saveToURL 메소드를 호출하여 즉시 저장한다.
  • 열려진(선택된) 문서를 기억시킨다.
  • 테이블에 항목을 추가하고 showDetail를 이어서 수행한다.

saveToURL의 completionHandler가 메인쓰레드에서 실행된다고 보장할 수 없기 때문에 메인 큐상에서 실행될 수 있도록 dispatch_async를 사용해야 함에 유의하자.

마지막 변경 작업

자, 테스트할 준비가 거의 다 되었다. PTKMasterViewController.m에 아래와 같이 마지막 변경 작업을 하자.

// viewDidLoad 아래 부분에 이 코드를 추가하자.
_objects = [[NSMutableArray alloc] init];
 
// tableView:cellForRowAtIndexPath를 아래의 내용으로 변경하자.
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"Cell"];
 
    PTKEntry *entry = [_objects objectAtIndex:indexPath.row];
    cell.imageView.image = entry.metadata.thumbnail;
    cell.textLabel.text = entry.description;
    cell.detailTextLabel.text = [entry.version.modificationDate mediumString];    
 
    return cell;
}
 
// prepareForSegue를 아래와 같이 변경하자.
- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender
{
    if ([[segue identifier] isEqualToString:@"showDetail"]) {
        // TODO
    } 
}

여기서 우리는 _objects 배열을 초기화하고 문서들의 정보를 표시하기 위해 테이블 뷰를 설정하였다. 나중에 스토리보드를 위한 segue와 디테일 뷰 컨트롤러를 수정할 것이다.

컴파일과 실행을 해보자. + 버튼을 탭하여 문서 생성이 가능해야 한다.

Creating local UIDocuments in PhotoKeeper

콘솔 출력창에서 아래와 같이 문서의 전체경로가 표시되어야 한다.

File created at file://localhost/Users/rwenderlich/Library/Application%20Support/
iPhone%20Simulator/5.1/Applications/
194CFF63-BE3F-4CAF-90CC-BFA843C2169B/Documents/Photo%202.ptk

이 앱에는 하나의 큰 문제점이 있다. 앱을 다시 실행해 보면 목록에 아무것도 나타나지 않는다는 것이다. 그것은 문서 목록을 위한 코드를 아직 추가하지 않았기 때문이다. 지금 그것을 해 보자.

로컬 문서 목록 구현

로컬 문서 목록을 위해 로컬문서 디렉토리에서 모든 문서의 URL을 가져 오고 각 문서를 열어볼 것이다. 실제 데이터가 아닌 간단한 섬네일을 얻기 위해 메타데이터를 읽어들일 것이고, 그런 다음 그것을 다시 닫고 테이블 뷰에 추가할 것이다.

먼저 주어진 파일 URL을 이용해 문서를 로드하는 메소드를 추가하자. “View lifecycle” 섹션의 awakeFromNib: 바로 위에 아래의 코드를 추가하자.

#pragma mark File management methods
 
- (void)loadDocAtURL:(NSURL *)fileURL {
 
    // Open doc so we can read metadata
    PTKDocument * doc = [[PTKDocument alloc] initWithFileURL:fileURL];        
    [doc openWithCompletionHandler:^(BOOL success) {
 
        // Check status
        if (!success) {
            NSLog(@"Failed to open %@", fileURL);
            return;
        }
 
        // Preload metadata on background thread
        PTKMetadata * metadata = doc.metadata;
        NSURL * fileURL = doc.fileURL;
        UIDocumentState state = doc.documentState;
        NSFileVersion * version = [NSFileVersion currentVersionOfItemAtURL:fileURL];
        NSLog(@"Loaded File URL: %@", [doc.fileURL lastPathComponent]);
 
        // Close since we're done with it
        [doc closeWithCompletionHandler:^(BOOL success) {
 
            // Check status
            if (!success) {
                NSLog(@"Failed to close %@", fileURL);
                // Continue anyway...
            }
 
            // Add to the list of files on main thread
            dispatch_async(dispatch_get_main_queue(), ^{                
                [self addOrUpdateEntryWithURL:fileURL metadata:metadata state:state version:version];
            });
        }];             
    }];
 
}

문서를 열고 필요한 것을 얻은 다음 계속 열어 놓는 것 보다 다시 닫는 것에 주의하자. 아래의 두 가지 이유 때문이다.

  1. 문서의 한 부분이 필요할 때 메모리의 전체 UIDocument 유지의 오버헤더를 막기 위함이다.
  2. UIDocument는 단지 한 번만 열리고 닫힐 수 있다. UIDocument는 한 번에 한 번씩만 열고 닫을 수 있는 원샷 클래스이다. 만일 같은 파일 URL로 다시 열기를 원하면 새로운 UIDocument 인스턴스를 생성해야 한다.

위 코드의 바로 아래에 새로고침 동작을 위해 다음의 메소드를 추가하자.

#pragma mark Refresh Methods
 
- (void)loadLocal {
 
    NSArray * localDocuments = [[NSFileManager defaultManager] contentsOfDirectoryAtURL:self.localRoot includingPropertiesForKeys:nil options:0 error:nil];
    NSLog(@"Found %d local files.", localDocuments.count);    
    for (int i=0; i < localDocuments.count; i++) {
 
        NSURL * fileURL = [localDocuments objectAtIndex:i];
        if ([[fileURL pathExtension] isEqualToString:PTK_EXTENSION]) {
            NSLog(@"Found local file: %@", fileURL);
            [self loadDocAtURL:fileURL];
        }        
    }
 
    self.navigationItem.rightBarButtonItem.enabled = YES;
}
 
- (void)refresh {
 
    [_objects removeAllObjects];
    [self.tableView reloadData];
 
    self.navigationItem.rightBarButtonItem.enabled = NO;
    if (![self iCloudOn]) {
        [self loadLocal];       
    }        
}

파일 관리에 관한 꽤 간단한 코드이다.  loadLocal은 문서 디렉토리의 모든 것을 위해  loadDocAtURL을 호출하고, 그것들이 새로 고쳐지도록 한다.

바의 오른쪽 버튼을 활성/비활성으로 하는 이유는 파일 목록을 모두 불러들이기까지 새로운 파일 생성을 하지 못하도록 하기 위함이다. 이것은 새로운 파일을 생성했을 때 유일한 이름을 보장받기 위함이다. 지금은 파일 목록을 바로 가져오기 때문에 별로 문제가 되지 않을 뿐더러 , 아이클라우드를 사용할 경우가 아니라면 좋은 방법이다.

마지막으로, 아래의 코드를 viewDidLoad: 하단부에 추가하자.

[self refresh];

컴파일과 실행을 하여 마직막 실행 이후에 문서 목록을 제대로 가져 오는지 확인하자.

다음으로 가야 할 곳

지금까지의 코드를 다운로드하기 위한 링크가 여기 있다.

이제, 데이터 모델과 파일을 생성하고 목록을 구성하도록 해주고 wrapper로 동작하는 UIDocument를 가지게 되었다. 아직 완전하지 않아 보이지만 제일 중요한 부분이다.

다음 파트에서는 로컬 파일에 대한 전체 기능을 가진 완벽한 앱을 만들 것이다. 테이블 뷰 셀을 커스터마이징 하고, 디테일 뷰를 추가 하고, 삭제와 이름 변경과 함께 다른 것들을 더 할 것이다. 그 때가 되면 아이클라우드로 이동을 위한 준비가 될 것이다.

더 좋은 방법을 위한 질문, 코멘트, 제안이 있으면 포럼에 남겨 주기 바란다.


본 포스팅은 독립 소프트웨어 개발자이자 게이머이면서 사이트 관리자인 Ray Wenderlich에 의해 작성되었습니다.

 

 

 

본 튜토리얼의 한글 번역은 Wizsoft의 대표이면서 OS X와 iOS 개발자로 일하고 있는 장영준(@istsest)에 의해 작성되었습니다.

Ray Wenderlich

Ray is an indie software developer currently focusing on iPhone and iPad development, and the administrator of this site. He’s the founder of a small iPhone development studio called Razeware, and is passionate both about making apps and teaching others the techniques to make them.

When Ray’s not programming, he’s probably playing video games, role playing games, or board games.

User Comments

0 Comment

Other Items of Interest

Ray의 월간 뉴스레터

Sign up to receive a monthly newsletter with my favorite dev links, and receive a free epic-length tutorial as a bonus!

Advertise with Us!

Hang Out With Us!

Every month, we have a free live Tech Talk - come hang out with us!


Coming up in July: Facebook Pop Tech Talk!

Sign Up - July

RWDevCon Conference?

We are considering having an official raywenderlich.com conference called RWDevCon in DC in early 2015.

The conference would be focused on high quality Swift/iOS 8 technical content, and connecting as a community.

Would this be something you'd be interested in?

    Loading ... Loading ...

Our Books

Our Team

Tutorial Team

  • Jake Gundersen

... 49 total!

Update Team

  • Riccardo D'Antoni

Editorial Team

  • Matt Galloway

... 23 total!

Code Team

  • Orta Therox

... 1 total!

번역 팀

  • Victor Grushevskiy
  • Fabio Casado

... 33 total!

Subject Matter Experts

  • Richard Casey

... 4 total!