MyDiaryคอร์ส Flutter · 6 สัปดาห์ ทุกสัปดาห์
สัปดาห์ที่ 3 · เอกสารประกอบการสอน
สารบัญหน้านี้

สัปดาห์ที่ 3: เชื่อมต่ออินเทอร์เน็ต (Async + REST API)

เป้าหมายสัปดาห์นี้: ให้แอป ดึงข้อมูลจากอินเทอร์เน็ต มาแสดง — เพิ่ม "คำคมประจำวัน" บนหัวหน้าไดอารี และเข้าใจ async/await เต็มตัว

ต่อจากสัปดาห์ที่แล้ว: แอปเก็บข้อมูลถาวรได้แล้ว แต่ข้อมูลทั้งหมดมาจากตัวเราเอง ยังไม่เชื่อมโลกภายนอก — วันนี้ให้แอป "คุย" กับเซิร์ฟเวอร์บนอินเทอร์เน็ต

สัปดาห์นี้เราจะทำอะไร

เราจะเพิ่มให้ MyDiary
- แสดง คำคมประจำวัน จากอินเทอร์เน็ตบนหัวหน้ารายการ
- เข้าใจ async/await (การรอข้อมูล)
- ใช้ FutureBuilder แสดง 3 สถานะ (กำลังโหลด / สำเร็จ / ผิดพลาด)
- จัดการเมื่อ เน็ตหลุด (try-catch)

เส้นทางสัปดาห์นี้

ส่วน A: เข้าใจ Async    → ทำไมต้อง "รอ" และรอยังไง
ส่วน B: ดึงข้อมูล       → http + JSON + Model
ส่วน C: แสดงผล         → FutureBuilder + จัดการ error

ส่วนที่ 1: ทำความเข้าใจ "อะไรเป็นอะไร"

1.1 ปัญหา: บางอย่างใช้เวลา

เวลาขอข้อมูลจากอินเทอร์เน็ต มันไม่ได้ตอบทันที
— ต้องส่งคำขอ รอเซิร์ฟเวอร์ตอบ (อาจ 1-3 วินาที)

ถ้าโค้ดเรา "รอเฉยๆ" แอปจะค้าง (หน้าจอแข็ง กดอะไรไม่ได้)
— เราต้องบอก Flutter ว่า "รอตรงนี้ แต่อย่าให้แอปค้าง"

1.2 async / await — คำสั่งสำหรับ "การรอ"

คำ ความหมาย
async ประกาศว่า "ฟังก์ชันนี้มีการรอนะ"
await "รอตรงนี้จนกว่าจะเสร็จ แล้วค่อยไปบรรทัดถัดไป"
Future "ผลลัพธ์ที่จะได้ในอนาคต" (ยังไม่มีตอนนี้ แต่กำลังจะมา)

เปรียบเทียบ: สั่งกาแฟที่ร้าน
- await = ยืนรอกาแฟจนเสร็จ แล้วค่อยเดินไปโต๊ะ
- แต่คนอื่นในร้านยังทำอะไรได้ปกติ (แอปไม่ค้าง)

เหมือนตอนเรียก await DatabaseHelper.getAll() ก็คือ "รอ" ให้ DB อ่านข้อมูลเสร็จ หลักการเดียวกัน

1.3 REST API คืออะไร

API (Application Programming Interface) = ช่องทางที่เซิร์ฟเวอร์เปิดให้เราขอข้อมูล( ชุดคำสั่งหรือตัวกลางที่ช่วยให้ซอฟต์แวร์หรือระบบคอมพิวเตอร์สองระบบสามารถสื่อสารและแลกเปลี่ยนข้อมูลกันได้) REST(Representational State Transfer) API = รูปแบบ API ที่นิยมสุด เราส่ง URL ไป เซิร์ฟเวอร์ตอบกลับเป็นข้อมูล

ตัวอย่าง: ขอคำคม → ส่ง https://...quote → ได้กลับ {"quote": "ความพยายามอยู่ที่ไหน...", "author": "..."}

1.4 JSON — รูปแบบข้อมูลที่เซิร์ฟเวอร์ตอบ

JSON = รูปแบบข้อความที่จัดเป็นคู่ "ชื่อ: ค่า" (เหมือน Map ใน Dart!)

{
  "quote": "ความพยายามอยู่ที่ไหน ความสำเร็จอยู่ที่นั่น",
  "author": "สุภาษิตไทย"
}

JSON หน้าตาเหมือน Map ที่เราใช้ตอน toMap/fromMap สัปดาห์ที่แล้ว (แปลงไปมาได้)

1.5 เครื่องมือที่จะใช้

เครื่องมือ หน้าที่
http (package) ส่งคำขอไปเซิร์ฟเวอร์
jsonDecode แปลง JSON (ข้อความ) → Map
FutureBuilder Widget แสดงผลตามสถานะการรอ

1.6 ประเภทของ async ที่ควรรู้ (ภาพรวม)

async มีหลายแบบ — สัปดาห์นี้เราเรียน Future เป็นหลัก แต่ควรรู้จักภาพรวมไว้ เพราะจะเจอตัวอื่นในสัปดาห์หน้า

แบ่งตาม "ได้ผลลัพธ์กี่ครั้ง":

แบบ ได้ผลลัพธ์ เปรียบเทียบ Widget เรียนเมื่อ
Future ครั้งเดียวจบ สั่งกาแฟ 1 แก้ว ได้แล้วจบ FutureBuilder สัปดาห์นี้
Stream ไหลมาเรื่อยๆ เปิดก๊อกน้ำ ไหลต่อเนื่อง StreamBuilder สัปดาห์ 5 (Firebase)

ความต่าง:
-
Future = โหลดคำคม 1 ครั้ง (ได้คำคมแล้วจบ) / อ่านข้อมูล SQLite 1 ครั้ง
-
Stream = ข้อมูลบน cloud ที่คนอื่นแก้แล้วเด้งมาที่เราทันที / ห้องแชตที่ข้อความไหลเข้ามาเรื่อยๆ async ที่อาจเห็นในโค้ดคนอื่น (รู้จักไว้):** await ... รอผล (แบบที่เราใช้) .then((value) {...}) รอผลแบบเก่า (ก่อนมี await) เจอในโค้ดเก่าจากเน็ต คือ await เวอร์ชันอื่น try { } catch (e) { } จับ error ตอน await


ส่วนที่ 2: เตรียมเครื่องมือ

2.1 เปิดโปรเจกต์เดิม

เปิด mydiary จากสัปดาห์ที่แล้ว (ต่อยอด ไม่สร้างใหม่)

2.2 ติดตั้ง package http

flutter pub add http

2.3 เพิ่มสิทธิ์ใช้อินเทอร์เน็ต (สำคัญ!)

เปิด android/app/src/main/AndroidManifest.xml เพิ่มบรรทัดนี้เหนือ <application>:

<uses-permission android:name="android.permission.INTERNET"/>

permission นี้จะสำคัญอีกครั้งตอน build APK ถ้าลืม แอปที่ build แล้วจะต่อเน็ตไม่ได้


ส่วนที่ 3: ลงมือทำทีละขั้น

ส่วน A เข้าใจ Async

ขั้นที่ 1 ทดลอง async/await ให้เห็นภาพ

เป้าหมาย: เข้าใจการทำงานของ await ก่อนใช้จริง

ลองเขียนฟังก์ชันนี้ทดสอบ (วางใน main ชั่วคราว หรือทดลองใน DartPad)

Future<void> testAsync() async {          // async = ฟังก์ชันนี้มีการรอ
  print('1. เริ่ม');
  print('2. กำลังรอ 2 วินาที...');
  await Future.delayed(Duration(seconds: 2));   // รอ 2 วินาที (จำลองการโหลด)
  print('3. รอเสร็จแล้ว!');
}

ผลที่เห็นใน console: พิมพ์ 1, 2 ทันที → รอ 2 วินาที → พิมพ์ 3

อธิบาย: - async บอกว่าฟังก์ชันนี้มีการรอ - await Future.delayed(...) = รอ 2 วินาที (จำลองการโหลดจากเน็ต) - บรรทัดหลัง await จะทำงานหลังรอเสร็จเท่านั้น


ส่วน B: ดึงข้อมูลจากเน็ต

ขั้นที่ 2 สร้าง Model สำหรับคำคม

เป้าหมาย: เตรียมแม่พิมพ์รับคำคม ก่อนเขียน Model — เปิดดู JSON จริงก่อน! เอา URL https://dummyjson.com/quotes/random ไปเปิดในเบราว์เซอร์ จะเห็นข้อมูลหน้าตาแบบนี้:

{
  "id": 42,
  "quote": "Life is what happens when you're busy making other plans.",
  "author": "John Lennon"
}

หลักการสำคัญ: ดู JSON จริงก่อนเสมอ เพื่อรู้ว่า field ชื่ออะไร (ที่นี่คือ quote กับ author) — จะได้เขียน fromJson ให้ตรง

เพิ่ม class (ท้ายไฟล์ หรือแยกไฟล์):

class Quote {
  final String text;
  final String author;
  Quote({required this.text, required this.author});

  // แปลง JSON (Map) → Quote — เหมือน fromMap ของ SQLite!
  factory Quote.fromJson(Map<String, dynamic> json) {
    return Quote(
      text: json['quote'] ?? '',        // dummyjson ใช้ field ชื่อ 'quote'
      author: json['author'] ?? 'ไม่ทราบ',
    );
  }
}

อธิบาย: - fromJson = แปลง Map จาก API เป็น object — หลักการเดียวกับ fromMap ของ SQLite - ?? '' = ถ้า field นั้นไม่มี ใช้ค่าว่างแทน (กัน error)


ขั้นที่ 3 — สร้าง ApiService (ตัวเรียก API)

เป้าหมาย: สร้างไฟล์จัดการการเรียก API (แยกเหมือน DatabaseHelper)

สร้างไฟล์ lib/api_service.dart:

import 'dart:convert';                    // สำหรับ jsonDecode
import 'package:http/http.dart' as http;
import 'main.dart';                        // ใช้ class Quote

class ApiService {
  // ดึงคำคมสุ่ม
  static Future<Quote> fetchQuote() async {
    final url = Uri.parse('https://dummyjson.com/quotes/random');   // API คำคม (ฟรี ไม่ต้อง key)
    final response = await http.get(url);                       // ส่งคำขอ + รอ

    if (response.statusCode == 200) {       // 200 = สำเร็จ
      final data = jsonDecode(response.body);   // แปลง JSON → Map
      return Quote.fromJson(data);
    } else {
      throw Exception('โหลดคำคมไม่สำเร็จ (${response.statusCode})');
    }
  }
}

ส่วนสำคัญ:
- http.get(url) = ส่งคำขอไปเซิร์ฟเวอร์ + await รอคำตอบ
- response.statusCode == 200 = เช็คว่าสำเร็จไหม (200 = OK, 404 = ไม่เจอ, 500 = เซิร์ฟเวอร์พัง)
- jsonDecode(response.body) = แปลงข้อความ JSON เป็น Map
- throw Exception(...) = ถ้าไม่สำเร็จ โยน error ให้คนเรียกจัดการ

โครงเหมือน DatabaseHelper: แยกไฟล์ + static method + async — แค่เปลี่ยนจากคุยกับ DB เป็นคุยกับเซิร์ฟเวอร์


ส่วน C: แสดงผล + จัดการ error

ขั้นที่ 4 — FutureBuilder แสดงคำคม

เป้าหมาย: แสดงคำคมบนหัวหน้ารายการ พร้อมจัดการ 3 สถานะ

แนวคิด: FutureBuilder = Widget ที่แสดงผลตามสถานะการรอ — กำลังโหลด / สำเร็จ / ผิดพลาด

เพิ่มเหนือ ListView ในหน้ารายการ (ใน body ห่อด้วย Column):

body: Column(
  children: [
    // ── กล่องคำคมประจำวัน ──
    FutureBuilder<Quote>(
      future: ApiService.fetchQuote(),
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const Padding(
            padding: EdgeInsets.all(16),
            child: CircularProgressIndicator(),
          );
        } else if (snapshot.hasError) {
          return const Padding(
            padding: EdgeInsets.all(16),
            child: Text('โหลดคำคมไม่ได้',
                style: TextStyle(color: Colors.grey)),
          );
        } else {
          final quote = snapshot.data!;
          return Container(
            width: double.infinity,
            margin: const EdgeInsets.all(12),
            padding: const EdgeInsets.all(16),
            decoration: BoxDecoration(
              color: Colors.indigo[50],
              borderRadius: BorderRadius.circular(12),
            ),
            child: Column(
              children: [
                Text('"${quote.text}"',
                    style: const TextStyle(
                        fontStyle: FontStyle.italic, fontSize: 15)),
                const SizedBox(height: 8),
                Text('— ${quote.author}',
                    style: const TextStyle(color: Colors.grey, fontSize: 13)),
              ],
            ),
          );
        }
      },
    ),

    // ── รายการบันทึก (ของเดิมทั้งก้อน ยัดใน Expanded) ──
    Expanded(
      child: diaries.isEmpty
          ? const Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Icon(Icons.book_outlined, size: 80, color: Colors.grey),
                  SizedBox(height: 16),
                  Text('ยังไม่มีบันทึก',
                      style: TextStyle(fontSize: 18, color: Colors.grey)),
                  Text('กดปุ่ม + เพื่อเขียนบันทึกแรก',
                      style: TextStyle(color: Colors.grey)),
                ],
              ),
            )
          : ListView.builder(
              itemCount: diaries.length,
              itemBuilder: (context, i) {
                // ... โค้ด Card / ListTile เดิมของคุณ วางตรงนี้ทั้งหมด ...
              },
            ),
    ),
  ],
),

อธิบาย 3 สถานะ:
- ConnectionState.waiting = กำลังรอ → แสดงวงกลมหมุน
- snapshot.hasError = เกิด error → แสดงข้อความสุภาพ (ไม่ crash!)
- else = สำเร็จ → snapshot.data! คือคำคมที่ได้

ควรเห็น: กล่องคำคมโหลดขึ้นมา (วงกลมหมุนแป๊บ → คำคมโผล่) เหนือรายการบันทึก

⚠ สังเกตปัญหา (จะแก้ในขั้น 6): ตอนนี้เขียน future: ApiService.fetchQuote() เรียกตรงๆ — ลองเพิ่ม/ลบบันทึกดู จะเห็นคำคมโหลดใหม่ทุกครั้ง (กระพริบ) เพราะทุกครั้งที่ setState (เพิ่ม/ลบ/แก้) build ทำงานใหม่ → fetchQuote() ถูกเรียกซ้ำ = ยิง API ใหม่ทุกที เปลืองเน็ตและกวนตา นี่คือปัญหาที่ขั้น 6 จะแก้ — อย่าเพิ่งกังวล ทำต่อไปก่อน


ขั้นที่ 5 — ทดสอบเมื่อเน็ตหลุด (สำคัญ!)

เป้าหมาย: พิสูจน์ว่าแอปไม่ crash เมื่อไม่มีเน็ต

วิธีทดสอบ: ปิด WiFi/เน็ตของ emulator หรือเครื่อง → รันใหม่

ควรเห็น: กล่องคำคมแสดง "โหลดคำคมไม่ได้ " (ข้อความสุภาพ) — แอปไม่ crash รายการบันทึกยังใช้ได้ปกติ

อธิบายว่าทำไมสำคัญ: โลกจริงเน็ตไม่เสถียร — แอปมืออาชีพต้องรับมือได้ ไม่ใช่ค้างหรือ crash / นี่คือเหตุผลที่เราต้องมี hasError และ try-catch


ขั้นที่ 6 — แก้ปัญหาคำคมกระพริบ + เพิ่มปุ่มรีเฟรช (ทุกคนต้องทำ)

เป้าหมาย: แก้ปัญหาจากขั้น 4 (คำคมโหลดใหม่ทุก setState) + ได้ปุ่มรีเฟรชเป็นของแถม

แนวคิด — ต้นตอของปัญหา: ที่ขั้น 4 เราเขียน future: ApiService.fetchQuote() ตรงใน build — พอ build ทำงานใหม่ทุกครั้งที่ setState (เพิ่ม/ลบ/แก้บันทึก) มันสร้าง Future ใหม่ = ยิง API ใหม่ทุกที ทางแก้: เก็บ Future ไว้ใน state สร้างครั้งเดียว แล้วสร้างใหม่เฉพาะตอนที่เราต้องการ (กดรีเฟรช)

สำคัญ: ขั้น 6 นี้ แก้ของเดิม ไม่ได้สร้างใหม่ทั้งหมด — มี 3 จุดที่ต้องแตะ (เพิ่ม 2 จุด แก้ 1 จุด) ทำตามทีละจุด

จุดที่ 1 — เพิ่มตัวแปรเก็บ Future (เพิ่ม 1 บรรทัด)

ที่หัวของ _DiaryListPageState เพิ่ม 1 บรรทัด ใต้ List<Diary> diaries = [];

โค้ดเดิมที่มีอยู่:

class _DiaryListPageState extends State<DiaryListPage> {
  List<Diary> diaries = [];

แก้เป็น (เพิ่มบรรทัดที่ทำเครื่องหมาย):

class _DiaryListPageState extends State<DiaryListPage> {
  List<Diary> diaries = [];
  late Future<Quote> _quoteFuture;          //  เพิ่มบรรทัดนี้

จุดที่ 2 — โหลดคำคมใน initState (เพิ่ม 1 บรรทัด)

นักศึกษามี initState อยู่แล้ว (จากสัปดาห์ 2 ที่โหลด diaries) — ไม่ต้องสร้างใหม่ แค่เพิ่ม 1 บรรทัด เข้าไป

โค้ดเดิมที่มีอยู่:

@override
void initState() {
  super.initState();
  _loadDiaries();                            // มีอยู่แล้ว (สัปดาห์ 2)
}

แก้เป็น (เพิ่มบรรทัดที่ทำเครื่องหมาย):

@override
void initState() {
  super.initState();
  _quoteFuture = ApiService.fetchQuote();   // เพิ่มบรรทัดนี้ (โหลดคำคมครั้งเดียว)
  _loadDiaries();                            // บรรทัดนี้มีอยู่แล้ว ไม่ต้องแตะ
}

จุดที่ 3 — แก้ FutureBuilder ให้ใช้ตัวแปร (แก้ 1 บรรทัด)

ไปที่ FutureBuilder ในส่วน body (ที่ทำในขั้น 4) — แก้บรรทัด future:

โค้ดเดิมจากขั้น 4:

FutureBuilder<Quote>(
  future: ApiService.fetchQuote(),        //  เรียกตรงๆ (ต้นเหตุคำคมกระพริบ)

แก้เป็น:

FutureBuilder<Quote>(
  future: _quoteFuture,                    //  ใช้ตัวแปรที่เก็บไว้ (จุดที่ 1-2)

ลองทันที: เพิ่ม/ลบบันทึกดู — คราวนี้คำคมไม่กระพริบแล้ว เพราะไม่ได้ยิง API ใหม่ทุก setState (ปัญหาจากขั้น 4 หายไป)

⚠ ถ้าลืมจุดที่ 3: ประกาศตัวแปร (จุด 1-2) แต่ไม่แก้ future: (จุด 3) — คำคมยังกระพริบเหมือนเดิม เพราะ FutureBuilder ยังเรียก fetchQuote() ตรงๆ อยู่ ต้องแก้ครบทั้ง 3 จุด

จุดที่ 4 เพิ่มปุ่มรีเฟรชบน AppBar

ตอนนี้อยากได้คำคมใหม่ต้องกดเอง — เพิ่ม actions: ใน AppBar:

appBar: AppBar(
  title: Text('บันทึกของฉัน (${diaries.length})'),
  actions: [                                 //  เพิ่มส่วนนี้
    IconButton(
      icon: const Icon(Icons.refresh),
      onPressed: () {
        setState(() {
          _quoteFuture = ApiService.fetchQuote();   // สร้าง Future ใหม่ = โหลดใหม่ (ตั้งใจ)
        });
      },
    ),
  ],
),

สังเกตว่าตอนนี้คำคมโหลดใหม่เฉพาะตอนที่เราสั่ง (กดปุ่มรีเฟรช) ไม่ใช่ทุก setState นี่คือความต่างของ "เก็บ Future ใน state" กับ "เรียกตรงๆ ใน build"


สรุปสิ่งที่ได้สัปดาห์นี้

ขั้น ทำอะไร แนวคิดหลัก
1 เข้าใจ async async/await + Future
2 Model คำคม fromJson (เหมือน fromMap)
3 ApiService http.get + jsonDecode + statusCode
4 แสดงคำคม FutureBuilder 3 สถานะ
5 ทดสอบเน็ตหลุด hasError ไม่ crash
6 แก้คำคมกระพริบ + รีเฟรช เก็บ Future ใน state (ไม่สร้างใหม่ทุก build)

ต้องจำ 3 ข้อ:
1. async/await = รอข้อมูลโดยไม่ให้แอปค้าง (เหมือนที่ใช้กับ SQLite)
2. FutureBuilder 3 สถานะ = waiting (โหลด) / hasError (พัง) / success (สำเร็จ)
3. ต้องจัดการ error เสมอ — เน็ตหลุดแอปห้าม crash


จุดที่นักศึกษามักติด:

  • ลืม INTERNET permission → เน็ตใช้ไม่ได้ (เงียบๆ ไม่มี error ชัด)
  • API ตัวอย่างบางตัวช้า/ล่มได้ → เตรียม API สำรอง หรือให้ทดสอบ
  • jsonDecode แล้ว field ไม่ตรง → เปิด API ดู JSON จริงก่อน (ชื่อ field ต่างกันแต่ละ API)

  • การบริหารเวลา: ส่วน A-B (async + ดึงข้อมูล) ต้องได้ทุกคน / ส่วน C ขั้น 6 (รีเฟรช) ถ้าไม่ทันยกไป Lab — ห้ามข้าม ขั้น 4 (FutureBuilder) และขั้น 5 (ทดสอบ error)