Featured image of post [Android] getString のために Context を渡すのをやめる

[Android] getString のために Context を渡すのをやめる

getString のために Context を渡すのをやめる。文字列リソースの解決を View まで遅延させる設計

要点

Android 開発で context.getString(R.string.xxx) のために Context を ViewModel やロジック層に引き回す設計は避けるべきである。

本記事では、文字列リソース ID の解決を 画面描画時まで遅延 させるクラス AdaptiveString を提案する。

1
2
3
4
5
6
// ViewModel(Context 不要)
_uiState.title = AdaptiveString(R.string.screen_title)
_uiState.errorMessage = AdaptiveString("サーバーエラーが発生しました")

// Composable(ここで初めて解決)
Text(text = uiState.title.value)

問題:なぜ Context を引き回してはいけないのか

Android の文字列リソースは context.getString(resId) で取得する。この API が Context を要求するため、以下のようなコードが生まれやすい。

1
2
3
4
5
6
7
// NG: ViewModel に Context が漏れている
class MyViewModel(private val context: Context) : ViewModel() {
    fun onError(throwable: Throwable) {
        val message = context.getString(R.string.error_message)
        _uiState.value = UiState(errorMessage = message)
    }
}

問題点は明確である。

問題 影響
ViewModel が Android フレームワークに依存 ユニットテストで Context のモックが必要
Context のライフサイクル管理 Activity の Context を保持するとメモリリーク
責務の混在 「文字列を解決する」のは View の仕事

本質的に、文字列リソースの解決は UI 層の責務 である。ViewModel が知るべきは「何を表示するか」であり、「どう表示するか」ではない。

解決:AdaptiveString

文字列リソース ID をそのまま保持し、解決を遅延させるクラスである。

コンストラクタ

1
2
3
AdaptiveString("固定文字列")                    // API レスポンス等
AdaptiveString(R.string.error_title)            // 文字列リソース ID
AdaptiveString(R.string.hello_name, userName)   // フォーマット引数付き

呼び出し側は中身が文字列なのかリソース ID なのかを意識しない。この統一が設計の核である。

解決のタイミング

Composable 関数内で .value を参照した時点で、stringResource() を通じて文字列が解決される。

Composable 以外の場所(Android View、通知、Service など)で解決が必要な場合は、resolveString(context) を使う。

使い方

UiState の定義

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@Stable
interface ScreenUiState {
    val title: AdaptiveString
    val errorMessage: AdaptiveString?
}

class MutableScreenUiState : ScreenUiState {
    override var title: AdaptiveString by mutableStateOf(AdaptiveString(""))
    override var errorMessage: AdaptiveString? by mutableStateOf(null)
}

ViewModel(Context 不要)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class MyViewModel @Inject constructor(
    private val errorMessageProvider: ErrorMessageProvider,
) : ViewModel() {
    private val _uiState = MutableScreenUiState()
    val uiState: ScreenUiState = _uiState

    fun onError(throwable: Throwable) {
        // Context なしでエラーメッセージを設定
        _uiState.errorMessage = errorMessageProvider.getMessage(throwable)
    }
}

Composable(ここで解決)

1
2
3
4
5
6
7
8
@Composable
fun MyScreen(uiState: ScreenUiState) {
    Text(text = uiState.title.value)

    uiState.errorMessage?.let {
        Text(text = it.value, color = MaterialTheme.colorScheme.error)
    }
}

.value@Composable プロパティである。Composable 関数の中でのみ呼び出せる。これは制約ではなく、「文字列の解決は UI 層で行う」という設計意図をコンパイラが強制する 仕組みとして機能する。

実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class AdaptiveString {
    private var text: String? = null
    @StringRes private var resId: Int? = null
    private var formatArgs: Array<out Any>? = null

    // Composable での解決(メインの使い方)
    val value: String
        @Composable get() {
            text?.let { return it }
            val resId = resId ?: return ""
            val args = formatArgs ?: return stringResource(resId)
            return stringResource(resId, *args)
        }

    // Composable 以外での解決(Android View、通知、Service 等)
    fun resolveString(context: Context): String {
        text?.let { return it }
        val resId = resId ?: return ""
        val args = formatArgs ?: return context.getString(resId)
        return context.getString(resId, *args)
    }

    // コンストラクタ
    constructor(text: String) { this.text = text }
    constructor(@StringRes resId: Int) { this.resId = resId }
    constructor(@StringRes resId: Int, vararg formatArgs: Any) {
        this.resId = resId
        this.formatArgs = formatArgs
    }

    // equals / hashCode 省略
}

応用:AdaptiveImage

同じ発想は画像にも適用できる。Drawable リソース ID と画像 URL の混在を、単一のクラスで吸収する。

1
2
AdaptiveImage(R.drawable.ic_error)             // Drawable リソース
AdaptiveImage("https://example.com/img.png")   // リモート画像 URL

まとめ

context.getString() のために Context を引き回す必要はない。文字列リソース ID をそのまま保持し、画面描画時に解決すればよい。

AdaptiveString の設計原則は以下の通り。

  1. 遅延解決: リソース ID を保持し、解決を UI 層まで遅延
  2. 統一インターフェース: 固定文字列・リソース ID・フォーマット付きリソースを同じ型で扱う
  3. コンパイラによる保証: @Composable プロパティにより、解決場所の誤りをコンパイル時に検出

DI 環境の有無に関係なく、Context は出口(View)が持っている。そこまで getString しなければよいだけである。

Hugo で構築されています。
テーマ StackJimmy によって設計されています。