iT邦幫忙

2025 iThome 鐵人賽

DAY 10
0
Mobile Development

我的 Flutter 進化論:30 天打造「Crew Up!」的架構之旅系列 第 10

Day 10 - 錯誤處理與日誌記錄:建立錯誤追蹤機制

  • 分享至 

  • xImage
  •  

繼續 Day 9 的 TTL 快取策略,今天我們來分享 Crew Up! 專案中的錯誤處理經驗。開發過程中,我們發現使用者最在意的不是 App 有沒有問題,而是遇到問題時能不能清楚知道發生什麼事、該怎麼解決。

這篇文章會分享我們怎麼建立一套完整的錯誤處理機制:

  • 🎯 Result Pattern:讓錯誤處理變得更清楚、更安全
  • 🏗️ 分層錯誤架構:從技術問題到使用者友善訊息的轉換
  • 📊 視覺化日誌:用表情符號讓除錯變得更有效率
  • 🌍 多國語系錯誤訊息:讓全世界的使用者都能理解

Result Pattern:讓錯誤處理變得清楚明白

🤔 為什麼不用傳統的 try-catch?

剛開始開發 Crew Up! 時,我們也是用傳統的 try-catch 來處理錯誤。不過用了一陣子後發現,光看函式的簽名,很難知道它可能會拋出什麼錯誤,也不知道該怎麼處理。

後來我們改用 Result Pattern,把成功 (Success) 和失敗 (Failure) 的可能性直接寫在回傳型別裡。這樣一來,所有可能的錯誤都變得「看得見」,程式碼也更好維護。

// lib/app/core/result/result.dart

/// 結果封裝類別,用於處理成功或失敗的結果
sealed class Result<T> {
  const Result();

  /// 檢查是否為成功/失敗結果
  bool get isSuccess => this is Success<T>;
  bool get isFailure => this is Failure<T>;

  /// 取得成功值或錯誤
  T get value => (this as Success<T>).value;
  AppException get error => (this as Failure<T>).error;

  /// 處理成功和失敗情況
  R fold<R>(
    R Function(T value) onSuccess,
    R Function(AppException error) onFailure,
  ) {
    return this is Success<T>
        ? onSuccess((this as Success<T>).value)
        : onFailure((this as Failure<T>).error);
  }
}

/// 成功結果
class Success<T> extends Result<T> {
  final T value;
  const Success(this.value);
}

/// 失敗結果
class Failure<T> extends Result<T> {
  final AppException error;
  const Failure(this.error);
}

Result Pattern 的實際使用:

// 簡單的使用範例
Future<Result<Activity>> createActivity(Activity activity) async {
  try {
    await _remoteDataSource.saveActivity(activity);
    await _localDataSource.saveActivity(activity); // 同步到本地
    return Success(activity);
  } catch (e) {
    return Failure(AppException.from(e));
  }
}

// 在 UI 中的使用
final result = await activityRepository.createActivity(activity);
result.fold(
  (activity) => UiHelper.showSuccessSnackBar(
    context, 
    S.of(context).activityCreatedSuccessfully
  ),
  (error) => UiHelper.showErrorSnackBar(
    context, 
    ErrorHandlerService.getErrorMessage(context, error)
  ),
);

我們還加了一些實用的擴展方法,讓日常開發更方便:

extension ResultExtensions<T> on Result<T> {
  T getValueOr(T defaultValue) => fold((value) => value, (_) => defaultValue);
  T? getValueOrNull() => fold((value) => value, (_) => null);
}

🎯 使用 Result Pattern 後的心得:

  • 型別安全:編譯時就能檢查錯誤處理,避免遺漏
  • 一目了然:看到函式回傳 Result<T> 就知道可能會失敗
  • 鏈式操作:可以用 mapfold 等方法優雅地串接操作
  • 不會閃退:避免未處理的異常導致 App 突然關閉
  • 方便工具getValueOr 這些方法在實際開發中很好用

錯誤分層架構:讓錯誤訊息更友善

🔧 技術錯誤 vs 使用者友善訊息

開發 Crew Up! 的過程中,我們遇到一個問題:技術上的錯誤訊息(像是 NetworkException: Connection timeout)對使用者來說根本看不懂。所以我們建立了一套分層的錯誤處理架構,把技術錯誤轉換成使用者能理解的訊息。

// lib/app/core/errors/app_exception.dart

/// 應用程式異常基類
abstract class AppException implements Exception {
  final String message;
  final String? code;
  
  const AppException(this.message, {this.code});

  factory AppException.from(dynamic error) {
    if (error is AppException) return error;
    return UnknownException(error.toString());
  }
}

/// 常見異常類型
class NetworkException extends AppException {
  const NetworkException(super.message, {super.code});
  
  factory NetworkException.timeout([StackTrace? stackTrace]) {
    // 使用 LocalizationService 獲取多國語系錯誤訊息
    final localizationService = LocalizationService.instance;
    return NetworkException(
      localizationService.current.networkTimeout,
      code: 'NETWORK_TIMEOUT',
      stackTrace: stackTrace,
    );
  }
}

class ValidationException extends AppException {
  const ValidationException(super.message, {super.code});
}

🎯 各功能模組的專屬錯誤處理

除了基本的 AppException,我們還為每個功能建立了專屬的錯誤類型。比如活動相關的功能就有 ActivityError,這樣可以針對不同情境提供更精準的錯誤處理:

// 活動專屬的錯誤類型
enum ActivityErrorType { validation, network, database, unknown }

class ActivityError {
  final ActivityErrorType type;
  final String message;

  const ActivityError._(this.type, this.message);

  factory ActivityError.validation(String message) =>
      ActivityError._(ActivityErrorType.validation, message);
  
  factory ActivityError.network(String message) =>
      ActivityError._(ActivityErrorType.network, message);

  bool get canRetry => type == ActivityErrorType.network;
  
  String userFriendlyMessage(S localizations) => switch (type) {
    ActivityErrorType.network => localizations.errorNetworkConnectionFailed,
    ActivityErrorType.validation => 
      '${localizations.suggestedActionCheckInput}:$message',
    _ => '${localizations.unknownError}:$message',
  };
}

全域錯誤處理:讓使用者看得懂的錯誤訊息

🌍 多國語系的錯誤訊息系統

有了技術層面的錯誤處理後,我們還要考慮一個重要問題:怎麼讓不同國家的使用者都能看懂錯誤訊息?我們建立了一套完整的錯誤代碼系統,搭配多國語系支援:

// lib/common/enums/error_code.dart

enum ErrorCode {
  networkError, networkTimeout, unauthorized, unknownError
}

extension ErrorCodeExtension on ErrorCode {
  String displayName(BuildContext context) {
    final localizations = S.of(context);
    return switch (this) {
      ErrorCode.networkError => localizations.networkError,
      ErrorCode.networkTimeout => localizations.networkTimeout,
      ErrorCode.unauthorized => localizations.unauthorized,
      ErrorCode.unknownError => localizations.unknownError,
    };
  }

  static ErrorCode fromException(Exception exception) {
    final message = exception.toString().toLowerCase();
    if (message.contains('network')) return ErrorCode.networkError;
    if (message.contains('timeout')) return ErrorCode.networkTimeout;
    if (message.contains('unauthorized')) return ErrorCode.unauthorized;
    return ErrorCode.unknownError;
  }
}

🛠️ 實用的全域錯誤處理服務

接下來看看我們怎麼把這些錯誤代碼轉換成實際的使用者介面。我們建立了一個 ErrorHandlerService,可以根據不同的錯誤類型顯示對應的頁面或提示:

// lib/common/services/error_handler_service.dart (純業務邏輯)

class ErrorHandlerService {
  /// 根據錯誤類型獲取錯誤訊息
  static String getErrorMessage(BuildContext context, Object? error) {
    final errorCode = _extractErrorCode(error);
    return errorCode.displayName(context);
  }

  /// 根據錯誤類型判斷是否可以重試
  static bool canRetry(Object? error) {
    final errorCode = _extractErrorCode(error);
    return errorCode == ErrorCode.networkError || 
           errorCode == ErrorCode.networkTimeout;
  }

  static ErrorCode _extractErrorCode(Object? error) {
    if (error is Exception) {
      return ErrorCodeExtension.fromException(error);
    }
    return ErrorCode.unknownError;
  }
}

// lib/common/widgets/ui_helper.dart (UI 相關)

class UiHelper {
  /// 顯示成功 SnackBar
  static void showSuccessSnackBar(BuildContext context, String message) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(message),
        backgroundColor: AppColors.success,
      ),
    );
  }

  /// 顯示錯誤 SnackBar
  static void showErrorSnackBar(BuildContext context, String message) {
    final localizations = S.of(context);
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(message),
        backgroundColor: AppColors.error,
        action: SnackBarAction(
          label: localizations.dismiss,
          onPressed: () => ScaffoldMessenger.of(context).hideCurrentSnackBar(),
        ),
      ),
    );
  }
}

🌍 多國語系整合的實際效果:

  • 不用擷心 BuildContext:用 LocalizationService 在任何地方都能取得多國語系文字
  • 自動跟隨手機語言:使用者換語言,App 的錯誤訊息也會自動切換
  • 提供解決建議:不只告訴使用者出錯了,還告訴他們可以怎麼解決

實作案例分享:把理論變成實際程式碼

🎯 多國語系錯誤處理的實際應用

理論講完了,來看看我們在專案中實際怎麼應用這些概念。我們的每個 ErrorHandler 都整合了多國語系支援,使用起來是這樣的:

class ActivityErrorHandler {
  /// 取得全域多國語系實例
  static S get _l10n => LocalizationService.instance.current;

  /// 處理異常並轉換為 ActivityError
  static ActivityError handleException(
    Object exception, {
    StackTrace? stackTrace,
    String? context,
  }) {
    developer.log(
      '🚨 處理活動層級錯誤',
      name: 'ActivityErrorHandler',
      error: exception,
      stackTrace: stackTrace,
    );

    // 處理已知的異常類型
    if (exception is TimeoutException) {
      return ActivityError.network(_l10n.errorNetworkTimeout, code: 'TIMEOUT');
    }
    
    if (exception is FormatException) {
      return ActivityError.validation(
        _l10n.errorDataFormatInvalid,
        field: context,
      );
    }

    // 兜底處理:未知錯誤
    return ActivityError.unknown(_l10n.errorUnexpected(exception.toString()));
  }
}

🔄 完整的錯誤處理流程:

我們的錯誤處理是分層進行的,每一層都有自己的責任:

  1. Repository 層:用 Result Pattern 包裝錯誤,避免直接拋出異常
  2. UseCase 層:把技術錯誤轉換成業務邏輯錯誤
  3. ErrorHandler 層:產生多國語系的錯誤訊息和解決建議
  4. UI 層:顯示使用者看得懂的錯誤訊息和操作按鈕

Repository 層的錯誤恢復策略:

Future<Result<List<Activity>>> searchActivities(String query) async {
  try {
    // 先試遠端搜尋
    final remoteResults = await _remoteDataSource.searchActivities(query);
    developer.log('🚀 遠端搜尋成功: ${remoteResults.length} 個活動');
    return Success(remoteResults);
  } catch (e) {
    developer.log('⚠️ 遠端搜尋失敗,改用本地: $e');
    
    // 失敗時改用本地搜尋
    try {
      final localResults = await _localDataSource.getAllActivities()
          .then((activities) => activities.where(
              (a) => a.title.toLowerCase().contains(query.toLowerCase())
          ).toList());
      developer.log('✅ 本地搜尋成功: ${localResults.length} 個活動');
      return Success(localResults);
    } catch (localError) {
      developer.log('❌ 所有搜尋都失敗: $localError');
      return Failure(ActivityErrorHandler.handleException(localError));
    }
  }
}

🔍 怎麼確保錯誤處理真的有用?

建立錯誤處理機制後,我們會檢查幾個重要的點:

  • 錯誤轉換是否正確:技術錯誤能不能正確轉換成使用者看得懂的訊息
  • 訊息是否有幫助:使用者看到錯誤訊息後,知道該怎麼處理嗎
  • 重試邏輯合理嗎:哪些錯誤可以重試,哪些需要使用者手動處理
  • 除錯資訊夠不夠:開發者能從日誌中快速找到問題原因嗎

視覺化日誌記錄:讓除錯變有趣

📊 用表情符號讓日誌更好讀

以前看日誌就像在看密碼,密密麻麻的文字很難快速找到重點。後來我們想到一個好方法:用表情符號來分類不同的日誌類型!現在一眼就能看出哪些是開始、哪些是成功、哪些是錯誤。

🛠️ 為什麼選擇 dart:developer?

我們使用 dart:developer 來記錄日誌,主要有幾個原因:

  • 和 Flutter DevTools 整合得很好,除錯時很方便
  • 在正式版本中會自動被移除,不影響效能
  • 可以記錄物件和 Stack Trace,資訊很完整

📈 實際使用的經驗

剛開始我們也是什麼都記錄,結果日誌太多反而找不到重點。後來學會「適量記錄」:

  • 重要操作的開始和結束
  • 錯誤和警告一定要記
  • 成功的操作簡單記就好

Repository 層的日誌實作範例:

表情符號日誌系統:

class BaseRepository {
  Future<Result<T>> executeLocalFirst<T>(
    String operation,
    Future<T> Function() localOperation,
    Future<T> Function() remoteOperation,
  ) async {
    developer.log('🚀 開始: $operation');

    try {
      final result = await localOperation();
      developer.log('✅ 本地成功: $operation');
      return Success(result);
    } catch (localError) {
      developer.log('⚠️ 本地失敗,試遠端: $localError');
      
      try {
        final result = await remoteOperation();
        developer.log('🔄 遠端救援成功: $operation');
        return Success(result);
      } catch (remoteError) {
        developer.log('❌ 全部失敗: $remoteError');
        return Failure(AppException.from(remoteError));
      }
    }
  }
}

🎨 我們的表情符號日誌系統:

看一下我們怎麼用表情符號來讓日誌變得更好讀:

表情符號 代表什麼 什麼時候用 實際範例
🚀 開始 重要操作開始時 🚀 開始執行: 獲取活動列表
成功 操作順利完成 ✅ 主要數據源成功: 獲取活動列表
錯誤 真的出問題了 ❌ 主要數據源 Exception: NetworkException
⚠️ 警告 有問題但還能處理 ⚠️ 主要數據源 AppException: 網路超時
🔄 重試 換個方式試試看 🔄 嘗試fallback數據源: 獲取活動列表
👂 監聽 開始監聽資料流 👂 開始監聽 Stream: 活動更新
📡 接收 收到新資料 📡 Stream 數據接收: 活動更新
🚫 跳過 不執行某個策略 🚫 不使用fallback或無fallback源

實際使用後覺得很棒的地方:

  • 快速掃描:一眼就能看出日誌的重要程度
  • 追蹤流程:從 🚀 開始到 ✅ 成功(或 ❌ 失敗),很容易看出完整流程
  • 問題分級:⚠️ 是還能處理的警告,❌ 是真的需要關注的錯誤

簡潔的錯誤記錄服務:

class ActivityErrorHandler {
  /// 取得全域多國語系實例
  static S get _l10n => LocalizationService.instance.current;

  static ActivityError handleException(Object exception) {
    developer.log('🚨 活動錯誤: $exception', name: 'ActivityErrorHandler');

    return switch (exception.runtimeType) {
      TimeoutException => ActivityError.network(_l10n.errorNetworkTimeout),
      FormatException => ActivityError.validation(_l10n.errorDataFormatInvalid),
      _ => ActivityError.unknown(_l10n.errorUnexpected(exception.toString())),
    };
  }
}

與前幾天的關聯

本文的錯誤處理機制與先前章節緊密相連:它體現了 Day 1 的依賴反轉原則,與 Day 7 的 Repository 模式和 Day 9 的快取策略無縫整合,共同構建了一個分層、解耦且具備彈性恢復能力的系統。


我們的錯誤處理架構總結

經過這段時間的開發,我們在 Crew Up! 專案中建立了一套完整的錯誤處理架構。回頭看看,覺得這些作法確實讓開發和維護變得更輕鬆:

🌍 多國語系整合的實際效果

  • 不用擔心 BuildContext:用 LocalizationService 在任何地方都能取得多國語系文字
  • 自動跟隨手機語言:使用者換語言,App 的錯誤訊息也會自動切換
  • 訊息一致性:所有功能的錯誤訊息都用同一套字典,不會有翻譯不一致的問題
  • 提供解決建議:不只告訴使用者出錯了,還告訴他們可以怎麼解決

🔧 型別安全帶來的好處

  • 編譯時就能發現問題:用 Object exception 而不是 dynamic,IDE 會提醒你
  • 清楚的錯誤轉換流程:從技術錯誤到使用者訊息,每一步都很明確
  • 完整的除錯資訊:StackTrace 從頭到尾都保留著,找問題很方便
  • 穩定可靠:用型別檢查取代字串比對,減少出錯機會

📊 表情符號日誌的意外收穫

  • 除錯變有趣了:🚀 ✅ ❌ 這些符號讓日誌不再枯燥
  • 快速找重點:一眼就能看出哪些是重要的錯誤
  • 團隊溝通更順暢:討論問題時可以直接說「那個 ❌ 的日誌」

🏗️ 架構設計的長遠考量

  • 每個功能獨立管理:ActivityError、AuthError 各自負責,不會互相干擾
  • 統一的使用方式:所有 ErrorHandler 的 API 都一樣,學會一個就會全部
  • 工具方法很實用getValueOr 這些小工具在日常開發中真的很方便
  • 為未來做準備:架構設計時就考慮了 Sentry 等監控服務的整合

🎯 實際使用的成果

  • 品質有保障flutter analyze 都通過,沒有警告
  • 覆蓋很完整:6 個主要功能的錯誤處理都統一了
  • 國際化沒問題:中文、英文都支援,要加其他語言也很容易
  • 開發體驗不錯:錯誤分類清楚,知道該怎麼處理

未來還可以怎麼改善?

目前我們建立的錯誤處理機制已經很實用了,不過還有一些可以繼續優化的方向:

  • 整合遠端監控:可以考慮把錯誤資訊送到 Sentry 或 Firebase Crashlytics,這樣就能看到錯誤率趨勢、影響多少使用者等統計資料
  • 加入使用者追蹤:在日誌中記錄使用者 ID,這樣就能知道特定使用者遇到了什麼問題,提供更好的客服支援
  • 建立監控儀表板:把日誌資料視覺化,可以更容易發現問題的模式和趨勢
  • 智慧錯誤分類:未來甚至可以用 AI 來自動分析錯誤模式,提早發現潛在問題

不過目前這套系統已經讓我們的開發和維護工作輕鬆很多了!


下一步

明天我們會來聊聊「Unit Test 與 AAA 模式」,看看怎麼為這些錯誤處理邏輯寫測試,確保它們真的能正常運作。畢竟程式碼寫得再好,沒有測試保護還是會讓人不安心。

期待與您在 Day 11 相見!


📋 相關資源

📝 專案資訊

  • 專案名稱: Crew Up!
  • 開發日誌: Day 10 - 錯誤處理與日誌記錄:建立完整的錯誤追蹤機制
  • 文章日期: 2025-09-24
  • 技術棧: Flutter, Clean Architecture, dart:developer, Result Pattern, Error Handling

上一篇
Day 9 - Cache、TTL 與版本管理:實現高效能的資料快取策略
系列文
我的 Flutter 進化論:30 天打造「Crew Up!」的架構之旅10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言