Kotlin らしく SharedPreferences をシンプルに扱う Extension を実装する (Moshi Json シリアライズ対応)

20190925更新: `compileSdkVersion 29` 設定でビルドできるようにコードを修正しました。Enum のシリアライズに対応しました。

Kotlin の委譲プロパティを使って、Android SharedPreferences を無駄なくシンプルに扱えるようにします。

kotlinx.serialization 対応版は以下の記事を参照してください。

* [Kotlin らしく SharedPreferences をシンプルに扱う Extension を実装する (kotlinx.serialization シリアライズ対応)](https://qiita.com/irgaly/items/406b8ef1ae024c4dc15c)

ライブラリを利用せず Extension だけで Kotlin らしく解決する方針です。

SharedPreferences へ簡単な構造化されたデータを保持したいときはモデルクラスを JSON 文字列化して保存する方法が一般的です。JSON 化に [Moshi](https://github.com/square/moshi) を使った委譲プロパティにも対応します。

プロパティとして利用できる型は以下の通りです。

* Boolean
* Float
* Int
* Long
* String
* Enum
* Moshi JsonConverter 対応クラス

# 使い方

この Extension は次のように使用します。以下の点が大きなメリットとなります。

* SharedPreferences の key 名に文字列定数を定義する必要がない
* nullable、非 nullable を明確に使い分けられる

```kotlin
class FooConfiguration (private val preferences: SharedPreferences, moshi: Moshi)  {
    // 要素の存在確認
    val containsFoo: Boolean get() = preferences.contains(::foo.name)

    // key 名 "foo" での Int 値読み書き (他 boolean, float, int, long, string 対応)
    var foo: Int by preferences.int()

    // デフォルト値と保存キー名を指定するとき
    var foo2: Int by preferences.int(42, "my_foo_key")

    // key 名 "bar" での nullable Int 読み書き
    var bar: Int? by preferences.nullableInt()

    // key 名 "person" での JSON 対応クラス読み書き。デフォルト値に Person() を指定
    var person: Person by preferences.json(moshi, lazy { Person() })

    // 値が存在しないときに例外を発生させたいとき
    var person2: Person by preferences.json(moshi, lazy { throw Throwable() })  

    // nullable 版
    var person3: Person? by preferences.nullableJson(moshi)

    // key 名 "persons" で、任意の JsonAdapter に対応したいとき
    var persons: List<Person> by preferences.json(
        moshi.adapter(Types.newParameterizedType(List::class.java, Person::class.java),
        lazy { throw Throwable() })
}
```

# SharedPreferencesExtension.kt

Extension の実装です。

```kotlin
import android.content.SharedPreferences
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty

/**
 * Boolean Read Write Delegate
 */
fun SharedPreferences.boolean(defaultValue: Boolean = false, key: String? = null): ReadWriteProperty<Any, Boolean> =
    delegate(defaultValue, key, SharedPreferences::getBoolean, SharedPreferences.Editor::putBoolean)

/**
 * Nullable Boolean Read Write Delegate
 */
fun SharedPreferences.nullableBoolean(key: String? = null): ReadWriteProperty<Any, Boolean?> =
    nullableDelegate(false, key, SharedPreferences::getBoolean, SharedPreferences.Editor::putBoolean)

/**
 * Float Read Write Delegate
 */
fun SharedPreferences.float(defaultValue: Float = 0f, key: String? = null): ReadWriteProperty<Any, Float> =
    delegate(defaultValue, key, SharedPreferences::getFloat, SharedPreferences.Editor::putFloat)

/**
 * Nullable Float Read Write Delegate
 */
fun SharedPreferences.nullableFloat(key: String? = null): ReadWriteProperty<Any, Float?> =
    nullableDelegate(0f, key, SharedPreferences::getFloat, SharedPreferences.Editor::putFloat)

/**
 * Int Read Write Delegate
 */
fun SharedPreferences.int(defaultValue: Int = 0, key: String? = null): ReadWriteProperty<Any, Int> =
    delegate(defaultValue, key, SharedPreferences::getInt, SharedPreferences.Editor::putInt)

/**
 * Nullable Int Read Write Delegate
 */
fun SharedPreferences.nullableInt(key: String? = null): ReadWriteProperty<Any, Int?> =
    nullableDelegate(0, key, SharedPreferences::getInt, SharedPreferences.Editor::putInt)

/**
 * Long Read Write Delegate
 */
fun SharedPreferences.long(defaultValue: Long = 0, key: String? = null): ReadWriteProperty<Any, Long> =
    delegate(defaultValue, key, SharedPreferences::getLong, SharedPreferences.Editor::putLong)

/**
 * Nullable Long Read Write Delegate
 */
fun SharedPreferences.nullableLong(key: String? = null): ReadWriteProperty<Any, Long?> =
    nullableDelegate(0, key, SharedPreferences::getLong, SharedPreferences.Editor::putLong)

/**
 * String Read Write Delegate
 */
fun SharedPreferences.string(defaultValue: String = "", key: String? = null): ReadWriteProperty<Any, String> =
    delegate(defaultValue, key, SharedPreferences::getString, SharedPreferences.Editor::putString)

/**
 * Nullable String Read Write Delegate
 */
fun SharedPreferences.nullableString(key: String? = null): ReadWriteProperty<Any, String?> =
    nullableDelegate("", key, SharedPreferences::getString, SharedPreferences.Editor::putString)

/**
 * Enum Read Write Delegate
 */
fun <T : Enum<T>> SharedPreferences.enum(valueOf: (String) -> T, defaultValue: Lazy<T>, key: String? = null) =
    object : ReadWriteProperty<Any, T> {
        override fun getValue(thisRef: Any, property: KProperty<*>): T {
            return getString(key ?: property.name, null)?.let { valueOf(it) } ?: defaultValue.value
        }

        override fun setValue(thisRef: Any, property: KProperty<*>, value: T) {
            edit().putString(key ?: property.name, value.name).apply()
        }
    }

/**
 * Nullable Enum Read Write Delegate
 */
fun <T : Enum<T>> SharedPreferences.nullableEnum(valueOf: (String) -> T, key: String? = null) =
    object : ReadWriteProperty<Any, T?> {
        override fun getValue(thisRef: Any, property: KProperty<*>): T? {
            return getString(key ?: property.name, null)?.let { valueOf(it) }
        }

        override fun setValue(thisRef: Any, property: KProperty<*>, value: T?) {
            edit().putString(key ?: property.name, value?.name).apply()
        }
    }

/**
 * Moshi Json String Read Write Delegate
 */
inline fun <reified T : Any> SharedPreferences.json(
    moshi: Moshi,
    defaultValue: Lazy<T>,
    key: String? = null
): ReadWriteProperty<Any, T> =
    json(moshi.adapter(T::class.java), defaultValue, key)

/**
 * Nullable Moshi Json String Read Write Delegate
 */
inline fun <reified T : Any> SharedPreferences.nullableJson(
    moshi: Moshi,
    key: String? = null
): ReadWriteProperty<Any, T?> =
    nullableJson(moshi.adapter(T::class.java), key)

/**
 * Moshi Json String Read Write Delegate
 */
fun <T : Any> SharedPreferences.json(adapter: JsonAdapter<T>, defaultValue: Lazy<T>, key: String? = null) =
    object : ReadWriteProperty<Any, T> {
        override fun getValue(thisRef: Any, property: KProperty<*>): T {
            return getString(key ?: property.name, null)?.let(adapter::fromJson) ?: defaultValue.value
        }

        override fun setValue(thisRef: Any, property: KProperty<*>, value: T) {
            edit().putString(key ?: property.name, adapter.toJson(value)).apply()
        }
    }

/**
 * Nullable Moshi Json String Read Write Delegate
 */
fun <T : Any> SharedPreferences.nullableJson(adapter: JsonAdapter<T>, key: String? = null) =
    object : ReadWriteProperty<Any, T?> {
        override fun getValue(thisRef: Any, property: KProperty<*>): T? {
            return getString(key ?: property.name, null)?.let(adapter::fromJson)
        }

        override fun setValue(thisRef: Any, property: KProperty<*>, value: T?) {
            edit().putString(key ?: property.name, value?.let(adapter::toJson)).apply()
        }
    }

private inline fun <T : Any> SharedPreferences.delegate(
    defaultValue: T, key: String?,
    crossinline getter: SharedPreferences.(key: String, defaultValue: T) -> T?,
    crossinline setter: SharedPreferences.Editor.(key: String, value: T) -> SharedPreferences.Editor
) = object : ReadWriteProperty<Any, T> {
    override fun getValue(thisRef: Any, property: KProperty<*>) = getter(key ?: property.name, defaultValue) ?: defaultValue
    override fun setValue(thisRef: Any, property: KProperty<*>, value: T) =
        edit().setter(key ?: property.name, value).apply()
}

private inline fun <T : Any> SharedPreferences.nullableDelegate(
    dummy: T, key: String?,
    crossinline getter: SharedPreferences.(key: String, defaultValue: T) -> T?,
    crossinline setter: SharedPreferences.Editor.(key: String, value: T) -> SharedPreferences.Editor
) = object : ReadWriteProperty<Any, T?> {
    override fun getValue(thisRef: Any, property: KProperty<*>): T? {
        val target = key ?: property.name
        return if (contains(target)) getter(target, dummy) else null
    }

    override fun setValue(thisRef: Any, property: KProperty<*>, value: T?) {
        val target = key ?: property.name
        if (value == null) {
            edit().remove(target).apply()
        } else {
            edit().setter(target, value).apply()
        }
    }
}
```

`fun <T : Any> SharedPreferences.delegate` と `fun <T : Any> SharedPreferences.nullableDelegate` が ReadWriteProperty を返す本体です。それぞれ、呼び出すべき SharedPreferences の getter と setter を引数で受取り、適切に `getValue` と `setValue` を実装しています。

非 nullable の `fun <T : Any> SharedPreferences.delegate` は defaultValue を受取り、値が存在しなければ defaultValue を返すシンプルな実装。

nullable の `fun <T : Any> SharedPreferences.nullableDelegate` は、getter では key の存在チェックを挟んでから読み込むかどうかを決定し、setter では value が null であれば key を削除するというように null 値の扱いが特殊となります。

同様に、`<T : Any> SharedPreferences.json` と `fun <T : Any> SharedPreferences.nullableJson` は getString, putString と Moshi JsonConverter を組み合わせて読み書きを実装します。putString は value = null を指定すると、指定した key の要素を削除する動作となるため、nullable の実装に分岐はありません。

# Proguard について

Kotlin の KProperty は Proguard の影響を受けません。Proguard ありでビルドをしても KProperty::name で正しいプロパティ名を取得できていることは確認済みです。
この Extension 向けの Proguard 除外設定は必要ありません。 

# 参考

以下の記事を参考にしました。

* [Kotlin delegates in Android development — Part 1](https://hackernoon.com/kotlin-delegates-in-android-development-part-1-50346cf4aed7)