สัปดาห์ที่ 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)