0
0

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]Jetpack Compose MVI Orbit Array

Posted at

46456456.png

はじめに

Clean Architectureでマルチモジュール構成にリファクタリングを行いましたが、今回はArrowライブラリを使ったエラー処理について整理・記録

なぜArrow-ktなのか?

Android開発をしていると、エラー処理のコードがビジネスロジックより多くなる経験をします。try-catchがネストし、nullチェックが繰り返され、どんな例外が発生するか把握しにくくなる

Arrow-ktはこのような問題を解決してくれるKotlinの関数型プログラミングライブラリ

インストール

// build.gradle.kts
dependencies {
    implementation("io.arrow-kt:arrow-core:1.2.1")
    implementation("io.arrow-kt:arrow-fx-coroutines:1.2.1")
}

従来のエラー処理の問題点

問題1:Exceptionは型システムに現れない

// この関数を見ても、どんな例外が発生するか分からない
fun getUserName(userId: String): String {
    return api.getUser(userId).name  // IOException? NetworkException? 
}

// 呼び出し側も分からない
val name = getUserName("123")  // 突然アプリがクラッシュする可能性

関数シグネチャにはStringしか見えないため、この関数が失敗する可能性があることが分からなく、ドキュメントを読むかコードを直接確認しないと分からない

問題2:try-catchは冗長でネストする

fun processUser(userId: String): String {
    try {
        val user = getUser(userId)
        try {
            val posts = getPosts(user.id)
            try {
                val comments = getComments(posts[0].id)
                return comments[0].content
            } catch (e: Exception) {
                return "コメント読み込み失敗"
            }
        } catch (e: Exception) {
            return "投稿読み込み失敗"
        }
    } catch (e: Exception) {
        return "ユーザー読み込み失敗"
    }
}

インデント地獄になり、実際のビジネスロジックよりエラー処理コードの方が多くなる

問題3:nullは情報が不足している

fun findUser(id: String): User? {
    return database.find(id)
}

val user = findUser("123")
if (user == null) {
    // なぜnull?
    // DBに存在しない?
    // DB接続失敗?
    // 権限がない?
    // 分からない
}

nullは「値がない」ことしか伝えず、なぜないのかという情報がない

問題4:Resultの限界

suspend fun refreshMessages(): Result {
    return runCatching {
        val messages = remoteDataSource.getMessages()
        localDataSource.insertAll(messages)
    }
}

// 使用時
refreshMessagesUseCase()
    .onSuccess { /* ... */ }
    .onFailure { error: Throwable ->
        // errorはThrowable - 具体的な型が分からない
        // whenで分岐するにはisチェックが必要
        showError(error.message ?: "Unknown error")
    }

Result<T>は失敗の型が常にThrowableに固定されているため、ドメインに合ったエラー型を使いにくいです。

現在よく使われているエラー処理方式

これらの問題を解決するため、Android開発では様々なエラー処理パターンが使われています。

1. sealed classでResultラッパーを自作

sealed class Resource {
    data class Success(val data: T) : Resource()
    data class Error(val message: String, val exception: Throwable? = null) : Resource()
    data object Loading : Resource()
}

// 使用
fun getUser(): Flow<Resource> = flow {
    emit(Resource.Loading)
    try {
        val user = api.getUser()
        emit(Resource.Success(user))
    } catch (e: Exception) {
        emit(Resource.Error(e.message ?: "Unknown error", e))
    }
}

最もよく見られるパターンですが、毎回自分で実装する必要があり、mapflatMapなどのユーティリティ関数も自作が必要です。

2. Retrofit Responseラッピング

suspend fun  safeApiCall(apiCall: suspend () -> Response): Result {
    return try {
        val response = apiCall()
        if (response.isSuccessful) {
            Result.success(response.body()!!)
        } else {
            Result.failure(HttpException(response))
        }
    } catch (e: Exception) {
        Result.failure(e)
    }
}

ネットワーク呼び出しに特化していますが、エラー型が依然としてThrowableに制限されています。

3. Flowでcatchを使用

fun getMessages(): Flow<List> = flow {
    emit(api.getMessages())
}.catch { e ->
    // エラー処理...でもどうやって?
    emit(emptyList())  // 空リストで代替?
}

Flowのcatchはエラーを処理できますが、エラー情報をUIまで伝えるのが難しいです。

なぜArrow-ktを選んだのか

上記の方式にはそれぞれ長所がありますが、以下の理由でArrow-ktを選びました。

  1. 検証済みライブラリ:Resultラッパーを自作する必要なく、すでに良く設計されたEither型を使用
  2. 豊富なユーティリティmapflatMapfoldrecoverなど多様な演算子を提供
  3. Raise DSLbind()ensure()などでクリーンなエラー処理コードが書ける
  4. 型安全なエラーEither<AppError, T>でドメインに合ったエラー型を使用
  5. 関数型プログラミング:チェーンと合成で可読性の高いコードが書ける

Either - 成功と失敗の明確な区別

Eitherの基本概念

Either<E, A>は2つの状態のうち1つを持てる型です。

sealed class Either {
    data class Left(val value: E) : Either()   // 失敗
    data class Right(val value: A) : Either()  // 成功
}

慣例としてLeftはエラー、Rightは成功を表します。

Eitherの利点

// 1. 関数シグネチャに失敗の可能性が明示される
fun getUser(id: String): Either
//                              ↑ 失敗型    ↑ 成功型

// 2. コンパイラがエラー処理を強制する
val result = getUser("123")
result.fold(
    ifLeft = { error -> /* 必ず処理が必要 */ },
    ifRight = { user -> /* 成功処理 */ }
)

// 3. エラーに豊富な情報を含められる
sealed interface AppError {
    data class NotFound(val id: String) : AppError
    data class NetworkError(val cause: Throwable) : AppError
    data class Unauthorized(val reason: String) : AppError
}

Either主要メソッド

fold - 両方を処理

val result: Either = divide(10, 2)

val message: String = result.fold(
    ifLeft = { error -> "失敗: $error" },
    ifRight = { value -> "成功: $value" }
)

map - 成功した場合のみ変換

val result: Either = Either.Right(5)

val doubled: Either = result.map { it * 2 }
// Right(10)

// Leftの場合、mapは実行されずそのまま通過
val failed: Either = Either.Left("エラー")
val stillFailed = failed.map { it * 2 }
// Left("エラー") - map実行されず

mapLeft - 失敗した場合のみ変換

val result: Either = Either.Left(IOException("ネットワークエラー"))

// Throwable → AppErrorに変換
val mapped: Either = result.mapLeft { throwable ->
    AppError.NetworkError(throwable)
}

flatMap - Eitherを返す関数のチェーン

fun divide(a: Int, b: Int): Either =
    if (b == 0) Either.Left("0で割れません")
    else Either.Right(a / b)

// flatMapでチェーン
val result: Either = divide(10, 2)
    .flatMap { divide(it, 2) }  // Right(2)

// 途中で失敗すると即座に停止
val failed: Either = divide(10, 0)
    .flatMap { divide(it, 2) }  // Left("0で割れません")

getOrElse - デフォルト値を提供

val result: Either = getUser("123")

// 失敗時にデフォルト値を使用
val user: User = result.getOrElse { 
    User(id = -1, name = "Guest") 
}

// またはnullに変換
val nullableUser: User? = result.getOrNull()

Either.catch - 例外をEitherに変換

// 例外が発生する可能性のあるコードをEitherでラップ
val result: Either> = Either.catch {
    api.getMessages()  // IOException発生の可能性
}
// 成功: Either.Right(messages)
// 失敗: Either.Left(IOException)

// mapLeftと組み合わせてドメインエラーに変換
val domainResult: Either> = Either.catch {
    api.getMessages()
}.mapLeft { throwable ->
    throwable.toNetworkError()
}

Raise DSL - 型安全な早期リターン

Eitherを直接使うとコードが複雑になることがあります。ArrowのRaise DSLを使えば、例外を投げるように自然にコードを書けます。

Raiseの核心アイデア

Raiseは**「失敗が許容された特別な空間(コンテキスト)」**を作ることです。

val result: Either = either {
    // この空間内ではraise()を使用できる
    if (someCondition) {
        raise("失敗!")  // 即座に脱出、Either.Left("失敗!")を返す
    }
    42  // 成功時 Either.Right(42)を返す
}

例えると:

  • throw:非常ベルを押してビル全体を止める(アプリクラッシュの危険)
  • raise:特殊任務空間で脱出ボタンを押す(安全にEither.Leftに変換)

either { } ブロック

fun divide(a: Int, b: Int): Either = either {
    if (b == 0) {
        raise("0で割ることはできません")  // 即座にEither.Leftを返す
    }
    a / b  // 成功時 Either.Rightを返す
}

bind() - Eitherから値を抽出

bind()EitherからRightの値を抽出し、Leftの場合は即座に脱出します。

fun processUser(userId: String): Either = either {
    // getUser()はEitherを返す
    val user: User = getUser(userId).bind()  // Rightなら User抽出、Leftなら即座に脱出
    val posts: List = getPosts(user.id).bind()  // 同様
    
    posts.first().title  // すべての作業成功時に返す
}

bind()なしで書くと?

// bind()なし(冗長)
fun processUser(userId: String): Either {
    val userResult = getUser(userId)
    val user = when (userResult) {
        is Either.Left -> return Either.Left(userResult.value)
        is Either.Right -> userResult.value
    }
    
    val postsResult = getPosts(user.id)
    val posts = when (postsResult) {
        is Either.Left -> return Either.Left(postsResult.value)
        is Either.Right -> postsResult.value
    }
    
    return Either.Right(posts.first().title)
}

// bind()使用(簡潔)
fun processUser(userId: String): Either = either {
    val user = getUser(userId).bind()
    val posts = getPosts(user.id).bind()
    posts.first().title
}

Either.catchとbind()の組み合わせ

Either.catchbind()を組み合わせると、例外処理とチェーンをきれいに書けます。

fun fetchAndSaveMessages(): Either> = either {
    // 1. API呼び出し(例外 → Eitherに変換 → ドメインエラーに変換 → 値抽出)
    val messages = Either.catch { api.getMessages() }
        .mapLeft { it.toNetworkError() }
        .bind()
    
    // 2. DB保存
    Either.catch { database.insertAll(messages) }
        .mapLeft { it.toDatabaseError() }
        .bind()
    
    messages
}

ensure() - 条件検証

ensure(condition) { error }は条件がfalseならraiseします。

fun createUser(email: String, age: Int): Either = either {
    ensure(email.contains("@")) {
        AppError.ValidationFailed("email", "メール形式が正しくありません")
    }
    
    ensure(age >= 18) {
        AppError.ValidationFailed("age", "18歳以上である必要があります")
    }
    
    // すべての検証通過
    User(email = email, age = age)
}

ensureNotNull() - Nullチェック

ensureNotNull(value) { error }は値がnullならraise、そうでなければnon-null値を返します。

fun getUserProfile(userId: String): Either = either {
    val user: User? = repository.findUser(userId)
    
    // nullならraise、そうでなければnon-null Userを返す
    val nonNullUser: User = ensureNotNull(user) {
        AppError.NotFound(userId)
    }
    
    // ここからnonNullUserは絶対にnullではない!
    Profile(name = nonNullUser.name, email = nonNullUser.email)
}

recover() - エラー回復

特定のエラーを回復して処理を続行できます。

fun getMessageWithFallback(id: Int): Either = either {
    val result = repository.getMessage(id)
    
    result.recover { error ->
        when (error) {
            is AppError.NetworkError -> {
                // ネットワークエラーはキャッシュデータで代替
                Message(id = id, title = "キャッシュメッセージ", body = "オフライン")
            }
            else -> raise(error)  // 他のエラーはそのまま伝播
        }
    }.bind()
}

Clean Architectureに適用する

レイヤー別責任分離

ArrowをClean Architectureに適用する際、最も重要なのは各レイヤーの責任を明確に分離することです。

┌─────────────────────────────────────────────────────────────────┐
│                       レイヤー別責任                               
├─────────────────────────────────────────────────────────────────┤
│                                                                 
│  Repository(技術的エラー変換)                                    
│  ├── Either.catch { api.call() }                                
│  │       .mapLeft { it.toNetworkError() }                       
│  │       .bind()                                                
│  ├── IOException → AppError.NetworkError.ConnectionFailed       
│  ├── SocketTimeoutException → AppError.NetworkError.Timeout     
│  └── HttpException → AppError.NetworkError.ServerError(code)    
│                                                                 
│  ✗ ビジネス検証はしない                                        
│  ✗ ユーザーメッセージは入れない                                        
│                                                                 
├─────────────────────────────────────────────────────────────────┤
│                                                                 
│  UseCase(ビジネスロジック検証)                                     
│  ├── repository.getData().bind()                                
│  ├── ensure(data.isNotEmpty()) { EmptyData }                    
│  └── ensure(id > 0) { InvalidId(id) }                           
│                                                                 
│  ✓ Repository結果にビジネスルールを適用                           
│  ✓ ensure()、bind()を使用                                        
│                                                                 
├─────────────────────────────────────────────────────────────────┤
│                                                                 
│  Presentation(ユーザーメッセージ生成)                                
│  ├── useCase().fold(ifLeft, ifRight)                            
│  ├── AppError.toUserMessage() 拡張関数                          
│  └── 多言語対応、UIコンテキストに合ったメッセージ                       
│                                                                 
│  ✓ fold()でEitherを処理                                         
│  ✓ エラー型別に適切なメッセージを決定                                  
│                                                                 
└─────────────────────────────────────────────────────────────────┘

なぜこのように分離するのか?

  1. Repositoryにメッセージを入れると:多言語対応が難しく、UIコンテキストが分からない
  2. UseCaseで技術的エラーを処理すると:Repository実装の変更時にUseCaseも修正が必要
  3. Presentationでメッセージを生成すると:同じエラーでも画面ごとに異なるメッセージが可能、多言語対応が容易

実践例

エラー型定義(Domain Layer)

sealed interface AppError {
    // 技術的エラー(Repositoryで発生)
    sealed interface NetworkError : AppError {
        data class ConnectionFailed(val cause: Throwable? = null) : NetworkError
        data class Timeout(val cause: Throwable? = null) : NetworkError
        data class ServerError(val code: Int) : NetworkError
    }
    
    sealed interface DatabaseError : AppError {
        data class WriteFailed(val cause: Throwable? = null) : DatabaseError
        // ...
    }
    
    // ビジネスエラー(UseCaseで発生)
    sealed interface BusinessError : AppError {
        data object EmptyData : BusinessError
        data class InvalidId(val id: Int) : BusinessError
        // ...
    }
    
    data class Unknown(val cause: Throwable) : AppError
}

Repositoryインターフェース(Domain Layer)

interface MessageRepository {
    fun observeMessages(): Flow<List>
    suspend fun refreshMessages(): Either>
    suspend fun deleteMessage(id: Int): Either
    // ...
}

Throwable → AppError変換(Data Layer)

fun Throwable.toNetworkError(): AppError = when (this) {
    is SocketTimeoutException -> AppError.NetworkError.Timeout(this)
    is IOException -> AppError.NetworkError.ConnectionFailed(this)
    is HttpException -> AppError.NetworkError.ServerError(code())
    else -> AppError.Unknown(this)
}

fun Throwable.toDatabaseError(): AppError = when (this) {
    is SQLiteException -> AppError.DatabaseError.WriteFailed(this)
    else -> AppError.DatabaseError.WriteFailed(this)
}

Repository実装(Data Layer)

核心はEither.catch { }.mapLeft { }.bind()パターンです。

class MessageRepositoryImpl @Inject constructor(
    private val localDataSource: MessageLocalDataSource,
    private val remoteDataSource: MessageRemoteDataSource
) : MessageRepository {

    override suspend fun refreshMessages(): Either> = either {
        // 1. API呼び出し → 例外をEitherに → ドメインエラーに変換 → 値抽出
        val remoteMessages = Either.catch { remoteDataSource.getMessages() }
            .mapLeft { it.toNetworkError() }
            .bind()

        // 2. DB保存
        Either.catch { localDataSource.insertAll(remoteMessages.map { it.toEntity() }) }
            .mapLeft { it.toDatabaseError() }
            .bind()

        remoteMessages.map { it.toDomain() }
    }

    override suspend fun deleteMessage(id: Int): Either = either {
        Either.catch { localDataSource.deleteById(id) }
            .mapLeft { it.toDatabaseError() }
            .bind()

        Either.catch { remoteDataSource.deleteMessage(id) }
            .mapLeft { it.toNetworkError() }
            .bind()
    }
    
    // ... 他のメソッドも同じパターン
}

UseCase実装(Data Layer)

UseCaseはbind()でRepository結果を受け取り、ensure()でビジネス検証を行います。

class RefreshMessagesUseCaseImpl @Inject constructor(
    private val repository: MessageRepository
) : RefreshMessagesUseCase {

    override suspend fun invoke(): Either> = either {
        val messages = repository.refreshMessages().bind()
        
        // ビジネス検証
        ensure(messages.isNotEmpty()) {
            AppError.BusinessError.EmptyData
        }
        
        messages
    }
}

class DeleteMessageUseCaseImpl @Inject constructor(
    private val repository: MessageRepository
) : DeleteMessageUseCase {

    override suspend fun invoke(id: Int): Either = either {
        ensure(id > 0) { AppError.BusinessError.InvalidId(id) }
        repository.deleteMessage(id).bind()
    }
}

AppError → ユーザーメッセージ(Presentation Layer)

fun AppError.toUserMessage(): String = when (this) {
    is AppError.NetworkError.ConnectionFailed -> 
        "ネットワーク接続に失敗しました。インターネット接続を確認してください。"
    is AppError.NetworkError.Timeout -> 
        "リクエストがタイムアウトしました。もう一度お試しください。"
    is AppError.NetworkError.ServerError -> 
        "サーバーエラーが発生しました。(コード: $code)"
    is AppError.DatabaseError.WriteFailed -> 
        "データの保存中にエラーが発生しました。"
    AppError.BusinessError.EmptyData -> 
        "表示するデータがありません。"
    is AppError.BusinessError.InvalidId -> 
        "無効なIDです。"
    is AppError.Unknown -> 
        "不明なエラーが発生しました。"
    // ...
}

ViewModel(Presentation Layer)

fold()でEitherを処理します。

@HiltViewModel
class HomeViewModel @Inject constructor(
    private val refreshMessagesUseCase: RefreshMessagesUseCase,
    private val deleteMessageUseCase: DeleteMessageUseCase
) : ContainerHost, ViewModel() {

    private fun loadInitialData() = intent {
        reduce { state.copy(isLoading = true) }

        refreshMessagesUseCase().fold(
            ifLeft = { error ->
                postSideEffect(HomeSideEffect.ShowError(error.toUserMessage()))
            },
            ifRight = { messages ->
                postSideEffect(HomeSideEffect.ShowSnackBar("${messages.size}件読み込み完了"))
            }
        )

        reduce { state.copy(isLoading = false) }
    }

    fun onDeleteMessage(id: Int) = intent {
        val previousItems = state.items
        
        // Optimistic UI
        reduce { state.copy(items = state.items.filter { it.id != id }) }

        deleteMessageUseCase(id).fold(
            ifLeft = { error ->
                reduce { state.copy(items = previousItems) }  // ロールバック
                postSideEffect(HomeSideEffect.ShowError(error.toUserMessage()))
            },
            ifRight = {
                postSideEffect(HomeSideEffect.ShowSnackBar("削除完了"))
            }
        )
    }
    
    // ...
}

データフロー例

ユーザー → 「更新ボタンをクリック」
                │
                ↓
┌─────────────────────────────────────────────────────────────────┐
│  ViewModel                                                      
│    refreshMessagesUseCase()                                     
└───────────────────────────┬─────────────────────────────────────┘
                            ↓
┌─────────────────────────────────────────────────────────────────┐
│  UseCase                                                        
│    repository.refreshMessages().bind()                          
│    ensure(messages.isNotEmpty()) { EmptyData }                  
└───────────────────────────┬─────────────────────────────────────┘
                            ↓
┌─────────────────────────────────────────────────────────────────┐
│  Repository                                                     
│    Either.catch { remoteDataSource.getMessages() }              
│        .mapLeft { it.toNetworkError() }                         
│        .bind()                                                  
└───────────────────────────┬─────────────────────────────────────┘
                            ↓
┌─────────────────────────────────────────────────────────────────┐
│  DataSource                                                     
│    api.getMessages() → IOException発生!                        
└───────────────────────────┬─────────────────────────────────────┘
                            ↓
                (Either.catchが例外をキャッチ)
                            ↓
┌─────────────────────────────────────────────────────────────────┐
│  Repository                                                     
│    Either.Left(IOException)                                     
│        .mapLeft { it.toNetworkError() }                         
│        → Either.Left(NetworkError.ConnectionFailed)             
│        .bind() → raise!                                         
└───────────────────────────┬─────────────────────────────────────┘
                            ↓
┌─────────────────────────────────────────────────────────────────┐
│  UseCase                                                       
│    .bind() → Leftなので即座に脱出                               
│    → Either.Left(NetworkError.ConnectionFailed)を返す             
└───────────────────────────┬─────────────────────────────────────┘
                            ↓
┌─────────────────────────────────────────────────────────────────┐
│  ViewModel                                                      
│    .fold(                                                       
│        ifLeft = { error ->                                      
│            postSideEffect(ShowError(error.toUserMessage()))     
│        },                                                       
│        ifRight = { ... }                                        
│    )                                                            
└───────────────────────────┬─────────────────────────────────────┘
                            ↓
┌─────────────────────────────────────────────────────────────────┐
│  UI                                                             
│    Snackbar: "ネットワーク接続に失敗しました。インターネット接続を確認してください。" 
└─────────────────────────────────────────────────────────────────┘

おわりに

Arrow-kt核心まとめ

関数 説明
either { } 失敗可能な「特別な空間」を生成
raise(error) 即座に脱出 → Either.Leftを返す
Either.catch { } 例外をEitherに変換
.mapLeft { } Left値(エラー)を変換
.bind() Rightなら値抽出、Leftなら即座に脱出
ensure(条件) { error } 条件がfalseならraise
ensureNotNull(値) { error } nullならraise
fold(ifLeft, ifRight) 両方を処理
recover { } 特定エラーを回復

従来コード vs Arrowコード比較

// ❌ 従来方式(Result + try-catch)
suspend fun refreshMessages(): Result {
    return runCatching {
        val messages = remoteDataSource.getMessages()
        localDataSource.insertAll(messages)
    }
}

refreshMessagesUseCase()
    .onSuccess { /* ... */ }
    .onFailure { error: Throwable ->
        // Throwable - 具体的な型が分からない
        showError(error.message ?: "Unknown")
    }

// ✅ Arrow方式(Either.catch + mapLeft)
suspend fun refreshMessages(): Either> = either {
    val remoteMessages = Either.catch { remoteDataSource.getMessages() }
        .mapLeft { it.toNetworkError() }
        .bind()
    
    Either.catch { localDataSource.insertAll(remoteMessages) }
        .mapLeft { it.toDatabaseError() }
        .bind()
    
    remoteMessages.map { it.toDomain() }
}

refreshMessagesUseCase().fold(
    ifLeft = { error: AppError ->
        // AppError - whenですべてのケースを型安全に処理
        showError(error.toUserMessage())
    },
    ifRight = { messages -> /* ... */ }
)

Arrow-ktを導入すると良い点

  1. 型安全性:関数シグネチャだけで失敗の可能性が分かる
  2. コンパイラサポート:エラー処理を強制してランタイムクラッシュを防止
  3. 豊富なエラー情報Throwableの代わりにドメインエラー型を使用
  4. 簡潔なコードEither.catchbind()ensure()などでボイラープレートを削減
  5. 関数型スタイル:チェーンで可読性向上
  6. 明確な責任分離:レイヤーごとにエラー処理の責任が明確

参考資料

💻 全体コード

GitHub : https://github.com/GEUN-TAE-KIM/Mvi_Orbit_Study

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?