บทที่ 10: App Architecture และ State Management
สารบัญเนื้อหา
บทที่ 10: App Architecture และ State Management
🎯 เป้าหมายการเรียนรู้
หลังจากเรียนบทนี้แล้ว คุณจะสามารถ:
- เข้าใจ App Architecture Patterns (MVC, MVVM)
- จัดโครงสร้างโปรเจค Flutter อย่างมีระเบียบ
- ใช้ Service Layer สำหรับ Business Logic
- จัดการ State ด้วย ValueNotifier และ StatefulWidget
- สร้าง Navigation System
- จัดการ Session และ Authentication State
📚 ทฤษฎีและแนวคิด
App Architecture คืออะไร?
Architecture คือการจัดโครงสร้างโค้ดให้เป็นระเบียบ แยกหน้าที่ชัดเจน
ทำไมต้องมี Architecture:
- 📁 Organized: โค้ดเป็นระเบียบ หาง่าย
- 🔧 Maintainable: แก้ไขง่าย ไม่พัง
- 🧪 Testable: เขียน test ได้
- 👥 Scalable: ขยายโปรเจคได้
Architecture Patterns
1. MVC (Model-View-Controller)
┌─────────┐ ┌────────────┐ ┌───────┐
│ View │─────▶│ Controller │─────▶│ Model │
│ (UI) │◀─────│ (Logic) │◀─────│ (Data)│
└─────────┘ └────────────┘ └───────┘
QUOM ใช้ MVC แบบง่าย:
- Model:
models/- Data structures - View:
screens/และwidgets/- UI - Controller:
services/- Business logic
โครงสร้างโปรเจค QUOM
mobile/lib/
├── main.dart # Entry point
├── models/ # Data Models
│ ├── note.dart
│ ├── quote.dart
│ └── user.dart
├── screens/ # UI Screens
│ ├── app_shell.dart # Main navigation
│ ├── home_screen.dart
│ ├── login_screen.dart
│ └── ...
├── widgets/ # Reusable Widgets
│ ├── mesh_background.dart
│ ├── skeleton_loader.dart
│ └── ...
├── services/ # Business Logic
│ ├── auth_api.dart # API calls
│ ├── session.dart # State management
│ ├── theme_controller.dart
│ └── ...
├── theme/ # Styling
│ └── app_theme.dart
└── utils/ # Utilities
└── file_saver.dart
💻 โค้ดจริงจาก QUOM App
1. App Shell - Main Navigation
ไฟล์: mobile/lib/screens/app_shell.dart
import 'package:flutter/material.dart';
import '../widgets/mesh_background.dart';
import 'home_screen.dart';
import 'discover_screen.dart';
import 'favorites_screen.dart';
import 'settings_screen.dart';
import 'record_screen.dart';
class AppShell extends StatefulWidget {
const AppShell({super.key});
@override
State<AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<AppShell> {
int index = 0;
Key _homeKey = UniqueKey();
void _onFabPressed() async {
final res = await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const RecordScreen()),
);
// If returned true, refresh home
if (res == true) {
setState(() {
_homeKey = UniqueKey();
index = 0; // Go to home
});
}
}
@override
Widget build(BuildContext context) {
final currentPages = [
HomeScreen(key: _homeKey),
const SizedBox(), // Placeholder
const SizedBox(),
const FavoritesScreen(),
const SettingsScreen(),
];
return MeshBackground(
child: Scaffold(
backgroundColor: Colors.transparent,
body: Stack(
children: [
// Keep Home alive
Offstage(
offstage: index != 0,
child: currentPages[0],
),
// Rebuild other screens
if (index == 1) DiscoverScreen(),
if (index == 3) FavoritesScreen(),
if (index == 4) SettingsScreen(),
],
),
floatingActionButton: FloatingActionButton(
onPressed: _onFabPressed,
child: const Icon(Icons.add_rounded),
),
floatingActionButtonLocation:
FloatingActionButtonLocation.centerDocked,
bottomNavigationBar: NavigationBar(
selectedIndex: index,
onDestinationSelected: (i) {
if (i == 2) return; // Skip FAB spacer
setState(() => index = i);
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.format_quote_rounded),
label: 'Home',
),
NavigationDestination(
icon: Icon(Icons.explore_outlined),
label: 'Discover',
),
NavigationDestination(
icon: SizedBox(width: 48),
label: '',
),
NavigationDestination(
icon: Icon(Icons.favorite_border_rounded),
label: 'Favorites',
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
label: 'Settings',
),
],
),
),
);
}
}
อธิบายแนวคิด:
1. StatefulWidget สำหรับ Navigation
class _AppShellState extends State<AppShell> {
int index = 0; // Current tab index
index: เก็บว่าอยู่ tab ไหน (0-4)- เปลี่ยน
index→ UI rebuild
2. Keep Home Alive Pattern
Offstage(
offstage: index != 0,
child: currentPages[0],
),
ทำไมต้องใช้ Offstage:
HomeScreenจะไม่ถูก dispose เมื่อเปลี่ยน tab- เก็บ state (scroll position, data)
- ไม่ต้อง reload ทุกครั้ง
3. Conditional Rendering
if (index == 1) DiscoverScreen(),
if (index == 3) FavoritesScreen(),
- Rebuild screen เมื่อเปลี่ยน tab
- ได้ข้อมูลล่าสุดเสมอ
4. Navigation with Result
void _onFabPressed() async {
final res = await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const RecordScreen()),
);
if (res == true) {
setState(() {
_homeKey = UniqueKey(); // Force rebuild
index = 0;
});
}
}
Flow:
- กด FAB → เปิด RecordScreen
- สร้าง note เสร็จ → return
true - AppShell รับ
true→ refresh Home
2. Session Management
ไฟล์: mobile/lib/services/session.dart
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
class Session {
static String? token;
static Map<String, dynamic>? user;
static Future<void> init() async {
final prefs = await SharedPreferences.getInstance();
token = prefs.getString('auth_token');
final userStr = prefs.getString('auth_user');
if (userStr != null) {
try {
user = jsonDecode(userStr);
} catch (e) {
print("Error decoding user: $e");
}
}
}
static Future<void> save(
String newToken,
Map<String, dynamic> newUser
) async {
token = newToken;
user = newUser;
final prefs = await SharedPreferences.getInstance();
await prefs.setString('auth_token', newToken);
await prefs.setString('auth_user', jsonEncode(newUser));
}
static Future<void> clear() async {
token = null;
user = null;
final prefs = await SharedPreferences.getInstance();
await prefs.remove('auth_token');
await prefs.remove('auth_user');
}
}
อธิบาย:
1. Static Variables (Global State)
static String? token;
static Map<String, dynamic>? user;
static: เข้าถึงได้จากทุกที่โดยไม่ต้องสร้าง instance?: Nullable (อาจเป็น null)- เก็บ state ใน memory
2. Initialize from Storage
static Future<void> init() async {
final prefs = await SharedPreferences.getInstance();
token = prefs.getString('auth_token');
final userStr = prefs.getString('auth_user');
if (userStr != null) {
user = jsonDecode(userStr);
}
}
เมื่อไหร่เรียก:
- เรียกตอน app start (ใน
main.dart) - โหลด session จาก storage
3. Save Session
static Future<void> save(
String newToken,
Map<String, dynamic> newUser
) async {
// 1. Update memory
token = newToken;
user = newUser;
// 2. Save to storage
final prefs = await SharedPreferences.getInstance();
await prefs.setString('auth_token', newToken);
await prefs.setString('auth_user', jsonEncode(newUser));
}
2-Step Save:
- อัปเดต memory (เร็ว)
- บันทึกลง storage (ถาวร)
4. Clear Session (Logout)
static Future<void> clear() async {
token = null;
user = null;
final prefs = await SharedPreferences.getInstance();
await prefs.remove('auth_token');
await prefs.remove('auth_user');
}
3. Theme Controller (State Management)
ไฟล์: mobile/lib/services/theme_controller.dart
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class ThemeController {
static final ThemeController instance = ThemeController._();
ThemeController._();
final ValueNotifier<ThemeMode> themeMode =
ValueNotifier(ThemeMode.system);
Future<void> init() async {
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString('theme_mode');
if (saved != null) {
themeMode.value = ThemeMode.values.firstWhere(
(e) => e.name == saved,
orElse: () => ThemeMode.system,
);
}
}
Future<void> setTheme(ThemeMode mode) async {
themeMode.value = mode;
final prefs = await SharedPreferences.getInstance();
await prefs.setString('theme_mode', mode.name);
}
}
อธิบาย:
1. Singleton Pattern
static final ThemeController instance = ThemeController._();
ThemeController._();
- มี instance เดียวทั้งแอป
ThemeController.instanceเข้าถึงได้จากทุกที่
2. ValueNotifier (Reactive State)
final ValueNotifier<ThemeMode> themeMode =
ValueNotifier(ThemeMode.system);
ทำไมใช้ ValueNotifier:
- เมื่อ
themeMode.valueเปลี่ยน ValueListenableBuilderจะ rebuild อัตโนมัติ- ไม่ต้องใช้
setState()
3. การใช้งานใน main.dart
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<ThemeMode>(
valueListenable: ThemeController.instance.themeMode,
builder: (context, mode, child) {
return MaterialApp(
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
themeMode: mode, // จะเปลี่ยนเมื่อ mode เปลี่ยน
home: const SplashScreen(),
);
},
);
}
4. Service Layer Pattern
ไฟล์: mobile/lib/services/auth_api.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'api_config.dart';
class AuthApi {
static Future<Map<String, dynamic>> login({
required String email,
required String password
}) async {
final uri = Uri.parse("$kApiBase/api/auth/login");
final res = await http.post(
uri,
headers: {"Content-Type": "application/json"},
body: jsonEncode({"email": email, "password": password}),
);
if (res.statusCode != 200) {
throw Exception("Login failed: ${res.statusCode}");
}
final data = jsonDecode(res.body) as Map<String, dynamic>;
final token = data["token"] as String?;
if (token == null || token.isEmpty) {
throw Exception("Missing token");
}
return data;
}
static Future<Map<String, dynamic>> register({
required String email,
required String password,
required String displayName,
}) async {
final uri = Uri.parse("$kApiBase/api/auth/register");
final res = await http.post(
uri,
headers: {"Content-Type": "application/json"},
body: jsonEncode({
"email": email,
"password": password,
"displayName": displayName,
}),
);
if (res.statusCode != 200) {
final data = jsonDecode(res.body) as Map<String, dynamic>;
final message = data["message"] ?? "Registration failed";
throw Exception(message);
}
return jsonDecode(res.body) as Map<String, dynamic>;
}
}
อธิบายแนวคิด:
1. Static Methods (No Instance Needed)
class AuthApi {
static Future<Map<String, dynamic>> login(...) async {
- ไม่ต้องสร้าง instance
- เรียกได้เลย:
AuthApi.login(...)
2. Separation of Concerns
Screen (UI)
↓ calls
Service (API)
↓ calls
Backend
ข้อดี:
- Screen ไม่ต้องรู้ว่า API ทำงานอย่างไร
- เปลี่ยน API logic ไม่กระทบ UI
- Test ได้ง่าย
3. Error Handling
if (res.statusCode != 200) {
throw Exception("Login failed: ${res.statusCode}");
}
- Throw exception เมื่อ error
- Screen จะจับด้วย
try-catch
🔄 State Management Patterns
1. Local State (StatefulWidget)
class CounterWidget extends StatefulWidget {
@override
State<CounterWidget> createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State<CounterWidget> {
int count = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Count: $count'),
ElevatedButton(
onPressed: () => setState(() => count++),
child: Text('Increment'),
),
],
);
}
}
เมื่อไหร่ใช้:
- State ที่ใช้เฉพาะ widget นี้
- ไม่ต้องแชร์กับ widget อื่น
2. Global State (Static Variables)
class Session {
static String? token;
static Map<String, dynamic>? user;
}
// ใช้งาน
if (Session.token != null) {
// User is logged in
}
เมื่อไหร่ใช้:
- State ที่ใช้ทั้งแอป
- เช่น authentication, settings
3. Reactive State (ValueNotifier)
class ThemeController {
final ValueNotifier<ThemeMode> themeMode =
ValueNotifier(ThemeMode.system);
void setTheme(ThemeMode mode) {
themeMode.value = mode; // Triggers rebuild
}
}
// ใช้งาน
ValueListenableBuilder<ThemeMode>(
valueListenable: ThemeController.instance.themeMode,
builder: (context, mode, child) {
return Text('Current mode: $mode');
},
)
เมื่อไหร่ใช้:
- State ที่ต้อง rebuild UI เมื่อเปลี่ยน
- ไม่ต้องใช้
setState()
📝 แบบฝึกหัด
คำถามทบทวน
- MVC ย่อมาจากอะไร?
- Service Layer ทำหน้าที่อะไร?
- ValueNotifier ต่างจาก StatefulWidget อย่างไร?
- Singleton Pattern คืออะไร?
- ทำไมต้องแยก API logic ออกจาก UI?
โจทย์ปฏิบัติ
โจทย์ที่ 1: สร้าง Counter Service
// TODO: สร้าง CounterService ที่ใช้ ValueNotifier
// - มี increment() และ decrement()
// - บันทึกค่าลง SharedPreferences
// - โหลดค่าตอน init()
โจทย์ที่ 2: สร้าง Settings Service
// TODO: สร้าง SettingsService สำหรับ app settings
// - language (th/en)
// - notifications (bool)
// - บันทึกและโหลดจาก SharedPreferences
โจทย์ที่ 3: สร้าง API Service
// TODO: สร้าง ProductApi service
// - getProducts()
// - getProduct(id)
// - createProduct(data)
// - Error handling
📌 สรุป
ในบทนี้เราได้เรียนรู้:
✅ App Architecture - MVC Pattern
✅ Project Structure - จัดโครงสร้างอย่างมีระเบียบ
✅ Session Management - จัดการ authentication state
✅ Service Layer - แยก business logic จาก UI
✅ State Management - StatefulWidget, Static, ValueNotifier
✅ Navigation - AppShell และ tab navigation
ไฟล์ที่เกี่ยวข้อง
mobile/lib/screens/app_shell.dart- Main navigationmobile/lib/services/session.dart- Session managementmobile/lib/services/theme_controller.dart- Theme statemobile/lib/services/auth_api.dart- Auth API service
🔜 บทถัดไป
บทที่ 11: UI/UX Design และ Theme System
เราจะเรียนรู้การออกแบบ UI ที่สวยงาม, Theme System, และ Custom Widgets