[Objective-C]KVCを用いてJSONとモデルの相互変換を行う

2014.06.11

はじめに

REST-APIやWebSocketを用いたアプリを作るときにはカスタムモデルクラスをAPIのJSONから作成して、そのモデルクラスをまたJSONに戻してAPIに投げることがよくあります。マッピングのための便利なOSSとしてMuntleやJSONModelがありますが、これらはサブクラス化を前提としており、使うには少し不便です。

OSSを使わずにKVC(Key Value Coding)を用いる方法は割りと個人的に気に入っている方法でして、今回そのやり方を紹介するのと合わせて、マッピングのためのカスタムカテゴリも作成したので公開します。

サンプルプロジェクトはこちらです。

JSON -> Object Mapper

JSONの文字列からObjectにマッピングするのはカテゴリを使わずに標準的なKVCを用いるだけで行けます。

MYModel.json

{
    "canUpdate": true,
    "childs": [
               "daughter",
               "son",
               "musuco",
               "musume"
               ],
    "degree": 3.0,
    "identifier": 1122,
    "name": "HOGEMOGE",
    "number": 5
}

こちらのJSONから以下のモデルクラスインスタンスをJSONから生成したNSDictionaryを用いて生成します。

MYModel.h

/**
 *  JSONと互換性のあるモデルクラス
 */
@interface MYModel : NSObject

@property (nonatomic) NSInteger identifier;

@property (nonatomic) BOOL canUpdate;

@property (nonatomic) CGFloat degree;

@property (nonatomic) NSNumber *number;

@property (nonatomic) NSString *name;

@property (nonatomic) NSArray *childs;

/**
 *  JSONからDictionaryを生成し、指定イニシャライザでインスタンス生成します。
 *
 *  @param dictionary JSONから生成されたDictionary
 *
 *  @return インスタンス
 */
- (instancetype)initWithDictionary:(NSDictionary *)dictionary;

MYModel.m

- (instancetype)initWithDictionary:(NSDictionary *)dictionary
{
    self = [super init];
    if (self) {
        [self configureValuesWithDictionary:dictionary];
    }
    return self;
}

- (void)configureValuesWithDictionary:(NSDictionary *)dictionary
{
    for (NSString *key in dictionary) {
        if ([self respondsToSelector:NSSelectorFromString(key)]) {
            [self setValue:dictionary[key] forKeyPath:key];
        }
    }
}

JSON文字列に含まれるキーがプロパティにあるかどうかを [self respondsToSelector:NSSelectorFromString(key)]で確認しています。プロパティがあったらKVCで取得したオブジェクトをモデルにセットしています。

ここで注意したいのがMYModelのプリミティブ型のプロパティはKVCを使ってNSNumberとしてセットするとそのまま内部でNSInteger型やBOOL型等のプリミティブ型としてモデルの値にセットされるということです。

辞書から取得した値はそのままオブジェクトとして[self setValue:dictionary[key] forKeyPath:key]のコード一つでプロパティにセットできます。

もちろんサーバサイドで返されるキー値がiosクライアント側のモデルプロパティ名と異なっていた場合はfor文の中に分岐を追加する必要がありますが、モデルのプロパティの数が多いとき、逐一辞書からキーを指定して値を取得し、プロパティをセットする手間を考えればかなり楽です。

JSON文字列からNSDictionaryを生成するまでの流れは特にここでは解説しません。NSJSONSerializationのドキュメントを各自当たってみてください。

Object -> JSON Mapper

オブジェクトからJSONにマッピングするためにはすべてのプロパティ名をキーとして取得する為にランタイムAPIを用います。

List of class properties in Objective-C - Stackoverflowを参考にクラスからプロパティのリストを取得するStaticメソッドを作成します。

NSObject+KVC.h

/**
 *  オブジェクトの全てのプロパティのキーを返します。
 *
 *  @return 全てのキー文字列
 */
+ (NSArray *)allKeys;

NSObject+KVC.m

#import <objc/objc-runtime.h>

+ (NSArray *)allKeys
{
    u_int count;

    objc_property_t *properties = class_copyPropertyList([self class], &count);
    NSMutableArray *propertyArray = [NSMutableArray arrayWithCapacity:count];

    for (int i = 0; i < count; i++) {
        objc_property_t property = properties[i];
        const char *propertyName = property_getName(property);
        [propertyArray addObject:@(propertyName)];
    }

    free(properties);

    return [NSArray arrayWithArray:propertyArray];
}

このメソッドを用いてMYModelクラスにプロパティのリストを辞書として取得するメソッドを生やします。

MYModel.h

/**
 *  インスタンスの情報をマッピングしたDictionary
 *  内部ではdictionaryWithValuesForKeysを用いて情報を取得します。
 *
 *  @return インスタンスの情報
 */
- (NSDictionary *)valueDictionary;

MYModel.m

#import "NSObject+KVC.h"

- (NSDictionary *)valueDictionary
{
    NSArray *allKeys = [[self class] allKeys];
    return [self dictionaryWithValuesForKeys:allKeys];
}

ついでと言ってはなんですが、今までの作業の出力結果を確認するためにMYModelクラスでdescriptionメソッドをオーバライドします。

MYModel.m

- (NSString *)description
{
    NSMutableString *descriptionString = [NSMutableString new];

    [descriptionString appendString:[NSString stringWithFormat:@"%@\n", [super description]]];

    NSArray *allKeys = [[self class] allKeys];

    for (NSString *key in allKeys) {
        NSString *appendedString =
        [NSString stringWithFormat:@"%@ : %@ \n", key, [self valueForKey:key]];
        [descriptionString appendString:appendedString];
    }

    return [NSString stringWithString:descriptionString];
}

適当なクラスで今まで宣言したメソッドを動かしてみます。

MYViewController.m

#import "NSBundle+MYResource.h"
#import "NSString+JSON.h"
#import "MYModel.h"

@implementation MYViewController

- (void)viewDidLoad
{
    [super viewDidLoad];

    NSString *jsonString = [[NSBundle mainBundle] myModelJSONString];
    NSDictionary *modelDictionary = jsonString.jsonObject;
    MYModel *model = [[MYModel alloc] initWithDictionary:modelDictionary];

    NSLog(@"model : %@", model);
    NSLog(@"json : %@",[NSString stringWithJSONObject:model.valueDictionary]);
}

@end

ここで-myModelJSONStringはNSBundle+MYResourceカテゴリで宣言され、バンドルからMYModel.jsonの中の文字列を取り出すメソッドです。

また、-jsonObjectメソッドはNSString+JSONカテゴリで宣言され、JSON文字列から対応するNSObject(NSDictionary or NSArray)を取得するカテゴリメソッドで、+stringWithJSONObject:メソッドはJSONに対応したNSObject(NSDictionary or NSArray)からJSON文字列を取得するメソッドです。

以上のコードで得られたログ出力結果を見てみましょう

2014-06-03 01:10:57.496 JSONObjectMapper[1509:60b] model : <MYModel: 0x944eae0>
identifier : 1122 
canUpdate : 1 
degree : 3.1 
number : 5 
name : HOGEMOGE 
childs : (
    daughter,
    son,
    musuco,
    musume
) 
2014-06-03 01:10:57.498 JSONObjectMapper[1509:60b] json : {
  "name" : "HOGEMOGE",
  "childs" : [
    "daughter",
    "son",
    "musuco",
    "musume"
  ],
  "canUpdate" : 1,
  "identifier" : 1122,
  "number" : 5,
  "degree" : 3.1
}

JSON文字列からオブジェクトが生成され、オブジェクトからJSON文字列が生成されていることがわかります。

BOOL値プロパティでツボる

先の出力結果に関連して、つまずいた点があります。 オブジェクトから生成したJSON文字列の結果をじっくり見てみましょう

2014-06-03 01:10:57.498 JSONObjectMapper[1509:60b] json : {
  "name" : "HOGEMOGE",
  "childs" : [
    "daughter",
    "son",
    "musuco",
    "musume"
  ],
  "canUpdate" : 1,
  "identifier" : 1122,
  "number" : 5,
  "degree" : 3.1
}

canUpdateのプロパティに対応するJSONの"canUpdate"フィールドは真偽値trueだったはずですが、JSONに出力される際には数値型の1になっています。

サーバサイドのAPIによってはjsonパラメータのフィールドとして真偽値のみを受け付ける処理をしている場合もあるでしょう。そのような場合、上記のような実装では対応できない部分が出てきてしまいます。

さて、

Key Value Coding: BOOL property evaluated as NSNumber of type “i”, not “c” - Stackoverflow

によれば、-valueForKey:のメソッドによって取得されるInteger型とBOOL型のNSNumberは同じエンコード表現iを持っています。

つまり-valueForKey:メソッドで取得されるBOOLプロパティに対するオブジェクトは整数型として認識されてしまうため、JSON表現でも数値型として表現されてしまっていると言えそうです。

カスタムカテゴリを作った

この問題を解決するため、BOOLプロパティのエンコード表現の判定処理を行う-valueForKey:の代替メソッドをNSObject+KVCカテゴリに作成しました。

NSObject+KVC.h

/**
 *  BOOL値に関する処理を挟んだvalueForKeyメソッドです。値が存在しない場合は必ずNSNullを返します。
 *  BOOL値のエンコードはcharと同等であるため、charのプロパティを持つオブジェクトに対しては使用に注意を要します。
 *
 *  @param key キー文字列
 *
 *  @return プロパティのオブジェクト
 */
- (id)propertyValueForKey:(NSString *)key;

/**
 *  BOOL値に関する処理を挟んだdictionaryWithValuesForKeysメソッドです。
 *
 *  @param keys 全てのキー文字列
 *
 *  @return プロパティのオブジェクトとその名前をキーとする辞書
 */
- (NSDictionary *)dictionaryWithPropertyValuesForKeys:(NSArray *)keys;

NSObject+KVC.m

- (id)propertyValueForKey:(NSString *)key
{
    objc_property_t property = class_getProperty([self class], [key UTF8String]);
    
    if (!property) {
        return [NSNull null];
    }
    
    const char *type = property_getAttributes(property);
    NSArray *attributes = [@(type) componentsSeparatedByString:@","];
    
    if (!attributes) {
        return [NSNull null];
    }
    
    NSString *typeAttribute = attributes[0];
    NSString *propertyType = [typeAttribute substringWithRange:NSMakeRange(1, 1)];
    
    // charの型情報と同一になってしまっているためcharのプロパティに関してもこのIF文を通ります。
    if ([propertyType isEqualToString:@(@encode(BOOL))]) {
        BOOL boolValue = [[self valueForKey:key] boolValue];
        return [NSNumber numberWithBool:boolValue];
    } else {
        return [self valueForKey:key];
    }
}

- (NSDictionary *)dictionaryWithPropertyValuesForKeys:(NSArray *)keys
{
    NSMutableDictionary *dictionary = [NSMutableDictionary new];

    for (NSString *key in keys) {
        id value = [self propertyValueForKey:key];
        dictionary[key] = value;
    }

    return [NSDictionary dictionaryWithDictionary:dictionary];
}

プロパティの属性を取得するメソッドproperty_getAttributes()がランタイムAPIに用意されており、プロパティの型エンコード情報も属性を通じて取得できます。

Property Type and Functions - Objectiv-C Runtime Programming Guide

このカテゴリメソッドを用いて先ほどのMYModelからDictionaryを取得するメソッドを用意します。

MYModel.h

/**
 *  インスタンスの情報をマッピングしたDictionary
 *  内部ではNSObject+KVCのdictionaryWithPropertyValuesForKeysを用いて情報を取得します。
 *
 *  @return インスタンスの情報
 */
- (NSDictionary *)propertyValueDictionary;

MYModel.m

- (NSDictionary *)propertyValueDictionary
{
    NSArray *allKeys = [[self class] allKeys];
    return [self dictionaryWithPropertyValuesForKeys:allKeys];
}

このメソッドを用いた出力を先ほどと同じように確認してみます

MYViewController.m

- (void)viewDidLoad
{
    [super viewDidLoad];

    NSString *jsonString = [[NSBundle mainBundle] myModelJSONString];
    NSDictionary *modelDictionary = jsonString.jsonObject;
    MYModel *model = [[MYModel alloc] initWithDictionary:modelDictionary];

    NSLog(@"model : %@", model);
    NSLog(@"json : %@",[NSString stringWithJSONObject:model.propertyValueDictionary]);
}

結果を見てみます

2014-06-03 01:41:49.365 JSONObjectMapper[1715:60b] model : <MYModel: 0x8ca80d0>
identifier : 1122 
canUpdate : 1 
degree : 3.1 
number : 5 
name : HOGEMOGE 
childs : (
    daughter,
    son,
    musuco,
    musume
) 
2014-06-03 01:41:49.366 JSONObjectMapper[1715:60b] json : {
  "name" : "HOGEMOGE",
  "childs" : [
    "daughter",
    "son",
    "musuco",
    "musume"
  ],
  "canUpdate" : true,
  "identifier" : 1122,
  "number" : 5,
  "degree" : 3.1
}

真偽値も正しく表現されています!

注意点

BOOL値のエンコード情報@encode(BOOL)とchar値のエンコード情報@encode(char)が等しい為に、char型プロパティを持ったモデルに対しては-propertyValueForKey: -dictionaryWithPropertyValuesForKeys:メソッドは使用を控えたほうが無難です。

参考サイト