Android
android開発
SharedPreferences

SharedPreferencesの管理方法

Androidアプリでちょっとした設定値を保存するには、SharedPreferencesを利用するのが一般的だと思います。
ただ、安直に利用しているとアプリがスケールしていった場合などで管理が難しくなっていったりするので、初期段階から将来を見越した管理方法を考えておいた方がよいでしょう。
管理方法について私の持論や、実践している管理方法について紹介します。(異論は認める、というか穴だらけだと思うので突っ込み歓迎)

実践についてはこの辺参照:
https://github.com/ohmae/DmsExplorer/tree/develop/mobile/src/main/java/net/mm2d/dmsexplorer/settings
https://github.com/ohmae/VoiceMessageBoard/tree/develop/app/src/main/java/net/mm2d/android/vmb/settings

SharedPreferencesの仕組み

その前に、SharedPreferencesはどういう仕組みで設定を保存しているのかというところを軽く説明します。

SharedPreferencesのインスタンスは、PreferenceManager#getDefaultSharedPreferences(Context)で取得することができますが、他にContext#getSharedPreferences(String, int)など、名前を指定したSharedPreferencesを使うこともできます。(第二引数はすでに意味を失っているので第二引数なしのAPIができててもいい気がする)
Context#getSharedPreferences(String, int)の第一引数がそのままファイル名として利用されてファイルに書き出されます。

PreferenceManager#getDefaultSharedPreferences(Context)の実装は以下のようになっていて、「<アプリのパッケージ名>_preferences」をContext#getSharedPreferences(String, int)の第一引数に指定しています。

public static SharedPreferences getDefaultSharedPreferences(Context context) {
    return context.getSharedPreferences(getDefaultSharedPreferencesName(context),
            getDefaultSharedPreferencesMode());
}

private static String getDefaultSharedPreferencesName(Context context) {
    return context.getPackageName() + "_preferences";
}

private static int getDefaultSharedPreferencesMode() {
    return Context.MODE_PRIVATE;
}

SharedPreferencesが読み書きしているファイル

デバッグモードのアプリであればadbコマンドでアプリ用ディレクトリ以下をみることができるので、一度みてみるとよいでしょう。SharedPreferencesの保存ファイルはアプリ用ディレクトリ以下の shared_prefs というディレクトリの下に保存されます。

$ adb shell run-as <アプリのパッケージ名> ls -la /data/data/<アプリのパッケージ名>/shared_prefs
total 4
-rw-rw---- 1 u0_a96 u0_a96 1943 2018-06-10 02:59 <アプリのパッケージ名>_preferences.xml

PreferenceManager#getDefaultSharedPreferences(Context)で取得できるSharedPreferencesを使っている場合は、<アプリのパッケージ名>_preferences.xml というファイルになります。Context#getSharedPreferences(String, int)を使った場合は、第一引数に指定した名前にxmlの拡張子をつけたものになります。

拡張子からわかるように中身はxmlです。
実際に中身をみてみましょう。

コード上から以下のように書き出した場合の結果です。

PreferenceManager.getDefaultSharedPreferences(this)
        .edit()
        .putBoolean("boolean", true)
        .putInt("int", 0)
        .putLong("long", 0L)
        .putFloat("float", 0f)
        .putString("string", "string")
        .putStringSet("string-set", setOf("string", "set"))
        .apply()
$ adb shell run-as <アプリのパッケージ名> cat /data/data/<アプリのパッケージ名>/shared_prefs/<ファイル名>
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <boolean name="boolean" value="true" />
    <string name="string">string</string>
    <float name="float" value="0.0" />
    <set name="string-set">
        <string>set</string>
        <string>string</string>
    </set>
    <int name="int" value="0" />
    <long name="long" value="0" />
</map>

このように非常にシンプルなxmlとして書き出されます。

SharedPreferencesの実装

SharedPreferencesはインターフェースなので実装は隠蔽されていますが、実装クラス(SharePreferencesImpl.java)もSDKの中にあるので簡単に読むことができます。どういう実装になっているのか、見ておいても損はないでしょう。


ここから本題です。

DefaultSharedPreferencesを使うべきか?

前述の通り、PreferenceManager#getDefaultSharedPreferences(Context)というデフォルトのファイル名を指定するメソッドが用意されている一方、任意のファイル名を指定することもできます。

このDefaultSharedPreferencesを使うべきか否かですが、アプリとしての基本的な設定はこれを使った方がいいと思います。積極的な理由はありませんが、単一のファイルに書き出すだけなら、わざわざ標準外の名前をつける必要がありません。標準から離れる積極的理由がないなら標準に従うべきでしょう。

こちらも理由としては弱いものの、PreferenceFragmentなどの仕組みを利用するなら(PreferenceFragmentの是非については置いておいてください)DefaultSharedPreferencesを使わざるを得ませんし、ユーザーが明示的に行う設定はここに保存しておいた方がよいと思います。

DefaultSharedPreferences以外は使うべきで無いか?

必要に応じて使ってよいと思います。

例えばABテストを行うための保存値など、一時的にのみ利用し将来残していく必要のない値というものもあるはずです。これをずっと残し続けていく値と一緒に保存してしまうと、不要になった値をクリアする処理が面倒になります。残しておいてもそれほど害はないとは言え、シンボルの重複を避けるためにその使われていないシンボルを残し続ける必要があり、精神衛生上よくないです。
短期間のみ使用し、将来にわたって残す必要の無いデータだけを別のファイルに分離しておけば、定期的に内容をクリアすることで不要になった設定値を将来に持ち越さなくてもよくなります。
また、ライブラリがアプリとは別に設定値を保持したい場合は、アプリ本体のファイルに混ぜるといろいろと問題が起きるので名前が衝突しないようにしておく必要があるでしょう。

ただ、分割するといっても設定値一つにつき1ファイルみたいな極端な分割をすると、今度はそのファイル名の重複やら命名規則に頭を悩ませる必要が出てきます。

一度作ったSharedPrefenrecesファイルを削除するのは難しいため、安易に分割すると将来にわたってゴミが残り続ける可能性があるということも考えておく必要があります。(Context#deleteSharedPreferences(String)はAPI level 24である。「えっ、無かったの!?」というメソッド)
削除自体はできたとしても、過去書き込んでいたファイルのことを完全に忘れてしまえるのは、市場から当時のバージョンを使用しているユーザーがいなくなり、削除するようになったバージョンが完全に浸透し、全ユーザがそれを実行した後、となるので、数年間は残り続けてしまう覚悟が必要です。

分割する場合は、将来不要になる可能性が十分に低い程度の粒度で、その設定値の性質ごとの適切なまとまりで分割するようにしましょう。

SharedPreferencesへの操作はどこで行うべきか?

SharedPreferencesのインスタンスはContextが使えるところであればどこでも取得することができますが、SharedPreferencesのインスタンスを直接操作するのは避け、処理をカプセル化すべきでしょう。ある意味当たり前と言えば当たり前ですが。

SharedPreferencesのインターフェースは自由度が高すぎるため、直接読み書きしていると収拾がつかなくなってしまいます。SharedPreferencesにアクセスしてよいクラスを限定し、特定のパッケージ下で実装等を隠蔽、集約する仕組みを作った方がよいでしょう。

ちなみに、SharedPreferencesはContextから取得するたびに新規のインスタンスが作られるわけでは無く、内部でインスタンスがキャッシュされています。つまり、Singleton等でインスタンスを持ち続けてもメモリの逼迫具合は変わりませんので、必要があるなら気兼ねなくインスタンスを保持し続けてよいと思います。

絶対に書き込んでおくべき値はあるか?

どのような値を保持すべきかはアプリによって自由に設定すればよいとは思います。
しかし、どのようなアプリであろうとも、設定のバージョン番号は必ず書き込んでおくようにしましょう。今現在は必要なくても将来必ず必要になります。アプリのバージョンではありません(対応づけができるのであればそれでもよいですが)。どのような値をどのように書き込んでいるかという、設定ファイルのバージョン番号です。

読み書きする値が増えたり減ったり、読み替える必要があるタイミングでバージョン番号をインクリメントしておくと、後ほど設定ファイルのマイグレーションを行う際に役に立ちます。

私のアプリでは起動時に以下のような処理を行っており、
設定バージョンを読み出して、現在のバージョンなら何もしない。旧バージョンならクリアするということをしています。

fun maintain(storage: SettingsStorage) {
    val currentVersion = getSettingsVersion(storage)
    if (currentVersion == SETTINGS_VERSION) {
        return
    }
    storage.clear()
    storage.writeInt(Key.SETTINGS_VERSION, SETTINGS_VERSION)
    writeDefaultValue(storage, false)
}

少し前までは、ここに旧設定値を新設定値に書きかえる処理を書いていましたが、変更してから十分に時間がたった(1年以上)ため、マイグレーションサポートは打ち切り、設定値をクリアするだけにしています。

バージョン番号だけをみて一定バージョンより古い場合はクリアするという処理が入っているため、それ以前にどのような内容を書き込んでいたかは完全に忘れることができます。

永続化情報は負債となりやすいものですので、それを清算できる仕組みを組み込んでおくことは非常に重要です。

キーをどのように管理すべきか?

SharedPreferencesではStringをキーとして読み書きします。内部では

private Map<String, Object> mMap;

という一つのMapにObjectとして各値が保持されています。
当然、キーの値が重複することは絶対に避けなければなりません。
キーの定義ということで、以下のような定義をしていると、設定値が数個ならよいですが、これが数十個とかに増えてくると値が重複しているのかどうかのチェックも難しくなります。さらにキーの定義があちこちに散らばったりするともう手に負えません。
データ型が同じならば重複していても動いてしまったりするところも発見が難しい要因になります。

const val KEY_HOGE = "hogehoge"
const val KEY_FUGA = "fuga"

リフレクションを使ったテストを書いておくとか、アノテーションをつけて警告が出るように、などの方法もあるとは思いますが、労力が一番小さな方法としてenumとして定義し、enumの#name()の値をキーにするのがよいと思います。

enum class Key {
    HOGE,
    FUGA,
...
}

同じ名前を定義してしまうとそもそもコンパイルが通りませんので、重複が無いことを保証できます。
SharedPreferencesへのアクセスをラップして、引数として必ずこのkeyを指定するようにすれば、新規のキーをどこに追加すべきかということも明確になりますので、定義が散らばってしまって収拾がつかなくなるなんてことも防ぐことができるでしょう。

保存するデータ型が変わったキー

もともと、ある機能のon/offという意味でboolean値で保存していたが、機能が追加され、3択の設定となったため、int値で保存したい。というようなことはよくあることでしょう。
このような場合は、設定値の意味が同じであろうとも、必ずキーの値を変更する必要があります
旧バージョンではboolean値で書き込んでいるので、それを新バージョンでint値として読み込もうとするとClassCastExceptionが発生してしまいます。

これは旧バージョンで設定値を書き込んだ後、バージョンアップを行った場合にのみ問題が発生するので、テストでは見つけにくい問題です。
リリース直後にクラッシュの嵐という経験をされたかたも少なからずいるのではないでしょうか?

使わなくなったキー

アプリの歴史が長くなれば当然、機能の変更などにより、かつては保存していたが必要なくなった、という設定値がすくなからず出てくるでしょう。
前項のような型が変わったために名前を変更した場合のそれまで使っていたキーも同じです。
そのような場合に注意しなければならないのが、一度使用したキーは再利用してはならない。ということです。
保存されたファイルのデータはアプリをアップデートしても残り続けますので、旧バージョンでほぞされたそのままのファイルを、新バージョンでも読み出せなくてはいけません。

そのとき、同じキーを使ってしまったら・・・

データ型が違っていればClassCastExceptionが発生します。
データ型が同じだった場合は、ぱっと見は動いて見えるけど、初期設定が想定したものと違う、という非常に微妙な不具合になったり、想定していない範囲の値が読めてしまったがためのクラッシュなども発生するでしょう。

当時と同じ開発者であっても気づくのは難しいでしょうし、違う開発者が変更したのであれば気づくことは不可能でしょう。
かつてその設定値を使っていたバージョンで使用し、その値を書き込んだ後でバージョンアップした場合にしか問題が発生しませんから、おそらくリリースまでのテストで見つけることは非常に難しいでしょう。
そして、長く使ってくれているユーザーのいる市場では高頻度で問題が発生します。

このような問題に対応するため、前述のようにenumでキーを管理しつつ、使われなくなったキーは削除するのでは無く、残し続けることをルールとしましょう。使わなくなっていることを明示するため、@Deprecatedをつけるなどしておくとよいでしょう。

@Deprecatedをつけた使用しなくなったキーは、設定バージョンを上げ、マイグレーションサポートを切り捨てられた時にやっと削除できるようになります。

デフォルト値の管理はどうすればいいか?

SharedPreferencesからの読み出しでは以下のように必ずデフォルト値を指定します。値が書き込まれていない場合はこの値が返ります。

sharedPreferences.getInt(key.name, defaultValue)

このデフォルト値をどのように管理すべきかという問題があります。
SharedPreferencesをラップするクラスをもうけて、読み書きメソッドでデフォルト値を書いておくというのもありですが、実コード上にデフォルト値が分散するため、数が多くなると見通しが悪くなってしまいます。

キーの管理で説明したように、キーをenumで定義しているのであれば、キーのenumにデフォルト値を持たせてしまうのがよいと思います。

こんな感じ、使用する設定のキーとそのデフォルト値をまとめて書いておくことができます。

enum class Key {
    HOGE(true),
    ...
}

こうすると、読み出しのメソッドは、Keyを指定するだけで値が無いときにそのキーに応じた適切なデフォルト値を返せるようになります。

fun readBoolean(key: Key): Boolean {
    return preferences.getBoolean(key.name, key.getDefaultBoolean())
}

欠点は、読み出す場所によってデフォルト値を変えたい場合でしょうが、そんなことが必要な場合はロジックを見直した方がいいでしょうね。

なお、PreferenceFragmentなどを使う場合、xmlにデフォルト値を書くことができ、PreferenceFragmentで読み込んだときか、PreferenceManager#setDefaultValues で呼び出したときに、値が書かれていなければそのデフォルト値が書き込まれる仕組みになっています。

これを利用するという手も無くはないですが、初期値の管理と仕方としては余計な情報が多いこと、汎用的に使えないこと、PreferenceManager#setDefaultValuesではPreferenceScreen等が作られており、実行コストが高い、またPreferenceFragmentCompatを使っている場合はHandlerスレッド以外からのコールでエラーになる、などなど問題があるため、enum管理のものを書き込む独自の仕組みを作った方がよいと思います。

初期値を書き込んでおくべきか?

SharedPreferencesの読み出しメソッドに指定するデフォルト値は値が書き込まれていない場合の値となります。
PreferenceFragmentなどを利用している場合は画面をinflateした時にxmlに記述したデフォルト値が書き込まれますが、そうでなければ、設定を変更したときにのみその値が書き込まれているという状態になります。
保存すべき値すべてを書き込んでおく必要が無いため効率的ではありますが、バージョンアップ時にデフォルト値を変更したいときに問題がでてきます。
読み出しのさいのデフォルト値を変更すると、過去バージョンで設定変更を一度も行っていない場合、動作が変化します。
すでにその設定で使い始めているユーザーはそのままで、新規ユーザーのデフォルト値のみを変化させたい場合があります。
そうなった場合、設定バージョンを変更し、旧バージョンの場合は旧デフォルト値を書き込んでおくようにする、などの対応が必要になってきます。
しかし、はじめからデフォルト値を全部書き込んでおけばこのようなマイグレーション処理は必要なくなります。

これはどちらも一長一短なのでどちらがよいとは言えないと思いますが、デフォルト値を変更する際、すでに使い始めているユーザーをどうするかという観点は抜け落ち安いため、特に多くの開発メンバーが関わるようなプロジェクトであれば、書き出しておいた方がトラブルが少ないと思います。

値の型をどのように管理すべきか?

SharedPreferencesへの読み書きのキーはStringで共通ですが、そのキーに紐付く値の型は統一しておく必要があります。
一カ所でも間違っていると ClassCastException が発生してしまうのですが、必ずしも間違った箇所で発生するのでは無く、後で本来の型でアクセスしたときに発生するというものなので、単純なユニットテストでは見つけにくいという問題があります。

これを適切に管理するにはどうすればいいでしょう?

ここについてはどうすればいいかを模索している最中です。

①enum名に型名を含める

enum class Key {
   HOGE_INT,
   HOGE_BOOLEAN,
}

一度やったけどこれはないなと思ったものから。

人間の目で見たときに多少わかりやすくする方法です。
全くルールがないよりはましだけど、テストを書いた場合も見つけにくさも変わらないし、うーん。誰かが間違った名前つけたらもうどうしようも無くなりますな。安易にリファクタリングできないから後で直すことも難しいし。

preferenceWrapper.putInt(Key.HOGE_BOOLEAN, hoge)

こんな風に書けてしまって、問題なく動かすことができてしまう。そしてミスに気づいても安易にはなおせない。

②型ごとに別のenumを定義する

enum class IntKey {
   HOGE,
...
}

enum class LongKey {
   FUGA,
...
}
void putInt(IntKey key, int value) {
    ...
}
void putLong(LongKey key, long value) {
    ...
}

型ごとにキーのenumを分け、読み書きをラップするクラスの引数にそのenumのみを許すようにする方法です。アクセスのルールを守る限りは型を間違うと必ずコンパイルエラーになるため、問題が起こりにくくなります。

enumを分けて定義するので、別々のenumに同じ名前の要素を定義できてしまう問題がでてきます。キーとして使うときはプレフィックスなどをつけるなどで簡単に回避できるとは言え、やや気持ち悪さが残ります。

また、たかだか6種類とはいえ、それだけのenumを作る必要がありますし、型ごとに別々のenumにキーを定義するため、関連したキーをまとめて書いておくことができなくなります。
さらに複数のSharedPreferencesを使う場合、一つのenumで複数のSharedPreferences用のキーを定義するのはどこで使うかが曖昧になるのでやめた方がいいでしょうから、そのバリエーション×型の数のenumが必要になります。

強制力は強いものの、ルールが複雑でコストがかかりすぎている感があります。

③enumの初期化時にデフォルト値を指定し、その型以外では使えないようにする

enum class Key {
    HOGE(0),
    ;
    private enum class Type {
        BOOLEAN,
        INT,
        LONG,
        STRING,
        STRING_SET,
    }

    private val type: Type
    private val defaultValue: Any

    constructor(value: Any) {
        type = when (value) {
            is Boolean -> Type.BOOLEAN
            is Int -> Type.INT
            is Long -> Type.LONG
            is String -> Type.STRING
            is Set<*> -> Type.STRING_SET
            else -> throw IllegalArgumentException()
        }
        defaultValue = value
    }

    internal fun isBooleanKey(): Boolean {
        return type === Type.BOOLEAN
    }
    ...
}
fun writeBoolean(key: Key, value: Boolean) {
    if (!key.isBooleanKey()) {
        throw IllegalArgumentException(key.name + " is not key for Boolean")
    }
    ...
}

初期値を必ずenumと併せて定義するようにして、そのときの型以外でのアクセスができなくします。
初期値をenumに持たせるのであれば定義に関するコストは変わりませんし、②の案のように定義がばらついたりむやみにenumが増えたりしません。

ただ、間違った使い方はコンパイルエラーではなく実行時例外になるため②の案よりは弱いです。また、指定したデフォルト値から型の判定を行うため、特に直接リテラルを記述する場合、intlongの取り違えが起こりやすくなり、間違いに気づきにくいというデメリットがあるかと思います。(Lの付け忘れによるlongのint化、桁あふれによるintのlong化など)

いずれも、簡単なユニットテストで見つけられる範疇ではあります。

④enumの初期化引数に型を含める

public enum Key {
    HOGE(Long.class, 0L),
    ;
    @Nullable
    private final Class<?> mType;
    @Nullable
    private final Object mDefaultValue;

    <T> Key(@NonNull final Class<T> type, @NonNull final T defaultValue) {
        mType = type;
        mDefaultValue = defaultValue;
    }

③とどっちがいいかと迷っている案、初期化引数として値の型を指定するようにしたものです。デフォルト値との型の不一致はコンパイルエラーになります。
例えば、上記の0LLを忘れるとコンパイルエラーになるため、3のintとlongの取り違えは起こりにくくなります、

しかし、そのためだけに全部に型を指定する必要があるのでちょっと面倒です。
また、③の案と同様、読み書きの段階での間違いは実行時例外になります。

③との違いは、パスワードを二回入力させるような手間をかけることでミスを起こしにくくすると言うものなので、コストに見合うメリットがあるのかと言われれば正直微妙なところ。

あと、これのkotlinでの表現方法がまだ見つけられていないのでここだけJavaです。できるのかな?


情報の永続化はトラブルが起きやすいので工夫が必要です。
今回紹介した方法も、まだまだ改善の余地のあるもの、もしくは根本的な問題をはらんだものかもしれません。
ツッコミどころも多いとは思いますので、ご指摘等いただければ幸いです。

以上です。