บทที่ 10mobile|18 KB

บทที่ 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:

  1. กด FAB → เปิด RecordScreen
  2. สร้าง note เสร็จ → return true
  3. 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:

  1. อัปเดต memory (เร็ว)
  2. บันทึกลง 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()

📝 แบบฝึกหัด

คำถามทบทวน

  1. MVC ย่อมาจากอะไร?
  2. Service Layer ทำหน้าที่อะไร?
  3. ValueNotifier ต่างจาก StatefulWidget อย่างไร?
  4. Singleton Pattern คืออะไร?
  5. ทำไมต้องแยก 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

ไฟล์ที่เกี่ยวข้อง


🔜 บทถัดไป

บทที่ 11: UI/UX Design และ Theme System
เราจะเรียนรู้การออกแบบ UI ที่สวยงาม, Theme System, และ Custom Widgets


📚 แหล่งเรียนรู้เพิ่มเติม