flutter-data-layer
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseFlutter Data 레이어
Flutter Data 层
에러 처리 연계
错误处理联动
Data 레이어가 반환하는 타입은 flutter-error-handling 스킬에서 정의한 를 기반으로 한다. 플랫폼 예외는 Data 레이어에서 catch 해 typed error 로 변환한다. 자세한 규칙은 해당 스킬을 참고.
Result<D, E extends Error>Data层返回的类型基于flutter-error-handling技能中定义的。平台异常会在Data层被捕获并转换为类型化错误。详细规则请参考该技能。
Result<D, E extends Error>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 인터페이스는 도메인 타입() 을 주고받지만, DataSource 인터페이스는 원시 타입/DTO() 를 주고받는다는 점이 핵심이다. 매핑은 Repository 구현체가 담당한다.
RecipeMap- 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接口以**领域类型()进行数据交互,而DataSource接口以原始类型/DTO()**交互是核心差异。映射工作由Repository实现类负责。
RecipeMap도메인 계약 (lib/domain)
领域契约(lib/domain)
- 은 순수 Dart 레이어다.
lib/domain/import 금지.package:flutter/... - 포함: 도메인 모델(), Repository 인터페이스, 에러 타입, UseCase.
freezed - ViewModel이 쓰는 모든 Repository는 이 레이어에 인터페이스가 있어야 한다 — Presentation 이 Data를 직접 참조하지 못하도록 보장하기 위함.
dart
// lib/domain/repository/recipe_repository.dart
abstract interface class RecipeRepository {
Future<List<Recipe>> getRecipes();
Future<Recipe?> getRecipe(int id);
}- 是纯Dart层。禁止导入
lib/domain/。package:flutter/... - 包含:领域模型()、Repository接口、错误类型、UseCase。
freezed - 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)
모델은 항상 + 조합으로 만든다.
freezedjson_serializabledart
// 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模型始终使用 + 组合创建。
freezedjson_serializabledart
// 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-outputsDataSource 구현
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 가이드라인과 다르게 이 프로젝트는 구현체에 접미어를 일관되게 사용하므로, 기존 컨벤션을 따른다.
Impldart
// lib/data/data_source/local/default_local_storage.dart
class DefaultLocalStorage implements LocalStorage {
// 基于SharedPreferences、sqflite等实现
}命名规则(本项目规范):
- 接口:、
RecipeDataSource—— 表示“是什么”的名称LocalStorage - 实现类:、
RemoteRecipeDataSourceImpl—— 表示“在哪里/如何实现”的名称 +DefaultLocalStorage或技术前缀Impl
与Android指南不同,本项目统一在实现类后添加后缀,遵循现有约定。
ImplRepository 구현
Repository 实现
Repository 구현체는 DataSource 결과를 도메인 모델로 매핑한다. 같은 raw 타입은 여기서 끝나야 한다.
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 - 매핑은 같은 freezed 생성 팩토리를 이용한다. 별도 mapper 파일이 필요 없을 때가 많다.
Recipe.fromJson
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 - 映射使用这类freezed生成工厂方法,多数情况下无需单独的mapper文件。
Recipe.fromJson
반응형 저장소 — BehaviorSubject 패턴
响应式存储库 —— BehaviorSubject 模式
이 프로젝트는 북마크처럼 여러 화면이 같은 상태를 관찰해야 할 때 의 를 쓴다. 최근 값이 있는 브로드캐스트 스트림이므로 새 구독자가 즉시 현재 상태를 받을 수 있다.
rxdartBehaviorSubjectdart
// 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 에서 스트림과 일회성 Future 를 합성할 때:
BehaviorSubjectdart
// 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 집합)만 책임지고, 유즈케이스가 그걸 화면 목적에 맞게 합성한다.
本项目在需要多个页面观察同一状态(如书签)时,使用的。它是带有最新值的广播流,新订阅者可立即获取当前状态。
rxdartBehaviorSubjectdart
// 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中组合流和一次性Future的示例:
BehaviorSubjectdart
// 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
- — freezed 모델
lib/domain/model/<name>.dart - —
lib/domain/repository/<name>_repository.dartabstract interface class - — 실제 소스 접근
lib/data/data_source/{remote|local}/<name>_data_source_impl.dart - — DataSource 주입, 매핑, Repository 계약 구현
lib/data/repository/<name>_repository_impl.dart - 필요하면 로 스트림 노출
BehaviorSubject - 에 인터페이스 타입으로 등록
diSetup() -
dart run build_runner build --delete-conflicting-outputs
- —— freezed模型
lib/domain/model/<name>.dart - ——
lib/domain/repository/<name>_repository.dartabstract interface class - —— 实际数据源访问实现
lib/data/data_source/{remote|local}/<name>_data_source_impl.dart - —— 注入DataSource、映射、实现Repository契约
lib/data/repository/<name>_repository_impl.dart - 必要时使用暴露流
BehaviorSubject - 在中以接口类型注册
diSetup() - 执行
dart run build_runner build --delete-conflicting-outputs
안티 패턴
反模式
- ❌ Repository 구현체가 을 호출자에게 노출 → 매핑은 Data 레이어 안에서 끝나야 한다.
Map<String, dynamic> - ❌ 파일에
domain/import → 순수성이 깨진다.package:flutter/material.dart - ❌ 구현 클래스 타입을 ViewModel/UseCase 에서 참조 → 인터페이스로 참조하라.
- ❌ UseCase 안에서 가 해야 할 캐싱/저장을 대신 처리 → 책임이 흐려진다.
Repository
- ❌ Repository实现类向调用者暴露→ 映射必须在Data层内部完成。
Map<String, dynamic> - ❌ 在文件中导入
domain/→ 破坏纯Dart层的纯净性。package:flutter/material.dart - ❌ 在ViewModel/UseCase中引用实现类类型 → 应引用接口类型。
- ❌ 在UseCase中处理Repository应负责的缓存/存储逻辑 → 职责混淆。