NSUserDefaults の値をプロパティアクセスできるようにする

2014年1月8日水曜日 | Published in | 1 コメント

このエントリーをはてなブックマークに追加

Defaults値を保存したい。あるいは参照したい。ベタには必要な箇所で書く(あちこち)。
[[NSUserDefaults.standardUserDefaults setObject:@"hoge" forKey:@"name"];
でもキー名を直接書くわけにもいかず定数定義のヘッダを作る。
[[NSUserDefaults.standardUserDefaults setObject:@"hoge" forKey:KEY_NAME];
でも synchronize を忘れたりするのでいっそのこと独自クラスを作ってそちらで管理する。
[myDefaults setObject:@"hoge" forKey:KEY_NAME];
でも定数定義が毎回面倒。
なによりも設定/参照のコードが長い。なんかかえって手間が増えてるような。。もちろん一括管理で後でのメンテはしやすい。でもなんかちがう感が。

本当はプロパティでアクセスできるのがベスト。こんなイメージ。
myDefaults.name = @"hoge"
こうすると自動的に NSUserDefaults へ書き込んでくれる。
これなら定数定義も不要だし、コード補完にコンパイルチェックもできる。何よりも書いていて気分いい。



そんなわけで今回はこんなオレオレデフォルトを作ってみる。

完成イメージはこう
@interface UserDefaults : NSObject

@property (weak, nonatomic) NSString* string;
@property (assign, nonatomic) BOOL flag;
@property (assign, nonatomic) NSInteger integer;

+ (instancetype)sharedDefaults;

@end
プロパティの string や flagへ代入すると NSUserDefaults へ書き込み、逆に参照すると NSUserDefaults から読みだして返す。格納時のキー名はプロパティ名となる(例えば @"name")。


これを実現するにはプロパティへの書き込み・読み出しをフックする必要がある。プロパティへの書き込みをフックできれば、そのタイミングで渡された値をメンバ変数へ格納する代わりに NSUserDefaults へ書き出すことができる。

幸い Objective-C/Cococa はメソッドのフォワーディング機構があり、それを利用することでこれを実現できる。フォワーディングの利用アイディアは下記がとても参考になった。

NSProxy を使って UIWebView のイベントハンドリングをフックする

上記からコードを少々拝借して UserDefaultsProxy という NSProxy のサブクラスを作る。
@interface UserDefaultsProxy : NSProxy
@property (strong, nonatomic) NSObject* target;
- (instancetype)initWithTarget:(NSObject *)target;
@end

@implementation UserDefaultsProxy

- (instancetype)initWithTarget:(NSObject *)target
{
    self.target = target;
    return target ? self : nil;
}

UserDefaults.sharedDefaults ではこのプロキシのインスタンスを返すようにする。
@implementation UserDefaults
//@dynamic name;    // not work

+ (instancetype)sharedDefaults
{
    static UserDefaults* _sharedDefaults = nil;

    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        _sharedDefaults = (UserDefaults*)[[UserDefaultsProxy alloc] initWithTarget:UserDefaults.new];
    });
    return _sharedDefaults;
}@end
こうすると UserDefaults へのメッセージはすべて UserDefaultsProxy へ送られるようになる(メッセージ転送)。つまりプロパティアクセスは実際には UserDefaultsProxy が処理することになる。

UserDefaultsProxy は UserDefaults で実装されているプロパティ(実体はsetter/getterメソッド)を実装していないので普通はこのままだとランタイムエラーになるが、NSProxy(NSObject)で用意されている forwardInvocation: を実装しておくと、未実装のメソッド呼び出しはすべてここでハンドリングすることができるようになる。つまりここで NSUserDefaults へ書き込み・読み出ししてやればいいわけだ。

(イメージ)
UserDefaults.sharedUserDefaults.name = @"hoge";

↓ メッセージ(セレクタ) setName:

-[UserDefaultsProxy forwardInvocation:]

実際には forwardInvocation: と合わせて methodSignatureForSelector: も実装してセレクタ→シグネチャ変換の定義をしておく必要もある。シグネチャ(引数、戻り値の型情報)は元のクラス(UserDefaults)が持っているので取っておいた self.target のものを返してやればいい。
- (NSMethodSignature*)methodSignatureForSelector:(SEL)sel
{
    return self.target ? [self.target methodSignatureForSelector:sel] : [super methodSignatureForSelector:sel];
}


さていよいよ forwardInvocation: の実装
- (void)forwardInvocation:(NSInvocation *)invocation
{
    if (self.target) {
// [1]
        invocation.target = self.target;
        // not call [invocation invoke];

// [2]
       NSString* selectorName = NSStringFromSelector(invocation.selector);
        NSString* key = nil;

// [3]
        if (invocation.methodSignature.numberOfArguments > 2) {

// [4]
            // setter
            // "set" +  + ":"
            key = [selectorName substringWithRange:NSMakeRange(3, selectorName.length-(3+1))].lowercaseString;
            id value = [self _getArgumentAtIndex:2 invocation:invocation];
            [NSUserDefaults.standardUserDefaults setObject:value forKey:key];
            [NSUserDefaults.standardUserDefaults synchronize];

        } else {
// [5]
            // getter
            key = selectorName;
            id ret = [NSUserDefaults.standardUserDefaults objectForKey:key];
            [self _setReturnValue:ret invocation:invocation];
        }

    }
}
[1] 前準備。通常は invoke を実行して self.targetの実際のメソッドを呼び出す。今回 self.target(つまりUserDefaults)のプロパティの実体は NSUserDefaults にするのでメンバ変数への書き込みは必要ないので呼び出さない。@propertyによって暗黙的にメンバ変数の定義されるのだがこれは使わない。

[2] 後で使う情報を用意。key は NSUserDefaults への格納キー名として使い、プロパティ名を割り当てる。

[3] setter/getterの分岐。メソッドの引数の1、2番めはランタイムが使うので3番め以降の存在で判断する。例えば setName: だと引数=3、nameだと引数=2。

[4] setterの場合は先頭の "set" と最後の ":" を取り除き、さらに小文字にしたものがキー名となる。"setName:" なら key=@"name" となる。 次に渡された引数を取り出す。ここは引数の型によって細かく処理する必要があるので別メソッドで処理している。

こんな感じ(長いので割愛)。
- (id)_getArgumentAtIndex:(NSInteger)index invocation:(NSInvocation*)invocation
{
    const char* valueType = [invocation.methodSignature getArgumentTypeAtIndex:index];
    while(strchr("rnNoORV", valueType[0]) != NULL) {
  valueType += 1;
    }

    switch (valueType[0]) {
        case 'c':
        {
            char value;
            [invocation getArgument:&value atIndex:index];
            return [NSNumber numberWithChar:value];
        }
        case 'i':
        {
            int value;
            [invocation getArgument:&value atIndex:index];
            return [NSNumber numberWithInt:value];
        }
   :
扱い易いように整数とかのプリミティブな型は全て NSNumber につめて取り出す。
この辺りは下記コードが参考になった。
NSInvocation+OCMAdditions.m

あとは NSUserDefaults へ詰めるだけ。

なおオブジェクト型を取り出す時は __unsafe_unretained を忘れずに(ARC環境で)。当初これをやらなくて数秒後にクラッシュするという謎の問題が起きてハマった。
case '#':
        case '@':
        {
            __unsafe_unretained id value;
            [invocation getArgument:&value atIndex:index];
            return value;
        }
NSUserDefaults は非同期で書きだすのでそのタイミングで参照した時に寿命が尽きていたということらしい。

[5] getterの実装。こちらも戻り値の型にごとの処理が必要なので別メソッド処理。
- (void)_setReturnValue:(id)value invocation:(NSInvocation*)invocation
{
    const char* valueType = [invocation.methodSignature methodReturnType];
    while(strchr("rnNoORV", valueType[0]) != NULL) {
  valueType += 1;
    }
    
    switch (valueType[0]) {
        case 'c':
        {
            char ret = [value isKindOfClass:NSNumber.class] ? ((NSNumber*)value).charValue : 0;
            [invocation setReturnValue:&ret];
            break;
        }
        case 'i':
        {
            int ret = [value isKindOfClass:NSNumber.class] ? ((NSNumber*)value).intValue : 0;
            [invocation setReturnValue:&ret];
            break;
        }
   :
さっきとは逆で取り出した NSNumber を型毎に変換して返してやる。戻り値は -[NSInvocation setReturnValue:(void*)ret] にセットしておけば呼び出し元に返るようになっている。


さて使ってみよう。
NSLog(@"string=%@", UserDefaults.sharedDefaults.string);
UserDefaults.sharedDefaults.string = [NSString stringWithFormat:@"%@", NSDate.date];
    
NSLog(@"flag=%d", UserDefaults.sharedDefaults.flag);
UserDefaults.sharedDefaults.flag = YES;

NSLog(@"integer=%d", UserDefaults.sharedDefaults.integer);
UserDefaults.sharedDefaults.integer = 1000;

結果
ProxySample[45342:70b] string=2014-01-07 14:37:50 +0000
ProxySample[45342:70b] flag=1
ProxySample[45342:70b] integer=1000
動いた。わかりずらいが、初回は値なしで表示され次回以降は NSUserDefaults から読み出された値が表示されるようになる。

forwardInvocation: の実装を変えれば NSUserDefaults 以外にも使える。例えばキャッシュとか iCloud へ保存するとか。あとキー名をらくだ文字(MacBookAirとか)にしたい時はもうひと手間必要。

なお今回 NSProxy を使ったが実際は NSObject でも動く。違いは何かと言われると今回の用途ではおおざっぱなところでは無い気がする。NSProxyは元々こういう目的で使われるのでラベリング的な目的が大きいか(実装が少ないというのもあるし、isProxyメソッドで区別ができるとかそんなところ?)。

ともあれようやく面倒な定数定義から開放されそう。


サンプルコードはこちら
iOS7-Sample/ProxySample
※分類上 iOS7になっているが今回のコードは iOS7固有では無い

問題や改良があれば是非(pullrequestとかも可)。


参考情報:
ダイナミックObjective-C / 80 デザインパターンをObjective-Cで - Proxy (2)
ダイナミックObjective-C / 45 AspectCocoa (3) - フォワーディングとポージングの利用
Obj-C関係で木下氏の記事は鉄板。

Objective-C Runtime Programming Guide - Type Encodings
NSMethodSignature から得られる型のエンコード一覧表。



- - - -
Objective-C が面白いと感じるのはこういうところだなー。今回のは車輪の再発明だと思うけど書いていて面白かった。

コメント

  1. Tomohisa Ota says:
    2014年1月8日 9:20

    僕はカテゴリでプロパティを足しています。
    せっかくなので、qiitaにまとめてみました。
    http://qiita.com/tomohisaota/items/0b74db5d78473829d2fd

  2. Tomohisa Ota says:
    2014年1月8日 9:20

    僕はカテゴリでプロパティを足しています。
    せっかくなので、qiitaにまとめてみました。
    http://qiita.com/tomohisaota/items/0b74db5d78473829d2fd

Leave a Response

人気の投稿(過去 30日間)