首页
/ Immich 移动端 Domain 层解析:业务逻辑分层、核心模型与服务实现机制

Immich 移动端 Domain 层解析:业务逻辑分层、核心模型与服务实现机制

2026-09-06 16:17:49作者:宣聪麟

Immich 移动端 App 将业务逻辑集中放在 mobile/lib/domain/ 领域层中,遵循"服务实现业务逻辑、通过依赖注入消费仓库接口"的分层架构。本文基于仓库中 Domain 层说明文档,结合当前源码的实际结构与服务实现,讲清领域层的职责边界、目录组织、核心数据模型设计、服务与 Riverpod 的衔接方式,以及该层当前正在进行的仓库迁移状态,帮助你在阅读或扩展 Immich 移动端代码时快速建立正确的分层心智模型。

领域层的职责定位

Domain 层文档对这一层的定位非常明确:该目录承载 Immich 移动端的业务逻辑(business logic),包含仓库(repositories)接口、模型(models)、服务(services)与工具(utilities)四个组成部分,并且有一条硬性依赖规则:

领域层永远不应依赖表现层(presentation layer)或基础设施层(infrastructure layer)中的任何内容。

这条规则的含义是:领域层不感知 UI 组件、不感知页面路由,也不直接创建数据库或网络客户端的具体实现,而是通过注入的依赖完成数据读写。文档给出的理想结构如下:

domain/
├── interfaces/
│   └── user.interface.dart
├── models/
│   └── user.model.dart
├── services/
│   └── user.service.dart
└── utils/
    └── date_utils.dart

需要指出的是,当前仓库的 domain/ 目录实际包含 models/services/utils/ 三个子目录(文档中的 interfaces/ 目录在现有目录树中已不存在)。从源码结构看,仓库接口定义已迁出领域目录——例如数据库接口现位于 mobile/lib/interfaces/database.interface.dart,而 基础设施层文档也注明:Drift schema、数据库类与服务器 API 基座已移动到 lib/data/infrastructure/repositories/ 正在逐实体迁移,新数据访问应加到 lib/data/ 下。换言之,文档描述的是分层的目标形态,代码正处于一次进行中的数据访问层重构过渡期。

目录结构与实际内容

结合当前源码,四个组成部分的实际落地情况如下:

Models:核心业务数据类

models/ 下有 40 余个模型文件,覆盖资产(asset/,含本地/远端/元数据模型)、相册(album/)、配置(config/,含 app_config.dartbackup_config.darttheme_config.dart 等 13 个配置模型)、人脸、OCR、记忆、时间线、标签等领域概念。模型统一使用 Freezed 生成不可变类,典型代表是 user.model.dart

  • UserDto 使用 @Freezed(equal: false) 声明,字段包含 idemailnameisAdminavatarColor、配额字段等,并提供 hasQuota 便捷属性(见 user.model.dart#L38-L59);
  • 由于 Freezed 不支持自定义相等性,UserDto 手写了 operator ==,用 DateTime.isAtSameMomentAs 做跨时区比较,注释中明确这是为了规避 Freezed 默认按值比较 DateTime 的时区问题(见 user.model.dart#L61-L84);
  • AvatarColor 枚举带有一条重要约束:"do not change this order or reuse indices"——枚举顺序即持久化序号,只能追加不能改动,这在自托管应用的本地存储场景中属于兼容性命中的设计细节;
  • 文件中还保留了向无 Isar 时代演进的痕迹:UserAuthUserPartner 三类手写模型与 UserDto 并存,UserDto 上的 TODO 注明"Isar 移除后重命名为 User"。

另一个关键模型是 store.model.dart 中的 StoreKey<T> 泛型枚举:每个键名绑定一个静态整型 id 和一个值类型,如 currentUser<UserDto>._(2)serverUrl<String>._(10)accessToken<String>._(11)。枚举中一大段 legacyXxx 键(备份开关、主题模式、地图选项等)标注为"已迁移到新的 metadata store",仅保留键位用于历史数据兼容。这类带类型的键设计让键值存储获得了编译期类型安全。

Services:业务逻辑的载体

services/ 下现有 23 个服务,覆盖资产、本地同步、备份流、人脸聚类、地图、搜索、时间线、用户等核心业务,是领域层最密集的部分。下面用三个真实服务说明其实现模式。

UserService:依赖注入 + 缓存优先的典型服务。 user.service.dart 通过构造函数注入 UserApiRepository(网络 API 仓库)、UserRepository(本地 Drift 仓库)与 StoreService(键值存储)三个依赖(见 user.service.dart#L11-L17),对外提供四组能力:

方法 行为 数据源
getMyUser() 同步取当前用户,缺失时抛异常 StoreService(本地缓存)
tryGetMyUser() 同上,缺失时返回 null StoreService
watchMyUser() 以 Stream 监听当前用户变化 StoreService
refreshMyUser() UserApiRepository 拉取远端用户并写回 Store 远端 API + Store

这种"读走本地缓存、写走远端再落缓存"的模式,是 Immich 移动端"离线优先"体验的根基:UI 读取从不阻塞网络请求,需要最新数据时再显式调用 refreshMyUser

StoreService:带内存缓存的键值存储门面。 store.service.dartStoreRepository 之上维护了一个 Map<int, Object?> 内存缓存:启动时 populateCache() 全量装载,运行期订阅 watchAll() 流增量更新(见 store.service.dart#L41-L52)。两个值得注意的实现细节:

  • put 会先比较缓存值,未变化则跳过写盘(store.service.dart#L80-L86),避免无谓的数据库写入;
  • 键缺失且无默认值时抛出领域内定义的 StoreKeyNotFoundException 异常,而不是让 cast 失败产生难以诊断的运行时错误。

TimelineService:分页缓冲的业务算法。 timeline.service.dart 展示了领域层承载真正复杂业务逻辑的一面。TimelineFactory 依据 TimelineOrigin 枚举(mainlocalAlbumremoteAlbumfavoritetrashpersonmapsearch 等 17 种来源)构造统一的 TimelineService(见 timeline.service.dart#L21-L95)。TimelineService 内部维护一个"窗口缓冲区":监听 bucket(分组计数)流变化后自动重算总量并重置缓冲区;loadAssets(index, count) 在请求超出缓冲区时,按滚动方向(前滚/回滚)向外扩展加载窗口,用小批量请求填充到 kTimelineAssetLoadBatchSize 并预留反向余量,以避免每次小范围滚动都触发数据库查询(见 timeline.service.dart#L149-L175)。所有并发访问经由 AsyncMutex 串行化,保证 UI 读取与后台重载之间的一致性。这正是文档所说"服务包含业务逻辑并与仓库交互"的具体体现。

Utils:领域内通用工具

utils/ 现有四个文件:background_sync.dartevent_stream.dartmigrate_cloud_ids.dartsync_linked_album.dart,服务于跨领域服务的通用功能——例如 TimelineService 就依赖 utils/event_stream.dart 中的 EventStream.shared 广播 TimelineReloadEvent(见 timeline.service.dart#L139),实现"数据重载完成 → UI 刷新"的解耦通知。

Interfaces:数据操作契约的迁移去向

文档将 interfaces/ 描述为"定义数据操作契约的接口"。如前所述,该子目录在当前目录树中已不可见,接口职责由更外层的 mobile/lib/interfaces/(如 database.interface.dart)与迁移中的 mobile/lib/data/ 承接。这一变化在 基础设施层文档中得到了佐证:"Repositories here are migrating there one entity at a time; new data access should be added under lib/data/"。阅读旧版 Immich 移动端代码时若看到 domain/interfaces/ 路径,可据此对照当前结构。

服务如何暴露给表现层:Riverpod 提供者

文档 Usage 一节给出的消费方式是:领域服务通过根 providers/ 目录下的 Riverpod 提供者对外暴露,表现层通过 ref.watch(provider) 获取服务实例,而表现层绝不应直接使用仓库。当前代码完全印证了这一约定,以用户服务为例:

第一步,providers/infrastructure/user.provider.dart 组装依赖图——先把 OpenAPI 生成的 usersApi 包成 UserApiRepository,再注入 Drift 本地仓库与 StoreService,构造出 UserService

final userServiceProvider = Provider(
  (ref) => UserService(
    userApiRepository: ref.watch(userApiRepositoryProvider),
    userRepository: ref.watch(driftProvider).userRepository,
    storeService: ref.watch(storeServiceProvider),
  ),
);

第二步,表现层相关的提供者只依赖 userServiceProvider,不触碰任何仓库。例如 providers/user.provider.dart 中的 CurrentUserProvider 直接监听服务层的流:

CurrentUserProvider(this._userService) : super(null) {
  state = _userService.tryGetMyUser();
  streamSub = _userService.watchMyUser().listen((user) => state = user ?? state);
}

认证流程(providers/auth.provider.dart 中先 tryGetMyUser() 命中缓存、未命中再 refreshMyUser().timeout(...))与头像上传(providers/upload_profile_image.provider.dart 调用 createProfileImage)同样遵循这一路径。整个调用链呈现清晰的单向依赖:presentation → providers(组装)→ domain services → repositories(注入)→ data/infrastructure

依赖规则、迁移状态与扩展建议

将文档声明与当前源码对照后,可以归纳出三点事实与判断:

  1. 文档声明的是目标依赖方向。文档明确要求领域层不依赖表现层与基础设施层;而当前 domain/services/ 中的服务仍直接导入 infrastructure/repositories/ 下的具体仓库(如 user.service.dart#L7-L8 导入 user.repository.dartuser_api.repository.dart)。从源码结构看,这与基础设施层文档所述"逐实体迁移至 lib/data/"的过渡期一致:领域服务目前注入的是具体仓库类型而非抽象接口,接口抽象正在向 lib/data/ 收敛。
  2. 模型层是稳定的核心资产models/ 下的 Freezed 模型、带类型约束的 StoreKey、固定顺序的 AvatarColor 都直接关联本地持久化格式,修改时须遵守代码注释中写明的兼容性约束(枚举顺序不可变、等值比较语义等)。
  3. 扩展新业务的推荐路径。按文档给出的分层约定:业务算法写入 domain/services/ 的 Service 并通过构造函数注入依赖(参照 UserServiceTimelineService 的形态);持久化类型放入 domain/models/(Freezed + 值语义);服务在 providers/ 注册 Provider 后再供表现层 ref.watch 使用;而新的数据访问按基础设施层文档的指引落在 lib/data/ 而非 infrastructure/repositories/

小结

mobile/lib/domain/ 是 Immich 移动端业务逻辑的单一权威来源:models/ 提供与持久化格式绑定的不可变数据模型,services/ 承载缓存策略、分页窗口、同步编排等业务算法,utils/ 提供跨服务复用的流与迁移工具;Riverpod 提供者层负责把注入装配好的服务暴露给 UI,同时挡住表现层对数据仓库的越级访问。理解这一层时,建议以 README 的分层约定为纲,以 UserService/StoreService/TimelineService 三个实现为样本,并留意 interfaces/infrastructure/repositories/lib/data/ 迁移的过渡状态,即可准确把握当前代码的阅读路径与扩展规范。

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