繼續 Day 9 的 TTL 快取策略,今天我們來分享 Crew Up! 專案中的錯誤處理經驗。開發過程中,我們發現使用者最在意的不是 App 有沒有問題,而是遇到問題時能不能清楚知道發生什麼事、該怎麼解決。
這篇文章會分享我們怎麼建立一套完整的錯誤處理機制:
🤔 為什麼不用傳統的 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>
就知道可能會失敗map
、fold
等方法優雅地串接操作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(),
),
),
);
}
}
🌍 多國語系整合的實際效果:
LocalizationService
在任何地方都能取得多國語系文字🎯 多國語系錯誤處理的實際應用
理論講完了,來看看我們在專案中實際怎麼應用這些概念。我們的每個 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()));
}
}
🔄 完整的錯誤處理流程:
我們的錯誤處理是分層進行的,每一層都有自己的責任:
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
來記錄日誌,主要有幾個原因:
📈 實際使用的經驗
剛開始我們也是什麼都記錄,結果日誌太多反而找不到重點。後來學會「適量記錄」:
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! 專案中建立了一套完整的錯誤處理架構。回頭看看,覺得這些作法確實讓開發和維護變得更輕鬆:
LocalizationService
在任何地方都能取得多國語系文字Object exception
而不是 dynamic
,IDE 會提醒你getValueOr
這些小工具在日常開發中真的很方便flutter analyze
都通過,沒有警告目前我們建立的錯誤處理機制已經很實用了,不過還有一些可以繼續優化的方向:
不過目前這套系統已經讓我們的開發和維護工作輕鬆很多了!
明天我們會來聊聊「Unit Test 與 AAA 模式」,看看怎麼為這些錯誤處理邏輯寫測試,確保它們真的能正常運作。畢竟程式碼寫得再好,沒有測試保護還是會讓人不安心。
期待與您在 Day 11 相見!