Wireframe

20111220 :: [iOS] 저장이 가능한 간단 메모장 2 (1/2)

지난 글에서 간략한 예제로 만들었던 내용에 이어 오늘은 두 번째 시간. 두 번째 시간을 시작하기에 앞서 다뤘던 내용은 iOS에서 사용자의 데이터를 저장하는 방법과 관련하여 몇 가지를 알아보았고, 그 중에서 비교적 쉽게 접근할 수 있을 것으로 보이는 ‘아카이빙’에 대해 알아보았다.
실제로 아카이빙은 대부분의 코코아터치 객체들이 자신을 아카이빙하는 방법을 알고 있기 때문에 루트 객체를 아카이브하면 자동으로 엮여있는 모든 정보가 아카이브되고, 이것을 간단히 NSKeyedArchiverNSKeyedUnarchiver를 사용하여 직렬화된 정보를 파일에 쓸 수 있다는 것 까지 확인해 보았다.
오늘은 이 메모장을 조금 더 확장하여 여러 개의 메모를 생성하고 이를 한꺼번에 저장할 수 있는 메모장을 만들어볼 것이다. 사실 이 부분에서 가장 주요한 내용은 각각의 메모를 대표하는 클래스를 하나 만들어서 이 클래스가 어떻게 스스로를 아카이빙하는지를 기술해주기만 하면 되는 내용이고 나머지 대부분의 내용은 테이블 뷰를 사용하는 방법이나 앱 델리게이트를 사용하는 방법에 대한 내용이 될 것 같다. 내용들이 많고 글이 길어질 것 같으나 적당한 분량에서 조절하도록 하겠다.

프로젝트 시작

기존에 만들어 둔 내용을 토대로해도 무관하나, 그냥 새로 만들어보도록 한다. 싱글뷰 기반(윈도 기반) 프로젝트로 시작해도 되고, 빈 프로젝트로 시작해도 된다. 빈 프로젝트에서 시작하는 경우에는 스토리보드를 추가하여 UI를 구성하는 것이 “쉽다”.1빈 프로젝트에서 스토리 보드를 추가하여 사용하는 방법은 이전에 올린 포스팅을 참고로 한다.

메모 클래스

각각의 메모는 단지 하나의 텍스트가 아니라 제목, 본문, 최종수정일의 정보를 갖는 정보의 덩어리로 생각할 수 있다. 따라서 이는 별도의 클래스로 만든다.

프로토콜

커스텀 클래스를 정의해서, 이 객체들을 아카이빙하기 위해서는 이 객체들이 NSCoding 프로토콜을 따르도록 해야 한다. 프로토콜을 설명하기에는 이 글은 다른 내용이 너무 기니까 그건 따로 언급하지 않겠다. 어쨌든 NSCoding 프로토콜은 2개의 필수 메소드를 선언하고 있는데 그것은 -encodeWithcoder: 와  -initWithCoder: 의 두 개 메소드이다. 각각은 자신을 인코딩하는 방법과 그리고 인코드된 데이터로부터 복원하는 방법을 서술한다. 인코딩은 파일에 저장되는 바이너리 데이터로 객체스스로를, 즉 객체 내부에 존재하는 인스턴스 변수들을 코딩하는 것이고, 디코딩은 바이너리 데이터 스트림으로부터 자료를 뽑아내어 이를 다시 인스턴스 변수에 넣어주는 작업이고 실제 인코딩/디코딩이 이루어지는 과정에 대해서는 직접 관여할 필요가 없다.
다만 실질적으로 어떤 변수를 인코딩하였을 때 추후에 그 값을 그대로 복원하기 위해서는 이름을 주어야 하고 이는 키-밸류 형태의 짝으로 이루어지게 되고 복원할 때에도 그 키를 사용하여 올바른 변수의 값을 되찾게 된다.

메모 클래스 생성

cmd + n 을 눌러 새로운 파일을 하나 만든다. NSObject의 서브 클래스를 만들고 이름은 Memo라고 하자. 알겠지만 Memo.h 와 Memo.m 이 생긴다. 여기에 인스턴스 변수를 정의한다.  각각의 메모는 제목과 메모본문, 두 개의 문자열 인스턴스 변수를 가지며, 인코딩될 때 이 두 문자열 객체를 인코드하게 된다.

///Memo.h
#import <Foundation/Foundation.h>
@interface Memo : NSObject <NSCoding>
@property (retain, nonatomic) NSString *title;
@property (retain, nonatomic) NSString *memotext;
@end

이제 초기화하는 메서드를 붙이고 인스턴스 정보를 인코드/디코드하는 메서드를 추가한다.

///Memo.m
#import "Memo.h"
@implementation Memo
@systhesize title, memotext;
-(id)initWithCoder:(NSCoder *)aDecoder
{
    self = [super init];
    self.memo = [aDecoder decodeObjectForKey:@"memo"];
    self.title = [aDecoder decodeObjectForKey:@"title"];
    return self;
}
-(void)encodeWithCoder:(NSCoder *)aCoder
{
    [aCoder encodeObject:title forKey:@"title"];
    [aCoder encodeObject:memo forKey:@"memo"];
}
@end

이제 우리의 메모 클래스는 스스로를 인코딩할 수도 있고, 인코딩했던 정보를 다시 불러와서 스스로를 복원할 수도 있게되었다.  위 코드를 잘 살펴보면 NSCoder 클래스가 제공되면 이를 사용하여 인스턴스 변수를 키를 이름으로 주고 인코딩하고, 디코딩시에는 같은 키로 불러온 값을 각자의 변수에 넣어주는 일을 하고 있다.
이외에 인스턴스 변수가 객체가 아닌 정수 및 실수나 다른 객체들을 인코딩하게 되는데 이 때 사용되는 메소드는 encode<값 유형>:forKey: 메소드가 된다. 자세한 내용은 개발자 문서를 참고하자.
또한, Memo 객체는 신규로 생성되는 경우가 있을 수 있으므로 init 메소드를 따로 재정의한다.

-(id) init
{
    self = [super self];
    if(self){
        [self setTitle:@“”];
        [self setMemotext:@“”];
    }
    return self;
}

앱 델리게이트

앱 델리게이트에서는 크게 다음과 같은 기능들을 수행해야 한다.

인터페이스

인터페이스 파일에는 다음과 같은 내용을 선언한다. 먼저 파일이 저장되는 경로를 하나 선언한다. 또한 다른 클래스에서 액세스할 수 있도록 메모의 리스트를 담든 배열과 메모의 인덱스를 지정하는 정수를 하나 씩 프로퍼티로 만든다.

@interface AppDelegate : NSObject <UIApplicationDelegate>
{
    NSString *filePath;
}
@property (strong, nonatomic) NSMutableArray *memoListArray;
@property (readwrite, assign) int memoIndex;
-(void)saveData;
@end

구현부

먼저 앱이 실행되면 데이터 파일의 경로를 생성하고, 이에 따라 해당 파일이 있으면 이를 읽어 오는 등의 작업을 처리한다.

-(BOOL)application:didFinishLaunchingWithOptios:
{
    memoListArray = [[NSMutableArray alloc] init];
    NSString *docDir;
    NSArray *dirPaths;
    dirPaths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
    docsDir = [dirPaths objectAtIndex:0];
    filePath = [[NSString alloc] initWithString:[docsDir stringByAppendingPathComponent:@"data.archive"]];
    NSFileManager *fileManager = [NSFileManager defaultManager];
    if([fileManager fileExistsAtPath:filePath])
    {
        NSArray *savedMemo = [[NSArray alloc] init];
        savedMemo = [NSKeyedUnarchiver unarchiveObjectWithFile:filePath];
        memoListArray = [savedMemo mutableCopy];
    }
    //    또한, 기본적으로 아무런 메모도 선택되지 않은 상황이므로 인덱스는 -1로 둔다.
    memoIndex = -1;
    return YES;
}

이번에는 데이터를 저장한다. 기본적으로 생성된 메모와 기존 메모는 모두, memoListArray에 들어있다고 가정한 상태이다. -saveData:는 이 배열 전체를 그대로 파일에 저장한다.
앞서 말한바와 같이 배열을 인코딩하면 배열은 모든 요소 객체에 인코딩하라는 메시지를 보낸다. 각 객체는 다시 저장될 인스턴스 변수를 인코딩한다.

-(void)saveData
{
    NSArray *arrayToSave = [NSArray arrayWithArray:(NSArray *)memoListArray];
    if(![NSKeyedArchiver archiveRootObject:arrayToSave toFile:filePath])
    {
        NSLog(@"Fail to save");
    }else{
        NSLog(@"data saved");
    }
    arrayToSave = nil;
}

메모리스트

메모리스트는 테이블뷰를 사용하여 생성한다. (물론 그냥 UIViewController를 사용해서 만들 수도 있다.)
테이블뷰는 다시 네비게이션 컨트롤러로 감싸진다. 테이블 뷰 컨트롤러는 앱 델리게이트에서 메모 리스트를 가져와서 각 메모의 제목을 표시한다. (이는 나중에 구현한다.)
상단 아이템바에는 신규 메모 생성버튼을 만들고 이를 통해 새 메모를 추가한다. 또한 이미 작성된 메모를 탭하면 상세 뷰로 이동하면서 해당 내용을 열람하고, 원한다면 수정을 할 수 있도록 한다.
이 때 상세뷰에서는 별도의 저장버튼은 만들지 않고, 네비게이션 컨트롤러에서 뒤로 돌아갈 때 바로 해당 내용이 저장되도록 한다.

스토리보드

새로 스토리보드를 만들었다면, 비어 있는 스토리보드에 테이블뷰 컨트롤러를 하나 추가한다. 추가된 부 컨트롤러를 선택해서 매뉴에서 Editor > Embed >
Navigation Controller를 선택한다. 신규 생성한 테이블 뷰 앞에 뷰컨트롤러가 나타나고, 테이블뷰 위쪽에도 네비게이션 바가 추가된다. 다음 테이블 뷰 컨트롤러의 네비게이션 바에 바 아이템을 하나 추가한 후 이 버튼의 identify 속성을 add로 준다 (더하기 모양으로 바뀜)
다음 새 뷰 컨트롤러를 하나 더 추가한다. 그런다음 아울렛을 바인딩하는 것과 같은 방법으로 테이블 뷰 컨트롤러의 바 버튼과 새 뷰 컨트롤러를 연결한다. 타입은 Push를 선택한다.
여기까지하고 앱을 빌드하면 초기 화면에 테이블뷰가 보이고 버튼을 선택하면 새 뷰로 이동, 뒤로 버튼을 탭하면 다시 테이블뷰로 돌아오는 것 까지 동작하는 것을 확인할 수 있다.

컨트롤러 작성

이제 컨트롤러를 작성할 차례이다. 새 파일을 만들고 유형을 테이블 뷰 컨트롤러로 선택한다. 파일이름은 RootViewController 정도가 적당하겠다. 이 컨트롤러는 다음과 같은 기능을 수행한다.

  1. 뷰가 나타날 때 메모 리스트 정보를 가져와서 각 셀에 뿌려준다.
  2. 셀을 터치하면 앱델리게이트의 인덱스를 변경, 선택된 메모가 상세 뷰에서 표시되도록 한다.
  3. 셀을 삭제하면 해당 내용이 삭제되고 파일이 업데이트 되도록 한다.

하지만 실질적으로 우선은 새 메모를 추가하는 것만 생각하자. 대신 스토리보드에서 테이블뷰 컨트롤러를 선택해 클래스를 RootViewController로 지정한다.

디테일 뷰 컨트롤러

상세를 보여주고 새 메모를 작성하는 화면으로, 스토리보드에 마지막에 추가한 뷰 컨트롤러이다. 새 파일을 만들고 타입은 뷰컨트롤러가 되게하자. 이름은 DetailViewController가 좋겠다. 다시 스토리보드에서 디테일뷰를 마저 완성하자.

스토리보드

디테일뷰가 될 컨트롤러를 선택해 클래스 이름을 바꿔주고, 텍스트 필드 하나와 텍스트 뷰 하나를 달아준다. 택스트뷰는 지난 번 프로젝트와 마찬가지로 편집이 가능한 editable 속성을 가진다.
UI아울렛을 코드로 작성하기보다는 assistant editor를 열고 해당 인터페이스 정의 부분에 각 텍스트 필드와 뷰를 연결하면 아울렛을 생성하는 대화상자가 나타나니 이를 이용해서 두 개의 아울렛을 추가한다.2

인터페이스

디테일뷰는 현재 선택된 메모의 인스턴스를 가지고 UI에 내용을 그려주며, 편집된 내용을 반영하고 파일에 저장한다. 실제 데이터는 앱 델리게이트가 가지고 있으므로 앱 델리게이트의 인스턴스만 가지면 된다.

#import <UIKit/UIKit.h>
#import "AppDelegate.h"
@class Memo;
@interface DetailViewController : UIViewController
{
    AppDelegate *appDelegate;
    Memo *currentMemo;
}
// 아래는 스토리보드에서 끌어서 아울렛을 만들면 생기는 코드
@property (weak, nonatomic) IBOutlet UITextFiled *title;
@property (weak, nonatomic) IBOutlet UITextView *memotext;

구현부

구현부에서는 뷰컨트롤러가 초기화될 때 해야하는 일을 지정한다. 지금은 “추가” 버튼을 탭하여 디테일 뷰를 연 상황에 대해 코드를 작성한다.
#import “Memo.h” 를 추가해야 한다. 프로퍼티 2개에 대한 @synthesize 구문은 자동으로 작성되어 있을 것이다.

-(id)viewDidLoad
{
    appDelegate = [[UIApplicatioin sharedApplication] delegate];
    // appDelegate의 인스턴스를 얻었다.
    if([appDelegate.memoIndex < −1){
        //    메모의 인덱스가 −1이 아님 (새 메모가 아님)
        currentMemo = [[appDelegate memoListArray] objectAtIndex:[appDelegate memoIndex]];
    } else {
        currentMemo = [[Memo alloc] init];
    }
    [self.title setText:[currentMemo title]];
    [self.memotext setText:[currentMemo memo text]];
}

네비게이션 바의 ‘back’ 버튼을 누르면 작성된 값을 메모 리스트에 추가하고, 배열을 영구저장소에 저장한다. back 버튼을 누를 때는 moveToParentViewController: 가 호출되지만 이 메소드는 디테일뷰로 진입할 때에도 한 번 호출되므로 viewDidDisappera:에 구현하도록 한다. 신규 메모인 경우에는 appDelegate의 memoIndex가 −1이고, 이 경우에는 배열에 새롭게 추가한다.

-(void)viewWillDisappear:(BOOL)animated
{
    [currentMemo setTitle:[self.title text]];
    [currentMemo setMemotext:[self.memotext text]];
    if([appDelegate memoIndex] == −1){
        [[appDelegate memoListArray] insertObjectAtIndex:0];
    }
    [appDelegate saveData];
    [appDeleagte setMemoIndex:-1];
}

이제 앱을 빌드하고 실행하면 동작은 하지만 새로 생성된 메모를 확인할 수는 없다. 아직 테이블뷰의 데이터소스 부분을 구현하지 않았기 때문이다. 테이블뷰의 데이터소스는 추가적인 설명이 더 필요하므로 다음 글에서 계속 잇도록 하겠다.


  1. 많은 개발자들이 스토리보드를 쓰는게 참 안좋다라고 하는데, 코드상에서 UI를 짜는 일이 어려운 초보에게 이 스토리보드가 무척이나 편리한 것은 사실이다. 그렇지만 생각한대로 나오지 않아 삽질을 해야 하는 경우가 없지는 않다는 것이 단점일 수는 있다. 
  2. 이는 Xcode4의 가장 멋진 기능이다. 실컷 코드를 작성했는데 뭔가 동작이 안된다면 아울렛과 인터페이스 상의 객체를 연결하지 않았기 때문일 확률이 큰데, 이 기능은 상당수의 코드를 줄여주면서 이런 실수를 방지하게 해준다. 
Exit mobile version