flutter-data-layer

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Flutter Data 레이어

Flutter Data 层

에러 처리 연계

错误处理联动

Data 레이어가 반환하는 타입은 flutter-error-handling 스킬에서 정의한
Result<D, E extends Error>
를 기반으로 한다. 플랫폼 예외는 Data 레이어에서 catch 해 typed error 로 변환한다. 자세한 규칙은 해당 스킬을 참고.

Data层返回的类型基于flutter-error-handling技能中定义的
Result<D, E extends Error>
。平台异常会在Data层被捕获并转换为类型化错误。详细规则请参考该技能。

DataSource vs Repository

DataSource vs Repository

  • DataSource: 단일 소스에 접근. 원격 API, 로컬 DB, 파일시스템, 클립보드 같은 단일 채널 하나만 다룬다. Data 레이어 대부분의 클래스가 여기 해당한다.
  • Repository: 여러 DataSource를 조합해 도메인 관점으로 묶는다. 단일 소스만 쓰는데 굳이 "Repository" 이름을 붙이지 않는다.
이 프로젝트 기준:
dart
// 단일 소스 → DataSource
abstract interface class RecipeDataSource {
  Future<List<Map<String, dynamic>>> getRecipes();
}

// 도메인 관점의 접근 API → Repository
abstract interface class RecipeRepository {
  Future<List<Recipe>> getRecipes();
  Future<Recipe?> getRecipe(int id);
}
Repository 인터페이스는 도메인 타입(
Recipe
)
을 주고받지만, DataSource 인터페이스는 원시 타입/DTO(
Map
)
를 주고받는다는 점이 핵심이다. 매핑은 Repository 구현체가 담당한다.

  • DataSource:单一数据源访问。仅处理远程API、本地数据库、文件系统、剪贴板等单一渠道。Data层的大部分类都属于此类。
  • Repository:将多个DataSource组合,从领域视角进行封装。仅使用单一数据源时无需特意命名为“Repository”。
本项目示例:
dart
// 单一数据源 → DataSource
abstract interface class RecipeDataSource {
  Future<List<Map<String, dynamic>>> getRecipes();
}

// 领域视角的访问API → Repository
abstract interface class RecipeRepository {
  Future<List<Recipe>> getRecipes();
  Future<Recipe?> getRecipe(int id);
}
Repository接口以**领域类型(
Recipe
)进行数据交互,而DataSource接口以原始类型/DTO(
Map
)**交互是核心差异。映射工作由Repository实现类负责。

도메인 계약 (lib/domain)

领域契约(lib/domain)

  • lib/domain/
    은 순수 Dart 레이어다.
    package:flutter/...
    import 금지.
  • 포함: 도메인 모델(
    freezed
    ), Repository 인터페이스, 에러 타입, UseCase.
  • ViewModel이 쓰는 모든 Repository는 이 레이어에 인터페이스가 있어야 한다 — Presentation 이 Data를 직접 참조하지 못하도록 보장하기 위함.
dart
// lib/domain/repository/recipe_repository.dart
abstract interface class RecipeRepository {
  Future<List<Recipe>> getRecipes();
  Future<Recipe?> getRecipe(int id);
}

  • lib/domain/
    是纯Dart层。禁止导入
    package:flutter/...
  • 包含:领域模型(
    freezed
    )、Repository接口、错误类型、UseCase。
  • ViewModel使用的所有Repository都必须在此层拥有接口——为了确保Presentation层不会直接引用Data层。
dart
// lib/domain/repository/recipe_repository.dart
abstract interface class RecipeRepository {
  Future<List<Recipe>> getRecipes();
  Future<Recipe?> getRecipe(int id);
}

도메인 모델 (freezed)

领域模型(freezed)

모델은 항상
freezed
+
json_serializable
조합으로 만든다.
dart
// lib/domain/model/recipe.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'recipe_ingredient.dart';

part 'recipe.freezed.dart';
part 'recipe.g.dart';


class Recipe with _$Recipe {
  const factory Recipe({
    required String category,
    required int id,
    required String name,
    required String image,
    required String chef,
    required String time,
    required double rating,
    required List<RecipeIngredient> ingredients,
    (false) bool isFavorite,
  }) = _Recipe;

  factory Recipe.fromJson(Map<String, Object?> json) => _$RecipeFromJson(json);
}
파일 수정 후 반드시:
dart run build_runner build --delete-conflicting-outputs

模型始终使用
freezed
+
json_serializable
组合创建。
dart
// lib/domain/model/recipe.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'recipe_ingredient.dart';

part 'recipe.freezed.dart';
part 'recipe.g.dart';


class Recipe with _$Recipe {
  const factory Recipe({
    required String category,
    required int id,
    required String name,
    required String image,
    required String chef,
    required String time,
    required double rating,
    required List<RecipeIngredient> ingredients,
    (false) bool isFavorite,
  }) = _Recipe;

  factory Recipe.fromJson(Map<String, Object?> json) => _$RecipeFromJson(json);
}
文件修改后必须执行:
dart run build_runner build --delete-conflicting-outputs

DataSource 구현

DataSource 实现

원격

远程数据源

dart
// lib/data/data_source/remote/remote_recipe_data_source_impl.dart
class RemoteRecipeDataSourceImpl implements RecipeDataSource {
  
  Future<List<Map<String, dynamic>>> getRecipes() async {
    // http 호출 또는 mock
    await Future.delayed(const Duration(microseconds: 500));
    return _mockData['recipes']!;
  }
}
dart
// lib/data/data_source/remote/remote_recipe_data_source_impl.dart
class RemoteRecipeDataSourceImpl implements RecipeDataSource {
  
  Future<List<Map<String, dynamic>>> getRecipes() async {
    // http 请求或 mock
    await Future.delayed(const Duration(microseconds: 500));
    return _mockData['recipes']!;
  }
}

로컬

本地数据源

dart
// lib/data/data_source/local/default_local_storage.dart
class DefaultLocalStorage implements LocalStorage {
  // SharedPreferences, sqflite 등으로 구현
}
명명 규칙 (이 프로젝트 기준):
  • 인터페이스:
    RecipeDataSource
    ,
    LocalStorage
    — "무엇"을 나타내는 이름
  • 구현:
    RemoteRecipeDataSourceImpl
    ,
    DefaultLocalStorage
    — "어디/어떻게"를 나타내는 이름 +
    Impl
    또는 기술명 접두어
Android 가이드라인과 다르게 이 프로젝트는 구현체에
Impl
접미어를 일관되게 사용하므로, 기존 컨벤션을 따른다.

dart
// lib/data/data_source/local/default_local_storage.dart
class DefaultLocalStorage implements LocalStorage {
  // 基于SharedPreferences、sqflite等实现
}
命名规则(本项目规范):
  • 接口:
    RecipeDataSource
    LocalStorage
    —— 表示“是什么”的名称
  • 实现类:
    RemoteRecipeDataSourceImpl
    DefaultLocalStorage
    —— 表示“在哪里/如何实现”的名称 +
    Impl
    或技术前缀
与Android指南不同,本项目统一在实现类后添加
Impl
后缀,遵循现有约定。

Repository 구현

Repository 实现

Repository 구현체는 DataSource 결과를 도메인 모델로 매핑한다.
Map<String, dynamic>
같은 raw 타입은 여기서 끝나야 한다.
dart
// lib/data/repository/mock_recipe_repository_impl.dart
class MockRecipeRepositoryImpl implements RecipeRepository {
  final RecipeDataSource _recipeDataSource;

  const MockRecipeRepositoryImpl({
    required RecipeDataSource recipeDataSource,
  }) : _recipeDataSource = recipeDataSource;

  
  Future<List<Recipe>> getRecipes() async {
    final recipes = await _recipeDataSource.getRecipes();
    return recipes.map(Recipe.fromJson).toList();
  }

  
  Future<Recipe?> getRecipe(int id) async {
    final recipes = await getRecipes();
    return recipes.where((e) => e.id == id).firstOrNull;
  }
}
핵심:
  • 생성자에서 DataSource 를 주입받는다.
    get_it
    이 이걸 해결한다.
  • Repository
    인터페이스는
    domain
    에 있고, 구현은
    data
    에 있어 의존성 방향이 유지된다.
  • 매핑은
    Recipe.fromJson
    같은 freezed 생성 팩토리를 이용한다. 별도 mapper 파일이 필요 없을 때가 많다.

Repository实现类负责将DataSource的结果映射为领域模型。
Map<String, dynamic>
这类原始类型必须在此层处理完毕。
dart
// lib/data/repository/mock_recipe_repository_impl.dart
class MockRecipeRepositoryImpl implements RecipeRepository {
  final RecipeDataSource _recipeDataSource;

  const MockRecipeRepositoryImpl({
    required RecipeDataSource recipeDataSource,
  }) : _recipeDataSource = recipeDataSource;

  
  Future<List<Recipe>> getRecipes() async {
    final recipes = await _recipeDataSource.getRecipes();
    return recipes.map(Recipe.fromJson).toList();
  }

  
  Future<Recipe?> getRecipe(int id) async {
    final recipes = await getRecipes();
    return recipes.where((e) => e.id == id).firstOrNull;
  }
}
核心要点:
  • 通过构造函数注入DataSource。由
    get_it
    负责依赖注入。
  • Repository
    接口位于
    domain
    层,实现类位于
    data
    层,保持依赖方向正确。
  • 映射使用
    Recipe.fromJson
    这类freezed生成工厂方法,多数情况下无需单独的mapper文件。

반응형 저장소 — BehaviorSubject 패턴

响应式存储库 —— BehaviorSubject 模式

이 프로젝트는 북마크처럼 여러 화면이 같은 상태를 관찰해야 할 때
rxdart
BehaviorSubject
를 쓴다. 최근 값이 있는 브로드캐스트 스트림이므로 새 구독자가 즉시 현재 상태를 받을 수 있다.
dart
// lib/data/repository/mock_bookmark_repository_impl.dart
class MockBookmarkRepositoryImpl implements BookmarkRepository {
  final _ids = <int>{2, 3};
  final _controller = BehaviorSubject<Set<int>>();

  MockBookmarkRepositoryImpl() {
    _controller.add(_ids);
  }

  
  Stream<Set<int>> bookmarkIdsStream() => _controller.stream;

  
  Future<void> toggle(int id) async {
    if (_ids.contains(id)) {
      _ids.remove(id);
    } else {
      _ids.add(id);
    }
    _controller.add(_ids);
  }
}
언제 쓰나
  • 저장/해제 같은 변이가 생긴 직후 다른 화면이 즉시 최신 상태를 봐야 할 때.
  • 여러 feature가 동일한 데이터(북마크, 장바구니, 로그인 상태 등)를 공유해야 할 때.
언제 쓰지 말아야 하나
  • 한 화면에서만 쓰고 재진입 시 다시 불러오면 충분한 데이터 → 그냥
    Future
    반환.
UseCase 에서
BehaviorSubject
스트림과 일회성 Future 를 합성할 때:
dart
// lib/domain/use_case/get_saved_recipes_use_case.dart
Stream<List<Recipe>> execute() async* {
  final recipes = await _recipeRepository.getRecipes();

  await for (final ids in _bookmarkRepository.bookmarkIdsStream()) {
    yield recipes.where((e) => ids.contains(e.id)).toList();
  }
}
이 패턴이 깔끔하다: Repository는 원자적 데이터(전체 목록, id 집합)만 책임지고, 유즈케이스가 그걸 화면 목적에 맞게 합성한다.

本项目在需要多个页面观察同一状态(如书签)时,使用
rxdart
BehaviorSubject
。它是带有最新值的广播流,新订阅者可立即获取当前状态。
dart
// lib/data/repository/mock_bookmark_repository_impl.dart
class MockBookmarkRepositoryImpl implements BookmarkRepository {
  final _ids = <int>{2, 3};
  final _controller = BehaviorSubject<Set<int>>();

  MockBookmarkRepositoryImpl() {
    _controller.add(_ids);
  }

  
  Stream<Set<int>> bookmarkIdsStream() => _controller.stream;

  
  Future<void> toggle(int id) async {
    if (_ids.contains(id)) {
      _ids.remove(id);
    } else {
      _ids.add(id);
    }
    _controller.add(_ids);
  }
}
适用场景
  • 保存/取消等变更操作后,其他页面需立即显示最新状态时。
  • 多个功能需共享同一数据(如书签、购物车、登录状态等)时。
不适用场景
  • 仅单个页面使用,重新进入时重新加载即可的数据 → 直接返回
    Future
    即可。
在UseCase中组合
BehaviorSubject
流和一次性Future的示例:
dart
// lib/domain/use_case/get_saved_recipes_use_case.dart
Stream<List<Recipe>> execute() async* {
  final recipes = await _recipeRepository.getRecipes();

  await for (final ids in _bookmarkRepository.bookmarkIdsStream()) {
    yield recipes.where((e) => ids.contains(e.id)).toList();
  }
}
此模式简洁清晰:Repository仅负责原子数据(完整列表、ID集合),UseCase根据页面需求进行组合。

UseCase — Data와 Presentation 사이

UseCase —— Data与Presentation层之间

UseCase는 "비즈니스 동작 하나"를 나타낸다. 이 프로젝트의 관례는:
  • 위치:
    lib/domain/use_case/<verb>_<noun>_use_case.dart
  • 단일 진입점
    execute(...)
    하나만 공개
  • 상태 없음 (field 는 주입받은 의존성뿐)
  • 여러 Repository 를 조합하거나, 도메인 규칙(필터링/정렬)을 적용
  • 반환 타입:
    Future<T>
    ,
    Future<Result<D, E>>
    , 또는
    Stream<T>
dart
class GetSavedRecipesUseCase {
  final RecipeRepository _recipeRepository;
  final BookmarkRepository _bookmarkRepository;

  const GetSavedRecipesUseCase({
    required RecipeRepository recipeRepository,
    required BookmarkRepository bookmarkRepository,
  })  : _recipeRepository = recipeRepository,
        _bookmarkRepository = bookmarkRepository;

  Stream<List<Recipe>> execute() async* { ... }
}
ViewModel 은 Repository 를 직접 호출해도 되지만, 여러 소스를 섞거나 도메인 규칙이 끼어드는 순간 UseCase 로 분리한다.

UseCase代表“一个业务操作”。本项目的惯例:
  • 位置:
    lib/domain/use_case/<verb>_<noun>_use_case.dart
  • 仅公开一个入口
    execute(...)
  • 无状态(仅包含注入的依赖项)
  • 组合多个Repository,或应用领域规则(过滤/排序)
  • 返回类型:
    Future<T>
    Future<Result<D, E>>
    Stream<T>
dart
class GetSavedRecipesUseCase {
  final RecipeRepository _recipeRepository;
  final BookmarkRepository _bookmarkRepository;

  const GetSavedRecipesUseCase({
    required RecipeRepository recipeRepository,
    required BookmarkRepository bookmarkRepository,
  })  : _recipeRepository = recipeRepository,
        _bookmarkRepository = bookmarkRepository;

  Stream<List<Recipe>> execute() async* { ... }
}
ViewModel可以直接调用Repository,但当需要组合多个数据源或涉及领域规则时,需拆分为UseCase。

체크리스트 — 새 DataSource / Repository 추가

检查表 —— 添加新的DataSource / Repository

  • lib/domain/model/<name>.dart
    — freezed 모델
  • lib/domain/repository/<name>_repository.dart
    abstract interface class
  • lib/data/data_source/{remote|local}/<name>_data_source_impl.dart
    — 실제 소스 접근
  • lib/data/repository/<name>_repository_impl.dart
    — DataSource 주입, 매핑, Repository 계약 구현
  • 필요하면
    BehaviorSubject
    로 스트림 노출
  • diSetup()
    에 인터페이스 타입으로 등록
  • dart run build_runner build --delete-conflicting-outputs

  • lib/domain/model/<name>.dart
    —— freezed模型
  • lib/domain/repository/<name>_repository.dart
    ——
    abstract interface class
  • lib/data/data_source/{remote|local}/<name>_data_source_impl.dart
    —— 实际数据源访问实现
  • lib/data/repository/<name>_repository_impl.dart
    —— 注入DataSource、映射、实现Repository契约
  • 必要时使用
    BehaviorSubject
    暴露流
  • diSetup()
    中以接口类型注册
  • 执行
    dart run build_runner build --delete-conflicting-outputs

안티 패턴

反模式

  • ❌ Repository 구현체가
    Map<String, dynamic>
    을 호출자에게 노출 → 매핑은 Data 레이어 안에서 끝나야 한다.
  • domain/
    파일에
    package:flutter/material.dart
    import → 순수성이 깨진다.
  • ❌ 구현 클래스 타입을 ViewModel/UseCase 에서 참조 → 인터페이스로 참조하라.
  • ❌ UseCase 안에서
    Repository
    가 해야 할 캐싱/저장을 대신 처리 → 책임이 흐려진다.
  • ❌ Repository实现类向调用者暴露
    Map<String, dynamic>
    → 映射必须在Data层内部完成。
  • ❌ 在
    domain/
    文件中导入
    package:flutter/material.dart
    → 破坏纯Dart层的纯净性。
  • ❌ 在ViewModel/UseCase中引用实现类类型 → 应引用接口类型。
  • ❌ 在UseCase中处理Repository应负责的缓存/存储逻辑 → 职责混淆。