Fork me on GitHub

iOS Practice

iOSアプリ開発におけるプラクティス集

See also

Objective-Cによるプログラミング
AppleによるObjective-Cでのベストプラクティスについてかかれた文章
github/objective-c-conventions
GithubによるObjective-Cのコーディングルール

サンプルコード

サンプルコードは azu/ios-practice · GitHub のCodeディレクトリにあります。

git clone https://github.com/azu/ios-practice.git
cd ios-practice/Code && open ios-practice.xcodeproj

Contents:

プロパティとインスタンス変数(ivar)

github/objective-c-conventions をベースとして書かれています。

getter/setter, init, dealloc 以外でivarにアクセスしない

インスタンス変数にアクセスする時は、基本的にgetter/setter(アクセッサメソッド、プロパティ)を経由してアクセスすべきです。

インスタンス変数を直接使わない理由としては、 インスタンス変数を直接使う場合は不必要なretain等、参照カウントを操作するコードが必要になり見通しが悪くなる事や、 KVO(Key-Value Observing)が使えない事や、アクセッサメソッドを経由しないため変更に弱い部分があることなどがあげられます。

以下の条件を満たしているならインスタンス変数を直接使っても問題は無いですが、統一性という観点から インスタンス変数を直接参照するのは -init-dealloc 以外では避けるべきです:

1.Is declared in a class using ARC.
2.Is used solely for class implementation (not exposed as part of the class interface).
3.Does not require any KVO.
4.Does not require any custom getter/setter.
http://stackoverflow.com/questions/7836182/property-vs-ivar-in-times-of-arc

逆に -init-dealloc ではインスタンス変数を参照するべき理由は、 下記の記事に詳細に書かれています。

単純にまとめると、プロパティでアクセスすためには self が存在していないといけないため、 その self が初期化(init)、破棄(dealloc)されているかを -init-dealloc 内では気をつけないといけません。

そのため、-init-dealloc 内では プロパティを使わない とすることで単純化できるため、そういう習慣としています。

外から書き込む必要のないプロパティはreadonly属性にする

Objective-C のプロパティにはreadonly属性が指定できます。 そのプロパティのスコープを小さくするために、外から書き込みが必要のないプロパティにはreadonly属性を指定しましょう。

/Code/ios-practice/ReadOnly.h

#import <Foundation/Foundation.h>

@interface ReadOnly : NSObject {
}

@property(nonatomic, strong, readonly) NSArray *array;

@end

/Code/ios-practice/ReadOnly.m

#import "ReadOnly.h"

@implementation ReadOnly {

@private
    NSArray *_array;
}

@synthesize array = _array;

- (id)init {
    self = [super init];
    if (!self){
        return nil;
    }
    // initで一回のみ初期化
    _array = [NSArray arrayWithObjects:@"readonly", @"first", @"init", nil];

    return self;
}

@end

また、readonly属性を指定すると、そのクラス内部からも self.array = @[]; のような代入はできなくなりますが、 クラスエクステンションを使いプライベートカテゴリ内で、プロパティに readwrite を付けることで、 外からはreadonlyだけど、中からはreadwriteのプロパティを作ることができます。

インスタンス変数には接頭辞に_を付ける

インスタンス変数とプロパティの名前が被らないようにするため、頭か末尾に_(アンダーバー)を付けると思いますが、 基本的には統一されていることが大事なのでどちらでも構いませんが、 最近のAppleのドキュメントでは接頭辞に_を付けることを推奨しているため、新規に書くコード等はこの作法に則った方が良いでしょう。

インスタンス変数とプロパティのまとめ

ここまでをまとめるてみると

/Code/ios-practice/Property.h

#import <Foundation/Foundation.h>

@interface Property : NSObject

@property(nonatomic, strong, readonly) NSArray *array;// 必須なのはここのみ

@end

/Code/ios-practice/Property.m

#import "Property.h"

@interface Property ()

@property(nonatomic, strong, readwrite) NSArray *array;// readwriteを付ける
// 外から見えないようにするなら@interfaceのプロパティを消す
@end

@implementation Property {
// @implementにインスタンス変数が書けるようになったのはXcode 4.2から
@private
    NSArray *_array;// Xcode 4.4からここは省略してもいい
}

@synthesize array = _array;// XCode4.5からはここも省略できる

- (id)init {
    self = [super init];
    if (!self){
        return nil;
    }
    // 初期化
    _array = [NSArray arrayWithObjects:@"a",@"b", nil];

    return self;
}


@end

Xcodeのバージョン(Clang)が上がるに連れて省略出来る箇所が増えてきているので、 一般に理解できるであろう最低限の記述を考えながら書いていくとよい。

Note

January 22, 2016 現在では、@interface の @property 宣言だけでもコンパイルは問題なく通る。 どこまで省略するかは周りのチームの人に合わせて行うのがよいと思われる。

不必要なivarは宣言しない

そのインスタンス変数が本当に必要なのか、ローカル変数で間に合わないかを検討しましょう。

コードの構造について

if/for文は常に波括弧 {} をつける

ifやfor等、波括弧 {} を省略できるものがありますが、基本的に省略せずにかきましょう

// Bad!
if (something)
        // do something

// Good!
if (something) {
        // do something
}

Note

if (something) return; というようにスグにreturnするだけの場合は省略しても悪くは無いが、 出来れば付けていたほうがよい。

比較は常に厳密に行う

NSStringを比較する場合 == で文字列を比較するのは避けるべきです。 == による比較は文字列としての比較ではなく、同じポインタの値かどうかの比較になります。

文字列の比較を行う場合は isEqualToString: メソッドを使い比較します。

/Code/Tests/CompareTests.m

#import <SenTestingKit/SenTestingKit.h>
#import <OCHamcrestIOS/OCHamcrestIOS.h>

@interface CompareTests : SenTestCase

@end

@implementation CompareTests

- (void)testEqual {
    NSString *one = @"1";
    NSString *eins = [NSString stringWithFormat:@"%c", '1'];
    BOOL isEqualOperator = (one == eins);// ポインタ値は一致しない
    assertThatBool(isEqualOperator, equalToBool(NO));
    BOOL isEqualMethod = [one isEqualToString:eins];// 文字列的には一致する
    assertThatBool(isEqualMethod, equalToBool(YES));
}
@end

Note

定義済みの文字列定数同士を == で比較した場合は問題無いですが、 それを考えて行うのは難しい所もあるので、基本的に文字列の比較はメソッドを使って行う方がよい。

一般にオブジェクト同士の値を比較する場合は適当な isEqual*:compare: といったメソッドが用意されているはずなので、 それを利用し比較するべきです。(intやNSUInteger等のプリミティブな値はC言語と同様に == で比較して問題ありません)

See also

Objective-Cによるプログラミング P40
“オブジェクトの等価性を判断する” に == , isEqual: , compare: について書かれている。

真偽値の比較は省略する

BOOL型等の真偽値をif文等において比較するのは、あまり意味がなく冗長になるだけなので、 避けるべきです。

BOOL hasYES = YES;
// Bad!
if(hasYES == YES){ /* somthing */ }
// Good!
if(hasYES){ /* somthing */ }

StoryBoardの使用

iOS5以降を対象とした場合に、StoryBoardを利用する事のデメリットはXibを直接使う場合に比べて少ないです。

StoryBoardを使うことで以下のようなメリットも得られるので、すべての画面において特殊なUIが求められない場合は StoryBoardを使ったほうが効率がよくなると思います。

  • Static Cell
  • iOS6以降のContainer View
  • Segueでの遷移

画面間で値を渡す場合にXibの時のようにインスタンスを作って設定する方法はStoryBoardでも運用的カバーできます。

UITableViewについて

TableViewにおいていくつか気をつけておくと良いことがあります。

下記を参考に書いています。

Cellの表示更新を別のメソッドに分ける

tableView:cellForRowAtIndexPath: のdelegateメソッドでそれぞれのUITableViewCellを生成しますが、 このメソッド内で、Cell内容を更新する処理を直接書くのは避けましょう。

- (void)updateCell:(UITableViewCell *)cell atIndexPath:(NSIndexPath *)indexPath {
    // Update Cells
}
- (UITableViewCell *)tableView:(UITableView *)tableView
                     cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    NSString *cellIdentifier = @"Cell";
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:cellIdentifier];
    if (!cell){
        cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:cellIdentifier];
    }
    // Update Cell
    [self updateCell:cell atIndexPath:indexPath];

    return cell;
}

updateCell:atIndexPath: といったCellの更新を行うメソッドを用意し、 tableView:cellForRowAtIndexPath: からそのメソッドを呼び表示の更新を行う事で、表示更新処理を分離できます。

なぜ、このように表示更新処理を分離するかというと、 Cellの表示内容を変えようと思った場合に、 [self.tableView reloadData]; として、 tableView:cellForRowAtIndexPath: で全てのCellを生成し直すのはあまり効率的ではありません。

表示の更新だけを行うメソッドを用意しておけば、以下のように見えている部分だけの表示更新なども簡単に行う事ができます。

// 画面上に見えているセルの表示更新
- (void)updateVisibleCells {
    for (UITableViewCell *cell in [self.tableView visibleCells]){
        [self updateCell:cell atIndexPath:[self.tableView indexPathForCell:cell]];
    }
}

Cellに直接Viewを追加しない

Cellに対して addSubView: でUIButtonやLabelといったViewを追加する事ができるが、 UITableViewCellのインスタンスに直接ではなく、cell.contentViewに追加する。

TableViewCellの構成要素

Cellに対して直接追加した場合、編集モードなどにおいて適切な動作にならない事があるため、 Viewを追加するのはcell.contentViewの方にする。

UITableViewCell *cell = [[UITableViewCell alloc] init];
UILabel *label = [[UILabel alloc] init];
// Bad!!
[cell addSubview:label];
// Good!
[cell.contentView addSubview:label];

ControllerでCellにaddSubView:するのを避ける

可能ならば、Controller上で上記のようにaddSubview:等をしてCellをカスタマイズするのは避けたほうがよい。

最初の表示更新メソッドが利用しにくくなることや dequeueReusableCellWithIdentifier: によるセルのキャッシュも利用しくくなるため、 UITableViewCellのサブクラス(いわゆるカスタムセル)を作り、Cellの拡張したViewを作る方がよい。

Controller上で [cell.contentView addSubview:] した場合に起きる問題点としては、 そのcellが dequeueReusableCellWithIdentifier: により再利用され、再びaddSubview:が行われ、 cellに対して複数回Viewが追加されてしまう事が起こってしまう。

そのため、以下のような目に見える問題やメモリ効率的にもあまり良くない場合が多い。

  1. UITableViewのセルの値がスクロールするごとに重なったり壊れる現象 - hachinobuのメモ
  2. UITableViewでセル再描画時に文字が重ならないようにする « sabitori works
  3. UITableViewCell セルの再利用の問題 | DevCafeJp

この問題を解決するには、以下のような方法がある。

  1. キャッシュをしない(or identifierをCell毎に変える)
  2. addSubViewする前に、CellのsubViewを除去する
  3. CellのtextLabelやaccessoryView等のデフォルトのセルコンテンツで表示する
  4. カスタムセルを作って利用する

1と2は 上記のリンクのような方法であるが、 3と4のような手法を使い表示したほうが、コード的にも綺麗に書くことができ、バグも減ると思われる。

次は3と4の手法についてあたっていく

UITableViewCellデフォルトのセルコンテンツの利用

セルコンテントの構成要素

UITableViewCellデフォルトのセルコンテンツ

UITableViewCellオブジェクトにはデフォルトでセルコンテンツ用に次のプロパティが定義されています。

  • textLabel — セル内のテキストのメインラベル(UILabelオブジェクト)
  • detailTextLabel — セル内のテキストのサブラベル(UILabelオブジェクト)
  • imageView - 画像を保持する画像ビュー(UIImageViewオブジェクト)
  • accessoryView - アクセサリビュー(UIViewオブジェクト)

textLabelとdetailTextLabelの配置はUITableViewCellStyle(4種類)によって異なるので、下記を参考にして下さい。

accessoryViewは見落としがちですが、Cellの右側に任意のUIViewオブジェクト(UILabelやUIButtonもUIViewを継承してる)を配置できるので、 色々と使い道があります。

凝った表示を求めない場合は、これらのデフォルトセルコンテンツを使い解決出来る場合が多いため、 まずは、デフォルトセルコンテンツで解決できないかを考えてみるとよいです。

デフォルトのセルコンテンツを利用したサンプルはCodeのTableViewに入っています。

/Code/ios-practice/tableView/MyTableViewController.m

#import "MyTableViewController.h"

#define kCellIdentifier @"CellIdentifier"

@interface MyTableViewController ()

@property(nonatomic, retain, readwrite) NSArray *dataSource;

@end

@implementation MyTableViewController {
@private
    NSArray *_dataSource;
}

@synthesize dataSource = _dataSource;

- (id)initWithStyle:(UITableViewStyle)style {
    self = [super initWithStyle:style];
    if (!self){
        return nil;
    }
    return self;
}

#pragma mark - View lifecycle
- (void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];
    // deselect cell
    [self.tableView deselectRowAtIndexPath:[self.tableView indexPathForSelectedRow] animated:YES];
    // update dataSource
    [self updateDataSource];
    // update visible cells
    [self updateVisibleCells];
}

- (void)updateDataSource {
    self.dataSource = [NSArray arrayWithObjects:@"tableview", @"cell", @"custom cell", nil];
}

#pragma mark - Cell Operation
- (void)updateVisibleCells {
    // 見えているセルの表示更新
    for (UITableViewCell *cell in [self.tableView visibleCells]){
        [self updateCell:cell atIndexPath:[self.tableView indexPathForCell:cell]];
    }
}

// Update Cells
- (void)updateCell:(UITableViewCell *)cell atIndexPath:(NSIndexPath *)indexPath {
    // imageView
    cell.imageView.image = [UIImage imageNamed:@"no_image.png"];
    // textLabel
    NSString *text = [self.dataSource objectAtIndex:(NSUInteger) indexPath.row];
    cell.textLabel.text = text;
    NSString *detailText = @"詳細のtextLabel";
    cell.detailTextLabel.text = detailText;
    // arrow accessoryView
    UIImage *arrowImage = [UIImage imageNamed:@"arrow.png"];
    cell.accessoryView = [[UIImageView alloc] initWithImage:arrowImage];
}
//--------------------------------------------------------------//
#pragma mark -- UITableViewDataSource --
//--------------------------------------------------------------//
- (NSInteger)tableView:(UITableView *)tableView
             numberOfRowsInSection:(NSInteger)section {
    return [self.dataSource count];
}

- (UITableViewCell *)tableView:(UITableView *)tableView
                     cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    NSString *cellIdentifier = kCellIdentifier;
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:cellIdentifier];
    if (!cell){
        cell = [[UITableViewCell alloc]
                                 initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:cellIdentifier];
    }
    // Configure the cell...
    cell.accessoryType = UITableViewCellAccessoryNone;
    [self updateCell:cell atIndexPath:indexPath];

    return cell;
}

//--------------------------------------------------------------//
#pragma mark -- UITableViewDelegate --
//--------------------------------------------------------------//

- (void)tableView:(UITableView *)tableView
        didSelectRowAtIndexPath:(NSIndexPath *)indexPath {

    // ハイライトを外す
    [tableView deselectRowAtIndexPath:indexPath animated:YES];
}
@end

上記のコードでは、デフォルトのセルコンテンツにそれぞれ指定をしています。

MyTableViewの実行結果

カスタムセルを利用する

利点としては見た目について扱うものが分離できるためコードが綺麗になる事や、 Interface Builderを使い見た目を決定できるため細かい調整が簡単になることがある。

カスタムセルの作り方は下記の記事を見るといい。

See also

シンプルなカスタムセルの作り方とセル内のボタンへのイベント設定方法
xibを使ったカスタムセルとセル内部のUIButtonのイベント設定方法について
TableViewでDynamic PrototypesだけどStatic Cellsのように見た目をGUIで作成する方法 | Technology-Gym
カスタムセルもUITableViewController上で作る方法を利用した例

UILocalNotificationを使った通知の設定について

UILocalNotification を使ったローカル通知の設定方法について

設定済みの通知をキャンセルしてから設定し直す

ローカル通知が重複して登録されてしまうことがあるため、基本的に設定済みのローカル通知をキャンセルしてから 通知を設定し直した方が管理が楽になります。(複数の通知がある場合はそれを設定し直す)

[[UIApplication sharedApplication] cancelAllLocalNotifications];

通知を設定する期間の問題

アプリによって設定する通知は時間や繰り返しなど様々だと思いますが、 遠い未来や無限に繰り返す内容の通知をそのまま設定するのは無理がでてきます。

そのため、現在の情報をもとに1週間から1ヶ月程度の範囲に通知だけを設定する等の制限を設けたほうがいいと思います。 (これは上記の毎回キャンセルしてから設定するのと相性がいいです)

そして、アプリを起動 or 終了 した時などに、通知を設定し直すことで、 ずっと放置してる場合は通知がでなくなりますが、使っている人に対しては通知が継続されるような仕組みが自然とできると思います。

通知の発火時間をチェックしてから通知の設定を行う

ローカル通知を設定する際は、必ず通知の fireDate プロパティが、現在時間より後なのかを 確認してから設定するべきです。

現在時間より前に通知を設定すると、設定した瞬間に通知が発火してしまいます。

/Code/ios-practice/LocalNotification/LocalNotificationManager.m

#import "LocalNotificationManager.h"

@implementation LocalNotificationManager

+ (void)setNotificationAtDate:(NSDate *) fireDate {
    // 通知時間 < 現在時 なら設定しない
    if (fireDate == nil || [fireDate timeIntervalSinceNow] <= 0) {
        return;
    }
    // 設定する前に、設定済みの通知をキャンセルする
    [[UIApplication sharedApplication] cancelAllLocalNotifications];
    // 設定し直す
    UILocalNotification *localNotification = [[UILocalNotification alloc] init];
    localNotification.fireDate = fireDate;
    localNotification.alertBody = @"Fire!";
    localNotification.timeZone = [NSTimeZone localTimeZone];
    localNotification.soundName = UILocalNotificationDefaultSoundName;
    localNotification.alertAction = @"OPEN";
    [[UIApplication sharedApplication] scheduleLocalNotification:localNotification];
}
@end

通知を設定するのはいつ?

ローカル通知は必ず、通知を管理するクラスを経由して設定すべきですが、 いつ設定するのがいいのかという問題もあります。 (毎回、UILocalNotificationを書くのはバッドプラクティスだと思います)

データを保存した際に通知を設定すると、保存するコードごとに通知について書かないといけなくなる事や、 “設定済みの通知をキャンセルしてから設定し直す” というパターンとも相性があまり良くありません。

比較的シンプルに書けるのが、アプリがバックグラウンドに行く時に設定するパターンです。

- (void)applicationDidEnterBackground:(UIApplication *)application {
    // 通知の設定クラスを呼び出す
}

メリットとしては、同期的に通知を設定してもUIスレッドに対しての影響が少ない事や、 アプリのライフサイクル的に、大体の場合はここを通るので、保存するごとに通知を設定しないで、 applicationDidEnterBackground のみで設定すればよくなるためコードもシンプルになります。

デメリットとして、アプリ表示中にローカル通知を受け取って表示する( application:didReceiveRemoteNotification: )など、 保存した時にローカル通知を付けないといけないような条件があるときには利用できないパターンです。

別スレッドで通知を設定する場合

ローカル通知の設定は同期的に行われるので大量に設定する場合は、dispatch_async等を使い、 UIが固まらないように設定すればいいです。

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
    // 通知を設定する処理
    dispatch_async(dispatch_get_main_queue(), ^{
        /* メインスレッドでのみ実行可能な処理 */
    });
});

しかし、別スレッドで設定する際、通知を設定する基準を決めるデータを取るために、 CoreData等スレッドセーフではないもの等を触る時に問題が起きやすいことがあります。

そのような場合は、同期的に設定できてUIスレッドを意識しないでシンプルに行える、アプリがバックグラウンドに移行する時がお手軽です。

通知登録の管理クラス

実際に動かせるサンプルプロジェクトは以下にあります。 (通知登録のテストについても書いてあるので、ここにでてくるものとは少し違います)

See also

azu/LocalNotificationPattern
これまででてきたパターンを使った通知登録管理クラスのサンプルプロジェクト

ローカル通知は一箇所にまとめておくと見通しがよく管理がしやすくなります。 これまで、でてきたパターンをまとめたようなクラス LocalNotificationManager というものを見てみます。

@interface LocalNotificationManager : NSObject
// ローカル通知を登録する
- (void)scheduleLocalNotifications;
@end

@implementation LocalNotificationManager
#pragma mark - Scheduler
- (void)scheduleLocalNotifications {
    // 一度通知を全てキャンセルする
    [[UIApplication sharedApplication] cancelAllLocalNotifications];
    // 通知を設定していく...
    [self scheduleWeeklyWork];
}

// 例: weeklyWorkSchedule の時間を通知登録する
- (void)scheduleWeeklyWork {
    // ...
    // makeNotification: を呼び出して通知を登録する
}

#pragma mark - helper
- (void)makeNotification:(NSDate *) fireDate alertBody:(NSString *) alertBody userInfo:(NSDictionary *) userInfo {
    // 現在より前の通知は設定しない
    if (fireDate == nil || [fireDate timeIntervalSinceNow] <= 0) {
        return;
    }
    [self schedule:fireDate alertBody:alertBody userInfo:userInfo];
}

- (void)schedule:(NSDate *) fireDate alertBody:(NSString *) alertBody userInfo:(NSDictionary *) userInfo {
    // ローカル通知を作成する
    UILocalNotification *notification = [[UILocalNotification alloc] init];
    [notification setFireDate:fireDate];
    [notification setTimeZone:[NSTimeZone systemTimeZone]];
    [notification setAlertBody:alertBody];
    [notification setUserInfo:userInfo];
    [notification setSoundName:UILocalNotificationDefaultSoundName];
    [notification setAlertAction:@"Open"];
    [[UIApplication sharedApplication] scheduleLocalNotification:notification];
}
@end

AppDelegate から以下のようにバックグラウンドへ移行する際に呼び出して使います。

- (void)applicationDidEnterBackground:(UIApplication *) application {
    // バックグラウンドに移行際に通知を設定する
    LocalNotificationManager *notificationManager = [[LocalNotificationManager alloc] init];
    [notificationManager scheduleLocalNotifications];
}

- (void)scheduleLocalNotifications; を呼び出すと、まず全ての通知をキャンセルしてから通知を登録していくようになっています。

それぞれの通知は、種類ごとなどでメソッドにまとめておくとテストがしやすくなると思います。 また、実際に通知を登録するときは - (void)makeNotification:(NSDate *) fireDate を経由して、 通知の登録時間が過去になってないかをチェックしてから登録するようにしています。

ローカル通知登録のテストについて

UILocalNotification は名前に UI とついてるように、ロジックテストだと通知が登録出来ないためテストがしにくくなっています。

アプリケーションテストの場合は動作します。 (現在のXcodeはアプリケーションテストがデフォルトなので、あまり問題ないのかもしれませんが)

See also

iOS Unit Test
ロジックテストとアプリケーションテストの違いについて

それでも、ここはモックなりで潰してコントロールできたほうが嬉しいので、 以下のような通知を登録したふりをするspyとなるクラスを作ります。

実際のサンプルは azu/LocalNotificationPattern を見て下さい。

// テスト用にLocalNotificationManagerを継承したモッククラスを作る
@interface LocationNotificationManagerSpy : LocalNotificationManager
@property(nonatomic) NSMutableArray *schedules;
// helper
- (UILocalNotification *)notificationAtIndex:(NSUInteger) index;
// overwrite
- (void)schedule:(NSDate *) fireDate alertBody:(NSString *) alertBody userInfo:(NSDictionary *) userInfo;
@end

@implementation LocationNotificationManagerSpy
- (NSMutableArray *)schedules {
    if (_schedules == nil) {
        _schedules = [NSMutableArray array];
    }
    return _schedules;
}

- (UILocalNotification *)notificationAtIndex:(NSUInteger) index {
    if (index < [self.schedules count]) {
        return self.schedules[index];
    }
    return nil;
}

// 通知を登録するメソッドを乗っ取り、呼ばれたことを記録する(いわゆるspy)
- (void)schedule:(NSDate *) fireDate alertBody:(NSString *) alertBody userInfo:(NSDictionary *) userInfo {
    UILocalNotification *notification = [[UILocalNotification alloc] init];
    notification.fireDate = fireDate;
    notification.alertBody = alertBody;
    notification.userInfo = userInfo;
    [self.schedules addObject:notification];
}
@end

実際にテストを行う際には、 LocalNotificationManager ではなく、 それを継承した LocationNotificationManagerSpy を使うことで、通知登録が呼ばれたことを記録できるようになります。

ローカル通知のテストケース

先ほどの、 LocalNotificationManager.m では、 実装されていなかった実際の登録内容を決める - (void)scheduleWeeklyWork を以下のように実装します。

[self.scheduleDataSource weeklyWorkSchedule]; を呼び出す事で、 NSDateの配列を返してくれるようにして、 それをスケジュールを登録するという感じになっています。

// 例: weeklyWorkSchedule の時間を通知登録する
- (void)scheduleWeeklyWork {
    // NSDateの配列が返ってくる
    NSArray *schedules = [self.scheduleDataSource weeklyWorkSchedule];
    for (NSDate *date in schedules) {
        [self makeNotification:date alertBody:@"Work Schedule" userInfo:@{
            @"key" : LocalNotification.weeklyWork
        }];
    }
}

テストする際には、 [self.scheduleDataSource weeklyWorkSchedule] をテストコード内で、 モックにすり替えればいいので、以下のようにOCMockObjectを使って、 self.scheduleDataSource をモックに変更しています。

Note

userInfo に 通知の種類毎のkeyを指定する事で、どの通知から起動したのかを判別することができる

こうすることで、テスト時に任意の NSDate を通知登録させられるのでテスト側から制御しやすくなります。

@implementation LocalNotificationManagerTest {
}
- (void)setUp {
    [super setUp];
    self.managerSpy = [[LocationNotificationManagerSpy alloc] init];
}

- (void)tearDown {
    self.managerSpy = nil;
    [super tearDown];
}

- (void)testWeeklyWorkSchedule {
    // 通知に登録される日付オブジェクト
    NSDate *expectedDate = [[NSDate date] dateByAddingDays:5];
    NSArray *expectedScheduleDates = @[
        expectedDate
    ];
    // データソースをモックに差し替える
    id dataSourceMock = [OCMockObject mockForClass:[ExampleScheduleDataSource class]];
    [[[dataSourceMock stub] andReturn:expectedScheduleDates] weeklyWorkSchedule];
    self.managerSpy.scheduleDataSource = dataSourceMock;
    // 通知を登録
    [self.managerSpy scheduleLocalNotifications];
    // 期待するもの
    // self.managerSpy.schedules には登録されるUILocalNotificationが入る
    for (UILocalNotification *localNotification in self.managerSpy.schedules) {
        STAssertEquals(localNotification.fireDate, expectedDate,
        @"通知に登録されたものはexpectedDateである");
    }
}
@end

LocationNotificationManagerSpy には self.managerSpy.schedules というように、 通知登録した内容を記録するプロパティがあるので、これの中身を検証すれば、ロジックテストからも UILocalNotification のテストが行えます。

See also

Intro to Objective-C TDD [Screencast] - Quality Coding
今回のようなプロパティで依存するDataSourceを注入してモックですり替える方法について詳しく解説した動画
azu/LocalNotificationPattern
通知登録管理クラスのサンプルプロジェクト

データの保存方法について

iOSに複数のデータ保存方法が用意されているが、それは得意/不得意があるため、 目的にあった保存方法を正しく選択できる事が重要である。

一度決めた保存方法から別の方法へ変更するには面倒な事が多いため、適切なものを選べるようにしておくべきである。

See also

iOS でデータを永続化する方法 - A Day In The Life
iOSでのデータ保存方法について詳しく書かれている

CoreDataについては別途下記を参照

CoreDataについて

Todo

全体的にもう少し詳しく書くべき

mogenerator でモデルクラスを作成

mogenerator はCoreData定義を書くの .xcdatamodel ファイルから自動的にモデルクラスを作成するツールである。

Xcodeも Editor ‣ Create NSManagedObject SubClass で類似の機能が存在するが、 mogeneratorを使い生成した方が、拡張性等が優れているためこちらを利用する。

Todo

mogeneratorの親クラスを利用したサブクラスの作り方について

MagicalRecord のススメ

CoreDataをもっと使いやすく、ハマりどころを減らすために MagicalRecord ライブラリの使用を推奨。

少なくともCoreDataを扱う際にはManagerとなるクラスを経由するようにして利用しないと、 不必要にmanagedObjectContextが複数作成されてしまうことなどが発生してしまう可能性があるため、統一的な利用方法を定めておくべきである。

Entityにidentifier属性を作成する

Entityには任意の属性を作成できるが、どのEntityにも必ず identifier 属性を作成する。 また、そのidentifierにはUUIDを代入するようにしておく。

UUIDの代入を自動的に行えるようにするにはmogeneratorを利用し作成した拡張用のサブクラスで -awakeFromInsert をoverwriteし代入するようにしておくといい。

CoreDataではObjectIDという識別子が振られているがこれは内部的に使われるもののため、 外部から一意に特定するためにidentifier属性を付けることで、検索時にUUIDを使い探索が簡単に行えるようになる。

Indices and tables