Kodokon kodokon.com

架构一个应用:UI、逻辑、数据

用仓库模式和带类型的密封结果,将 Flutter 应用组织成互不渗透的分层。

11 分钟 · 3 题

在 Kodokon 中打开本课

一个为长久而构建的 Flutter 应用会把三种职责分开:表现层(widget、BuildContext)、逻辑层(状态、用例)和数据层(API、本地数据库)。首要原则是依赖的方向:表现层依赖逻辑层,逻辑层依赖数据抽象,绝不能反过来。具体来说,表现层之下不应出现任何 BuildContext,也不应出现任何 material.dart 的导入:一个自己弹出 SnackBar 的控制器是无法测试的控制器。另一个常被忽视的点:错误也是各层之间契约的一部分。与其让未类型化的异常泄漏出去,不如用一个 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;
}
密封类型让失败在签名中变得显式。

仓库(repository)是领域与外部世界之间的边界。它确切的职责是:暴露领域实体(绝不暴露原始的 API DTO)、隐藏访问策略(网络、缓存、本地数据库),并集中管理数据的新鲜度策略。在 Dart 3 中,用 abstract interface class 来声明契约:这个修饰符只允许从库外部进行 implements,你无法意外地继承某一部分实现,因此契约保持纯净。JSON 到实体的映射存在于实现之中:如果明天 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);
    }
  }
}
仓库负责映射、缓存,并把错误转换成 Result。

在表现层这一侧,一个通过构造函数注入ChangeNotifier 往往就足够了,无需搬出什么框架。避免使用全局单例:它们会冻结依赖图,并让测试中的替换变得痛苦。ListenableBuilder 只会重建它的 builder,而对 Result 进行的穷尽式 switch 则把 UI 锁得死死的:给密封类型再加上第三种状态,编译就会在所有没有处理它的地方失败。这正是这套模式的全部价值所在,一个疏忽会变成编译错误,而不是生产环境的 bug。

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 覆盖了密封类型的每一种情况。

知识检测

确认你已牢记本课的重点内容。

  1. 为什么 UI 依赖的是 CourseRepository 接口,而不是 ApiCourseRepository?
    • 为了在测试中替换成一个假的实现,并且在不改动 widget 的情况下更换数据源
    • 因为抽象类实例化起来更快
    • 因为 Dart 禁止从 widget 中实例化一个具体类
  2. 像 Result 这样的密封类型相比异常有什么优势?
    • 编译器会检查 switch 是否穷尽:你不可能遗漏错误的那种情况
    • 它让异步代码运行得更快
    • 它避免了在堆上分配任何对象
    • 它还能捕获来自 isolate 的错误
  3. JSON 到 Course 实体的映射应该放在哪里?
    • 放在数据层,这样领域层就对 API 的格式一无所知
    • 放在 widget 中,尽可能靠近显示的地方
    • 放在 main() 中,在 runApp 之前