モデルオブジェクトをファイルへ保存する

(NSKeyedArchiver/NSKeyedUnarchiver, NSCoding)

Cocoaバインディングを使ってみる」で驚くほど簡単にデータの入力・表示を行う事ができてしまったので、勢いに乗ってアプリケーションの終了/起動時にこのモデルデータ(Cocoaオブジェクト)をファイルに保存、およびファイルから読み込む機能を追加してみることにします。

追加/改修する箇所の概略は、「Cocoaバインディングを使ってみる」で示した下図の中のUCAppControllerの作成と、UCBookでのNSCodingプロトコルのサポートです。



1.調査/解決しなければならない項目

Cocoaバインディングのメカニズムを使用して作成されたモデルデータオブジェクトのファイルへの保存/読み込み機能を組み込むにあたって、調査/解決しなければならない項目に以下のものがあります。

(1) アプリケーションの起動/終了のタイミングを知るには?
(2) NSArrayControllerが抱えているモデルデータのオブジェクトを取得する方法は?
またファイルから読み込んだオブジェクトを設定する方法は?
(3) Cocoaのオブジェクトをファイルに書き出す方法は?
またファイルから読み込んで復元する方法は?
(4) 独自に定義したモデルデータのクラス、UCBookのオブジェクトはそのままファイルに書き出せるの?
またファイルから読み込こんで元のオブジェクトとして復元できるの?

1.1 アプリケーションの起動/終了のタイミングの検知

これについては「ウィンドウクローズ時にアプリも終了させるには」で使ったテクニックと同じように、Interface Builder上で自前のコントローラクラスの作成およびインスタンス化を行い、それを今度は「Window」ではなく「File's Owner」(=NSApplicationクラスのインスタンス)のdelegateとして接続。そして、自前のコントローラクラスにNSApplicationクラスの仕様でdelegateメソッドとして規定されている以下のメソッドをインプリメントすることで、これらが呼び出されアプリの起動/終了を知る事ができます。

// アプリケーション起動完了通知
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification

// アプリケーション終了通知
- (void)applicationWillTerminate:(NSNotification *)aNotification

なお、アプリケーションの起動通知には以下のdelegateメソッドも存在します。

 - (void)applicationWillFinishLaunching:(NSNotification *)aNotification

クラス仕様書によるとapplicationDidFinishLaunching:との違いは以下のように説明されています。

メソッド applicationWillFinishLaunching:
タイミング NSApplicationオブジェクトが初期化される前に送信される。
メソッド applicationDidFinishLaunching:
タイミング アプリケーションの起動と初期化が完了し、最初のイベントを受け取る前に送信される。
なお、ユーザがこのアプリケーションのドキュメントファイルをダブルクリックして起動した場合、applicationDidFinishLaunching:メッセージを受け取る前にapplication:openFile:メッセージを受け取る。(applicationWillFinishLaunching:はapplication:openFile:より前に送信される)

上記仕様から考えると、ダブルクリックで起動されるドキュメントファイルを使わなければどちらでもよいので、このサンプルでは「applicationDidFinishLaunching:」を使用することとします。

1.2 NSArrayControllerが保持するモデルデータオブジェクトの取得/設定方法

これについては、このサンプルで使用しているNSArrayControllerの親クラスであるNSObjectControllerの以下のメソッドが使用できます。

// コンテンツオブジェクトの取得
- (id)content

// コンテンツオブジェクトの設定
- (void)setContent:(id)content

1.3 Cocoaオブジェクトのファイルヘの入出力

これについては以下のいずれかのクラスが使用できます。

 【オブジェクトのファイルへの出力】
 NSArchiver
 NSKeyedArchiver

 【オブジェクトのファイルからの読み込みと復元】
 NSUnarchiver
 NSKeyedUnarchiver

なお、これらのクラスはオブジェクトのファイルへの入出力以外にもいろいろ機能を持っているのですが、ここではファイル入出力機能についてのみ説明します。(というか私自身がよくわかってなかったりする... ^_^;)

"Keyed"が付くクラスと付かないクラスがありますが、両者の違いは、出力に関して言えばファイルへ出力するときのオブジェクトの持つインスタンス変数の出力の仕方で、前者が順序のみに依存して出力するため、読み込み時も書き込み時と同じ順序で読み込まないと正しくオブジェクトを復元することができないのに対して、後者は各インスタンス変数にキー文字列を付与して書き込むため、読み込み時もキーを指定して読み込むことで、ファイル内での順序に依存しない事や、変数の追加があったときにも対応しやすいと言う利点があります。(これも一種のKey-Value Coding?)

これらのクラスを使ってCocoaオブジェクトをファイルに入出力するには以下のメソッドを使用します。

// オブジェクトをファイルに出力する (NSArchiver / NSKeyedArchuver)
+ (BOOL)archiveRootObject:(id)rootObject toFile:(NSString *)path

// ファイルからオブジェクトを復元する (NSUnarchiver / NSKeyedUnarchiver)
+ (id)unarchiveObjectWithFile:(NSString *)path

なお、複数のオブジェクトがツリー構造で繋がっている場合、そのルートのオブジェクトを指定するだけで、配下のすべてのオブジェクトがファイルに出力されます。(読み込み時はその逆にオブジェクトのツリー構造の復元が行われます)

1.4 自作クラスオブジェクトのファイルヘの入出力

NSArchiver/NSUnarchiver(Keyedを含む)経由でファイル等への保存/復元を行いたいクラスは、NSCodingプロトコルに準拠し、かつプロトコルで規定されたメソッドを実装、その中に自クラスの持つインスタンス変数を、メソッドのパラメータとして渡されるNSCoderオブジェクトを使用して書き出し/読み込み処理を組み込む必要があります。
これは、NSArchiver等のクラスがオブジェクトの保存/復元を行う際に、各オブジェクトに対してNSCodingプロトコルで定められた手順に従って保存/復元要求(メソッド呼び出し)を行うため、アーカイブ対象となるオブジェクトはそれに応答する責任があるからです。

具体的にはアーカイブ対象のクラスはNSCodingで規定される以下のメソッドを実装する必要があります。

// 指定されたエンコーダを使用してオブジェクトを符号化する(書き込み時)
- (void)encodeWithCoder:(NSCoder *)encoder

// 指定されたデコーダを使用してオブジェクトを復元する(読み込み時)
- (id)initWithCoder:(NSCoder *)decoder

NSArchiver/NSUnarchiver等を使ったオブジェクトの書き出しおよび読み込み方法については以下のドキュメントに記載されています。

「Cocoa: Archives and Serializations」
http://developer.apple.com/documentation/Cocoa/Conceptual/Archiving/index.html

この中の以下のセクションにNSCodingで規定されたメソッド内での具体的なコーディング例が載っています。

  Encoding and Decoding Objects


2.保存機能の組み込み

モデルデータオブジェクトの保存機能の組み込み手順を以下に示します。

2.1 自前コントローラクラスの作成/インスタンス化とFiles' Ownerのdelegateとしての登録

まず、課題(1)に対応するために、nibパネル上でNSObjectのサブクラスとしてUCAppControllerという自前のコントローラクラスを作成、インスタンス化した後、File's Ownerのdelegateとしてリンクします。
なお、この後Outletを作成しますので、まだソースファイルへの書き出しは行わないでください。



2.2 自前コントローラクラスからNSArrayControllerへのOutletの追加/接続

データ保存・読み込み時にNSArrayControllerのインスタンスにアクセスする必要があるため、UCAppControllerクラスにOutlet(このサンプルでは_outBookCtrlと名付けています)を追加します。

次に、追加したOutletをNSArrayControllerのインスタンスであるBookControllerにリンクします。



2.3 自前コントローラクラスのソースファイル作成とコーディング

nibパネル上から自前コントローラクラス(UCAppController)のソースファイルを作成します。
作成が完了したらヘッダファイルに以下のようにコードを追加します。

UCAppController.h:自前コントローラクラスインターフェースファイル
@interface UCAppController : NSObject
{
    IBOutlet NSArrayController *_outBookCtrl;
}

//
// NSApplication class delegation methods
//
// アプリケーション起動完了通知
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification;

// アプリケーション終了通知
- (void)applicationWillTerminate:(NSNotification *)aNotification;

//
// Private methods
//
// データ保存ファイルパス名取得
- (NSString *)pathOfDataFile;

@end

最後の「pathOfDataFile」というメソッドはデータを保存するためのファイルパスを求めるために追加したプライベートメソッドです。(保存時も読み込み時もファイルパス取得は必要ですからね)

次にインプリメントファイルを以下のように修正します。

UCAppController.m:自前コントローラクラスインプリメントファイル
@implementation UCAppController

//
// NSApplication class delegation methods
//
// アプリケーション起動完了通知
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification
{
}

// アプリケーション終了通知
- (void)applicationWillTerminate:(NSNotification *)aNotification
{
    // データファイルパス取得
    NSString    *pstrPath = [self pathOfDataFile];

    // BookController内のコンテンツオブジェクト取得
    id poContent = [_outBookCtrl content];

    // コンテンツデータ保存
    BOOL bRet = [NSKeyedArchiver archiveRootObject:poContent toFile:pstrPath];
    if ( bRet != YES ) {
        NSLog( @"Save error.\n" );
    }
}

//
// Private methods
//
// データ保存ファイルパス名取得(Path = $HOME/Documents/CBTest.dat)
- (NSString *)pathOfDataFile
{
    NSString    *pstrPath;

    pstrPath = [NSHomeDirectory( ) stringByAppendingPathComponent: @"Documents"];
    return [pstrPath stringByAppendingPathComponent: @"CBTest.dat"];
}

@end

プライベートメソッドの「pathOfDataFile」は以下のパス文字列を返すようになっています。

  $HOME/Documents/CBTest.dat

現在ログインしているユーザのホームディレクトリ(環境変数$HOME)はNSHomeDirectory()という関数で求める事ができます。
現時点では保存のみの組み込みを行うのでアプリケーション起動完了通知メソッドは空のままとします。
終了通知メソッドでは以下の処理を行っています。

(1) 保存するファイルのパス名取得
(2) Outletのインスタンス変数からポイントされているNSArrayControllerオブジェクト内のコンテンツオブジェクトを取得する
(3) NSKeyedArchiverクラスの「archiveRootObject:toFile:」メソッドで(2)で取得したオブジェクトを(1)で取得したファイルに出力する

このサンプルではファイルへの書き出しにはNSKeyedArchiverを使用します。なおこのクラスはJaguar(10.2)で導入されたクラスです。

試しにこの時点でビルドして走らせて、いくつかデータを入力した後アプリを終了してみてください。
実行ログウィンドウに以下のようなエラーメッセージが表示されると思います。

2004-06-02 14:01:26.664 CBSample[795] *** -[UCBook encodeWithCoder:]: selector not recognized
2004-06-02 14:01:26.665 CBSample[795] Exception raised during posting of notification.  Ignored.  
exception: *** -[UCBook encodeWithCoder:]: selector not recognized

これは、独自に作成したモデルデータのクラスUCBookがNSCodingプロトコルで規定されているメソッドをまだインプリメントしていないため実行時エラーとなったものです。

2.4 クラスUCBookをNSCodingプロトコルに準拠させる

モデルデータのクラスUCBookをNSCodingプロトコルに準拠させるために、まずヘッダファイルに以下の修正を加えます。

UCBook.h:書籍情報クラスインターフェースファイル
@interface UCBook : NSObject <NSCoding>
{
    // インスタンス変数
    NSString    *_pstrTitle;            // タイトル
    NSString    *_pstrAuthor;           // 作者
    NSString    *_pstrComment;          // コメント
}

    **** 中略 ****

// コメント用
- (NSString *)comment;
- (void)setComment:(NSString *)pstrComment;

//
// データのArchive/Unarchive(NSCoding Protocol)
//
// 読み込み時
- (id)initWithCoder:(NSCoder *)decoder;

// 書き込み時
- (void)encodeWithCoder:(NSCoder *)encoder;

@end

変更点は、UCBookのインターフェース定義宣言にNSCodingプロトコルに準拠している事を明示する「<NSCoding>」の記述を追加した事と、NSCodingプロトコルで規定されている以下のメソッドの宣言を追加した事です。

 - (id)initWithCoder:(NSCoder *)decoder;
 - (void)encodeWithCoder:(NSCoder *)encoder;

次にインプリメントファイルを以下のように修正します。

UCBook.m:書籍情報クラスインプリメントファイル
●アーカイブ内でのインスタンス変数キー定義追加

// ファイル内での各項目のキー
#define KEY_TITLE        @"UCBKeyTitle"
#define KEY_AUTHOR       @"UCBKeyAuthor"
#define KEY_COMMENT      @"UCBKeyComment"


●NSCoding関連メソッドのインプリメント追加

//
// ファイルからのオブジェクト読み込み時
//
- (id)initWithCoder:(NSCoder *)decoder
{
    // dummy
    return self;
}

//
// ファイルへのオブジェクト書き込み時
//
- (void)encodeWithCoder:(NSCoder *)encoder
{
    // 注:親クラスであるNSObjectはencodeWithCoder:をインプリメントしていないので呼び出しは行わない

    // 各インスタンス変数の書き込み
    [encoder encodeObject:_pstrTitle   forKey:KEY_TITLE];
    [encoder encodeObject:_pstrAuthor  forKey:KEY_AUTHOR];
    [encoder encodeObject:_pstrComment forKey:KEY_COMMENT];
}

読み込み時のメソッドは現時点では空としておきます。
なお、クラスUCBookの親クラスであるNSObjectはencodeWithCoder:を実装していませんので親クラスの呼び出しは不要です。もし親クラスがNSCodingプロトコルを実装している場合は呼び出さなければなりません。

インスタンス変数オブジェクトの書き込みにはNSCoderの「encodeObject:forKey:」メソッドを使用していますが、このメソッドはNSKeyedArchiverでアーカイブを行うときのみ使用可能なメソッドです。
NSKeyedArchiverが存在しない10.1以前のサポートが必要な場合や、汎用的なクラスでNSArchiver/NSKeyedArchiverどちらが使用されても正しく動作する必要がある場合はAppleのドキュメントによると以下のようにコーディングする必要があります。

- (void)encodeWithCoder:(NSCoder *)coder
{
    [super encodeWithCoder:coder];
    
    if ( [coder allowsKeyedCoding] ) {
        [coder encodeObject:mapName forKey:@"MVMapName"];
        [coder encodeFloat:magnification forKey:@"MVMagnification"];
        [coder encodeObject:legendView forKey:@"MVLegend"];
        [coder encodeConditionalObject:auxiliaryView forKey:@"MVAuxView"];

    } else {
        [coder encodeObject:mapName];
        [coder encodeValueOfObjCType:@encode(float) at:&magnification];
        [coder encodeObject:legendView];
        [coder encodeConditionalObject:auxiliaryView];
    }

    return;
}

NSCodingのallowsKeyedCodingメソッドでキー指定による符号化が可能か確認し、それによって符号化方法を切り替える様です。
このサンプルではCocoaバインディング自体が10.3以降でないと動作しないこと、NSKeyedArchiverを使用することと決めていることから、キー指定による符号化固定とします。

2.5 保存機能を動かしてみる

これで保存に必要なコーディングはすべて完了したのでビルドして実行(データ入力&終了)してみましょう。
どうでしょうか。ホームの書類フォルダの下にCBTest.datというファイルが作成されれば保存成功です。


3.読み込み機能を組み込む

読み込み機能を組み込むには、書き込み機能の組み込み時にメソッドは定義したが、中身をコーディングしなかった以下のメソッドをインプリメントします。

 UCAppControllerの「applicationDidFinishLaunching:」
 UCBookの「initWithCoder:」

3.1 自前コントローラクラスにアプリ起動時の読み込み処理を追加

NSApplicationクラスのdelegateメソッドとしてアプリケーション起動時に呼び出されるメソッド「applicationDidFinishLaunching:」を以下のようにコーディングします。

// アプリケーション起動完了通知
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification
{
    // データファイルパス取得
    NSString    *pstrPath = [self pathOfDataFile];

    // 存在チェック
    NSFileManager   *poFM = [NSFileManager defaultManager];
    BOOL bRet = [poFM fileExistsAtPath:pstrPath];
    if ( bRet != YES ) {
        NSLog( @"Data file not found.\n" );
        return;
    }

    // コンテンツデータの読み込み
    id poContent = [NSKeyedUnarchiver unarchiveObjectWithFile:pstrPath];
    if ( poContent == nil ) {
        NSLog( @"Data load error.\n" );
        return;
    }

    // BookControllerへ設定
    [_outBookCtrl setContent: poContent];
}

起動通知メソッドでは以下の処理を行っています。

(1) データが保存されているファイルのパス名取得
(2) 一番最初に起動された時はデータファイルは存在しないので、存在チェックを行い、存在しなければ何もしない(詳細はNSFileManagerの仕様参照)
(3) NSKeyedUnarchiverクラスの「unarchiveObjectWithFile:」メソッドで(1)で取得したファイルから保存時のオブジェクトの復元を行う
(4) Outletのインスタンス変数からポイントされているNSArrayControllerオブジェクト内のコンテンツとして、(3)で取得したオブジェクトを「setContent:」メソッドで設定する

このサンプルではファイルへの書き出しにはNSKeyedArchiverを使用しましたので読み込みにはペアとなるNSKeyedUnarchiverを使用します。

3.2 クラスUCBookにNSCodingの読み込み用メソッドを実装

先ほどはダミーとしていた「initWithCoder:」に具体的に以下のインスタンス変数の読み込み処理を組み込みます。

//
// ファイルからのオブジェクト読み込み時
//
- (id)initWithCoder:(NSCoder *)decoder
{
    // 注:親クラスであるNSObjectはinitWithCoder:をインプリメントしていないのでinitの呼び出しだけとする
    self = [super init];

    // 各インスタンス変数の読み込み
    _pstrTitle   = [[decoder decodeObjectForKey:KEY_TITLE] retain];
    _pstrAuthor  = [[decoder decodeObjectForKey:KEY_AUTHOR] retain];
    _pstrComment = [[decoder decodeObjectForKey:KEY_COMMENT] retain];

    return self;
}

クラスUCBookの親クラスであるNSObjectはinitWithCoder:を実装していませんので、そのまま親クラスにメソッドをパスすることはできません。しかし、このメソッドはオブジェクトの初期化処理でもあるため、initWithCoder:ではなく、initメソッドを送ってオブジェクトの初期化を行っています。
もし親クラスがNSCodingプロトコルを実装している場合はinitWithCoder:を呼び出さなければなりません。

インスタンス変数オブジェクトの書き込みにはNSCoderの「decodeObjectForKey:」メソッドを使用していますが、このメソッドはNSKeyedUnarchiverでアンアーカイブを行うときのみ使用可能なメソッドです。

3.3 Finish!!

以上でモデルデータの保存/読み込み処理の組み込みはすべて完了しました。
まずはビルドして走らせてみてください。「2.5 保存機能を動かしてみる」で保存した内容が画面に現れれば読み込みも成功です。

長々とした説明でしたが、おつきあいいただきありがとうございました。m(__)m

サンプルプログラムのダウンロード(1.5MB)

【作成・確認環境】
Mac OS X v10.3.4
Xcode v1.2

一覧に戻る