要点
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 の設計原則は以下の通り。
- 遅延解決: リソース ID を保持し、解決を UI 層まで遅延
- 統一インターフェース: 固定文字列・リソース ID・フォーマット付きリソースを同じ型で扱う
- コンパイラによる保証:
@Composable プロパティにより、解決場所の誤りをコンパイル時に検出
DI 環境の有無に関係なく、Context は出口(View)が持っている。そこまで getString しなければよいだけである。