首页
/ Immich 移动端 Infrastructure 层解析:用 Drift 与 Riverpod 构建数据访问实现

Immich 移动端 Infrastructure 层解析:用 Drift 与 Riverpod 构建数据访问实现

2026-09-06 22:50:10作者:宣利权Counsellor

本文以 Immich 移动端工程中的 mobile/lib/infrastructure/ 目录及其架构说明文档为核心,深入讲解 Immich Flutter 客户端基础设施层(Infrastructure Layer)的职责定位、目录组织、典型 Repository 实现模式(Drift 数据库访问、DTO 转换)以及通过 Riverpod 完成依赖注入的使用方式。读完本文,你将能够看懂 Immich 移动端数据层的分层设计,并掌握"领域接口 + 基础设施实现 + Provider 组装"这一套在真实大型 Flutter 项目中的落地范式。

基础设施层在 Immich 移动端中的定位

Immich 移动端(位于 mobile/ 目录)采用清晰的架构分层:domain 层定义业务逻辑与数据访问契约,infrastructure 层负责这些契约的具体实现,presentation 层则只消费上层服务。mobile/lib/infrastructure/README.md 对该层的定义是:

The infrastructure layer is responsible for the implementation details of the app. It includes data sources, APIs, and other external dependencies.(基础设施层负责应用实现细节,包括数据源、API 以及其他外部依赖。)

也就是说,这一层回答的问题是"数据从哪里来、如何落地":本地 SQLite(通过 Drift 框架)、HTTP API 请求、键值缓存、网络状态监听等具体技术选型全部被封装在这里。领域层(domain)只面向接口编程,从不直接 new 一个实现类——这一点在 mobile/lib/infrastructure/README.md 的 Usage 一节中被明确强调:

The domain layer should never directly instantiate repository implementations, but instead receive them through dependency injection.(领域层绝不应该直接实例化 Repository 实现,而应通过依赖注入获取。)

目录结构:Repositories 与 Utils

按照 mobile/lib/infrastructure/README.md 的说明,该目录的组织方式为:

infrastructure/
├── repositories/
│   └── user.repository.dart
└── utils/
    └── user.converter.dart
  • Repositories:领域层接口(或数据访问契约)的具体实现,单个接口可能对应多个实现(例如本地数据源与远端 API 数据源分离);
  • Utils:与基础设施实现绑定的工具类与函数,典型代表是 DTO 之间的转换器(converter)。

实际目录中,mobile/lib/infrastructure/repositories/ 下已沉淀了约 28 个仓储实现,覆盖了 Immich 移动端几乎所有核心数据域:user.repository.dart(用户本地表)、user_api.repository.dart(用户远端 API)、settings.repository.dart(设置)、log.repository.dart(日志)、network.repository.dart(网络监听)、timeline.repository.dart(时间线)、tags_api.repository.dart(标签)等。从命名规律可以推断出该项目的双数据源惯例:*_api.repository.dart 负责与服务端 HTTP 接口交互,同名不带后缀的 *.repository.dart 负责本地 Drift 数据库持久化,两者在领域服务中组合使用(先取缓存、再刷新、后写回),这正是"一个接口可能有多个实现"的典型体现。

实现细节一:基于 Drift 的本地数据库仓储

mobile/lib/infrastructure/repositories/user.repository.dart 为例,可以完整看到基础设施层如何使用 Drift 构建类型安全的数据库访问:

@DriftAccessor()
class UserRepository extends DatabaseAccessor<Drift> with $UserRepositoryMixin {
  UserRepository(super.attachedDatabase);

  Drift get _db => attachedDatabase;

  Stream<Iterable<User>> getAll() => _db.select(_db.userEntity).map(mapToUser).watch();

  Stream<User?> watch(String id) =>
      (_db.select(_db.userEntity)..where((u) => u.id.equals(id))).map(mapToUser).watchSingleOrNull();
}

这里有几个值得注意的实现模式:

  1. @DriftAccessor() 代码生成:类继承 DatabaseAccessor<Drift> 并混入生成代码 $UserRepositoryMixin(来自 user.repository.drift.dart),查询能力由 Drift 的代码生成器从 schema 推导,避免了手写 SQL 字符串;
  2. 响应式返回类型getAll() / watch(id) 返回的是 Stream 而非一次性 Future,UI 层订阅即可随数据库变更自动刷新,这是 Drift 与 Flutter StreamBuilder/Riverpod 生态配合的标准用法;
  3. 实体到领域模型的映射:查询结果通过 .map(mapToUser) 转换为领域模型 User,映射函数定义在 mobile/lib/infrastructure/mapper.dart
User mapToUser(UserEntityData data) => User(
  id: data.id,
  name: data.name,
  email: data.email,
  hasProfileImage: data.hasProfileImage,
  profileChangedAt: data.profileChangedAt,
  avatarColor: data.avatarColor,
);

同一个文件中还包含 AuthUserRepository,它演示了更完整的"读 + 写"模式:读取时先从 authUserEntity 表按主键取行,再联表查询 userMetadataEntity 组装出 UserDtotoDto(metadata));写入时则通过 insertOnConflictUpdate(即 upsert)把 UserDto 的字段逐个装入 AuthUserEntityCompanion 落库。注意这里对 UserMetadataKey.preferences 的解析逻辑——memoryEnabled 默认 true,仅当用户元数据中显式保存了偏好时才覆盖,这种"默认值兜底"的防御式写法是基础设施层处理脏数据/缺字段的常见手法。

实现细节二:通用键值仓储与 Settings 的特化

并非所有仓储都是面向"表"的。mobile/lib/infrastructure/repositories/cached_key_value_repository.dart 定义了一个抽象泛型基类 CachedKeyValueRepository<K extends Enum, S>,它把"枚举键 → 字符串值 → 解码为强类型快照"这条通用链路固化下来:

abstract class CachedKeyValueRepository<K extends Enum, S> {
  S _snapshot;
  S get snapshot => _snapshot;

  List<K> get keys;
  Object decodeValue(K key, String raw);
  S buildSnapshot(Map<K, Object?> overrides);
  Selectable<({String key, String? value})> selectable();

  Future<void> refresh() async => _snapshot = _build(await selectable().get());
  Stream<S> watchSnapshot() => selectable().watch().map((rows) => _snapshot = _build(rows));
  // ..._build 内部将 key-value 行 fold 成 overrides 后调用 buildSnapshot
}

具体实现 mobile/lib/infrastructure/repositories/settings.repository.dart 只需要回答四个问题:键枚举是 SettingsKey、解码用 key.decode(raw)、快照用 AppConfig.fromEntries 构建、可查询对象指向 _db.settingsEntity。由此获得了两个有价值的语义:

  • 写回默认值即删除write() 中如果新值等于 defaultConfig.read(key),会走 clear([key]) 直接删行而不是写"默认值",让数据库只保存真正被用户改过的键,保持存储精简;
  • 响应式配置流watchConfig() 直接透传 watchSnapshot(),UI 监听该 Stream 即可在设置变化时热更新。

LogRepositorymobile/lib/infrastructure/repositories/log.repository.dart)则展示了另一侧:日志落库、按 logger 过滤的 watchMessages 流、以及 truncate 保留最近 kLogTruncateLimit 条的容量控制逻辑,同样基于 Drift 的 select/orderBy/limit 组合完成。

实现细节三:Utils 中的 DTO 转换器

utils/ 目录承载与具体数据源格式解耦的转换逻辑。以 mobile/lib/infrastructure/utils/user.converter.dart 为例,UserConverterabstract final class(纯静态工具类),负责把 OpenAPI 生成的三种服务端 DTO 统一归一为领域模型 UserDto

static UserDto fromSimpleUserDto(UserResponseDto dto) => UserDto(
  id: dto.id,
  email: dto.email,
  name: dto.name,
  isAdmin: false,
  updatedAt: DateTime.now(),
  hasProfileImage: dto.profileImagePath.isNotEmpty,
  profileChangedAt: dto.profileChangedAt,
  avatarColor: dto.avatarColor.toAvatarColor(),
);

此外还有 fromAdminDto(管理端响应,携带 isAdmin、配额等字段,并可附带偏好信息推导 memoryEnabled)与 fromPartnerDto(伙伴分享用户)。文件底部的 toAvatarColor() 扩展把 API 层的 UserAvatarColor 枚举逐项 switch 映射为领域层的 AvatarColor。这类 converter 存在的意义在于:外部协议(OpenAPI DTO)的任何变动都被隔离在基础设施层内消化,领域模型与 UI 层不感知协议细节。文件顶部的 TODO: Move to repository once all classes are refactored 注释也印证了文档中提到的仓库正在逐实体迁移的现状。

使用方式:Riverpod 依赖注入链路

README 给出的核心使用约定是:基础设施层的实现通过根 providers 目录下的 Riverpod Provider 暴露给上层,领域服务只持有"接口/实例引用"而不关心构造过程。以用户模块为真实示例,领域服务 mobile/lib/domain/services/user.service.dart 的构造如下:

class UserService {
  final UserApiRepository _userApiRepository;
  final UserRepository _userRepository;
  final StoreService _storeService;

  UserService({required this._userApiRepository, required this._userRepository, required this._storeService});

  Future<UserDto?> refreshMyUser() async {
    final user = await _userApiRepository.getMyUser();
    if (user == null) return null;
    await _storeService.put(StoreKey.currentUser, user);
    return user;
  }

  Stream<User?> watch(String id) => _userRepository.watch(id);
}

可以看到典型的双数据源协作:refreshMyUser() 通过远端 API 仓储拉取最新用户并写入共享状态,watch() 则直接暴露本地 Drift 仓储的响应式流给 UI 订阅。README 中给出的伪代码(final userRepository = ref.watch(userRepositoryProvider);)表达的就是这一组装思想——Provider 负责把 UserRepository 等实现注入 UserService,而 mobile/lib/domain/README.md 补充了对称的另一半约束:presentation 层不应直接使用 repositories,而应通过 services 交互。三层之间的依赖方向因此被严格约束为 presentation → domain → infrastructure(经 DI 反转)

演进中的注意:数据访问正在向 lib/data 迁移

README 中有一条重要的迁移注记:

The Drift schema, the database class, and the server API base moved to lib/data/. Repositories here are migrating there one entity at a time; new data access should be added under lib/data/.

也就是说,Drift schema 定义、数据库类以及服务端 API 基座已经迁移到 mobile/lib/data/(包含 db/ 数据库层与 server/ API 层),而 infrastructure/repositories/ 中的仓储正按实体逐个随之迁移;新增的数据访问代码应当落在 lib/data/,而不是继续往 infrastructure/ 里添加。这一信息对阅读旧代码与新代码的差异至关重要:你仍会看到部分仓储从 infrastructure/repositories/ import Drift 表定义(如 user.repository.dart 中 import 自 data/db/main/...),这正是过渡期的混合状态,而非两个数据库实现并存。

小结

mobile/lib/infrastructure/ 是 Immich 移动端"实现细节"的汇聚地:repositories/ 以 Drift 访问器、泛型键值仓储、API 仓储等形式实现数据契约,utils/mapper.dart 承担 DTO/实体与领域模型之间的转换隔离,Riverpod Provider 则把实现细节以依赖注入的方式交付给领域层。理解这一层后,再配合 mobile/lib/domain/README.md 的领域层说明与 mobile/lib/data/ 的迁移目标目录,即可完整把握 Immich 移动端从 UI 到数据库的整条数据流,也能在贡献代码时遵循"新数据访问进 lib/data/、实现经 Provider 注入"的现行规范。

登录后查看全文
热门项目推荐
相关项目推荐