本記事は 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でも使えると思います)
作成したクラス
結論から。まずは文字列を扱うクラスの、本体。
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を排除できるようになると思います。
良かったら使ってみてください!