Immich 移动端 Domain 层解析:业务逻辑分层、核心模型与服务实现机制
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.dart、backup_config.dart、theme_config.dart 等 13 个配置模型)、人脸、OCR、记忆、时间线、标签等领域概念。模型统一使用 Freezed 生成不可变类,典型代表是 user.model.dart:
UserDto使用@Freezed(equal: false)声明,字段包含id、email、name、isAdmin、avatarColor、配额字段等,并提供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 时代演进的痕迹:
User、AuthUser、Partner三类手写模型与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.dart 在 StoreRepository 之上维护了一个 Map<int, Object?> 内存缓存:启动时 populateCache() 全量装载,运行期订阅 watchAll() 流增量更新(见 store.service.dart#L41-L52)。两个值得注意的实现细节:
put会先比较缓存值,未变化则跳过写盘(store.service.dart#L80-L86),避免无谓的数据库写入;- 键缺失且无默认值时抛出领域内定义的
StoreKeyNotFoundException异常,而不是让cast失败产生难以诊断的运行时错误。
TimelineService:分页缓冲的业务算法。 timeline.service.dart 展示了领域层承载真正复杂业务逻辑的一面。TimelineFactory 依据 TimelineOrigin 枚举(main、localAlbum、remoteAlbum、favorite、trash、person、map、search 等 17 种来源)构造统一的 TimelineService(见 timeline.service.dart#L21-L95)。TimelineService 内部维护一个"窗口缓冲区":监听 bucket(分组计数)流变化后自动重算总量并重置缓冲区;loadAssets(index, count) 在请求超出缓冲区时,按滚动方向(前滚/回滚)向外扩展加载窗口,用小批量请求填充到 kTimelineAssetLoadBatchSize 并预留反向余量,以避免每次小范围滚动都触发数据库查询(见 timeline.service.dart#L149-L175)。所有并发访问经由 AsyncMutex 串行化,保证 UI 读取与后台重载之间的一致性。这正是文档所说"服务包含业务逻辑并与仓库交互"的具体体现。
Utils:领域内通用工具
utils/ 现有四个文件:background_sync.dart、event_stream.dart、migrate_cloud_ids.dart、sync_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。
依赖规则、迁移状态与扩展建议
将文档声明与当前源码对照后,可以归纳出三点事实与判断:
- 文档声明的是目标依赖方向。文档明确要求领域层不依赖表现层与基础设施层;而当前
domain/services/中的服务仍直接导入infrastructure/repositories/下的具体仓库(如 user.service.dart#L7-L8 导入user.repository.dart与user_api.repository.dart)。从源码结构看,这与基础设施层文档所述"逐实体迁移至lib/data/"的过渡期一致:领域服务目前注入的是具体仓库类型而非抽象接口,接口抽象正在向lib/data/收敛。 - 模型层是稳定的核心资产。
models/下的 Freezed 模型、带类型约束的StoreKey、固定顺序的AvatarColor都直接关联本地持久化格式,修改时须遵守代码注释中写明的兼容性约束(枚举顺序不可变、等值比较语义等)。 - 扩展新业务的推荐路径。按文档给出的分层约定:业务算法写入
domain/services/的 Service 并通过构造函数注入依赖(参照UserService、TimelineService的形态);持久化类型放入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/ 迁移的过渡状态,即可准确把握当前代码的阅读路径与扩展规范。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00