Blog

ViewModelでString resourcesを扱いたい

Androidで開発する際、文字列のリソースは res/values/strings.xml に書いて管理すると思います。

それらを実際に文字列として取得するためには、contextが必要になります。

val text = context.getString(R.string.hoge)

一方で、ViewModelにActivity contextを渡すことはメモリリークの危険性があるため、アンチパターンとされています。

Application contextで文字列を取得する方法もあると思いますが、言語切替時に正しくUIが更新されない等、あまり良くないと感じました。

class HogeViewModel(
    private val applicationContext: Context
): ViewModel() {
    val text: LiveData<String> = MutableLiveData(applicationContext.getString(R.string.text))
}

ViewModelではenum等を作成し、View側で文字列に変換する等の方法もあると思いますが、細かい文字列の制御が難しくなります。

enum class Status {
    Status1, Status2, Status3
}
class HogeViewModel: ViewModel() {
    val text: LiveData<Status> = MutableLiveData(Status.Status1)
}
class HogeActivity: ViewModel() {
    private val viewModel: HogeViewModel by viewModels()
    
    override fun onCreate() {
        viewModel.text.observe(this) {
            val resId = when(it) {
                Status.Status1 -> R.string.status1
                Status.Status2 -> R.string.status2
                Status.Status3 -> R.string.status3
            }
            val text = context.getString(resId)
        }
    }
}

今回はActivity contextを使いつつ、いい感じにString resourcesを扱う方法について紹介したいと思います。

StringResource

以下のようなStringResourceクラスを作成します。

data class StringResource(
    @StringRes private val resId: Int,
    private val params: List<Any> = emptyList()
) {
    companion object {
        fun create(
            @StringRes resId: Int,
            vararg params: Any
        ): ResStringResource {
            return ResStringResource(resId, listOf(*params))
        }
    }

    override fun getString(context: Context): String {
        if (params.isEmpty()) {
            return context.getString(resId)
        }

        return context.getString(resId, *params.toTypedArray())
    }
}

ViewModelではStringResourceを返すようにします。

class HogeViewModel : ViewModel() {
    val text1: LiveData<StringResource> = MutableLiveData(StringResource.create(R.string.hello_world))

    // パラメータ指定もできます
    val text2: LiveData<StringResource> = MutableLiveData(StringResource.create(R.string.hello_world, param))
}

あとは、View側でgetStringをすればOKです。

class HogeActivity: AppCompatActivity() {
    private val viewModel: HogeViewModel by viewModels()
    
    override fun onCreate() {
        viewModel.text1.observe(this) {
            val text1 = it.getString(this)
        }
        viewModel.text2.observe(this) {
            val text2 = it.getString(this)
        }
    }
}

DataBindingを使ってる場合、xmlから直接呼び出すこともできます

android:text="@{viewModel.text1.getString(context)}"
android:text="@{viewModel.text2.getString(context)}"

シンプルなため、使い方にはそこまで困らないと思います。

また、data classにしていることで、testも書きやすくなっています。

より複雑な文字列の生成のために

上記のクラスで、ほとんどのケースは担保できると思いますが、下記のように、複数のリソースを組み合わせたり、フォーマットの中にさらにリソースを渡したいみたいなケースも稀にあると思います。

val a = context.getString(R.string.a)
val b = context.getString(R.string.b)
val text1 = "$a:$b"

val param = context.getString(R.string.param)
val text2 = context.getString(R.string.text2, param)

それらの実現のために、StringResourceを抽象化し、以下のように拡張します。

interface StringResource {
    fun getString(context: Context): String
}
data class ResStringResource(
    @StringRes private val resId: Int,
    private val params: List<Any> = emptyList()
) : StringResource {
    companion object {
        fun create(
            @StringRes resId: Int,
            vararg params: Any
        ): ResStringResource {
            return ResStringResource(resId, listOf(*params))
        }
    }

    override fun getString(context: Context): String {
        if (params.isEmpty()) {
            return context.getString(resId)
        }

        val params = params.map {
            if (it is StringResource) {
                it.getString(context)
            } else {
                it
            }
        }
        return context.getString(resId, *params.toTypedArray())
    }
data class FormatStringResource(
    private val format: String,
    private val params: List<Any>
) : StringResource {
    companion object {
        fun create(
            val format: String,
            vararg params: Any
        ): ResStringResource {
            return FormatStringResource(format, listOf(*params))
        }
    }

    override fun getString(context: Context): String {
        val params = params.map {
            if (it is StringResource) {
                it.getString(context)
            } else {
                it
            }
        }
        return format.format(*params.toTypedArray())
    }
}

これらを組み合わせることで、上記のような複雑な文字列も以下のように表現ができます。

val a = ResStringResource.create(R.string.a)
val b = ResStringResource.create(R.string.b)
val text1 = FormatStringResource.create("%s:%s", a, b)

val param = ResStringResource.create(R.string.param)
val text2 = ResStringResource.create(R.string.text2, param)

まとめ

今回はあくまでActivity contextを使いつつViewModelで文字列整形をする方法について紹介をしました。

冒頭で述べたとおり、Application contextを使ってしまう方法や、ViewModelではenum等の抽象化されたデータのみを扱うようにする方法等、解決策は色々あると思います。

また、ViewModel以下のlayerでどう扱うのか、みたいな問題も出てくると思います。(今回の方法でも解決できると思います。)

今回紹介した方法が、なにか参考になれば幸いです。

人気の記事

kotlin coroutinesのFlow, SharedFlow, StateFlowを整理する

Jetpack ComposeとKotlin Coroutinesを連携させる

Layout Composableを使って複雑なレイアウトを組む【Jetpack Compose】

テスト用Dispatcherの使い分け【Kotlin Coroutines】

Flow.combineの内部実装がすごい話

Jetpack ComposeのRippleエフェクトを深堀り、カスタマイズも