iOS Advent Calendar 2016 24日目の記事です。
はじめに
今更Objective-Cでごめんなさい
Objctive-Cの黒魔術「objc/runtime.h」
runtimeAPIはメソッドの挙動を差し替えたりとても強力な黒魔術です。
黒魔術については下記
[Objective-C] ランタイムAPIのメモ
黒魔術の有効活用
ユーザ情報を管理するUser
というモデルクラスがあったとします。
NS_ASSUME_NONNULL_BEGIN
@interface User : NSObject
@property (nonatomic, strong) NSString *userId;
@property (nonatomic, strong) NSString *userName;
@property (nonatomic, nullable, strong) NSString *address;
@property (nonatomic) NSInteger age;
- (instancetype)initWithUserData:(NSDictionary<NSString *, NSString *> *)userData;
@end
NS_ASSUME_NONNULL_END
#import "User.h"
@implementation User
- (instancetype)initWithUserData:(NSDictionary<NSString *, NSString *> *)userData {
self = [super init];
if (self) {
_userId = userData[@"userId"];
_userName = userData[@"userName"];
_address = userData[@"address"];
_age = [userData[@"age"] integerValue];
}
return self;
}
@end
これをそのままNSLog
で表示するとオブジェクトのポインタが表示されます。
- (void)createUser {
NSDictionary *tarouData = @{@"userId": @"001"
, @"userName": @"田中太郎"
, @"address": @"東京都"
, @"age": @25};
User *tarou = [[User alloc] initWithUserData:tarouData];
NSLog(@"%@", tarou);
NSDictionary *hanakoData = @{@"userId": @"002"
, @"userName": @"山田花子"
, @"age": @23};
User *hanako = [[User alloc] initWithUserData:hanakoData];
NSLog(@"%@", hanako);
}
ポインタではなく、userName
やaddress
の中身がみたいときはuser.userName
と変数を指定してNSLogを出さないといけません。
少し面倒です。
そこでobjc/runtime.h
<objc/runtime.h>
をインポートし、User
クラスのdescription
メソッドを下記の様に記述します。
#import <objc/runtime.h>
- (NSString *)description {
NSMutableString *description = [NSMutableString string];
[description appendString:@"{\n"];
unsigned int outCount, i;
// 自身が持つPropertyの一覧を取得
objc_property_t *properties = class_copyPropertyList([self class], &outCount);
for (i = 0; i < outCount; i++) {
objc_property_t property = properties[i];
const char *name = property_getName(property);
// 文字列変換
NSString *propertyName = [NSString stringWithUTF8String:name];
// KVCを使ってPropertyの中身を取得
NSString *propertyValue = [self valueForKey:propertyName];
[description appendFormat:@"\t%@: %@", propertyName, propertyValue];
[description appendString:@"\n"];
}
free(properties);
[description appendString:@"}\n"];
return description;
}
@end
そしてもう一度先程のコードを実行すると、、、
Propertyの中身が表示されます。
これならPropertyが増えても自動で出力項目が増えるので便利です。
ここから本題
私はSESでObjective-Cをメインに開発を行っているのですが、今の現場でこのようなコードに出会いました。
@interface NSUserDefaults(Category)
+(void)switchStringForKey;
@end
@implementation NSUserDefaults(Category)
+(void)switchStringForKey {
[self switchInstanceMethodFrom:@selector(stringForKey:) To:@selector(nonNilStringForKey:)];
}
- (NSString *)nonNilStringForKey:(NSString *)defaultName{
NSString *str = [self nonNilStringForKey:defaultName];
if (str) {
return str;
} else {
NSLog(@"String nil key: %@", defaultName);
return @"";
}
}
+(void)switchInstanceMethodFrom:(SEL)from To:(SEL)to {
Method fromMethod = class_getInstanceMethod(self,from);
Method toMethod = class_getInstanceMethod(self,to );
method_exchangeImplementations(fromMethod, toMethod);
}
何をやっているかというとNSUserDefaults
のstringForKey:
のメソッドを置き換えて(Method Swizzling)
絶対にnilが返らないようにしています。 ← ここ大事
なぜこのようなコードがあるのか疑問だったのですが、コードを読み解くにつれて理由がわかりました。
詳しくはかけないのですが、アプリ内使用する文字列を一度すべてNSUserDefaultsにセットして各画面でNSUserDefaultsから文字列を取得し描画していました。
なかなか糞なコードユニークなコードですね。
そのような処理がになっているので、StringForKey:
で取得できなかった際に、画面に(null)
と表示されるのを避けるためのMethod Swizzling
なのでしょう。
黒魔術が暴発しました
そんな中9月の終わりぐらいに、とあるチームの方たちが騒いでいました。
どうやらiOS10
でカメラロールを表示するとアプリが落ちるみたいです。
私もヘルプで原因調査を行っていたのですが、カメラロールを表示する際に気になるログを発見しました。
2016-12-22 23:36:46.545 hogehoge[5750:242757] String nil key: com.apple.CoreData.Logging.ほげほげ
2016-12-22 23:36:46.545 hogehoge[5750:242757] String nil key: com.apple.CoreData.Logging.ふがふが
2016-12-22 23:36:46.550 hogehoge[5750:242757] String nil key: com.apple.CoreData.ほげふが
※ 表示内容を一部ぼかしています
あれ?これ黒魔術の部分じゃね?
ものは試しとMethod Swizzling
の処理を外したところカメラロールが落ちなくなるではありませんか!!!
なにが問題だったのか
Appleのドキュメントにもあるように
Returns nil if the default does not exist or is not a string or number value.
Method Swizzling
で置き換えた処理は本来存在しないValueにアクセスしたら、nil
を返すものになります。
そしてカメラロールを表示する際に、UserDefaultsにアクセスしてnilかどうかで処理を分岐している箇所があり、その部分でクラッシュしていた
と予測されます。
どう対策したか
今回の例で言うと既存の処理を変えてしまったのが直接の要因だったのでnonNilStringForKey
を廃止して元通りnil
を返すようにしました。
そもそも今回の事例はnonNilStringForKey
というカテゴリを作っているので、メソッドのすり替えなんてしないで最初からこちらを使用していれば今回のクラッシュは起きなかったのです。
また全画面でStringForKey
が使用されていて簡単に置換ができない状態だったので私も放置してしまいました。
そもそもNSUserDefaults
の使い方がなっていないというところはありますが
終わりに
objc/runtime
はとても強力な関数なので、皆さん用法用量を正しく守って黒魔術を使用しましょう。