1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Android開発30日間マスターシリーズ - Day18: ViewModelとStateFlow - MVVMアーキテクチャによる現代的な状態管理

Posted at

はじめに

本日は、Androidアプリ開発で最も重要な設計パターンの一つであるMVVM(Model-View-ViewModel)アーキテクチャについて学びます。特に、UIの状態管理を効率的に行うためのViewModelStateFlowを中心に、現代的な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アーキテクチャの詳細

アーキテクチャの構成要素

  1. View (UI層)

    • Activity、Fragment、Compose UI
    • ユーザーの操作を受け取りViewModelに伝達
    • ViewModelの状態変化を監視してUIを更新
  2. ViewModel (プレゼンテーション層)

    • ViewとModelの仲介役
    • UIの状態を管理
    • ビジネスロジックの実行
    • ライフサイクルに依存しないデータ保持
  3. 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を設計・実装していきます。お楽しみに!

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?