iT邦幫忙

2025 iThome 鐵人賽

DAY 25
0
生成式 AI

30 天一人公司的 AI 開發實戰系列 第 25

Day 25: 架構師的相容性建議:KMP AppResult 跨平台錯誤處理實踐

  • 分享至 

  • xImage
  •  

前言:錯誤處理的困惑

寫 Grimo 專案時,錯誤處理一直困擾著我。

有時候用 try-catch:

try {
    val project = repository.getProject(id)
} catch (e: Exception) {
    // 處理錯誤
}

有時候返回 null:

val project = repository.getProject(id)
if (project == null) {
    // 處理找不到
}

有時候用 Kotlin 的 Result:

val result = runCatching { repository.getProject(id) }

三種方式混在一起,程式碼很亂。

而且聽說 KMP 如果要支援 iOS,Exception 的處理會有問題。

研究錯誤處理模式

請 ARS 研究

「請以 ARS 角色,研究 KMP 專案的錯誤處理最佳實踐,特別是跨平台相容性。」

ARS 的研究報告:

## 研究報告:KMP 錯誤處理方案

### 執行摘要
- iOS 互操作性問題:Exception 會變成 generic error
- 建議採用 Result Type Pattern
- 參考 Arrow-kt 的 Either 設計

### 發現的方案
1. **Arrow-kt Either** - 功能完整但較重
2. **Kotlin Result** - 官方但功能有限
3. **自訂 Result Type** - 輕量且可控

### 業界實踐
- Cash App:自訂 Result type
- Touchlab:避免 Exception,用 sealed class

「原來函數式程式設計有個概念叫 Result Type Pattern,」我理解了。

請教 Claude Code

看了 ARS 的報告後,我問 Claude Code:「基於 ARS 的研究,幫我設計一個輕量的 AppResult。」

Claude Code 分析:「有幾個考量點:

  1. iOS 的互操作性問題 - Exception 在 iOS 會變成 generic error
  2. 類型安全 - 編譯時就知道可能的錯誤
  3. 一致性 - 整個專案用同一種模式」

「Result Type Pattern 很適合 KMP,」Claude Code 建議,「它把錯誤當作正常的返回值,而不是異常。」

設計 AppResult

基於研究設計

根據 ARS 的研究,Arrow-kt 的 Either 概念很棒但太重了。

Claude Code 根據研究結果設計了輕量版:

sealed class AppResult<out T, out E : AppError> {
    data class Success<T>(val value: T) : AppResult<T, Nothing>()
    data class Failure<E : AppError>(val error: E) : AppResult<Nothing, E>()
}

簡單明瞭,Success 或 Failure。

定義錯誤類型

接下來要定義錯誤類型。

「錯誤該怎麼分類?」我思考。

翻了翻專案,大概有這幾類:

  • 資料庫錯誤
  • 網路錯誤
  • 驗證錯誤
  • 業務邏輯錯誤

開始寫 AppError:

sealed interface AppError {
    val message: String
    val isRecoverable: Boolean
    
    data class Database(
        override val message: String,
        val operation: String? = null,
        override val isRecoverable: Boolean = true
    ) : AppError
    
    data class Network(
        override val message: String,
        val statusCode: Int? = null,
        override val isRecoverable: Boolean = true
    ) : AppError
    
    data class Validation(
        val field: String,
        val reason: String,
        override val message: String = "$field: $reason",
        override val isRecoverable: Boolean = false
    ) : AppError
    
    data class Business(
        val code: String,
        override val message: String,
        override val isRecoverable: Boolean = false
    ) : AppError
    
    data class Unknown(
        override val message: String,
        override val isRecoverable: Boolean = false
    ) : AppError
}

實作工具函數

基本操作

看了函數式程式設計的資料,AppResult 需要一些基本操作:

// map - 轉換成功值
inline fun <T, E : AppError, R> AppResult<T, E>.map(
    transform: (T) -> R
): AppResult<R, E> = when (this) {
    is AppResult.Success -> AppResult.Success(transform(value))
    is AppResult.Failure -> this
}

// flatMap - 鏈式操作
inline fun <T, E : AppError, R> AppResult<T, E>.flatMap(
    transform: (T) -> AppResult<R, E>
): AppResult<R, E> = when (this) {
    is AppResult.Success -> transform(value)
    is AppResult.Failure -> this
}

// fold - 處理成功和失敗
inline fun <T, E : AppError, R> AppResult<T, E>.fold(
    onSuccess: (T) -> R,
    onFailure: (E) -> R
): R = when (this) {
    is AppResult.Success -> onSuccess(value)
    is AppResult.Failure -> onFailure(error)
}

catching 函數

最實用的是 catching,可以把 try-catch 轉成 AppResult:

companion object {
    inline fun <T> catching(block: () -> T): AppResult<T, AppError> {
        return try {
            Success(block())
        } catch (e: CancellationException) {
            // 協程取消要繼續傳播
            throw e
        } catch (e: Exception) {
            Failure(mapThrowableToError(e))
        }
    }
}

這個 CancellationException 的處理很重要,Claude Code 提醒我的。

處理平台差異

expect/actual 機制

不同平台的 Exception 不一樣,需要用 expect/actual:

// commonMain
expect fun mapThrowableToError(throwable: Throwable): AppError

// desktopMain (JVM)
actual fun mapThrowableToError(throwable: Throwable): AppError {
    return when (throwable) {
        is java.sql.SQLException -> {
            // SQLDelight 會拋出這個
            when {
                throwable.message?.contains("locked") == true -> 
                    AppError.Database(
                        message = "資料庫被鎖定",
                        isRecoverable = true
                    )
                else -> AppError.Database(
                    message = throwable.message ?: "資料庫錯誤"
                )
            }
        }
        
        is IllegalArgumentException -> 
            AppError.Validation(
                field = "unknown",
                reason = throwable.message ?: "參數錯誤"
            )
        
        else -> AppError.Unknown(
            message = throwable.message ?: "未知錯誤"
        )
    }
}

改寫 Repository

Before - 混亂的錯誤處理

原本的 Repository:

suspend fun getProject(id: String): Project? {
    return try {
        db.getProject(id)
    } catch (e: Exception) {
        logger.error("Failed to get project", e)
        null
    }
}

問題是呼叫端不知道是「找不到」還是「出錯了」。

After - 明確的 AppResult

改用 AppResult:

suspend fun getProject(id: String): AppResult<Project, AppError> {
    return AppResult.catching {
        db.getProject(id)
    }.flatMap { project ->
        if (project != null) {
            AppResult.Success(project)
        } else {
            AppResult.Failure(
                AppError.Business(
                    code = "PROJECT_NOT_FOUND",
                    message = "專案不存在"
                )
            )
        }
    }
}

現在錯誤類型很清楚。

在 ViewModel 使用

優雅的錯誤處理

ViewModel 裡可以用 fold 處理結果:

fun loadProject(id: String) {
    viewModelScope.launch {
        _uiState.update { it.copy(isLoading = true) }
        
        repository.getProject(id).fold(
            onSuccess = { project ->
                _uiState.update { 
                    it.copy(
                        isLoading = false,
                        project = project,
                        error = null
                    )
                }
            },
            onFailure = { error ->
                _uiState.update { 
                    it.copy(
                        isLoading = false,
                        error = error.message
                    )
                }
                handleError(error)
            }
        )
    }
}

private fun handleError(error: AppError) {
    when (error) {
        is AppError.Network -> {
            if (error.isRecoverable) {
                // 排程重試
            }
        }
        is AppError.Business -> {
            if (error.code == "PROJECT_NOT_FOUND") {
                // 導航到建立專案
            }
        }
        else -> {
            // 顯示錯誤
        }
    }
}

每種錯誤都有對應的處理方式。

進階功能

重試機制

網路錯誤經常需要重試,我加了個 retry 函數:

suspend fun <T> AppResult.Companion.retry(
    times: Int = 3,
    initialDelay: Long = 100,
    block: suspend () -> AppResult<T, AppError>
): AppResult<T, AppError> {
    var currentDelay = initialDelay
    
    repeat(times - 1) {
        val result = block()
        if (result.isSuccess) return result
        
        // 只重試可復原的錯誤
        val error = result.errorOrNull()
        if (error != null && !error.isRecoverable) {
            return result
        }
        
        delay(currentDelay)
        currentDelay *= 2  // exponential backoff
    }
    
    return block()  // 最後一次
}

使用起來很方便:

val result = AppResult.retry {
    repository.syncWithServer()
}

實戰心得

錯誤不是異常

最大的轉變是思維:錯誤是正常的程式流程,不是異常情況。

用 AppResult 後,錯誤處理變得很自然,編譯器會提醒你處理所有情況。

類型系統是好幫手

Sealed class 確保錯誤分類完整,when 表達式會檢查所有分支。

不會再有「忘記處理某種錯誤」的問題。

跨平台考量很重要

雖然現在只做 Desktop,但提前考慮 iOS 相容性,未來會輕鬆很多。

AppResult 模式在 Swift 也很自然,iOS 開發者會喜歡的。

總結

今天深入研究並實作了 AppResult 錯誤處理系統。

從混亂的 try-catch 和 null 檢查,到統一的 Result Type Pattern,程式碼變得更清晰、更安全。

Claude Code 在這個過程中幫了很多忙,特別是提醒我注意 CancellationException 和 iOS 相容性問題。

最大的收穫是理解了函數式錯誤處理的優勢。錯誤不再是意外,而是程式邏輯的一部分。

明天會繼續探索其他架構改進。

今日金句

「最好的錯誤處理,是讓錯誤成為類型系統的一部分。」

參考資源

關於作者:Sam,一人公司創辦人。正在打造 Grimo,智能任務管理平台。

專案連結GitHub - grimostudio


上一篇
Day 24: 架構師切分邊界:shared vs desktopApp 的架構重構
下一篇
Day 26: 架構師的 DB 管理術:SQLDelight Migration 的優雅解法
系列文
30 天一人公司的 AI 開發實戰27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言