はじめに
本日は、Androidアプリ開発で最も重要な設計パターンの一つであるMVVM(Model-View-ViewModel)アーキテクチャについて学びます。特に、UIの状態管理を効率的に行うためのViewModelとStateFlowを中心に、現代的なAndroid開発手法を解説します。
1. なぜ状態管理が必要なのか?
従来の問題点
従来のAndroid開発では、ActivityやFragmentがUIの表示とデータの取得・管理の両方を担当していました。しかし、これには深刻な問題がありました:
ライフサイクルの問題
// 悪い例:Activityに直接データを保持
class MainActivity : AppCompatActivity() {
private var userData: User? = null // 画面回転で消失!
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 画面回転のたびにAPIを再度呼び出してしまう
fetchUserData()
}
}
コードの複雑化
- UIロジックとビジネスロジックが混在
- テストが困難
- 単一責任原則の違反
- メモリリークのリスク
これらの問題を解決するために登場したのがMVVMアーキテクチャです。
2. MVVMアーキテクチャの詳細
アーキテクチャの構成要素
-
View (UI層)
- Activity、Fragment、Compose UI
- ユーザーの操作を受け取りViewModelに伝達
- ViewModelの状態変化を監視してUIを更新
-
ViewModel (プレゼンテーション層)
- ViewとModelの仲介役
- UIの状態を管理
- ビジネスロジックの実行
- ライフサイクルに依存しないデータ保持
-
Model (データ層)
- Repository、DataSource、API、Database
- データの取得、保存、変換を担当
データフローの仕組み
[View] ←--observe-- [ViewModel] ←--call-- [Repository]
↓ ↑ ↑
user action state update data source
↓ ↑ ↑
[ViewModel] --call--> [Repository] --fetch--> [API/DB]
3. 現代的なViewModel実装
依存関係の設定
// build.gradle.kts (Module: app)
dependencies {
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
implementation("androidx.activity:activity-ktx:1.8.2")
implementation("androidx.fragment:fragment-ktx:1.6.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
}
UI状態の定義
// UIの状態を表現するデータクラス
data class UserUiState(
val isLoading: Boolean = false,
val user: User? = null,
val errorMessage: String? = null,
val isRefreshing: Boolean = false
)
data class User(
val id: String,
val name: String,
val email: String,
val avatarUrl: String
)
ViewModelの実装
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class UserViewModel(
private val userRepository: UserRepository
) : ViewModel() {
// プライベートなMutableStateFlow
private val _uiState = MutableStateFlow(UserUiState())
// パブリックな読み取り専用StateFlow
val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
init {
// 初期化時にデータを取得
loadUser()
}
fun loadUser(userId: String = "current_user") {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
try {
val user = userRepository.getUser(userId)
_uiState.value = _uiState.value.copy(
isLoading = false,
user = user,
errorMessage = null
)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = "ユーザー情報の取得に失敗しました: ${e.message}"
)
}
}
}
fun refreshUser() {
if (_uiState.value.isRefreshing) return // 二重実行防止
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isRefreshing = true)
try {
val user = userRepository.refreshUser()
_uiState.value = _uiState.value.copy(
isRefreshing = false,
user = user,
errorMessage = null
)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isRefreshing = false,
errorMessage = "更新に失敗しました"
)
}
}
}
fun clearError() {
_uiState.value = _uiState.value.copy(errorMessage = null)
}
}
ViewModelFactory(DIなしの場合)
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
class UserViewModelFactory(
private val userRepository: UserRepository
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(UserViewModel::class.java)) {
return UserViewModel(userRepository) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
}
}
4. StateFlowによる現代的な状態管理
StateFlow vs LiveData
特徴 | StateFlow | LiveData |
---|---|---|
Kotlin Coroutines統合 | ネイティブサポート | 限定的 |
Null安全性 | Non-null by default | Nullable |
初期値 | 必須 | Optional |
バックプレッシャー | サポート | なし |
テスタビリティ | 優秀 | 良い |
Compose統合 | ネイティブ | collectAsStateが必要 |
StateFlowの利点
// StateFlowは常に最新の値を持つ
val currentState = viewModel.uiState.value
// LiveDataと異なり、ライフサイクル外でも安全に使用可能
viewModel.uiState
.map { it.user?.name }
.distinctUntilChanged()
.collect { userName ->
// ユーザー名が変更された時のみ実行
}
5. FragmentでのViewModel使用
Fragment実装
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch
class UserFragment : Fragment(R.layout.fragment_user) {
private var _binding: FragmentUserBinding? = null
private val binding get() = _binding!!
private val viewModel: UserViewModel by viewModels {
UserViewModelFactory(UserRepositoryImpl(ApiClient.userService))
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
_binding = FragmentUserBinding.bind(view)
setupUI()
observeViewModel()
}
private fun setupUI() {
binding.apply {
// プルリフレッシュ
swipeRefreshLayout.setOnRefreshListener {
viewModel.refreshUser()
}
// リトライボタン
retryButton.setOnClickListener {
viewModel.loadUser()
}
// エラー非表示
errorCloseButton.setOnClickListener {
viewModel.clearError()
}
}
}
private fun observeViewModel() {
// ライフサイクルを考慮した安全な監視
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
updateUI(state)
}
}
}
}
private fun updateUI(state: UserUiState) {
binding.apply {
// ローディング状態
progressBar.isVisible = state.isLoading
swipeRefreshLayout.isRefreshing = state.isRefreshing
// ユーザー情報
state.user?.let { user ->
userNameText.text = user.name
userEmailText.text = user.email
// 画像読み込み(Glide等を使用)
Glide.with(this@UserFragment)
.load(user.avatarUrl)
.into(avatarImage)
userInfoLayout.isVisible = true
} ?: run {
userInfoLayout.isVisible = false
}
// エラー状態
state.errorMessage?.let { error ->
errorLayout.isVisible = true
errorText.text = error
} ?: run {
errorLayout.isVisible = false
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
6. Repositoryパターンの実装
Repository インターフェース
interface UserRepository {
suspend fun getUser(userId: String): User
suspend fun refreshUser(): User
suspend fun updateUser(user: User): User
}
Repository 実装
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class UserRepositoryImpl(
private val apiService: UserApiService,
private val cacheManager: UserCacheManager = UserCacheManager()
) : UserRepository {
override suspend fun getUser(userId: String): User {
return withContext(Dispatchers.IO) {
try {
// まずキャッシュを確認
cacheManager.getUser(userId)?.let { cachedUser ->
if (!cacheManager.isExpired(userId)) {
return@withContext cachedUser
}
}
// APIから取得
val response = apiService.getUser(userId)
if (response.isSuccessful) {
val user = response.body()!!
cacheManager.saveUser(user)
user
} else {
throw ApiException("HTTP ${response.code()}: ${response.message()}")
}
} catch (e: Exception) {
// ネットワークエラー時はキャッシュを返す
cacheManager.getUser(userId) ?: throw e
}
}
}
override suspend fun refreshUser(): User {
return withContext(Dispatchers.IO) {
val response = apiService.getCurrentUser()
if (response.isSuccessful) {
val user = response.body()!!
cacheManager.saveUser(user)
user
} else {
throw ApiException("更新に失敗しました")
}
}
}
override suspend fun updateUser(user: User): User {
return withContext(Dispatchers.IO) {
val response = apiService.updateUser(user.id, user)
if (response.isSuccessful) {
val updatedUser = response.body()!!
cacheManager.saveUser(updatedUser)
updatedUser
} else {
throw ApiException("更新に失敗しました")
}
}
}
}
class ApiException(message: String) : Exception(message)
7. テストの実装
ViewModelのユニットテスト
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.*
import org.junit.Before
import org.junit.Test
import org.junit.Assert.*
import org.mockito.Mock
import org.mockito.Mockito.*
@ExperimentalCoroutinesApi
class UserViewModelTest {
@Mock
private lateinit var mockRepository: UserRepository
private lateinit var viewModel: UserViewModel
private val testDispatcher = UnconfinedTestDispatcher()
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
viewModel = UserViewModel(mockRepository)
}
@Test
fun `loadUser should update UI state with user data`() = runTest {
// Given
val expectedUser = User("1", "Test User", "test@example.com", "avatar.jpg")
`when`(mockRepository.getUser(anyString())).thenReturn(expectedUser)
// When
viewModel.loadUser("1")
// Then
val state = viewModel.uiState.value
assertFalse(state.isLoading)
assertEquals(expectedUser, state.user)
assertNull(state.errorMessage)
}
@Test
fun `loadUser should handle error correctly`() = runTest {
// Given
val exception = Exception("Network error")
`when`(mockRepository.getUser(anyString())).thenThrow(exception)
// When
viewModel.loadUser("1")
// Then
val state = viewModel.uiState.value
assertFalse(state.isLoading)
assertNull(state.user)
assertTrue(state.errorMessage?.contains("Network error") == true)
}
}
8. Compose統合(ボーナス)
Compose での使用例
import androidx.compose.runtime.collectAsState
@Composable
fun UserScreen(
viewModel: UserViewModel = viewModel()
) {
val uiState by viewModel.uiState.collectAsState()
Box(modifier = Modifier.fillMaxSize()) {
when {
uiState.isLoading -> {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
uiState.user != null -> {
UserContent(
user = uiState.user,
onRefresh = viewModel::refreshUser
)
}
uiState.errorMessage != null -> {
ErrorContent(
message = uiState.errorMessage,
onRetry = viewModel::loadUser,
onDismiss = viewModel::clearError
)
}
}
}
}
まとめ
今回学んだ現代的なMVVM実装のポイント:
重要な原則
- 単一責任原則: 各層が明確な責任を持つ
- 依存性の逆転: 上位層が下位層の抽象に依存
- 状態の一元管理: ViewModelが唯一の状態ソース
StateFlowの優位性
- Kotlin Coroutinesとのネイティブ統合
- より良いnull安全性と型安全性
- テストしやすい設計
- Jetpack Composeとの親和性
実装のベストプラクティス
- UI状態をデータクラスで表現
- プライベート/パブリックStateFlowの適切な分離
- ライフサイクルを考慮したStateFlow監視
- 適切なエラーハンドリング
- Repository パターンによるデータ層の抽象化
これらの概念とパターンを理解することで、スケーラブルで保守性の高いAndroidアプリを開発できるようになります。次回は、このMVVMアーキテクチャを基盤として、実際にLLM向けのUIを設計・実装していきます。お楽しみに!