beio Logobeio

【Android 架構系列 06】錯誤處理 (Error Handling):別讓 AI 吞掉你的 Exception

發布於

01. 前言:AI 的「鴕鳥心態」

這是系列文的第六篇。

在前幾篇,我們處理了資料的獲取(Data Layer)與同步(Offline-First)。現在,我們要來面對開發中最令人頭痛,也是 AI 最容易「搞砸」的部分——錯誤處理 (Error Handling)

AI 有一個壞習慣:它非常害怕程式崩潰。所以當你叫它處理錯誤時,它往往會寫出一個空的 try-catch,把錯誤吞掉。這導致 App 出錯時靜悄悄,用戶以為 App 當機了,開發者也查不到 Log。

這篇將介紹 ROP (Result Oriented Programming,結果導向程式設計) 的概念,教你如何建立一套全域的錯誤攔截機制,別讓 AI 吞掉你的 Exception。

當你要求 AI:「幫我處理 API 的錯誤,不要讓 App 閃退。」

AI 為了達成「不閃退」這個目標,通常會交出這樣的代碼:

// ❌ AI 典型的鴕鳥式寫法
fun getNews() {
    viewModelScope.launch {
        try {
            val data = api.fetchNews()
            _uiState.value = NewsUiState.Success(data)
        } catch (e: Exception) {
            // 😱 災難:錯誤被吞掉了!
            // 只有一行 Log,用戶看到的是「無盡的 Loading」
            e.printStackTrace() 
            // 或者隨便塞一個空 list
            _uiState.value = NewsUiState.Success(emptyList()) 
        }
    }
}

這種寫法在 Production 環境是致命的。

  1. 使用者困惑: 畫面一直在轉圈圈,或者顯示「無資料」,使用者不知道是網路斷了還是真的沒資料。
  2. 除錯困難: 如果這是 HTTP 401 (Token 過期),你應該要導向登入頁,而不是顯示空列表。

AI 傾向於寫出這種代碼,因為它最簡單,且符合「不 Crash」的指令。我們必須改變 AI 的思維模式:錯誤不是意外,錯誤是資料的一部分。

02. 核心觀念:ROP (鐵道導向程式設計)

Railway Oriented Programming (ROP) 是一個很棒的比喻。

想像你的程式邏輯是一條鐵軌:

  • Success Track (快樂路徑): 一路暢通,資料順利轉換。
  • Failure Track (失敗路徑): 一旦發生錯誤,就像火車切換軌道,進入錯誤處理流程,而不是直接脫軌(Crash)。

在 Kotlin 中,我們使用 Result<T> 來實作這個概念。我們要求 AI:不要拋出 Exception,而是回傳一個包含錯誤的 Result 物件。

03. 實戰演練:建立全域錯誤處理機制

我們要建立一套標準,讓 AI 在寫 Repository 和 ViewModel 時,只要套用公式就好。

步驟 1:定義領域錯誤 (Domain Error)

不要直接把 HttpExceptionIOException 丟給 UI 層。UI 層不應該知道你是用 Retrofit 還是 GraphQL。

我們需要叫 AI 定義一個「App 聽得懂」的錯誤分類。

Prompt:

"請定義一個 AppError sealed interface。 包含:NetworkError (無網路), ServerError (5xx), AuthError (401), UnknownError。"

// domain/model/AppError.kt
sealed interface AppError {
    data object NetworkError : AppError
    data object AuthError : AppError
    data class ServerError(val code: Int) : AppError
    data class UnknownError(val message: String) : AppError
}

// 擴充 Result 來支援轉型
fun Throwable.toAppError(): AppError {
    return when (this) {
        is IOException -> AppError.NetworkError
        is HttpException -> {
            when (this.code()) {
                401 -> AppError.AuthError
                in 500..599 -> AppError.ServerError(this.code())
                else -> AppError.UnknownError("HTTP ${this.code()}")
            }
        }
        else -> AppError.UnknownError(this.message ?: "Unknown")
    }
}

步驟 2:Repository 的 ROP 實作

在 Data Layer,我們要強制 AI 使用 runCatching,並立刻將 Exception 轉換為 Result<AppError>

Prompt:

"修改 NewsRepository。 使用 runCatching 包裹 API 呼叫。 在 onFailure 區塊中,使用 toAppError() 將 Exception 轉換為我們定義的錯誤型別。 回傳型別應為 Result<List<Article>>。"

(註:Kotlin 標準庫的 Result 雖然好用,但泛型限制較多,實務上也可以使用 Arrow 庫的 Either 或自定義 NetworkResult。這裡為了簡單演示,我們假設使用標準 Result)

// data/repository/NewsRepositoryImpl.kt
class NewsRepositoryImpl @Inject constructor(api: NewsApi) : NewsRepository {
    
    override suspend fun getNews(): Result<List<Article>> {
        // AI 寫出的 ROP 風格代碼
        return runCatching {
            val response = api.fetchNews()
            response.articles.map { it.toDomain() }
        }.fold(
            onSuccess = { Result.success(it) },
            onFailure = { Result.failure(Exception(it.toAppError().toString())) } 
            // 實務上建議自定義 Result 類別來攜帶 AppError
        )
    }
}

更進階的 Prompt:自定義 Result Wrapper

為了讓錯誤處理更優雅,通常我們會叫 AI 寫一個自定義的 Wrapper。

Prompt:

"請建立一個 NetworkResult<T> sealed class。包含 Success(data: T) 和 Error(error: AppError)。"

// common/NetworkResult.kt
sealed class NetworkResult<out T> {
    data class Success<T>(val data: T) : NetworkResult<T>()
    data class Error(val error: AppError) : NetworkResult<Nothing>()
}

// Repository 變得很乾淨
override suspend fun getNews(): NetworkResult<List<Article>> {
    return try {
        val response = api.fetchNews()
        NetworkResult.Success(response.articles.map { it.toDomain() })
    } catch (e: Exception) {
        NetworkResult.Error(e.toAppError()) // 錯誤被轉化為資料
    }
}

04. ViewModel 的消費:優雅的錯誤提示

現在,錯誤已經變成了資料 (NetworkResult.Error),流向了 ViewModel。AI 在處理時就不能假裝沒看到了。

Prompt:

"在 ViewModel 中處理 NetworkResult。 如果是 Success,更新 uiState 為成功。 如果是 Error,判斷錯誤類型:

  1. AuthError -> 發送登出事件。
  2. 其他錯誤 -> 更新 uiState 為 Error 並顯示訊息。"
// presentation/NewsViewModel.kt
fun fetchNews() {
    viewModelScope.launch {
        _uiState.value = NewsUiState.Loading
        
        when (val result = repository.getNews()) {
            is NetworkResult.Success -> {
                _uiState.value = NewsUiState.Success(result.data)
            }
            is NetworkResult.Error -> {
                // AI 被迫處理每一種錯誤情況
                val msg = when (val error = result.error) {
                    is AppError.NetworkError -> "請檢查網路連線"
                    is AppError.AuthError -> {
                        _oneTimeEvent.emit(NewsEvent.NavigateToLogin)
                        "登入已過期"
                    }
                    is AppError.ServerError -> "伺服器維修中 (${error.code})"
                    is AppError.UnknownError -> "發生錯誤:${error.message}"
                }
                _uiState.value = NewsUiState.Error(msg)
            }
        }
    }
}

05. 結論:讓錯誤現形

透過 ROP 機制,我們達成了一個重要的目標:Explicitness (明確性)

  1. Repository 層: 強制將 Exception 轉為 AppError,不讓底層細節洩漏。
  2. ViewModel 層: 強制透過 when 表達式處理 Success/Error 分支,AI 沒辦法只寫 Happy Path。

這樣一來,AI 再也無法當鴕鳥。它必須面對錯誤,並且依照我們制定的規則(例如 AuthError 就登出)來處理。

解決了資料流與錯誤處理,我們的後端邏輯已經非常穩固。接下來,我們要回到前端,面對那個變幻莫測的 UI 層。下一篇,我們將探討如何用 Sealed Interface 來終結 ViewModel 裡的變數大爆炸。

下集預告:【Part 7】狀態的藝術:Presentation Layer 設計——用 Sealed Interface 終結變數大爆炸


Ken Huang

關於作者

Ken Huang

熱衷於嘗試各類新技術的軟體開發者,現階段主力為 Android / iOS 雙平台開發,同時持續深耕前端與後端技術,以成為全端工程師與軟體架構師為目標。

最廣為人知的代表作為 BePTT。開發哲學是「以做身體健康為前提」,致力於在工作與生活平衡的基礎上,打造出擁有絕佳使用體驗的軟體服務。

這裡是用於紀錄與分享開發經驗的空間,希望能透過我的實戰筆記,幫助開發者解決疑難雜症並提升效率。

Android APP DevelopmentiOS APP DevelopmentBePTT CreatorFull Stack Learner