Kodokon kodokon.com

การออกแบบสถาปัตยกรรมแอป: UI ตรรกะ และข้อมูล

จัดโครงสร้างแอป Flutter เป็นชั้นที่แยกจากกันอย่างชัดเจนด้วยรูปแบบ repository และผลลัพธ์ชนิด sealed ที่มีชนิดกำกับ

11 นาที · 3 คำถาม

เปิดบทเรียนนี้ใน Kodokon

แอป Flutter ที่สร้างมาเพื่อให้อยู่ได้ยาวนานจะแยกความรับผิดชอบออกเป็นสามส่วน: การนำเสนอ (presentation) (วิดเจ็ต, BuildContext), ตรรกะ (logic) (สเตต, use case) และ ข้อมูล (data) (API, ฐานข้อมูลในเครื่อง) กฎสำคัญที่สุดคือ ทิศทางของการพึ่งพา (direction of dependencies): การนำเสนอพึ่งพาตรรกะ ตรรกะพึ่งพาการนามธรรมของข้อมูล ไม่ใช่ในทางกลับกันเด็ดขาด พูดให้เป็นรูปธรรมคือ ต้องไม่มี BuildContext และไม่มีการ import material.dart ปรากฏใต้ชั้นการนำเสนอ คอนโทรลเลอร์ที่เด้ง SnackBar ของตัวเองขึ้นมาคือคอนโทรลเลอร์ที่ทดสอบไม่ได้ อีกจุดที่มักถูกมองข้าม: ข้อผิดพลาดเป็นส่วนหนึ่งของสัญญาระหว่างชั้น แทนที่จะปล่อยให้ exception ที่ไม่มีชนิดกำกับรั่วออกมา จงจำลองผลลัพธ์ด้วยคลาส sealed แล้วคอมไพเลอร์จะบังคับให้คุณจัดการทุกกรณี

DART
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;
}
ชนิด sealed ทำให้ความล้มเหลวปรากฏชัดเจนในลายเซ็นของฟังก์ชัน

repository คือเส้นแบ่งระหว่างโดเมนกับโลกภายนอก บทบาทที่แน่ชัดของมันคือ: เปิดเผย เอนทิตีของโดเมน (domain entities) (ไม่ใช่ DTO ดิบจาก API เด็ดขาด) ซ่อนกลยุทธ์การเข้าถึง (เครือข่าย, แคช, ฐานข้อมูลในเครื่อง) และรวมศูนย์นโยบายความสดใหม่ของข้อมูล ใน Dart 3 จงประกาศสัญญาด้วย abstract interface class ตัวขยายนี้อนุญาตเฉพาะ implements จากภายนอกไลบรารีเท่านั้น คุณจะไม่เผลอสืบทอดชิ้นส่วนของการทำงานมาโดยไม่ตั้งใจ สัญญาจึงคงความบริสุทธิ์ไว้ได้ การแมป JSON ไปเป็นเอนทิตีอยู่ในตัวการทำงาน (implementation) หากพรุ่งนี้ API เปลี่ยนชื่อฟิลด์ ก็จะมีเพียงชั้นข้อมูลเท่านั้นที่เปลี่ยน

DART
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);
    }
  }
}
repository ทำหน้าที่แมป แคช และแปลงข้อผิดพลาดให้กลายเป็น Result

ในฝั่งการนำเสนอ ChangeNotifier ที่ ฉีดผ่านคอนสตรักเตอร์ มักเพียงพอแล้ว ไม่จำเป็นต้องพึ่งเฟรมเวิร์กใด ๆ จงหลีกเลี่ยง singleton แบบ global เพราะมันตรึงกราฟการพึ่งพาให้ตายตัวและทำให้การแทนที่ในการทดสอบยุ่งยาก ListenableBuilder จะสร้างใหม่เฉพาะ builder ของมันเท่านั้น และ switch แบบครอบคลุมทุกกรณีบน Result จะล็อก UI ไว้อย่างแน่นหนา: เพิ่มสเตตตัวที่สามให้ชนิด sealed แล้วการคอมไพล์จะล้มเหลวในทุกที่ที่ยังไม่ได้จัดการมัน นั่นคือคุณค่าทั้งหมดของรูปแบบนี้ ความพลั้งเผลอกลายเป็นข้อผิดพลาดตอนคอมไพล์ ไม่ใช่บั๊กบนโปรดักชัน

DART
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),
              ),
            ),
        };
      },
    );
  }
}
null = กำลังโหลด และ switch ครอบคลุมทุกกรณีของชนิด sealed

ทดสอบความรู้

ตรวจสอบว่าคุณจำประเด็นสำคัญของบทเรียนนี้ได้ครบถ้วน

  1. ทำไม UI จึงพึ่งพาอินเทอร์เฟซ CourseRepository แทนที่จะพึ่งพา ApiCourseRepository?
    • เพื่อสลับไปใช้ตัวการทำงานแบบ fake ในการทดสอบ และเปลี่ยนแหล่งข้อมูลได้โดยไม่ต้องแตะวิดเจ็ต
    • เพราะคลาสนามธรรมสร้างอินสแตนซ์ได้เร็วกว่า
    • เพราะ Dart ห้ามการสร้างอินสแตนซ์ของคลาสรูปธรรมจากวิดเจ็ต
  2. ชนิด sealed อย่าง Result มีข้อได้เปรียบเหนือ exception อย่างไร?
    • คอมไพเลอร์ตรวจสอบว่า switch ครอบคลุมทุกกรณี: คุณจะลืมกรณีข้อผิดพลาดไม่ได้
    • มันทำให้โค้ดแบบอะซิงโครนัสทำงานเร็วขึ้น
    • มันหลีกเลี่ยงการจองอ็อบเจกต์บน heap ทั้งหมด
    • มันดักจับข้อผิดพลาดจาก isolate ได้ด้วย
  3. การแมป JSON ไปเป็นเอนทิตี Course ควรอยู่ที่ไหน?
    • ในชั้นข้อมูล เพื่อให้โดเมนไม่รับรู้รูปแบบของ API
    • ในวิดเจ็ต ให้ใกล้กับการแสดงผลมากที่สุด
    • ใน main() ก่อน runApp