6
2

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-12-14

本記事は Android Advent Calendar 2021 15日目の記事です。

はじめに

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

作成したクラス

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

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

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

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

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
    }
}
<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

    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などではパフォーマンスが悪いと警告が出ます。
書き方次第で改善できるのかもしれませんが、ひとまず利用してしまっています。

StringSourceList

    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

    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に対応したクラスです。

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

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に対応したクラスです。

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を排除できるようになると思います。
良かったら使ってみてください!

6
2
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2