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日間マスターシリーズ - Day21: エラーハンドリングとユーザーフィードバック - 堅牢なアプリ設計の実践

Posted at

はじめに

アプリ開発において、予期せぬエラーは避けられません。ネットワークの切断、APIサーバーのダウン、無効なデータなど、さまざまな問題が発生する可能性があります。今日は、これらのエラーを適切に処理し、ユーザーに分かりやすくフィードバックを返すことで、アプリをより堅牢ユーザーフレンドリーにする方法を学びます。

1. なぜエラーハンドリングが重要なのか?

エラーを無視すると、アプリがクラッシュしたり、予期せぬ動作をしたりして、ユーザーを混乱させてしまいます。適切なエラーハンドリングは、以下の目的で不可欠です:

  • クラッシュの防止: アプリケーションが突然終了するのを防ぎます
  • ユーザー体験の向上: ユーザーに何が問題なのかを伝え、次の行動を促します
  • デバッグの効率化: 発生したエラーをログに残し、開発者が原因を特定しやすくします
  • 信頼性の確保: ユーザーからの信頼を維持し、アプリの評価向上につながります

よくあるエラーの種類

アプリ開発でよく遭遇するエラーを分類しておきましょう:

  • ネットワークエラー: インターネット接続不良、タイムアウト
  • APIエラー: サーバーエラー(500番台)、クライアントエラー(400番台)
  • データエラー: 不正なフォーマット、欠損データ
  • 認証エラー: トークン期限切れ、権限不足
  • システムエラー: メモリ不足、ストレージ不足

2. モダンな状態管理とエラーハンドリング

まず、現代的なアプローチとしてStateFlowを使った状態管理を実装しましょう。

UI状態の定義

// UI状態を表現するsealed class
sealed class UiState<out T> {
    object Loading : UiState<Nothing>()
    data class Success<T>(val data: T) : UiState<T>()
    data class Error(
        val exception: Throwable,
        val message: String,
        val isRetryable: Boolean = true
    ) : UiState<Nothing>()
    object Empty : UiState<Nothing>()
}

// ユーザー情報専用の状態
data class UserUiState(
    val userState: UiState<User> = UiState.Loading,
    val isRefreshing: Boolean = false
)

エラー種別の定義

// アプリ固有のエラー定義
sealed class AppError(
    override val message: String,
    override val cause: Throwable? = null
) : Exception(message, cause) {
    
    data class NetworkError(
        override val cause: Throwable? = null
    ) : AppError("インターネット接続を確認してください")
    
    data class ServerError(
        val code: Int,
        override val cause: Throwable? = null
    ) : AppError("サーバーで問題が発生しました(エラーコード: $code)")
    
    data class NotFoundError(
        val resourceName: String
    ) : AppError("$resourceName が見つかりませんでした")
    
    data class AuthenticationError(
        override val cause: Throwable? = null
    ) : AppError("認証に失敗しました。再度ログインしてください")
    
    data class ValidationError(
        val field: String
    ) : AppError("$field の入力内容を確認してください")
    
    data class UnknownError(
        override val cause: Throwable? = null
    ) : AppError("予期しないエラーが発生しました")
}

3. Repository層でのエラーハンドリング

Repository層で適切にエラーを変換し、上位層に分かりやすい形で伝えます。

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import retrofit2.HttpException
import java.net.UnknownHostException
import java.net.SocketTimeoutException

class UserRepository(
    private val apiService: ApiService,
    private val userDao: UserDao
) {
    suspend fun getUser(userName: String): User {
        return withContext(Dispatchers.IO) {
            try {
                // APIから取得
                val user = apiService.getUser(userName)
                // キャッシュに保存
                userDao.insertUser(user)
                user
            } catch (e: Exception) {
                // エラーを適切にマッピング
                when (e) {
                    is UnknownHostException -> {
                        // オフライン時はキャッシュから取得を試行
                        userDao.getUser(userName) ?: throw AppError.NetworkError(e)
                    }
                    is SocketTimeoutException -> {
                        userDao.getUser(userName) ?: throw AppError.NetworkError(e)
                    }
                    is HttpException -> {
                        when (e.code()) {
                            404 -> throw AppError.NotFoundError("ユーザー")
                            401, 403 -> throw AppError.AuthenticationError(e)
                            in 500..599 -> throw AppError.ServerError(e.code(), e)
                            else -> throw AppError.UnknownError(e)
                        }
                    }
                    else -> throw AppError.UnknownError(e)
                }
            }
        }
    }
}

4. 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
import android.util.Log

class UserViewModel(
    private val userRepository: UserRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(UserUiState())
    val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()

    fun fetchUser(userName: String) {
        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(
                userState = UiState.Loading
            )
            
            try {
                val user = userRepository.getUser(userName)
                _uiState.value = _uiState.value.copy(
                    userState = UiState.Success(user)
                )
            } catch (e: AppError) {
                handleError(e)
            } catch (e: Exception) {
                handleError(AppError.UnknownError(e))
            }
        }
    }

    fun retryFetch(userName: String) {
        fetchUser(userName)
    }

    fun refresh(userName: String) {
        viewModelScope.launch {
            _uiState.value = _uiState.value.copy(isRefreshing = true)
            
            try {
                val user = userRepository.getUser(userName)
                _uiState.value = _uiState.value.copy(
                    userState = UiState.Success(user),
                    isRefreshing = false
                )
            } catch (e: AppError) {
                _uiState.value = _uiState.value.copy(isRefreshing = false)
                handleError(e)
            }
        }
    }

    private fun handleError(error: AppError) {
        // ログに記録
        Log.e("UserViewModel", "Error occurred: ${error.message}", error)
        
        // Crashlyticsなどにレポート(本番環境のみ)
        if (BuildConfig.DEBUG.not()) {
            FirebaseCrashlytics.getInstance().recordException(error)
        }
        
        // UI状態を更新
        val isRetryable = when (error) {
            is AppError.ValidationError -> false
            is AppError.NotFoundError -> false
            else -> true
        }
        
        _uiState.value = _uiState.value.copy(
            userState = UiState.Error(
                exception = error,
                message = error.message,
                isRetryable = isRetryable
            )
        )
    }

    fun clearError() {
        val currentState = _uiState.value.userState
        if (currentState is UiState.Error) {
            _uiState.value = _uiState.value.copy(
                userState = UiState.Empty
            )
        }
    }
}

自動リトライ機能付きの実装

import kotlinx.coroutines.delay
import kotlin.random.Random

class UserViewModel(
    private val userRepository: UserRepository
) : ViewModel() {
    
    private suspend fun fetchUserWithRetry(
        userName: String,
        maxRetries: Int = 3,
        initialDelayMillis: Long = 1000
    ) {
        var currentDelay = initialDelayMillis
        var lastException: AppError? = null
        
        repeat(maxRetries) { attempt ->
            try {
                val user = userRepository.getUser(userName)
                _uiState.value = _uiState.value.copy(
                    userState = UiState.Success(user)
                )
                return // 成功したら終了
            } catch (e: AppError.NetworkError) {
                lastException = e
                if (attempt < maxRetries - 1) {
                    delay(currentDelay)
                    // 指数バックオフ + ジッター
                    currentDelay = (currentDelay * 2) + Random.nextLong(0, 1000)
                }
            } catch (e: AppError) {
                // ネットワークエラー以外はリトライしない
                handleError(e)
                return
            }
        }
        
        // 最大リトライ回数に達した場合
        lastException?.let { handleError(it) }
    }
}

5. UI層でのフィードバック実装

Compose を使用した現代的なUI実装

import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp

@Composable
fun UserScreen(
    viewModel: UserViewModel,
    userName: String
) {
    val uiState by viewModel.uiState.collectAsState()
    
    LaunchedEffect(userName) {
        viewModel.fetchUser(userName)
    }
    
    Box(modifier = Modifier.fillMaxSize()) {
        when (val state = uiState.userState) {
            is UiState.Loading -> {
                LoadingContent()
            }
            is UiState.Success -> {
                UserContent(
                    user = state.data,
                    isRefreshing = uiState.isRefreshing,
                    onRefresh = { viewModel.refresh(userName) }
                )
            }
            is UiState.Error -> {
                ErrorContent(
                    error = state,
                    onRetry = if (state.isRetryable) {
                        { viewModel.retryFetch(userName) }
                    } else null,
                    onDismiss = { viewModel.clearError() }
                )
            }
            is UiState.Empty -> {
                EmptyContent()
            }
        }
    }
}

@Composable
fun LoadingContent() {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Column(
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(16.dp)
        ) {
            CircularProgressIndicator()
            Text(
                text = "データを読み込んでいます...",
                style = MaterialTheme.typography.bodyMedium
            )
        }
    }
}

@Composable
fun ErrorContent(
    error: UiState.Error,
    onRetry: (() -> Unit)?,
    onDismiss: () -> Unit
) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp),
        colors = CardDefaults.cardColors(
            containerColor = MaterialTheme.colorScheme.errorContainer
        )
    ) {
        Column(
            modifier = Modifier.padding(16.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(12.dp)
        ) {
            Icon(
                imageVector = Icons.Default.Error,
                contentDescription = null,
                tint = MaterialTheme.colorScheme.error
            )
            
            Text(
                text = "エラーが発生しました",
                style = MaterialTheme.typography.headlineSmall,
                color = MaterialTheme.colorScheme.error
            )
            
            Text(
                text = error.message,
                style = MaterialTheme.typography.bodyMedium,
                textAlign = TextAlign.Center
            )
            
            Row(
                horizontalArrangement = Arrangement.spacedBy(8.dp)
            ) {
                if (onRetry != null) {
                    Button(onClick = onRetry) {
                        Text("再試行")
                    }
                }
                
                OutlinedButton(onClick = onDismiss) {
                    Text("閉じる")
                }
            }
        }
    }
}

従来のView システムでの実装

class UserFragment : Fragment() {
    
    private lateinit var binding: FragmentUserBinding
    private lateinit var viewModel: UserViewModel
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        setupObservers()
        setupRetryButton()
    }
    
    private fun setupObservers() {
        viewModel.uiState.observe(viewLifecycleOwner) { state ->
            updateUI(state)
        }
    }
    
    private fun updateUI(state: UserUiState) {
        when (val userState = state.userState) {
            is UiState.Loading -> showLoading()
            is UiState.Success -> showUser(userState.data)
            is UiState.Error -> showError(userState)
            is UiState.Empty -> showEmpty()
        }
        
        // Pull-to-refreshの状態
        binding.swipeRefreshLayout.isRefreshing = state.isRefreshing
    }
    
    private fun showError(errorState: UiState.Error) {
        binding.apply {
            progressBar.visibility = View.GONE
            userProfileLayout.visibility = View.GONE
            errorLayout.visibility = View.VISIBLE
            
            errorMessageTextView.text = errorState.message
            retryButton.visibility = if (errorState.isRetryable) {
                View.VISIBLE
            } else {
                View.GONE
            }
        }
        
        // Snackbar でも通知
        Snackbar.make(
            binding.root,
            errorState.message,
            Snackbar.LENGTH_LONG
        ).apply {
            if (errorState.isRetryable) {
                setAction("再試行") {
                    viewModel.retryFetch(getCurrentUserName())
                }
            }
        }.show()
    }
}

6. ログ管理とクラッシュレポート

ログ管理の実装

import android.util.Log

object AppLogger {
    private const val TAG = "MyApp"
    
    fun d(message: String, tag: String = TAG) {
        if (BuildConfig.DEBUG) {
            Log.d(tag, message)
        }
    }
    
    fun e(message: String, throwable: Throwable? = null, tag: String = TAG) {
        Log.e(tag, message, throwable)
        
        // 本番環境では Crashlytics に送信
        if (!BuildConfig.DEBUG) {
            FirebaseCrashlytics.getInstance().apply {
                log("$tag: $message")
                throwable?.let { recordException(it) }
            }
        }
    }
    
    fun recordUserAction(action: String, parameters: Map<String, String> = emptyMap()) {
        if (!BuildConfig.DEBUG) {
            FirebaseAnalytics.getInstance(context).logEvent(action, Bundle().apply {
                parameters.forEach { (key, value) ->
                    putString(key, value)
                }
            })
        }
    }
}

7. テスタブルな設計

エラーハンドリングの動作をテストするための設計も重要です:

// テスト用のRepository実装
class FakeUserRepository : UserRepository {
    var shouldReturnError = false
    var errorToReturn: AppError? = null
    
    override suspend fun getUser(userName: String): User {
        if (shouldReturnError) {
            throw errorToReturn ?: AppError.NetworkError()
        }
        return User(userName, "Test User")
    }
}

// ViewModelのテスト例
class UserViewModelTest {
    
    @Test
    fun `ネットワクエラ時に適切なエラ状態になること`() = runTest {
        // Given
        val fakeRepository = FakeUserRepository().apply {
            shouldReturnError = true
            errorToReturn = AppError.NetworkError()
        }
        val viewModel = UserViewModel(fakeRepository)
        
        // When
        viewModel.fetchUser("testuser")
        
        // Then
        val state = viewModel.uiState.value.userState
        assertTrue(state is UiState.Error)
        assertTrue((state as UiState.Error).isRetryable)
    }
}

まとめ

  • 適切なエラーハンドリングは、アプリの信頼性とユーザー体験向上の要です
  • 型安全な状態管理により、エラー状態を明確に表現し、UIに適切に反映できます
  • エラーの分類と変換により、ユーザーフレンドリーなメッセージを提供できます
  • 自動リトライ機能により、一時的な問題を透過的に処理できます
  • ログ管理とクラッシュレポートにより、本番環境での問題を効率的に特定・修正できます
  • テスタブルな設計により、エラーハンドリングの動作を確実に検証できます

これらの技術は、特にLLMのように外部APIへの依存度が高いアプリ開発において、安定性を確保するために非常に重要です。次回は、これまでの知識を活用して、包括的なテスト戦略について学びます!

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?