【Unity】ローカルデータの保存

はじめに

こんにちは、Unityエンジニアの清水です。
この記事はカヤックUnityアドベントカレンダー2016の18日目の記事になります。

今日はローカルデータの保存についてお送りします。

データの保存について

サーバの存在するゲームでは大抵のユーザデータはサーバ上に保存されると思います。
サーバにデータが置いてあればユーザが任意に書き換えられないのでチート対策になりますし、アプリをインストールしている端末を壊してしまったときに新しい端末とサーバのデータを紐付け直せば復帰できるかもしれません。
しかし、端末上には何もデータを保存しないかといえば、そんなことはありません。
例えば、サーバ上のデータと紐付けるためのIDのような値、通信や処理を減らすためのキャッシュデータ、書き変えられたり失われたりしてもあまり問題ない音量や表示の設定値といったデータは端末上に保存されるかもしれません。
そこで、この回では端末上にデータを保存するときにどのような選択肢があるのかと、それらを扱う際の注意点を書いていきます。
端末は主にiOSとAndroidを対象としています。

保存領域

まずは、よく使われる保存領域とその性質についてです。

PlayerPrefs

PlayerPrefsはUnityEngineが提供しているデータの保存方法で、もっとも手軽に使えます。
key-value形式で、int、float、stringを保存できます。
公式ドキュメント

使用するキーは、重複やタイポによるバグを防ぐためにも、定数化して1箇所にまとめて管理すると良いでしょう。
また、プロジェクトに組み込んだプラグインやSDKがPlayerPrefsを使っている場合、それらで定義しているキーと重複する可能性があるので、キーにはプロジェクト固有のprefixをつけると安全です。

public static class PlayerPrefsKey
{
    public const string bgmVolume = "kayac_bgm_volume";
    public const string seVolume = "kayac_se_volume";
}

PlayerPrefsの変更はアプリ終了時にディスクへ保存されますが、アプリがクラッシュすると保存されないため、変更後キリの良いタイミングで PlayerPrefs.Save() を実行するようにします。

using UnityEngine;

public class Sample
{
    public void SaveVolumes(float bgmVolume, float seVolume)
    {
        PlayerPrefs.SetFloat(PlayerPrefsKey.bgmVolume, bgmVolume);
        PlayerPrefs.SetFloat(PlayerPrefsKey.seVolume, seVolume);
        PlayerPrefs.Save();
    }
}

実際に保存される領域ですが、iOSはUserDefaults、AndroidはSharedPreferencesが使われているようです。
これらは1つのXMLファイルに書き込まれるため、PlayerPrefsに大量のデータや巨大なデータを入れるのは避けたほうが無難です。
ディスクからメモリに読み込んだりディスクに保存する際にPlayerPrefs全体を読み書きすることになるので、保存する内容が大きくなりすぎるとパフォーマンスの低下を引き起こすためです。
PlayerPrefsの使用用途としては、Prefsの名前の通り、音量設定などの単純な設定値の保存が適しています。
サイズが大きいデータは後述する方法で個別にファイルとして保存しましょう。

また、少し変わったPlayerPrefsの使い道としては、保存場所がUserDefaults、SharedPreferencesであることを利用して、ネイティブプラグインとの値のやり取りに使うこともできます。

データ用ディレクトリ

ファイルとしてデータを保存する場合、UnityEngine.Application.persistentDataPath で取得したディレクトリ以下にSystem.IOのStreamWriterやFileStreamなどを使って書き込みます。
保存データの読み込みにはStreamReaderやFileStreamなどを使います。
オブジェクトの内容をそのまま保存したい場合は、jsonやxml、BinaryFormatterなどでシリアライズして保存し、読み込み時にデシリアライズします。

iOSではサーバから再取得可能なキャッシュデータをここに置いておくとガイドライン違反でリジェクトされる可能性があるので、そのようなデータは後述のキャッシュ用ディレクトリに保存します。
そのため、AssetBundleのキャッシュシステムを自前で用意する場合などは、iOSの保存場所はキャッシュ用ディレクトリを使ったほうが無難でしょう。

また、ここに保存した内容はiCloudへバックアップされるので、あまり容量が大きくならないように注意します。
iCloudへのバックアップが必要無いなら、UnityEngine.iOS.Device.SetNoBackupFlag を使って除外することもできます。

キャッシュ用ディレクトリ

UnityEngine.Application.temporaryCachePath でキャッシュ用のディレクトリが取得できます。
ファイルの読み書き方法はデータ用のディレクトリと同じですが、以下のような違いがあります。

  • ディスク容量が足りない時はアプリ起動中であってもOSによって削除される
  • Androidは設定の「アプリ」の「キャッシュを消去」で削除される
  • iOSでiTunesやiCloudのバックアップ対象にならない

どのタイミングで削除されてもおかしくないので、その前提で使う必要があります。
特にAndroidでは容量確保のためにユーザ操作で頻繁に削除するケースが多いです。
そのためAndroidではiOSと逆で、AssetBundleの保存場所には使うべきではありません。

(2018.12.20追記) 最近のiOSはキャッシュ用ディレクトリがOSによって削除されやすくなっているようなので、AssetBundleの置き場所としてキャッシュ用ディレクトリは避けた方が良さそうです。

Keychain (iOS)

iOSではデータの保存先としてKeychainも候補になります。
UnityのAPIは用意されていないのでネイティブプラグインが必要にはなりますが、Keychainに保存した情報はアプリをアンインストールしても失われないという特徴があります。
これを利用して、ユーザを識別する値を保存しておくことで、アプリを再インストールした際にも端末とサーバ上のデータの紐付けを復活させるといったことができます。
Keychainに保存した内容は通常のバックアップでは他の端末に引き継がれませんが、暗号化ありのバックアップから復元した場合は引き継がれます。
保存時の設定によっては暗号化ありでも引き継げなくできます。

SQLite

上記の他にも、大量のデータを管理するような場合にローカルでSQLiteを使うという手がありますが、弊社では使っているプロジェクトが無かったので割愛します。

データの保存に関する注意点

暗号化

ローカルのディスクに保存したデータはユーザが見ようと思えば見ることができるし、編集もできます。
そのため、見られたくない、編集されたくないデータは暗号化して保存するべきです。
ただし、暗号化していれば絶対に大丈夫とも言えないため、重要なデータはできる限りサーバ上に保存しておくのが安全です。

SDカード (Android)

Androidではデータの保存先として内部ストレージと外部ストレージ(SDカード)の2種類があります。
PlayerSettingsのWrite AccessがInternal OnlyならApplication.persistentDataPathは常に内部ストレージを返しますが、Externalの場合はSDカードが刺さっているときはSDカード、刺さっていないときは内部ストレージを返します。
f:id:shimiu:20161216112000p:plain
外部ストレージにデータを保存するようにしていると、SDカードを抜いたときに保存データを取得できなくなるので注意が必要です。

例外処理

ディスク容量が足りないなどでデータの保存に失敗したり、読み込もうとしたデータが破損していて読み込みに失敗したりすることがあります。
そのため、ファイルの読み書き時の例外処理は必須になります。
特にディスク容量不足の場合は、ディスク容量を空けるように促すメッセージをユーザに対して表示すると良いでしょう。

また、ファイルの上書き保存中に処理が止まると、変更前後の両方のデータが失われることになるので対策が必要です。
上書き保存の際は一旦別のファイルとして保存して、保存完了後に元ファイルを新しいファイルで置き換えるといった方法をとると安全です。

バージョンアップ

できれば避けたいパターンですが、アプリのバージョンアップでローカルに保存しているデータの形式を変えるような場合は、既存データを考慮する必要があります。
例えば、シリアライズして保存するオブジェクトのフィールドを変更した場合に、データをデシリアライズできなくなってエラーが発生するかもしれません。
既存データは消して良いのか、それとも読み込んで新形式に変換する必要があるのか、検討して適切な処理を書きましょう。

おわりに

今日はデータの保存についてお送りしました。
明日はによる通信周りの話の予定となります。