iOSアプリデータの保存

  • 16
    いいね
  • 0
    コメント

iOSアプリでデータを保存する際に考察したこと。

データを保存する一例

保存したいもの

データ 具体例 制約 不可欠 固定長 セキュア
ユーザーの認証情報 ユーザーID/パスワード、アクセストークン、セッションID等 アプリを再起動しても、認証状態を維持させたい。大事な情報なので安全な場所に保存したい。
アプリのメタ情報、設定 初期チュートリアルを表示したか、起動回数、最後に起動したバージョン等 アプリを再起動しても消えない、信頼できる場所に保存したい。
通信で取得したモデルのキャッシュ 商品の一覧の1ページ目、商品画面の情報等 2回目以降の取得を高速化したい、無くてもアプリは動くようにしたい。増えていくので、適切に削除したい。
通信で取得した画像やバイナリデータのキャッシュ 商品画像等 2回目以降の取得を高速化したい、無くてもアプリは動くようにしたい。増えていくので、適切に削除したい。
アプリ側で保持するモデル ユーザーの分析や履歴データ、下書き保存したデータ等 アプリ側で取っておくモデル。アプリを再起動しても消えない、信頼できる場所に保存したい。

対応方法

データ 保存方法
ユーザーの認証情報 キーチェイン(kSecClassGenericPassword)
アプリのメタ情報、設定 NSUserDefaultsのデフォルト
通信で取得したモデルのキャッシュ NSTemporaryDirectoryディレクトリ上のRealmデーターベース
通信で取得した画像やバイナリデータのキャッシュ NSCachesDirectoryディレクトリ上のバイナリファイル
アプリ側で保持するモデル NSDocumentDirectoryディレクトリ上のRealmデーターベース

バイナリファイルの保存

自前でバイナリファイルをキャッシュ

キャッシュされたデータがあればそれを使用、なければサーバーから取得するシーケンスの一例。

データキャッシュ

画像の加工

次のようなニーズに出合った:
- 解凍:表示速度を考慮してJPEGやPNGを解凍したUIImageにして返す。
- リサイズ:表示する枠に合った目的のサイズにしたUIImageにする。(解凍と同時にできる)
- 画像の上に別の画像をオーバーレイ。

加工後のモノをキャッシュする機構を導入するのも有りかもしれないが、そこまで手が届かなかった。

画像を解凍サンプル
UIImage* dlImage = ...;
UIGraphicsBeginImageContextWithOptions(dlImage.size, NO, dlImage.scale);
[dlImage drawAtPoint:CGPointZero];
dlImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();

ファイル名

URLのMD5等ハッシュ値をファイル名にすると、実装が楽。
衝突無く、固定長のファイル名でファイルを保存できる。

保存時:

  1. 画像URLをMD5等にハッシュしてファイル名を作成する。
  2. キャッシュ用のディレクトリにそのファイル名でデータを書き出す。

参照時:

  1. 画像URLをMD5等にハッシュしてファイル名を作成する。
  2. キャッシュ用のディレクトリからそのファイル名のファイルの存在を確認したり読み込み。

書き込み時の考慮

ファイルを書き込んでる途中にアプリが落ちた場合等、破損ファイルができてしまう恐れがある。
NSDatawrite を使って保存すれば、atomicallytrueにして中途半端な状態でファイルが保存されるのを防げる。

NSDataのwriteの定義
func write(toFile path: String, atomically useAuxiliaryFile: Bool) -> Bool

削除ロジックの考慮

古いファイルを削除していかないと、空き容量が減っていく。
キャッシュはそもそも無くてもアプリは動作するが、容量が足りない原因で他の重要な書き込みが失敗するのは、あまりよろしくない。
他のアプリが容量取ってしまっている可能性もあるので完全にコントロールはできないが、自分のアプリぐらいはしっかりしたいので、削除ロジックを実装する。

ファイルの最終更新日から一定期間が経過したら、ファイルを削除する仕組みが比較的簡単に実装できるので入れる。
ファイルを列挙してfileModificationDateで書き込み日時を確認できる。
アプリを起動した後、バックグラウンドに行ったとき等に実行できる。
時間がかかる可能性があるので、別スレッドで行う。

指定dateより古いファイルを削除するサンプル
-(void)deleteCacheDataOlderThan:(NSDate*)date {
    NSFileManager* fileManager = [NSFileManager defaultManager];
    NSArray* filenameArray = [fileManager contentsOfDirectoryAtPath:_directoryPath error:nil];
    if (filenameArray == nil)
        return;

    NSLog(@"CacheData deleteCacheDataOlderThan: cached files: %lu", (unsigned long)filenameArray.count);
    for (NSString* filename in filenameArray) {
        NSString* filePath = [NSString stringWithFormat:@"%@/%@", _directoryPath, filename];
        NSDictionary* attributes = [fileManager attributesOfItemAtPath:filePath error:nil];
        if (attributes == nil)
            continue;

        NSDate* modificationDate = [attributes fileModificationDate];
        if ([modificationDate compare:date] == NSOrderedAscending) {
            NSLog(@"CacheData deleteCacheDataOlderThan: %@", filePath);
            [fileManager removeItemAtPath:filePath error:nil];
        }
    }
}

// ...

-(void)test {
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0), ^{
        NSDate* limitDate = [NSDate dateWithTimeIntervalSinceNow:-(60 * 60 * 24 * 7)];
        [[CacheManager sharedInstance] deleteCacheDataOlderThan:limitDate];
    });
}

キャッシュしたい期間は、用途によって違うが、そこまで手が届かなかった。
ディレクトリを分けることで対応はできそうな気がする。

空き容量の考慮

削除ロジックを入れるぐらいなら、空き容量が少ない場合はキャッシュをやめるという考慮。

空きが400MB以下になったら、キャッシュデータは保存しないようにするサンプル
+(unsigned long long)cacheFreeSpace {
    NSArray* cachePaths = NSSearchPathForDirectoriesInDomains(NSCachesDirectory,
                                                              NSUserDomainMask,
                                                              YES);
    NSString* cachePath = cachePaths[0];
    NSError* error;
    NSDictionary* dictionary = [[NSFileManager defaultManager] attributesOfFileSystemForPath:cachePath error: &error];
    if (dictionary == nil)
        return 0;

    NSNumber* sizeInBytes = dictionary[NSFileSystemFreeSize];
    return [sizeInBytes unsignedLongLongValue];
}

// ...
-(void)test {
    _lowStorageMode = ([CacheManager cacheFreeSpace] < 400 * 1024 * 1024);
}

空き容量が少ない状態のテスト

デバッグ機能で、ストレージを一杯にできる。
ストレージを一杯にした状態で書き込み失敗のテストがしやすくなる。

ストレージを一杯にするサンプル
// ダミーデータを用意
NSMutableData* tempData512KB = [NSMutableData data]; // 512KB
for (int i = 0; i < 32*1024; i++) {
    [tempData512KB appendBytes:"0123456789012345" length:16];
}
NSMutableData* tempData64MB = [NSMutableData data]; // 64MB
for (int i = 0; i < 4*1024*1024; i++) {
    [tempData64MB appendBytes:"0123456789012345" length:16];
}

// ディレクトリ作成
NSArray* documentPaths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
[[NSFileManager defaultManager] createDirectoryAtPath:[NSString stringWithFormat:@"%@/temp", documentPaths[0]]
                          withIntermediateDirectories:YES
                                           attributes:nil
                                                error:nil];

// ストレージを一杯にしていく
for (;;) {
    NSString* path = [NSString stringWithFormat:@"%@/temp/%@", documentPaths[0], [[NSUUID UUID] UUIDString]];
    if ([tempData64MB writeToFile:path atomically:NO]) {
        NSLog(@"Write: wrote 64MB to %@", path);
    } else {
        if ([tempData512KB writeToFile:path atomically:NO]) {
            NSLog(@"Write: wrote 512KB to %@", path);
        } else {
            NSLog(@"Write: failed, ending");
            break;
        }
    }
}

Realmデーターベースへの保存

API通信で取得したモデルをキャッシュ

サーバーAPI通信で取得したモデルのキャッシュをするシーケンス一例。
モデルキャッシュ

流れ:

  1. キャッシュされたモデルを参照してみて、存在すればそれをUIに表示する。(古いモデルを表示してユーザーを待たせない考え。)
  2. キャッシュされたモデルがあっても無くても通信して(非同期)サーバーから最新モデルを取得。
  3. 新しいモデルをUIに表示する。フェード等で優しく表示を切り替える。
  4. 新しいモデルをキャッシュに保存。

キャッシュするかの判断

全部キャッシュするような共通処理を実装してしまうと、不便だと感じた。
無駄にキャッシュしてしまっている箇所が出てくる。
「キャッシュの容量を使ってでも、ちょっと古いデータが表示されてでも良いからここは高速化するべき」という箇所だけに実装したくなった。
呼び出し側で判断できるように変更していった。

ちゃんと全パターン網羅して設計するか、処理を細かく分割しておいて直接使う所で判断できる実装にするか迷ったが、どんなパターンが出てくるか分からないので後者にした。

例:

キャッシュ有無 具体例
常にキャッシュしたい 商品の閲覧画面、頻繁に通信で取ってくる画面のモデル等
一部キャッシュしたい 商品リストの1ページ目(ファーストビュー)はキャッシュするが2ページ目以降はスクロール中に通信するのでしない等
常にキャッシュしたくない 商品カート画面、チャット画面のモデル等

データベースの分割(ファイルサイズの考慮)

データベースファイルをmmap()でメモリに乗せるので、大きすぎるデータベースはメモリ確保の失敗に繋がる。
バグトラッカー(Crashlytics等)でメモリ確保の失敗が多く報告される場合は、データベースの分割するべきと考える。
モデルの数が多いものは、設計段階で保存先のデータベースを複数に分けることもできる。

分けることでデータベースを開けないエラー率は実際に下がった。
どの程度の大きさで分けるべきかはよく分からないので、ある程度感覚をつかむために次の数字をアナリティクス(Google Analytics等)で取っておくと良いと感じた:

  • アプリ起動時の各データベースのファイルサイズ
  • アプリ起動時のユーザーのメモリの空き

アナリティクスでユーザーの空き容量を取る

一例ではあるが、Google AnalyticsやFirebaseで次の方法で統計を取っていた:

  • 容量のレンジごとにイベント名を分ける。(例:データベースのサイズなら0MB_1MB1MB_2MB、…)
  • アプリ起動時にユーザーがどのレンジに入るかみてイベントを発行。

大体どこかの範囲に集中していた。

その他、バグトラッカー(Crashlytics等)にユーザーの属性情報としてユーザーのメモリやデータベースのサイズを添付していた。データベースが開けなくて落ちる人がどれ位のファイルサイズになっているか分かる。

コンパクト化の考慮

Realmデータベースは変更する度に肥大化していく。データベースを別ファイルにコピーする機能が用意されているが、それを使うとファイルが最適化(コンパクト化)される。

アプリ起動時にコピーを作成すると良さげだった。
ただ毎回やるとアプリの起動時間が若干長く感じられるので、「ある程度の大きさになったら5回に1回」の条件付きが良さげだった。ここもアプリ起動時の各データベースのファイルサイズをアナリティクスでとっておくと調整がしやすい。

次の処理でRealmデータベースをコンパクト化できる:
1. テンポラリファイルがある場合は削除。(前回に失敗等による残骸がある場合の考慮)
2. writeCopyToURLでテンポラリファイルにコンパクト化された状態でRealmデータベースをコピー。
3. FileManagerreplaceItemでテンポラリファイルからRealmデータベースへ安全に上書きコピー。
4. テンポラリファイルを削除。
5. 上書きコピーした新しいRealmデータベースを使う。

削除ロジックの考慮

モデルをキャッシュしていくと、Realmデーターベースが大きくなっていく。
放っておくとデータベースファイルが大きくなり、メモリに乗らなくなる。
そこで削除のロジックを入れる。

削除するタイミングはモデルがメモリ上で参照されていないことが保証されている、アプリの起動直後が良かった。(無効なモデルをUIが参照して落ちる等が無い)
ついでにコンパクト化するなら、削除ロジックを行った後が良さげだった。

削除方法 モデル側実装 削除処理
論理削除 モデルに deleted: Bool(モデルが削除されたことを表すフラグ)を入れて適切に更新する。この値がtrueなモデルはUI等は無視するようにする。 アプリ起動時にこの値がtrueなモデルをまとめて削除する。
古いモデルを削除 モデルにupdate: Date(最後にモデルが変更された日時)を入れて、変更が合った場合に更新する。 アプリ起動時に最終更新から一定期間が経過したモデルをまとめて削除する。
古い又は論理削除されたモデルを削除するサンプル
NSDate* limitDate = [NSDate dateWithTimeIntervalSinceNow:-(60.0 * 60.0 * 24.0 * 7.0)];
RLMResults* modelResults = [Model objectsInRealm:cacheDb
                                           where:@"update < %@ || delete = YES", limitDate];
[cacheDb deleteObjects:modelResults];

初期パラメータの展開

Realmデータベースはアプリにバンドルすることができる。(Realmファイルをアプリに入れておくことができる)

アプリの起動時に重要なパラメータ等を取得するより、できるだけアプリ側に予め初期モデルを持たせておくと、ユーザーを待たせなくて済む。

パラメータの取得処理

  1. アプリ起動直後、ストレージ上でRealmファイルの存在を確認。
  2. 存在しない場合は、アプリにバンドルしてあるRealmファイルをコピー。
  3. 新しいRealmファイルを開いて処理を行う(マイグレーション等)。このときちょっと古い初期モデルが入っているので、既にある程度アプリの画面を表示できる。
  4. パラメータ取得APIが走って初期モデルが更新される。
バンドルファイルをストレージにコピーするサンプル
[[NSFileManager defaultManager] copyItemAtPath:[[NSBundle mainBundle] pathForResource:@"bundle_cache" ofType:@"realm"]
                                        toPath:cacheDbPath
                                         error:nil];

バンドルするパラメータの作成手順

  1. アプリのバンドルRealmファイルが使用されない特殊なビルドを作成し、真っさらな状態でアプリを起動する。
  2. パラメータ取得APIが走ってRealmファイルが作成される。
  3. デバッグ機能でそのRealmデータベースのモデルを別の一時Realmデータベースに書き出す。
  4. 書き出したRealmデータベースを更に別のRealmデータベースにコピー(コンパクト化)する。(コンパクト化した状態でバンドルしたいため)
  5. 書き出したRealmファイルのパスをコンソールのログに出し、そのファイルを回収し、アプリのプロジェクトに追加/更新する。

アプリ起動カウンターで再起不能を回避する考慮

アプリ起動時のデータベースの初期化等の操作の途中、何らかの理由でアプリが落ちる場合、
アプリが起動できない状態に陥ってしまう。

アプリが起動できなくなるぐらいなら、キャッシュを消した状態で起動したほうがマシであると考える。
更に、最悪重要なデータも削除してでも、初期起動状態にしたほうがマシであると考える。(サポートでアンインストールしてインストールさせる手間を省く)

アプリの起動に失敗したか確認するためのアプリ起動カウンターを設けて、一定を超えたらデータを削除する処理を入れる。

流れ(一例):
1. アプリを起動時、NSUserDefaultsで整数パラメータmodelInitFailedCounterをインクリメント。(初期値0)
2. modelInitFailedCounterが2(任意)だった場合、Realmデータベースを全て削除する。
3. モデルの初期化(初期パラメータの展開、マイグレーション、削除ロジック、コンパクト化等)を行う。
4. 正常に終わったらmodelInitFailedCounterを0に設定。

このロジックで何度かアプリ起動できない状態に陥るアプリのアップデートから抜けられた。本当はちゃんとデバッグするべきだが、考慮が漏れることはあると考える。

その他保存方法

キーチェイン

  • 第三者に知られたくない情報をセキュアに保存できる公式API。
  • アプリをアンインストールしても残る。
    • 再インストール時に残っていると都合悪い場合は、初回起動に削除する処理を入れる必要がある。(NSUserDefaultsはアンインストール時消えるので、それで区別できる)
  • Dictionaryを保存する場合はNSKeyedArchiverで1個のデータにまとめられる。

NSUserDefaults

  • Dictionaryを保存する場合はNSKeyedArchiverで1個のデータにまとめられる。

参考