さらばContext引き回し！リソースを扱うクラスを作ってみた

本記事は [Android Advent Calendar 2021](https://qiita.com/advent-calendar/2021/android) 15日目の記事です。

# はじめに
Androidでリソースを扱う際、例えば文字列だとstrings.xmlを使い、取得にはgetString(context)が必要になります。
「ViewModelでR.stingを使いたいけどcontextが無いからどうしよう…」といった悩みは誰もが持つものな気がします。
メソッドの引数に入れたり、AndroidViewModelやDagger-Hiltの@ApplicationContextを使えば、ViewModelでContextを扱うことも出来ますが、ViewModelやUseCaseなど、View外のスコープではContextをなるべく使いたくないという設計も多いと思います。
そんなツラミを解決できるクラスを作成したので、紹介します。
(Databinding利用を想定していますが、工夫すればcomposeでも使えると思います)

# 作成したクラス
結論から。まずは文字列を扱うクラスの、本体。

```kotlin:StringSource.kt
sealed class StringSource {
    abstract fun getString(context: Context): String

    private data class Raw(private val text: String) : StringSource() {
        override fun getString(context: Context): String = text
    }

    private data class Resource(@StringRes private val textRes: Int) : StringSource() {
        override fun getString(context: Context): String = context.getString(textRes)
    }

    private class FormatResource(@StringRes private val textRes: Int, private vararg val formatArgs: Any) : StringSource() {
        override fun getString(context: Context): String {
            val formatArgs = formatArgs.map { if (it is StringSource) it.getString(context) else it }.toTypedArray()
            return context.getString(textRes, *formatArgs)
        }
    }

    private class StringSourceList(private val list: List<StringSource>) : StringSource() {
        override fun getString(context: Context): String = list.joinToString(separator = "") { it.getString(context) }
    }

    operator fun plus(other: StringSource): StringSource = StringSourceList(listOf(this, other))

    companion object {
        operator fun invoke(text: String): StringSource = Raw(text)

        operator fun invoke(@StringRes textRes: Int): StringSource = Resource(textRes)

        operator fun invoke(@StringRes textRes: Int, vararg formatArgs: Any): StringSource = FormatResource(textRes, *formatArgs)
    }
}
```

続いてBindingAdatper

```kotlin
@BindingAdapter("stringSource")
fun TextView.setStringSource(source: StringSource?) {
    text = source?.getString(context)
}
```


利用例(ViewModelとDatabindingレイアウト)

```kotlin
class SampleViewModel : ViewModel() {
    private val _text = MutableLiveData<StringSource>()
    val text: LiveData<StringSource> = _text

    fun setString() {
        _text.value = StringSource("sample")
    }

    fun setResource() {
        _text.value = StringSource(R.string.sample)
    }

    fun setFormatResource() {
        _text.value = StringSource(R.string.sample_format, "sample", 10)
    }

    fun setWithPlus(message: StringSource) {
        _text.value = StringSource("The message is ") + message
    }
}
```

```xml
<TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    app:stringSource="@{viewModel.text}"/>
```

## 解説

クラス名は「Stringになれる要素(=源)」としてStringSourceという名前にしています。が、何でもいいところです。
思想としては、Stringになりうるものをsealed classで吸収して、contextを持っている人が最後にgetStringをすれば良いというものです。

Rawは生のString、ResourceはStringResourceを扱い、特殊なことをしていません。
それ以外の部分を解説します。

### FormatResource

```kotlin
    private class FormatResource(@StringRes private val textRes: Int, private vararg val formatArgs: Any) : StringSource() {
        override fun getString(context: Context): String {
            val formatArgs = formatArgs.map { if (it is StringSource) it.getString(context) else it }.toTypedArray()
            return context.getString(textRes, *formatArgs)
        }
    }
```
FormatResourceはstrings.xmlの`%d個`や`%$1sから%$2sまで`といったフォーマットするものに使います。
本家のgetStringと同様にAny型の可変長引数(vararg)で値を受けられるようにしています。getString時に、本家(context)のgetStringを呼んでいます。受けるのがAny型で、StringSourceも受けることができるので、varargで受けたArrayをmapで回し、StirngSourceだった場合はそこで展開しています。
最後に、そこで出来た値を`spread operator(*)`を使って本家のgetStringに入れています。
spread operatorはArrayを可変長引数に入れるために必要なのですが、[detekt](https://detekt.github.io/detekt/performance.html#spreadoperator)などではパフォーマンスが悪いと警告が出ます。
書き方次第で改善できるのかもしれませんが、ひとまず利用してしまっています。

### StringSourceList

```kotlin
    private class StringSourceList(private val list: List<StringSource>) : StringSource() {
        override fun getString(context: Context): String = list.joinToString(separator = "") { it.getString(context) }
    }

    operator fun plus(other: StringSource): StringSource = StringSourceList(listOf(this, other))
```
StringSourceListは、StringSource同士を足せるようにしているものです。
List\<StringSource>をStringSourceとして扱えるようにし、`plus operator`でStringSourceListを生成しています。
展開時にはListの各StringSourceに対してgetStringを呼んでいて、それがListだった場合は更にその中でgetStringが呼ばれて…と展開されていきます。
joinToStringは、デフォルトのseparatorが`, `なので、空文字を指定しています。

### invoke operator

```kotlin
    companion object {
        operator fun invoke(text: String): StringSource = Raw(text)

        operator fun invoke(@StringRes textRes: Int): StringSource = Resource(textRes)

        operator fun invoke(@StringRes textRes: Int, vararg formatArgs: Any): StringSource = FormatResource(textRes, *formatArgs)
    }
```
`invoke operator`を使うことで、引数が何であるかによって、内部的にクラスを分けてStringSourceを生成します。
これにより、フェイクコンストラクタとして、書く側が、引数によってRawなのかResourceなのか、StringSourceのどれに当たるのかを気にせず書けるようになっている + それによりsealed実装クラスをprivateにして外部に知られないように出来ています。

# 他のリソースに対応したクラス
ColorとDimensionに対応したクラスを紹介します。

## ColorSource
R.color/R.attr.colorに対応したクラスです。

```kotlin
sealed class ColorSource {
    @ColorInt
    abstract fun getColor(context: Context): Int
    fun getColorDrawable(context: Context): ColorDrawable = ColorDrawable(getColor(context))

    data class Text(private val colorText: String, @ColorRes private val defaultColor: Int) : ColorSource() {
        override fun getColor(context: Context): Int = runCatching { Color.parseColor(colorText) }
            .getOrElse {
                ContextCompat.getColor(context, defaultColor)
            }
    }

    data class Resource(@ColorRes private val colorRes: Int) : ColorSource() {
        override fun getColor(context: Context): Int = ContextCompat.getColor(context, colorRes)
    }

    data class Attribute(@AttrRes private val colorAttrRes: Int) : ColorSource() {
        override fun getColor(context: Context): Int = TypedValue().apply {
            context.theme.resolveAttribute(colorAttrRes, this, true)
        }.data
    }
}
```
ResourceとAttributeが共にIntなので、フェイクコンストラクタは作れません。
Attributeは、渡したcontextにセットされているthemeが適用されます。
`getColorDrawable`は、使ってて必要になったので作りました。

## DrawableSource

```kotlin
sealed class DrawableSource {

    abstract val resId: Int
    abstract fun getDrawable(context: Context): Drawable?

    data class Resource(@DrawableRes override val resId: Int) : DrawableSource() {
        override fun getDrawable(context: Context): Drawable? {
            return AppCompatResources.getDrawable(context, resId)
        }
    }

    data class ResourceWithTint(@DrawableRes override val resId: Int, val tintColor: ColorSource) : DrawableSource() {
        override fun getDrawable(context: Context): Drawable? {
            return AppCompatResources.getDrawable(context, resId)?.mutate()?.apply {
                setTint(tintColor.getColor(context))
            }
        }
    }

    companion object {
        operator fun invoke(@DrawableRes resId: Int) = Resource(resId)
        operator fun invoke(@DrawableRes resId: Int, tintColor: ColorSource) = ResourceWithTint(resId, tintColor)
    }
}
```
id指定でしか設定できないメソッドもあるため、resIdも公開しています。
ResourceWithTintは、アイコンと共にTintも指定できるようにしています。
Tintには上で作成したColorSourceを使うことで、様々なパターンに対応しています。

## DimensionSource
R.dimenに対応したクラスです。

```kotlin
sealed class DimensionSource {
    abstract fun getPixelFloat(context: Context): Float
    abstract fun getPixelInt(context: Context): Int

    data class Raw(private val value: Float) : DimensionSource() {
        constructor(value: Int) : this(value.toFloat())
        override fun getPixelFloat(context: Context): Float = value
        override fun getPixelInt(context: Context): Int = value.toInt()
    }

    data class Resource(@DimenRes private val dimenRes: Int) : DimensionSource() {
        override fun getPixelFloat(context: Context): Float = context.resources.getDimension(dimenRes)
        override fun getPixelInt(context: Context): Int = context.resources.getDimensionPixelSize(dimenRes)
    }
}
```
利用シーンでInt,Float両方必要だったので、getterを2つ作っています。不要なら一つで良いですね。
RawはFloatで持っていますが、privateなsealed classを作ってFloat/Intをそのまま持つようにすることも出来ます。
そうするとIntが大きい場合にも対応できますが、結局getPixelFloatでおかしくなるのと、Floatで扱えないIntを渡すことは無いと思うので、こうしてしまっています。
コンストラクタ引数をIntだけにして、呼ぶ側でtoFloatするのもありだと思います。

# さいごに
今回は、リソースを扱うクラスを紹介しました。
これを使うことで、ViewModelやビジネスロジックから極力Contextを排除できるようになると思います。
良かったら使ってみてください！