2021年3月13日土曜日

SharedPreferences#StringSetの挙動について

 SharedPreferencesでStringSetを用いて追加保存する際、以下が起こることがある
  • 1件しか保存されない
  • 保存してないのに勝手にデータが保存されている

結論を先に書くと、
SharedPreferencesからgetStringSetする際、シャローコピーすれば解決するが
 // java版
 Set<String> set = new HashSet<>(
   getSharedPreferences("set",MODE_PRIVATE).getStringSet("data", new HashSet<>()));

 // Kotlin版
 val set = getSharedPreferences("set", MODE_PRIVATE)
   .getStringSet("data", mutableSetOf())?.toMutableSet()
どういう理屈なのかをもう少し詳しくまとめる。



具体的にどんな事象が起こるのか


1件しか保存されない件
ボタンを押したら、SharedPreferencesからSet<String>を読み込んで、時刻を追加し保存するプログラムを例にする
fun onClick(view : View) {
  getSharedPreferences("set", Context.MODE_PRIVATE).run {
    val set = getStringSet("data", mutableSetOf())
    Log.d("TAG", "data = $set")
    set?.add(LocalTime.now().toString())
    edit().putStringSet("data2", set).apply()
  }
}
このプログラムの実行結果は
4行目のログから、ボタンを押すたびに時間が増えていくことが確認できるが、
実はSharedPreferencesの書き込み(set.xml)は1件のみしか保存されていない。
そのため、アプリを終了させて再度起動させるとデータは1件のみしか残っていない。


保存してないのに勝手にデータが保存されている
ボタンを押したら、SharedPreferencesからSet<String>、intを読み込んで両方のデータを変更。 その後、intのデータのみ保存を行うプログラムを例にする
fun onClick(view : View) {
  getSharedPreferences("set", Context.MODE_PRIVATE).run {
    val set = getStringSet("data", mutableSetOf())
    var num = getInt("num", 0)
    Log.d("TAG", "data = $set , num = $num")
    set?.add(LocalTime.now().toString())
    edit().putInt("num", ++num).apply()
  }
}
Set<String>を保存するプログラムは書いてないのに、SharedPreferencesへの書き込み(set.xml)が行われている。



原因

Set<String>の参照が、SharedPreferences内と外で同じなのが原因。
SharedPreferences自体はインターフェースで、実態はSharedPreferencesImpl.javaである。
プリファレンスのファイルを読み込み、XMLをパースしMapに保存(loadFromDisk())して、getStringSet()時にはそのまま参照を渡していることが読み取れる。
※正直ここはバグな気がする。通常こういう設計をするなら、シャローコピーを渡す。

今回の2件の事象はこの参照が共有されていることで発生している。

1件しか保存されない件
SharedPreferencesは書き込み対象があるのかチェックは
Editで持ってるMapとSharedPreferencesでファイル読み込み時に作ったMapを突き合わせ、ValueのObjectが同じかどうかで判定している(commitToMemory)。 なので、put時にSharedPreferencesから取得したSetを渡すと、参照同値で「変更がなかった」と解釈される。
以上から保存処理を行っていないことが原因となる。


保存してないのに勝手にデータが保存されている
書き換えは部分的に書き換えているのではなく、SharedPreferencesのMapをEditのMapでマージして、SharedPreferencesのMapを全て書き換える動作となっている。
よって、SharedPreferencesのMapと参照を同じにしているSetに要素を加えると、
勝手に他のcommit(apply)時にSetが更新される

他にこのようになるデータはないか確認したところ、
保存できる型は全部で以下であり、StringSetだけが問題であることがわかる。
取得メソッド備考
putBooleanbooleanプリミティブ型なので問題なし
putFloatfloatプリミティブ型なので問題なし
putIntintプリミティブ型なので問題なし
putLonglongプリミティブ型なので問題なし
putStringStringイミュータブルなので問題なし
putStringSetSet<String>外部からデータ書き換え可能



対策

つまり、SharedPreferences内と外で参照を変えてあげれば解決する。
よって、SharedPreferencesからデータを取ってくるとき、シャローコピーして互いに干渉することなく制御できる。
 // java版
 Set<String> set = new HashSet<>(
   getSharedPreferences("set",MODE_PRIVATE).getStringSet("data", new HashSet<>()));

 // Kotlin版
 val set = getSharedPreferences("set", MODE_PRIVATE)
   .getStringSet("data", mutableSetOf())?.toMutableSet()

0 件のコメント:

コメントを投稿