iOSDay 24

黒魔術は用法用量を守って正しくお使いください

More than 1 year has passed since last update.

iOS Advent Calendar 2016 24日目の記事です。


はじめに

今更Objective-Cでごめんなさい


Objctive-Cの黒魔術「objc/runtime.h」

runtimeAPIはメソッドの挙動を差し替えたりとても強力な黒魔術です。

黒魔術については下記

[Objective-C] ランタイムAPIのメモ


黒魔術の有効活用

ユーザ情報を管理するUserというモデルクラスがあったとします。


User.h

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



User.m

#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);
}

NSLog.png

ポインタではなく、userNameaddressの中身がみたいときはuser.userNameと変数を指定してNSLogを出さないといけません。

少し面倒です。


そこでobjc/runtime.h

<objc/runtime.h>をインポートし、Userクラスのdescriptionメソッドを下記の様に記述します。


User.m

#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


そしてもう一度先程のコードを実行すると、、、

NSLog2.png

Propertyの中身が表示されます。

これならPropertyが増えても自動で出力項目が増えるので便利です。


ここから本題

私はSESでObjective-Cをメインに開発を行っているのですが、今の現場でこのようなコードに出会いました。


NSUSerDefaults+Category

@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);
}


何をやっているかというとNSUserDefaultsstringForKey:のメソッドを置き換えて(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はとても強力な関数なので、皆さん用法用量を正しく守って黒魔術を使用しましょう。