324 lines
7.3 KiB
Markdown
324 lines
7.3 KiB
Markdown
---
|
|
id: mobile-flutter-patterns
|
|
title: Flutter — Widget / State / Riverpod
|
|
category: Coding
|
|
status: draft
|
|
source_trust_level: B
|
|
verification_status: conceptual
|
|
created_at: 2026-05-09
|
|
updated_at: 2026-05-09
|
|
tags: [mobile, flutter, dart, vibe-coding]
|
|
tech_stack: { language: "Dart / Flutter", applicable_to: ["iOS", "Android", "Web", "Desktop"] }
|
|
applied_in: []
|
|
aliases: [Flutter, Dart, Riverpod, Bloc, GoRouter, Material 3]
|
|
---
|
|
|
|
# Flutter
|
|
|
|
> Google 의 cross-platform UI. **단일 codebase → iOS / Android / Web / Desktop**. **Skia / Impeller 가 그림 — native 만큼 빠름**. Material 3 / Cupertino UI built-in.
|
|
|
|
## 📖 핵심 개념
|
|
- Widget: 모든 거. immutable.
|
|
- State: StatefulWidget 의 mutable.
|
|
- Riverpod / Bloc: state management.
|
|
- Hot reload: 변경 즉시 반영.
|
|
|
|
## 💻 코드 패턴
|
|
|
|
### 기본 widget
|
|
```dart
|
|
import 'package:flutter/material.dart';
|
|
|
|
void main() => runApp(const MyApp());
|
|
|
|
class MyApp extends StatelessWidget {
|
|
const MyApp({super.key});
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return MaterialApp(
|
|
theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.blue),
|
|
home: const HomePage(),
|
|
);
|
|
}
|
|
}
|
|
|
|
class HomePage extends StatefulWidget {
|
|
const HomePage({super.key});
|
|
@override
|
|
State<HomePage> createState() => _HomePageState();
|
|
}
|
|
|
|
class _HomePageState extends State<HomePage> {
|
|
int _count = 0;
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('Counter')),
|
|
body: Center(child: Text('$_count', style: Theme.of(context).textTheme.displayLarge)),
|
|
floatingActionButton: FloatingActionButton(
|
|
onPressed: () => setState(() => _count++),
|
|
child: const Icon(Icons.add),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Layout
|
|
```dart
|
|
Scaffold(
|
|
body: SafeArea(
|
|
child: Column(
|
|
children: [
|
|
const Text('Header'),
|
|
Expanded(
|
|
child: ListView.builder(
|
|
itemCount: items.length,
|
|
itemBuilder: (ctx, i) => ListTile(
|
|
title: Text(items[i].name),
|
|
onTap: () => Navigator.push(ctx, MaterialPageRoute(builder: (_) => DetailPage(items[i]))),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
```
|
|
|
|
### Riverpod (state management)
|
|
```yaml
|
|
# pubspec.yaml
|
|
dependencies:
|
|
flutter_riverpod: ^2.4.0
|
|
riverpod_annotation: ^2.3.0
|
|
```
|
|
|
|
```dart
|
|
@riverpod
|
|
class Counter extends _$Counter {
|
|
@override
|
|
int build() => 0;
|
|
void increment() => state++;
|
|
}
|
|
|
|
@riverpod
|
|
Future<List<User>> users(UsersRef ref) async {
|
|
final dio = ref.watch(dioProvider);
|
|
final r = await dio.get('/users');
|
|
return (r.data as List).map((j) => User.fromJson(j)).toList();
|
|
}
|
|
|
|
// Widget
|
|
class CounterPage extends ConsumerWidget {
|
|
@override
|
|
Widget build(BuildContext ctx, WidgetRef ref) {
|
|
final count = ref.watch(counterProvider);
|
|
return Column(
|
|
children: [
|
|
Text('$count'),
|
|
ElevatedButton(
|
|
onPressed: () => ref.read(counterProvider.notifier).increment(),
|
|
child: const Text('+'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### AsyncValue (network state)
|
|
```dart
|
|
class UsersPage extends ConsumerWidget {
|
|
@override
|
|
Widget build(BuildContext ctx, WidgetRef ref) {
|
|
final asyncUsers = ref.watch(usersProvider);
|
|
return asyncUsers.when(
|
|
data: (users) => ListView.builder(...),
|
|
loading: () => const CircularProgressIndicator(),
|
|
error: (e, _) => Text('Error: $e'),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### GoRouter (navigation)
|
|
```dart
|
|
final router = GoRouter(
|
|
routes: [
|
|
GoRoute(path: '/', builder: (_, __) => const HomePage()),
|
|
GoRoute(
|
|
path: '/user/:id',
|
|
builder: (_, state) => UserPage(id: state.pathParameters['id']!),
|
|
),
|
|
],
|
|
);
|
|
|
|
MaterialApp.router(routerConfig: router, ...);
|
|
```
|
|
|
|
### Network (Dio)
|
|
```dart
|
|
final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));
|
|
dio.interceptors.add(InterceptorsWrapper(
|
|
onRequest: (options, handler) {
|
|
options.headers['Authorization'] = 'Bearer $token';
|
|
handler.next(options);
|
|
},
|
|
onError: (e, handler) {
|
|
if (e.response?.statusCode == 401) refreshAuth();
|
|
handler.next(e);
|
|
},
|
|
));
|
|
|
|
final r = await dio.get('/users/$id');
|
|
final user = User.fromJson(r.data);
|
|
```
|
|
|
|
### Freezed (immutable model)
|
|
```dart
|
|
@freezed
|
|
class User with _$User {
|
|
const factory User({
|
|
required String id,
|
|
required String email,
|
|
String? name,
|
|
}) = _User;
|
|
|
|
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
|
|
}
|
|
```
|
|
|
|
```bash
|
|
dart run build_runner build # 코드 generate
|
|
```
|
|
|
|
### Async / await
|
|
```dart
|
|
Future<void> _load() async {
|
|
setState(() => _loading = true);
|
|
try {
|
|
final users = await api.fetchUsers();
|
|
setState(() => _users = users);
|
|
} catch (e) {
|
|
showError(e.toString());
|
|
} finally {
|
|
setState(() => _loading = false);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 새 architecture (riverpod hooks 같이)
|
|
```dart
|
|
class HomePage extends HookConsumerWidget {
|
|
@override
|
|
Widget build(BuildContext ctx, WidgetRef ref) {
|
|
final controller = useTextEditingController();
|
|
final search = useState('');
|
|
|
|
final users = ref.watch(searchUsersProvider(search.value));
|
|
|
|
return Column(
|
|
children: [
|
|
TextField(controller: controller, onChanged: (v) => search.value = v),
|
|
users.when(...),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Test
|
|
```dart
|
|
testWidgets('counter increments', (tester) async {
|
|
await tester.pumpWidget(const MyApp());
|
|
expect(find.text('0'), findsOneWidget);
|
|
|
|
await tester.tap(find.byIcon(Icons.add));
|
|
await tester.pump();
|
|
|
|
expect(find.text('1'), findsOneWidget);
|
|
});
|
|
|
|
// Riverpod
|
|
test('counter', () {
|
|
final container = ProviderContainer();
|
|
expect(container.read(counterProvider), 0);
|
|
container.read(counterProvider.notifier).increment();
|
|
expect(container.read(counterProvider), 1);
|
|
});
|
|
```
|
|
|
|
### Build / release
|
|
```bash
|
|
# iOS
|
|
flutter build ipa --release
|
|
|
|
# Android
|
|
flutter build appbundle --release
|
|
|
|
# Web
|
|
flutter build web --release
|
|
|
|
# Desktop
|
|
flutter build macos --release
|
|
flutter build windows --release
|
|
```
|
|
|
|
### Performance
|
|
- `const` constructor 사용 (rebuild skip).
|
|
- Widget trees 작게 + split.
|
|
- `RepaintBoundary` 큰 widget 분리.
|
|
- Image cache (cached_network_image).
|
|
- Lists = ListView.builder (lazy).
|
|
|
|
### vs React Native / Native
|
|
```
|
|
Flutter:
|
|
+ 빠름 (Skia compile)
|
|
+ 같은 UI 모든 platform
|
|
+ Hot reload 강력
|
|
- Dart language (학습)
|
|
- Native 통합 = platform channel
|
|
- Bundle 큼 (~5-15MB)
|
|
|
|
React Native:
|
|
+ JS / TS 친숙
|
|
+ 큰 ecosystem
|
|
- JS bridge / new arch 학습
|
|
- Native UI 위주
|
|
|
|
Native:
|
|
+ Best UX / performance
|
|
- 두 번 개발
|
|
```
|
|
|
|
## 🤔 의사결정 기준
|
|
| 상황 | 추천 |
|
|
|---|---|
|
|
| Cross-platform + 일관 UI | Flutter |
|
|
| 작은 팀 / 빠른 MVP | Flutter / RN |
|
|
| Native UX strict | Native |
|
|
| Web 도 같이 | Flutter Web |
|
|
| Desktop 도 같이 | Flutter |
|
|
| 큰 native ecosystem 의존 | Native / RN |
|
|
|
|
## ❌ 안티패턴
|
|
- **`const` 안 씀**: 매 rebuild.
|
|
- **거대 build method**: 분리.
|
|
- **setState 큰 tree**: 작은 stateful widget.
|
|
- **Provider 너무 많음 (각 var)**: combine.
|
|
- **Dio interceptor 안 + auth 매번**: 통일.
|
|
- **Async/await 없는 callback hell**: future 변환.
|
|
- **Native channel 직접 (필요 시)**: package 사용 / pigeon.
|
|
|
|
## 🤖 LLM 활용 힌트
|
|
- Riverpod + Freezed + GoRouter + Dio = 표준 stack.
|
|
- AsyncValue.when 으로 loading/error 깔끔.
|
|
- const constructor + ListView.builder.
|
|
|
|
## 🔗 관련 문서
|
|
- [[Mobile_KMP_Compose]]
|
|
- [[React_Native_Bridge_Performance]]
|
|
- [[Android_Compose_State_Hoisting]]
|