在完成 Day 17 的推播通知功能後,我們發現需要一個完整的錯誤監控系統,來追蹤使用者在使用推播功能時可能遇到的問題。今天我們要為 Crew Up! 建立錯誤追蹤與分析系統,就像 App 的健康檢查器,能讓我們快速發現和解決問題。
從專案開發經驗來看,Crashlytics 不只能記錄 crash,更能幫助我們了解使用者的真實使用情況,發現潛在的問題和優化機會。
開發階段時,我們可以用 debugger 和 console 來除錯。不過一旦 App 上線後,這些工具就派不上用場了。使用者回報問題時,我們常常無法重現,要修復就更困難了。
🚨 真實案例:我們遇到的問題
📊 數據驅動的決策
🔧 快速問題定位
# pubspec.yaml
dependencies:
firebase_crashlytics: ^5.0.2 # Firebase Crashlytics SDK
firebase_core: ^4.1.0 # Firebase 核心
flutter_riverpod: ^2.6.1 # 狀態管理
💡 關於 Firebase 專案設定,請參考 Day 13 - Firebase 專案設定
在開發 Crew Up! 的過程中,我們建立了完整的錯誤處理架構,包含四個核心元件:
🎯 四層錯誤處理架構
AppError Models (型別定義層)
↓
CrashlyticsService (底層)
↓
ErrorLogger (中層)
↓
ErrorHandler (上層)
// lib/app/core/models/app_error.dart
/// 應用程式錯誤的基礎抽象類別
abstract class AppError {
final String message;
final String? code;
final Map<String, dynamic>? context;
const AppError({
required this.message,
this.code,
this.context,
});
@override
String toString() => 'AppError: $message (code: $code)';
}
/// 資料庫錯誤
class DatabaseError extends AppError {
final String operation;
final String? query;
const DatabaseError({
required super.message,
required this.operation,
this.query,
super.code,
super.context,
});
@override
String toString() => 'DatabaseError: $message (operation: $operation, code: $code)';
}
/// 網路錯誤
class NetworkError extends AppError {
final int? statusCode;
final String? endpoint;
const NetworkError({
required super.message,
this.statusCode,
this.endpoint,
super.code,
super.context,
});
@override
String toString() => 'NetworkError: $message (statusCode: $statusCode, endpoint: $endpoint)';
}
/// 使用者操作錯誤
class UserActionError extends AppError {
final String action;
final String? userId;
const UserActionError({
required super.message,
required this.action,
this.userId,
super.code,
super.context,
});
@override
String toString() => 'UserActionError: $message (action: $action, userId: $userId)';
}
/// 驗證錯誤
class ValidationError extends AppError {
final String field;
final String? expectedValue;
const ValidationError({
required super.message,
required this.field,
this.expectedValue,
super.code,
super.context,
});
@override
String toString() => 'ValidationError: $message (field: $field, expected: $expectedValue)';
}
/// 權限錯誤
class PermissionError extends AppError {
final String permission;
final String? resource;
const PermissionError({
required super.message,
required this.permission,
this.resource,
super.code,
super.context,
});
@override
String toString() => 'PermissionError: $message (permission: $permission, resource: $resource)';
}
// lib/app/core/services/crashlytics_service.dart
// (imports omitted)
class CrashlyticsService {
static final FirebaseCrashlytics _crashlytics = FirebaseCrashlytics.instance;
/// 初始化 Crashlytics
static Future<void> initialize() async {
// 只有在 Crashlytics 啟用時 (Release 模式) 才上傳錯誤
if (isCrashlyticsEnabled) {
// 捕獲 Dart 層級的非同步錯誤
PlatformDispatcher.instance.onError = (error, stack) {
_crashlytics.recordError(error, stack, fatal: true);
return true; // 回傳 true 表示錯誤已被處理
};
// 捕獲 Flutter 框架內的錯誤
FlutterError.onError = (FlutterErrorDetails details) {
_crashlytics.recordFlutterError(details);
};
}
// 在 Debug 模式下,我們保留 Flutter 的預設行為(在控制台印出錯誤),因此此處不需 else。
}
/// 記錄日誌
static Future<void> log(String message) async {
if (!isCrashlyticsEnabled) return;
await _crashlytics.log(message);
}
/// 記錄錯誤
static Future<void> recordError(
dynamic exception,
StackTrace? stackTrace, {
String? reason,
bool fatal = false,
}) async {
if (!isCrashlyticsEnabled) return;
await _crashlytics.recordError(exception, stackTrace, reason: reason, fatal: fatal);
}
/// 設定自定義鍵值
static Future<void> setCustomKey(String key, dynamic value) async {
if (!isCrashlyticsEnabled) return;
await _crashlytics.setCustomKey(key, value);
}
/// 設定使用者識別碼
static Future<void> setUserIdentifier(String userId) async {
if (!isCrashlyticsEnabled) return;
await _crashlytics.setUserIdentifier(userId);
}
/// 檢查是否啟用(Release 模式才啟用)
static bool get isCrashlyticsEnabled => !kDebugMode;
}
🎯 設計重點說明
更清晰的模式區分
我們明確地只在 isCrashlyticsEnabled
為 true
時才設定 Crashlytics 的錯誤處理器。在 Debug 模式下,我們保留 Flutter 的預設行為——將錯誤完整地輸出到開發主控台,這對於本地除錯更為直觀。
雙重捕獲的必要性
PlatformDispatcher.instance.onError
:捕捉 Dart VM 中的非同步錯誤(例如 Future
鏈中的錯誤)FlutterError.onError
:處理 Flutter 框架中的錯誤(如 Widget build 過程中的錯誤)兩者結合才能達到最全面的錯誤覆蓋。
// lib/app/core/services/error_logger.dart
import 'package:crew_up/app/core/models/app_error.dart';
import 'package:crew_up/app/core/services/crashlytics_service.dart';
class ErrorLogger {
static final List<String> _errorLogs = [];
/// 記錄一般錯誤 - 使用具體的錯誤型別
static Future<void> logError(
String message, {
AppError? error,
StackTrace? stackTrace,
Map<String, Object>? context,
}) async {
final logEntry = '[${DateTime.now().toIso8601String()}] ERROR: $message';
_addToLocalLogs(logEntry);
await CrashlyticsService.log(logEntry);
if (error != null) {
await CrashlyticsService.recordError(error, stackTrace, reason: message);
}
// 記錄上下文 - 型別安全的 Map
if (context != null) {
for (final entry in context.entries) {
await CrashlyticsService.setCustomKey(entry.key, entry.value);
}
}
}
/// 記錄資料庫錯誤 - 使用 DatabaseError
static Future<void> logDatabaseError(
String operation,
DatabaseError error, {
Map<String, Object>? queryContext,
}) async {
final logEntry = '[${DateTime.now().toIso8601String()}] DATABASE_ERROR: $operation';
_addToLocalLogs(logEntry);
await CrashlyticsService.log(logEntry);
await CrashlyticsService.setCustomKey('database_operation', operation);
await CrashlyticsService.setCustomKey('error_code', error.code ?? 'unknown');
if (queryContext != null) {
for (final entry in queryContext.entries) {
await CrashlyticsService.setCustomKey('db_${entry.key}', entry.value);
}
}
}
/// 記錄使用者操作錯誤 - 使用 UserActionError
static Future<void> logUserActionError(
String action,
UserActionError error, {
Map<String, Object>? userContext,
}) async {
final logEntry = '[${DateTime.now().toIso8601String()}] USER_ACTION_ERROR: $action';
_addToLocalLogs(logEntry);
await CrashlyticsService.log(logEntry);
await CrashlyticsService.setCustomKey('user_action', action);
await CrashlyticsService.setCustomKey('error_code', error.code ?? 'unknown');
if (userContext != null) {
for (final entry in userContext.entries) {
await CrashlyticsService.setCustomKey('user_${entry.key}', entry.value);
}
}
}
/// 本地日誌管理
static void _addToLocalLogs(String logEntry) {
_errorLogs.add(logEntry);
if (_errorLogs.length > 100) _errorLogs.removeAt(0);
}
static List<String> getLocalLogs() => List.from(_errorLogs);
}
// lib/app/core/services/error_handler.dart
import 'package:crew_up/app/core/models/app_error.dart';
import 'package:crew_up/app/core/services/error_logger.dart';
class ErrorHandler {
/// 處理資料庫錯誤 - 型別安全
static Future<void> handleDatabaseError(
String operation,
DatabaseError error, {
Map<String, Object>? queryContext,
}) async {
await ErrorLogger.logDatabaseError(
operation,
error,
queryContext: queryContext,
);
}
/// 處理使用者操作錯誤 - 型別安全
static Future<void> handleUserActionError(
String action,
UserActionError error, {
Map<String, Object>? userContext,
}) async {
await ErrorLogger.logUserActionError(
action,
error,
userContext: userContext,
);
}
/// 處理一般錯誤 - 型別安全
static Future<void> handleError(
String message,
AppError error, {
StackTrace? stackTrace,
Map<String, Object>? context,
}) async {
await ErrorLogger.logError(
message,
error: error,
stackTrace: stackTrace,
context: context,
);
}
/// 從 Exception 建立對應的 AppError
static AppError createErrorFromException(
dynamic exception, {
String? operation,
String? action,
String? endpoint,
String? field,
}) {
if (exception is DatabaseError) return exception;
if (exception is NetworkError) return exception;
if (exception is UserActionError) return exception;
if (exception is ValidationError) return exception;
if (exception is PermissionError) return exception;
// 根據操作類型建立對應的錯誤
if (operation != null) {
return DatabaseError(
message: exception.toString(),
operation: operation,
code: exception.runtimeType.toString(),
);
}
if (action != null) {
return UserActionError(
message: exception.toString(),
action: action,
code: exception.runtimeType.toString(),
);
}
if (endpoint != null) {
return NetworkError(
message: exception.toString(),
endpoint: endpoint,
code: exception.runtimeType.toString(),
);
}
if (field != null) {
return ValidationError(
message: exception.toString(),
field: field,
code: exception.runtimeType.toString(),
);
}
// 預設建立一般錯誤
return DatabaseError(
message: exception.toString(),
operation: 'unknown',
code: exception.runtimeType.toString(),
);
}
}
// lib/features/activity/data/datasources/activity_firebase_datasource.dart
@override
Future<Activity?> getActivityById(String id) async {
try {
final doc = await _firestore.collection(_collectionName).doc(id).get();
if (!doc.exists) return null;
return _documentToActivity(doc);
} on FirebaseException catch (e, stackTrace) {
// 使用統一的錯誤處理
await ErrorHandler.handleError(
e,
stackTrace: stackTrace,
operation: 'getActivityById',
context: {
'activity_id': id,
'firebase_code': e.code,
'firebase_message': e.message ?? 'Unknown error',
},
);
throw NetworkException('Failed to fetch activity: ${e.message}');
}
}
// lib/features/auth/presentation/providers/auth_provider.dart
Future<void> signInWithGoogle() async {
try {
state = const AsyncValue.loading();
final googleUser = await _googleSignIn.signIn();
if (googleUser == null) return;
final googleAuth = await googleUser.authentication;
final credential = GoogleAuthProvider.credential(
accessToken: googleAuth.accessToken,
idToken: googleAuth.idToken,
);
await _firebaseAuth.signInWithCredential(credential);
state = AsyncValue.data(_firebaseAuth.currentUser);
} on Exception catch (e, stackTrace) {
// 使用統一的錯誤處理
await ErrorHandler.handleError(
e,
stackTrace: stackTrace,
action: 'signInWithGoogle',
context: {'provider': 'google'},
);
state = AsyncValue.error(e, stackTrace);
}
}
// 處理網路請求錯誤
try {
final response = await dio.get('/api/activities');
return response.data;
} on DioException catch (e) {
await ErrorHandler.handleError(
e,
endpoint: '/api/activities',
context: {
'endpoint': '/api/activities',
'dio_error_type': e.type.toString(),
'status_code': e.response?.statusCode,
},
);
throw NetworkException('Failed to fetch activities');
}
// 處理表單驗證錯誤
try {
final user = await validateUserInput(input);
return user;
} on ValidationException catch (e) {
await ErrorHandler.handleError(
e,
field: e.field,
context: {
'input_data': input.toString(),
'validation_rule': e.rule,
'expected_value': e.expectedValue,
},
);
throw ValidationException(e.message);
}
// 處理未知錯誤
try {
await performComplexOperation();
} on Exception catch (e, stackTrace) {
await ErrorHandler.handleError(
e,
stackTrace: stackTrace,
operation: 'performComplexOperation',
context: {
'timestamp': DateTime.now().toIso8601String(),
},
);
rethrow;
}
當使用者回報問題時,我們能在 Crashlytics 中看到完整上下文:
// 使用統一的錯誤處理
await ErrorHandler.handleError(
e,
operation: 'getActivityById',
context: {
'activity_id': id,
'firebase_code': e.code,
'firebase_message': e.message ?? 'Unknown error',
},
);
這樣就能快速知道是哪個活動 ID、什麼錯誤代碼,大幅縮短除錯時間。
僅有錯誤堆疊是不夠的,我們還需要知道是「哪位使用者」遇到了問題。Crashlytics 允許我們為報告關聯使用者 ID。
在 Crew Up! 專案中,我們在使用者成功登入後,會立即設定使用者識別碼:
// lib/features/auth/presentation/providers/auth_provider.dart
Future<void> signInWithGoogle() async {
try {
state = const AsyncValue.loading();
// 1. 執行可能拋出異常的登入流程
final googleUser = await _googleSignIn.signIn();
if (googleUser == null) {
state = AsyncValue.data(null); // 使用者取消登入
return;
}
final googleAuth = await googleUser.authentication;
final credential = GoogleAuthProvider.credential(
accessToken: googleAuth.accessToken,
idToken: googleAuth.idToken,
);
final userCredential = await _firebaseAuth.signInWithCredential(credential);
final user = userCredential.user;
// 2. 登入成功後,設定 Crashlytics 使用者資訊
if (user != null) {
await CrashlyticsService.setUserIdentifier(user.uid);
await CrashlyticsService.setCustomKey('user_email', user.email ?? 'N/A');
}
state = AsyncValue.data(user);
} on Exception catch (e, stackTrace) {
// 3. 使用統一的錯誤處理
await ErrorHandler.handleError(
e,
stackTrace: stackTrace,
action: 'signInWithGoogle',
context: {
'provider': 'google',
'error_type': e.runtimeType.toString(),
},
);
state = AsyncValue.error(e, stackTrace);
}
}
CrashlyticsService
中也需要增加對應的方法:
// lib/app/core/services/crashlytics_service.dart
/// 設定使用者識別碼
static Future<void> setUserIdentifier(String identifier) async {
if (!isCrashlyticsEnabled) return;
await _crashlytics.setUserIdentifier(identifier);
}
這樣做之後,當收到錯誤報告時,我們就能在 Firebase 後台直接看到受影響的使用者,甚至可以主動聯繫他們,大幅提升了問題解決的效率和使用者體驗。
⚠️ Crashlytics 的 Custom Key 有 1KB 限制,過長的資料需要截斷:
final truncatedData = requestDataString.length > 1023
? requestDataString.substring(0, 1023)
: requestDataString;
在 Crew Up! 專案中,我們完全避免了 dynamic
型別,改用具體的錯誤型別:
❌ 避免的寫法
// 使用 dynamic - 失去型別安全
static Future<void> handleError(
String message,
dynamic error, {
Map<String, dynamic>? context,
}) async { ... }
✅ 推薦的寫法
// 使用具體的錯誤型別 - 型別安全
static Future<void> handleFirebaseError(
String operation,
String message,
String code, {
Map<String, Object>? context,
}) async { ... }
在 Crew Up! 專案中,我們從原本的 dynamic
型別改為具體的錯誤型別後,獲得了以下改善:
改善前(使用 dynamic):
// ❌ 型別不安全,容易出錯
await ErrorHandler.handleError(
'Failed to fetch data: ${e.message}',
e, // dynamic - 編譯器無法檢查
context: {'key': 'value'}, // Map<String, dynamic>
);
改善後(統一錯誤處理):
// ✅ 統一入口,型別安全,編譯時檢查
await ErrorHandler.handleError(
e,
operation: 'getActivityById',
context: {
'activity_id': id,
'firebase_code': e.code,
},
);
量化改善:
我們的設計讓 Debug 模式專注本地開發,Release 模式完整記錄:
static bool get isCrashlyticsEnabled => !kDebugMode;
在 Crew Up! 專案中,我們為 CrashlyticsService
、ErrorLogger
等核心服務選擇了靜態類別,而非透過 Riverpod 進行依賴注入。這是一個經過權衡的務實決策。
🎯 優點:橫切關注點的全局訪問
錯誤處理是一個橫切關注點(Cross-cutting Concern),它需要被專案的任何地方輕易存取。靜態類別提供了最簡單的全局訪問方式,避免了在每個需要日誌記錄的地方傳遞 ref
或 context
,大幅簡化了呼叫方的程式碼。
// 使用靜態類別:簡潔直接
await ErrorHandler.handleDatabaseError('getActivityById', e);
// 如果用依賴注入:需要傳遞 ref
final errorHandler = ref.read(errorHandlerProvider);
await errorHandler.handleDatabaseError('getActivityById', e);
⚖️ 權衡:可測試性的取捨
我們也意識到靜態類別的主要缺點是可測試性。對靜態方法的 Mock 在 Dart 中相對困難,這使得單元測試變得複雜。
💡 我們的結論
對於像錯誤監控這種高度穩定、全局共享的基礎設施,其變動性極低。我們選擇透過整合測試來驗證其與 Firebase 的實際互動,而非依賴單元測試的 Mock。這樣,我們在享受靜態類別帶來便利性的同時,也透過另一種測試策略確保了程式碼的可靠性。
Firebase Crashlytics 不只是錯誤追蹤工具,更是我們了解使用者真實體驗的窗口。透過完整的錯誤監控系統,我們可以:
在 Crew Up! 專案中,我們不僅建立了完整的錯誤監控系統,更重要的是實現了統一的錯誤處理架構:
ErrorHandler.handleError
處理ErrorHandler.handleError
作為唯一入口dynamic
型別:使用具體的錯誤型別在 Crew Up! 專案中,Crashlytics 幫助我們建立了更穩定的應用程式,也讓我們能更專注在核心功能的開發上。統一的錯誤處理系統不僅提升了程式碼品質,也為團隊帶來了更好的開發體驗。
明天,我們將探討 Firebase Analytics,學習如何分析使用者行為和應用程式效能,並結合本專案的實際需求進行應用。
期待與您在 Day 19 相見!