จัดโครงสร้างแอป Flutter เป็นชั้นที่แยกจากกันอย่างชัดเจนด้วยรูปแบบ repository และผลลัพธ์ชนิด sealed ที่มีชนิดกำกับ
เปิดบทเรียนนี้ใน Kodokonแอป Flutter ที่สร้างมาเพื่อให้อยู่ได้ยาวนานจะแยกความรับผิดชอบออกเป็นสามส่วน: การนำเสนอ (presentation) (วิดเจ็ต, BuildContext), ตรรกะ (logic) (สเตต, use case) และ ข้อมูล (data) (API, ฐานข้อมูลในเครื่อง) กฎสำคัญที่สุดคือ ทิศทางของการพึ่งพา (direction of dependencies): การนำเสนอพึ่งพาตรรกะ ตรรกะพึ่งพาการนามธรรมของข้อมูล ไม่ใช่ในทางกลับกันเด็ดขาด พูดให้เป็นรูปธรรมคือ ต้องไม่มี BuildContext และไม่มีการ import material.dart ปรากฏใต้ชั้นการนำเสนอ คอนโทรลเลอร์ที่เด้ง SnackBar ของตัวเองขึ้นมาคือคอนโทรลเลอร์ที่ทดสอบไม่ได้ อีกจุดที่มักถูกมองข้าม: ข้อผิดพลาดเป็นส่วนหนึ่งของสัญญาระหว่างชั้น แทนที่จะปล่อยให้ exception ที่ไม่มีชนิดกำกับรั่วออกมา จงจำลองผลลัพธ์ด้วยคลาส sealed แล้วคอมไพเลอร์จะบังคับให้คุณจัดการทุกกรณี
sealed class Result<T> {
const Result();
}
class Success<T> extends Result<T> {
const Success(this.value);
final T value;
}
class Failure<T> extends Result<T> {
const Failure(this.error);
final Object error;
}
class Course {
const Course({required this.id, required this.title});
final String id;
final String title;
}repository คือเส้นแบ่งระหว่างโดเมนกับโลกภายนอก บทบาทที่แน่ชัดของมันคือ: เปิดเผย เอนทิตีของโดเมน (domain entities) (ไม่ใช่ DTO ดิบจาก API เด็ดขาด) ซ่อนกลยุทธ์การเข้าถึง (เครือข่าย, แคช, ฐานข้อมูลในเครื่อง) และรวมศูนย์นโยบายความสดใหม่ของข้อมูล ใน Dart 3 จงประกาศสัญญาด้วย abstract interface class ตัวขยายนี้อนุญาตเฉพาะ implements จากภายนอกไลบรารีเท่านั้น คุณจะไม่เผลอสืบทอดชิ้นส่วนของการทำงานมาโดยไม่ตั้งใจ สัญญาจึงคงความบริสุทธิ์ไว้ได้ การแมป JSON ไปเป็นเอนทิตีอยู่ในตัวการทำงาน (implementation) หากพรุ่งนี้ API เปลี่ยนชื่อฟิลด์ ก็จะมีเพียงชั้นข้อมูลเท่านั้นที่เปลี่ยน
abstract interface class CourseApi {
Future<List<Map<String, Object?>>> fetchRaw();
}
abstract interface class CourseRepository {
Future<Result<List<Course>>> getAll();
void invalidate();
}
class ApiCourseRepository implements CourseRepository {
ApiCourseRepository(this._api);
final CourseApi _api;
List<Course>? _cache;
@override
void invalidate() => _cache = null;
@override
Future<Result<List<Course>>> getAll() async {
final cached = _cache;
if (cached != null) {
return Success(cached);
}
try {
final rows = await _api.fetchRaw();
final courses = [
for (final row in rows)
Course(
id: row['id'] as String,
title: row['title'] as String,
),
];
_cache = courses;
return Success(courses);
} on Exception catch (error) {
return Failure(error);
}
}
}ในฝั่งการนำเสนอ ChangeNotifier ที่ ฉีดผ่านคอนสตรักเตอร์ มักเพียงพอแล้ว ไม่จำเป็นต้องพึ่งเฟรมเวิร์กใด ๆ จงหลีกเลี่ยง singleton แบบ global เพราะมันตรึงกราฟการพึ่งพาให้ตายตัวและทำให้การแทนที่ในการทดสอบยุ่งยาก ListenableBuilder จะสร้างใหม่เฉพาะ builder ของมันเท่านั้น และ switch แบบครอบคลุมทุกกรณีบน Result จะล็อก UI ไว้อย่างแน่นหนา: เพิ่มสเตตตัวที่สามให้ชนิด sealed แล้วการคอมไพล์จะล้มเหลวในทุกที่ที่ยังไม่ได้จัดการมัน นั่นคือคุณค่าทั้งหมดของรูปแบบนี้ ความพลั้งเผลอกลายเป็นข้อผิดพลาดตอนคอมไพล์ ไม่ใช่บั๊กบนโปรดักชัน
import 'package:flutter/material.dart';
class CourseListController extends ChangeNotifier {
CourseListController(this._repository);
final CourseRepository _repository;
Result<List<Course>>? state;
Future<void> load() async {
state = null;
notifyListeners();
state = await _repository.getAll();
notifyListeners();
}
}
class CourseListScreen extends StatelessWidget {
const CourseListScreen({
super.key,
required this.controller,
});
final CourseListController controller;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
return switch (controller.state) {
null => const Center(
child: CircularProgressIndicator(),
),
Failure() => const Center(
child: Text('Loading error'),
),
Success(value: final courses) =>
ListView.builder(
itemCount: courses.length,
itemBuilder: (context, i) => ListTile(
title: Text(courses[i].title),
),
),
};
},
);
}
}