Realm Advent Calendar 15日目を担当する@yusuga_です。
Realmは2014年10月末くらいからいじり始めてあーだこーだ悩みながらもRealmの採用を決め、Realm meetup #1でお話しさせていただいたりなんかもして、なんだかんだ1年以上使ってきました。最終的にはAplosというTwitterクライアントでの採用を決め、メインのツイート関連から細かい設定情報まで全てRealmを使用しています。
Realmで一番苦しめられたのは、例外によるクラッシュです。Realmの設計思想として間違った操作に対するエラーは基本的には全て例外が投げられます。
導入初期の例外はドキュメントも日本語で用意されていますし、例外自体にも何が原因かが明記されているので比較的簡単に対処できると思います。
問題はアプリを実際に稼働するようになった後に稀に発生する例外です。私の経験で一番多かったのはRLMObject(Realmのモデルクラス)に関する例外です。これがなかなか厄介で例外の内容を見ただけでは何が原因か判断つきづらいことが多かったです。
そこで今回は実際にアプリで運用していく中で一番多かったRLMObjectの例外を減らすために、スタンドアローンのRLMObjectの活用方法について紹介します。
※ この記事は Realm v0.96.3 を元に書いています。
前提知識 - RLMObjectの状態
RLMObjectには2種類の状態があります。
1. スタンドアローンのRLMObject
2. Realm(データベース)に紐づけられているRLMObject
スタンドアローンとは保存前のRLMObjectの事で-[RLMObject realm]がnilになっています。 (※ Realmのドキュメント中でstandalone objectsと記載されているのでこの記事ではこのような状態をスタンドアローンまたはスタンドアローンオブジェクトという呼び方にしています。)
この2つの大きな違いは永続化されているかどうかはもちろんですが、スタンドアローンだと通常のオブジェクトと同様にプロパティを変更できますが、データベースに保存後のRLMObjectはトランザクション内でのみ変更が可能となります。
// オブジェクトを作成する
Person *author = [[Person alloc] init];
// スタンドアローンなので変更可
author.name = @"David Foster Wallace";
// オブジェクトをRealmに追加する
[realm beginWriteTransaction];
[realm addObject:author]; // Realmに保存されました。
[realm commitWriteTransaction];
// Realmに保存済みなのでプロパティの変更は不可。以下はクラッシュします。
author.name = @"other name";
// Realmに保存後にプロパティを変更する場合はトランザクション内で行います。
[realm beginWriteTransaction];
author.name = @"other name";
[realm commitWriteTransaction];
CoreDataだとオブジェクトの生成には必ずコンテキスト(NSManagedObjectContext)が必要だったのに対して、Realmではinitしただけでは通常のオブジェクトとして扱え、永続化したければRealmに追加すればいいだけでモデルオブジェクトが非常に扱いやすくなっています。
Realmに紐づけられたRLMObjectをスタンドアローンに戻す
本題です!
Realmに紐づけられたRLMObjectには制約があります。
1. プロパティの変更はトランザクションが必要
2. -[RLMObject isInvalidated] == YES だとアクセス不可(例外が発生)
前述の通りスタンドアローンだと通常のオブジェクトと同様に扱えるのでRealmの制約が一切なくなります。永続化はしたいけど実際に扱うときには通常のオブジェクトと同様に扱いたいという場合にはスタンドアローンに戻してしまえばいいのです。
Copyでスタンドアローンに戻す
RLMObjectをコピーします! …あれRLMObjectってcopyできたっけ?と思いますよね。そうです、残念ながら現在(Realm v0.96.3)RLMObjectはNSCopyingに準拠していません。コピーの対応予定はあるそうですが、おそらく用途から考えるとrealmとの紐づけを維持したままコピーという可能性が高いですかね。
そこでRealm-JSON/RLMObject+Copyingを使います。-[RLMObject deepCopy]でRealmの紐づけをなくして同等のRLMObjectを生成することができます。
deepCopyしたRLMObjectはスタンドアローンになっているので以下のように扱うことができます。
// オブジェクトを作成する
Person *author = [[Person alloc] init];
author.name = @"David Foster Wallace";
// オブジェクトをRealmに追加する
[realm beginWriteTransaction];
[realm addObject:author]; // これでRealmに保存されました。
[realm commitWriteTransaction];
// 保存したauthorをコピー
Person *copiedAuthor = [author deepcopy];
XCTAssertNil(copiedAuthor.realm); // true
XCTAssertEqualObjects(author.name, copiedAuthor); // true
// トランザクションがなくても変更可能
copiedAuthor = @"other name";
スタンドアローンオブジェクトを使いこなす
さて、永続化したオブジェクトをわざわざコストかけてコピーするなんて 本末転倒じゃん! という声も聞こえてきそうですが、 実際に運用するにあたってRealmの制約はボディブローのようにじわじわきいてきて、原因が補足しづらいのRealmの例外に苦しむことになります。(※ Realmの仕様をきちんと把握していれば当然防げますがはまりやすい点が多いかなと思います)
RLMObjectの運用ルールは設けるとして、もっと手軽にRLMObjectを扱えばそういった事故も少なくなり、Realmのメリットを享受しつつデメリットを減らすということができます。
それでは次にスタンドアローンオブジェクトを利用することのメリットをあげてみます!
スタンドアローンのメリット1 - 自由な変更
(何回も書いてますが)スタンドアローンだと通常のObjective-Cのオブジェクトとして扱えるのでトランザクションなしで変更が行えます。
ちょっとした変更などではメリットを感じられないと思いますが、以下のようなケースだと非常に有用です。
Aplosで実際に使用している画面を例に説明します。Aplosの外観にはTWAppearanceというprimaryKeyを持つRLMObjectを使用しています。外観を変更する場合は以下の流れになっています。
1. 画面上部がプレビュー、下部が外観の変更になります。
2. 最初に現在の外観を元にプレビューします。
3. 様々な変更が行われ、それが逐一プレビューに反映されます。
4. 最後に「保存」か「キャンセル(1つ前の画面に戻る)」が行われて編集が終わります。
Aplosで採用している実装
TWAppearanceをdeepCopyしたものを元に変更を行っています。そして最終的に「保存」なら上書き更新し、「キャンセル」ならTWAppearanceを破棄するという流れになっています。
- (void)setAppearance:(TWAppearance *)appearance
{
_appearance = [appearance deepCopy];
}
- (void)saveButtonClicked
{
[self.realm transactionWithBlock:^{
[self.realm addOrUpdateObject:self.appearance];
}];
}
素直にRealmを使用する実装方法
変更中に常にトランザクションを開くという実装が考えられます。
※ RealmのSlackで岸川さんが紹介していました。
- (void)viewWillAppear:(BOOL)animated
{
[super viewWillAppear:animated];
[self.realm beginWriteTransaction];
}
- (void)viewWillDisappear:(BOOL)animated
{
[super viewWillDisappear:animated];
if (self.isCancelled) {
[self.realm cancelWriteTransaction];
} else {
[self.realm commitWriteTransaction];
}
}
このように編集画面が表示された時にトランザクションを開始して、編集画面を閉じる時(ボタンを押した時とか他のところでもいいですが)にトランザクションをコミットするかキャンセルするかで変更をデータベースに更新させるかというのを判断する形です。これはRealmの特徴で すべての書き込み処理は読み込み処理をブロックしない というのがあるのでトランザクションを開きっぱなしにするという選択が選びやすいです。
トランザクションを開きっぱなしにする場合の注意点
- 当然ながらトランザクションの閉じ忘れに十分注意しなければなりません。RLMRealmは二重でトランザクションを開こうとすると例外が発生します。-[RLMRealm inWriteTransaction]を使用すればすでにトランザクションが開かれているかの確認はできますが、トランザクションを開くたびに確認するような設計だと他の問題が生じそうですよね…。
ここでdeepCopyを採用した理由
変更画面の遷移などが今後複雑化した時を考えるとトランザクションの閉じ忘れに注意するよりかは、TWAppearanceを保存するか破棄するかのみに気をつける方が手軽に扱えると思います。編集中のTWAppearanceが別画面または別スレッドで意図しない更新がされることも防げます。またTWAppearanceを1つだけdeepCopyする程度ならローコストでコスト上の問題がないというのも採用理由の一つです。
スタンドアローンのメリット2 - isInvalidatedでのクラッシュを防ぐ
RLMObjetは削除されるとInvalidatedになりアクセスが不可(例外)になります。
@interface ViewController : UIViewController
@property (nonatomic) Tweet *tweet; // TweetはRLMObjectです
@end
例えばこのようにtweetを保持してこの画面を構成している場合に、別画面や別スレッド等でこのtweetが削除された場合を考慮しなければなりません。
Notificationで確認
一番素直なのは通知を受けることです。
// Realmの通知を監視するように登録します
self.token = [realm addNotificationBlock:^(NSString *note, RLMRealm * realm) {
[myViewController updateUI];
}];
データベースが変更された際に保持しているtweetがisInvalidatedになっていないかを確認しそれに応じてUIを更新します。
deepCopyする
deepCopyしてスタンドアローンに戻せば、これらの心配は無くなります。ただしtweet自体がデータベースによる更新がなくなるので注意が必要ですが、逆にtweet自体の変更が必要ない場合に有用です。
結局は利用する場面によると思うのですが、一時的な画面であったり、変更を受け取りたくなかったり、簡易的に扱いたいなどそういった場合にスタンドアローンオブジェクトを使うという選択ができると思います。
スタンドアローンのメリット3 - スレッドチェックによる例外を防ぐ
悲しい事実が発覚
さて、次は私が一番メリットに感じていて、実際にかなりのクラッシュを減らすことができたメリット3へと続く予定だったのが途中で悲しい事実が発覚しました。
メリット3を要約するとRLMObjectはdealloc時にスレッドチェックが行われるのでRLMObjectを直接保持(propertyとかで)しているとクラッシュする場合があるので直接保持したい場合はdeepCopyした方が安全で、もしもdeepCopyしたくないならRLMResultsで保持しようという内容でした。
そしてある程度このセクションの記事を書いた後、realm-cocoaの該当コードへのリンクを貼ろうといろいろ確認していたところv0.92.0でこのdeallocでのスレッドチェックが追加されていたのですが、v0.92.3で削除されました・・・。
It should be safe to deallocate an RLMObject from any thread #1893で話し合われている通り、マルチスレッドのblocksがRLMObjectをretainした場合に別スレッドでRLMObjectが解放されてしまうので、このdealloc時のスレッドチェックはなかなか辛かったです。最終的にはRealmのコア側に修正が入ってこの問題は解決したようです。
アプリでは、当時たまたまv0.92.0を使ったため起きたということが今頃判明して少し落ち込んでいます。(今は0.95.3を使っていますが、いまだにdealloc時のスレッドチェックの仕様に気をつけながら設計していました…。)
以下はv0.92.0〜v0.92.3でのみ起こったことです…書いちゃったので残しておきます。
Realmに紐づけられたRLMObjectは別スレッドに渡すことができません。(Rleam v0.96.3の時点)
これはRLMRealmはスレッド毎にキャッシュ、管理されており、RLMObjectにアクセスする際には紐づけられているrealmとアクセスしているスレッドが同一かが確認されるためです。
Tweet *tweet = [Tweet objectForPrimaryKey:tweetID];
dispatch_async(queue, ^{
NSLog(@"%@", tweet); // 例外
});
これもスタンドアローンオブジェクトなら問題なく行えます。
Tweet *tweet = [Tweet objectForPrimaryKey:tweetID];
tweet = [tweet deepCopy];
dispatch_async(queue, ^{
NSLog(@"%@", tweet);
});
これで通常のマルチスレッドでオブジェクトの扱い方に気をつければいいだけになります。
さて、通常はこんな使い方が活躍する場面は少ないと思いますが、これが一番生きるのはRLMObjectを直接保持する場合です。
@interface ViewController : UITableViewController
@property (nonatomic) Tweet *tweet; // TweetはRLMObjectです
@end
され、これは一見問題ないように見えますが、落とし穴が1つあります。 -[NSObject dealloc]の呼び出しは必ずしもメインスレッドではない ということです。
この例だとtweetは表示のためにメインスレッドで取り扱うのでrealmはメインスレッドと紐づいています。さてdeallocがメインスレッド以外で呼び出されたらどうでしょう。別スレッドでtweetにアクセスしているので例外が発生してしまいます。dealloc時のスレッドチェックは0.92.0で加わったので随分苦しみました。
RLMObject, RLMResultsの運用ルール
まず原則としてRLMObjectまたはRLMResultsを保持している場合は必ずデータベースの変更通知を受け取るということを守る必要があります。
// Realmの通知を監視するように登録します
self.token = [realm addNotificationBlock:^(NSString *note, RLMRealm * realm) {
[myViewController updateUI];
}];
通知を受け取った時のRLMObjectの対応
UIの更新に加えて、-[RLMObject isInvalidated]を必ず確認する必要があります。データが更新された場合は問題ないのですが、削除された場合には-[RLMObject isInvalidated] == YESになりアクセスした時点で 例外が発生 します。RLMObjectを直接保持している場合にはこの点に注意する必要があります。
通知を受け取った時のRLMResultsの対応
UIの更新を確実に行う必要があります。RLMResultsはクエリ条件に応じて保持している結果が自動で更新されます(CoreDataのNSFetchedResultsControllerのモデル版みたいな挙動です)。
自動で結果が更新されるのでもし削除された場合には、データベースの変更通知を受け取った時点でUIを更新しないと-[RLMResults objectAtIndex:]などで範囲外エラーを起こす場合があります。
結論: RLMObjectを直接保持しない方がよいのかもしれない
-[RLMObject isInvalidated]との確認って煩雑だし、もしも忘れた場合には即例外のクラッシュが起きるのでとても危険です。
そこで私は直接RLMObjectを保持するのは避けています。もし直接保持したい場合は、deppCopyしたスタンドアローンオブジェクトのみにしています(実際にはこれに加えて変更を受け取りたくないなど諸々の理由がある場合にdeepCopyしている)。
もしも単体のRLMObjectが必要な場面ではRLMResultsを保持してそれに対してクエリを使って取得します。
@interface TweetViewController ()
@property (nonatomic) int64_t tweetID; // この画面で使用するツイートのID
@property (nonatomic) RLMResults *tweets;
@end
@implementation TweetViewController
- (void)viewDidLoad
{
[super viewDidLoad];
self.tweets = [Tweet objectsWhere:@"id = %lld", self.tweetID];
}
- (RLMObject *)tweet
{
return [[self.tweets objectsWhere:@"id = %lld", self.tweetID] firstObject];
}
@end
ツイートにアクセスする場合には-[self tweet]を使用します(※ Realmのフェッチは非常に高速なのでこの例でのコスト面の問題は全くないです)。self.tweetsには常に最新の状態が反映されるので、もしもtweetIDのツイートが削除された場合にはself.tweetsは空になるので-[self tweet]はnilになります。
これなら-[RLMObject isInvalidated]を気にする必要がなくなり、データベースの更新通知を受け取った場合には、UIの更新にのみ気をつければよくなります。
まとめ
RLMObjectのコンテキストを使わずにデータベースのモデルクラスとして扱えるというのはRealmのトップクラスのメリットだと感じています。RLMObjectをスタンドアローンに戻すのに最初はRLMObjectからNSDictionaryを作成して再度-[RLMObject initWithValue:]する方法を検討していました。ただそれならRLMObjectをそのままコピーした方がシンプルだしコスト面でも優位なのでdeepCopyを使い始めました。
場面によってはスタンドアローンのRLMObjectはとても有用で手軽にRLMObjectを扱うことができるようになります!ぜひ活用してみてください!
