はじめに
アプリ開発において、予期せぬエラーは避けられません。ネットワークの切断、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への依存度が高いアプリ開発において、安定性を確保するために非常に重要です。次回は、これまでの知識を活用して、包括的なテスト戦略について学びます!