Dart/Flutter 模式
使用時機
當發生以下情況時,請使用此技能:
- 開始一個新的 Flutter 功能,需要狀態管理、導航或資料存取的慣用模式
- 審查或編寫 Dart 程式碼,需要空值安全、密封類型或非同步組合的指導
- 設定新的 Flutter 專案,並在 BLoC、Riverpod 或 Provider 之間做選擇
- 實作安全的 HTTP 客戶端、WebView 整合或本地儲存
- 為 Flutter Widget、Cubit 或 Riverpod Provider 編寫測試
- 使用帶有身份驗證守護的 GoRouter 進行連線設定
運作方式
此技能提供按關注點組織的、可即時複製貼上的 Dart/Flutter 程式碼模式:
- 空值安全 — 避免
!,偏好使用?./??/模式匹配 - 不可變狀態 — 密封類別、
freezed、copyWith - 非同步組合 — 並行
Future.wait、await後的安全BuildContext - Widget 架構 — 提取為類別 (而非方法)、
const傳播、作用域重建 - 狀態管理 — BLoC/Cubit 事件、Riverpod notifier 和衍生 Provider
- 導航 — 使用帶有
refreshListenable的反應式身份驗證守護的 GoRouter - 網路層 — 帶有攔截器的 Dio、帶有一次重試保護的 Token 刷新
- 錯誤處理 — 全域捕獲、
ErrorWidget.builder、Crashlytics 接線 - 測試 — 單元測試 (BLoC 測試)、Widget 測試 (ProviderScope 覆蓋)、使用 Fake 而非 Mock
範例
// 密封狀態 — 防止不可能的狀態
sealed class AsyncState<T> {}
final class Loading<T> extends AsyncState<T> {}
final class Success<T> extends AsyncState<T> { final T data; const Success(this.data); }
final class Failure<T> extends AsyncState<T> { final Object error; const Failure(this.error); }
// 帶有反應式身份驗證重導向的 GoRouter
final router = GoRouter(
refreshListenable: GoRouterRefreshStream(authCubit.stream),
redirect: (context, state) {
final authed = context.read<AuthCubit>().state is AuthAuthenticated;
if (!authed && !state.matchedLocation.startsWith('/login')) return '/login';
return null;
},
routes: [...],
);
// 帶有安全 firstWhereOrNull 的 Riverpod 衍生 Provider
@riverpod
double cartTotal(Ref ref) {
final cart = ref.watch(cartNotifierProvider);
final products = ref.watch(productsProvider).valueOrNull ?? [];
return cart.fold(0.0, (total, item) {
final product = products.firstWhereOrNull((p) => p.id == item.productId);
return total + (product?.price ?? 0) * item.quantity;
});
}
適用於 Dart 和 Flutter 應用程式的實戰、生產級模式。盡可能與函式庫無關,並明確涵蓋最常見的生態系套件。
1. 空值安全基礎
優先使用模式而非強制拆包運算子
// 不好 — 如果為 null 則在執行時崩潰
final name = user!.name;
// 好 — 提供備用值
final name = user?.name ?? 'Unknown';
// 好 — Dart 3 模式匹配 (針對複雜情況優先使用)
final display = switch (user) {
User(:final name, :final email) => '$name <$email>',
null => 'Guest',
};
// 好 — 提前返回保護
String getUserName(User? user) {
if (user == null) return 'Unknown';
return user.name; // 檢查後提升為非空
}
避免過度使用 late
// 不好 — 將空值錯誤推遲到執行時
late String userId;
// 好 — 使用可空類型並明確初始化
String? userId;
// 可以 — 僅在首次訪問前保證初始化時使用 late
// (例如在 initState() 中,在任何 Widget 互動之前)
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 300));
}
2. 不可變狀態
用於狀態層級的密封類別
sealed class UserState {}
final class UserInitial extends UserState {}
final class UserLoading extends UserState {}
final class UserLoaded extends UserState {
const UserLoaded(this.user);
final User user;
}
final class UserError extends UserState {
const UserError(this.message);
final String message;
}
// 詳盡的 switch — 編譯器強制所有分支
Widget buildFrom(UserState state) => switch (state) {
UserInitial() => const SizedBox.shrink(),
UserLoading() => const CircularProgressIndicator(),
UserLoaded(:final user) => UserCard(user: user),
UserError(:final message) => ErrorText(message),
};
使用 Freezed 實現無樣板不可變性
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart';
@freezed
class User with _$User {
const factory User({
required String id,
required String name,
required String email,
@Default(false) bool isAdmin,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
// 使用方式
final user = User(id: '1', name: 'Alice', email: '[email protected]');
final updated = user.copyWith(name: 'Alice Smith'); // 不可變更新
final json = user.toJson();
final fromJson = User.fromJson(json);
3. 非同步組合
使用 Future.wait 實現結構化並行
Future<DashboardData> loadDashboard(UserRepository users, OrderRepository orders) async {
// 並行執行 — 不要順序 await
final (userList, orderList) = await (
users.getAll(),
orders.getRecent(),
).wait; // Dart 3 記錄解構 + Future.wait 擴展
return DashboardData(users: userList, orders: orderList);
}
Stream 模式
// Repository 為即時資料暴露反應式串流
Stream<List<Item>> watchCartItems() => _db
.watchTable('cart_items')
.map((rows) => rows.map(Item.fromRow).toList());
// 在 Widget 層 — 宣告式,無需手動訂閱
StreamBuilder<List<Item>>(
stream: cartRepository.watchCartItems(),
builder: (context, snapshot) => switch (snapshot) {
AsyncSnapshot(connectionState: ConnectionState.waiting) =>
const CircularProgressIndicator(),
AsyncSnapshot(:final error?) => ErrorWidget(error.toString()),
AsyncSnapshot(:final data?) => CartList(items: data),
_ => const SizedBox.shrink(),
},
)
await 後的 BuildContext
// 關鍵 — 在 StatefulWidget 中的任何 await 之後務必檢查 mounted
Future<void> _handleSubmit() async {
setState(() => _isLoading = true);
try {
await authService.login(_email, _password);
if (!mounted) return; // ← 在使用 context 之前進行保護
context.go('/home');
} on AuthException catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
4. Widget 架構
提取為類別,而非方法
// 不好 — 返回 Widget 的私有方法,阻礙最佳化
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.all(16),
child: Text(title, style: Theme.of(context).textTheme.headlineMedium),
);
}
// 好 — 獨立的 Widget 類別,允許 const、元素重用
class _PageHeader extends StatelessWidget {
const _PageHeader(this.title);
final String title;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
child: Text(title, style: Theme.of(context).textTheme.headlineMedium),
);
}
}
const 傳播
// 不好 — 每次重建時都有新實例
child: Padding(
padding: EdgeInsets.all(16.0), // 不是 const
child: Icon(Icons.home, size: 24.0), // 不是 const
)
// 好 — const 停止重建傳播
child: const Padding(
padding: EdgeInsets.all(16.0),
child: Icon(Icons.home, size: 24.0),
)
作用域重建
// 不好 — 每次計數器改變時整個頁面重建
class CounterPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider); // 重建所有東西
return Scaffold(
body: Column(children: [
const ExpensiveHeader(), // 不必要地重建
Text('$count'),
const ExpensiveFooter(), // 不必要地重建
]),
);
}
}
// 好 — 隔離需要重建的部分
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Column(children: [
ExpensiveHeader(), // 永不重建 (const)
_CounterDisplay(), // 僅此部分重建
ExpensiveFooter(), // 永不重建 (const)
]),
);
}
}
class _CounterDisplay extends ConsumerWidget {
const _CounterDisplay();
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Text('$count');
}
}
5. 狀態管理:BLoC/Cubit
// Cubit — 同步或簡單的非同步狀態
class AuthCubit extends Cubit<AuthState> {
AuthCubit(this._authService) : super(const AuthState.initial());
final AuthService _authService;
Future<void> login(String email, String password) async {
emit(const AuthState.loading());
try {
final user = await _authService.login(email, password);
emit(AuthState.authenticated(user));
} on AuthException catch (e) {
emit(AuthState.error(e.message));
}
}
void logout() {
_authService.logout();
emit(const AuthState.initial());
}
}
// 在 Widget 中
BlocBuilder<AuthCubit, AuthState>(
builder: (context, state) => switch (state) {
AuthInitial() => const LoginForm(),
AuthLoading() => const CircularProgressIndicator(),
AuthAuthenticated(:final user) => HomePage(user: user),
AuthError(:final message) => ErrorView(message: message),
},
)
6. 狀態管理:Riverpod
// 自動銷毀的非同步 Provider
@riverpod
Future<List<Product>> products(Ref ref) async {
final repo = ref.watch(productRepositoryProvider);
return repo.getAll();
}
// 具有複雜變異的 Notifier
@riverpod
class CartNotifier extends _$CartNotifier {
@override
List<CartItem> build() => [];
void add(Product product) {
final existing = state.where((i) => i.productId == product.id).firstOrNull;
if (existing != null) {
state = [
for (final item in state)
if (item.productId == product.id) item.copyWith(quantity: item.quantity + 1)
else item,
];
} else {
state = [...state, CartItem(productId: product.id, quantity: 1)];
}
}
void remove(String productId) =>
state = state.where((i) => i.productId != productId).toList();
void clear() => state = [];
}
// 衍生 Provider (選擇器模式)
@riverpod
int cartCount(Ref ref) => ref.watch(cartNotifierProvider).length;
@riverpod
double cartTotal(Ref ref) {
final cart = ref.watch(cartNotifierProvider);
final products = ref.watch(productsProvider).valueOrNull ?? [];
return cart.fold(0.0, (total, item) {
// firstWhereOrNull (來自 collection 套件) 可避免產品缺失時的 StateError
final product = products.firstWhereOrNull((p) => p.id == item.productId);
return total + (product?.price ?? 0) * item.quantity;
});
}
7. 使用 GoRouter 的導航
final router = GoRouter(
initialLocation: '/',
// refreshListenable 在身份驗證狀態改變時重新評估重導向
refreshListenable: GoRouterRefreshStream(authCubit.stream),
redirect: (context, state) {
final isLoggedIn = context.read<AuthCubit>().state is AuthAuthenticated;
final isGoingToLogin = state.matchedLocation == '/login';
if (!isLoggedIn && !isGoingToLogin) return '/login';
if (isLoggedIn && isGoingToLogin) return '/';
return null;
},
routes: [
GoRoute(path: '/login', builder: (_, __) => const LoginPage()),
ShellRoute(
builder: (context, state, child) => AppShell(child: child),
routes: [
GoRoute(path: '/', builder: (_, __) => const HomePage()),
GoRoute(
path: '/products/:id',
builder: (context, state) =>
ProductDetailPage(id: state.pathParameters['id']!),
),
],
),
],
);
8. 使用 Dio 的 HTTP
final dio = Dio(BaseOptions(
baseUrl: const String.fromEnvironment('API_URL'),
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
headers: {'Content-Type': 'application/json'},
));
// 新增身份驗證攔截器
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await secureStorage.read(key: 'auth_token');
if (token != null) options.headers['Authorization'] = 'Bearer $token';
handler.next(options);
},
onError: (error, handler) async {
// 防止無限重試循環:每個請求僅嘗試刷新一次
final isRetry = error.requestOptions.extra['_isRetry'] == true;
if (!isRetry && error.response?.statusCode == 401) {
final refreshed = await attemptTokenRefresh();
if (refreshed) {
error.requestOptions.extra['_isRetry'] = true;
return handler.resolve(await dio.fetch(error.requestOptions));
}
}
handler.next(error);
},
));
// 使用 Dio 的 Repository
class UserApiDataSource {
const UserApiDataSource(this._dio);
final Dio _dio;
Future<User> getById(String id) async {
final response = await _dio.get<Map<String, dynamic>>('/users/$id');
return User.fromJson(response.data!);
}
}
9. 錯誤處理架構
// 全域錯誤捕獲 — 在 main() 中設定
void main() {
FlutterError.onError = (details) {
FlutterError.presentError(details);
crashlytics.recordFlutterFatalError(details);
};
PlatformDispatcher.instance.onError = (error, stack) {
crashlytics.recordError(error, stack, fatal: true);
return true;
};
runApp(const App());
}
// 用於生產環境的自訂 ErrorWidget
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: router,
builder: (context, widget) {
ErrorWidget.builder = (details) => const Scaffold(
body: Center(
child: Text('發生錯誤。請再試一次。'),
),
);
return widget!;
},
);
}
}
10. 測試
BLoC/Cubit 單元測試
void main() {
group('AuthCubit', () {
late MockAuthService authService;
late AuthCubit cubit;
setUp(() {
authService = MockAuthService();
cubit = AuthCubit(authService);
});
tearDown(() => cubit.close());
test('初始狀態是 AuthInitial', () {
expect(cubit.state, const AuthInitial());
});
blocTest<AuthCubit, AuthState>(
'登入成功時發射 [loading, authenticated]',
build: () => cubit,
act: (cubit) => cubit.login('email', 'password'),
setUp: () => when(authService.login(any, any)).thenAnswer((_) async => user),
expect: () => [
const AuthLoading(),
AuthAuthenticated(user),
],
);
});
}
Riverpod Widget 測試
void main() {
testWidgets('CartTotal 正確計算', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
cartNotifierProvider.overrideWith((ref) => _TestCartNotifier()),
productsProvider.overrideWith((ref) => testProducts),
],
child: const MaterialApp(home: CartTotalWidget()),
),
);
expect(find.text('\$150.00'), findsOneWidget);
});
}
偏好使用 Fake 而非 Mock
// Fake — 提供真實行為的精簡版,更易維護
class FakeAuthService extends Fake implements AuthService {
@override
Future<User> login(String email, String password) async {
if (email == '[email protected]' && password == 'password') {
return User(id: '1', name: 'Test User');
}
throw AuthException('無效的憑證');
}
}
關鍵原則: 始終偏好將狀態建模為密封類別或 Freezed 類別,以避免表示不可能狀態。在可重建的 Widget 樹中傳播
const。對於任何非同步操作,請檢查mounted。在 Widget 層使用提供者進行反應式資料流。撰寫測試以驗證狀態轉換(BLoC)或提供者輸出(Riverpod)是否正確,而非僅測試實作細節。