返回列表

2026年6月6日 · 约 9 分钟读完

Core-Variant Architecture:一种多 App 架构实践记录

把稳定业务语义沉到 Core,把具体产品实现留给 Variant,在业务效果一致的前提下保持工程独立性。

背景

有一类移动端产品会遇到这样的工程问题:多个 App 的核心业务效果高度一致,例如都需要完成文件选择、处理任务提交、状态同步、结果展示、记录管理、权益校验、文件保存等流程;但它们又不能只是同一套工程换个名字、图标和 bundle id。

如果每次都直接新建一个项目,然后把旧代码搬过去改一改,短期看很快,长期会出现几个问题:

  • 业务逻辑散落在页面、网络 action、SDK manager 和全局配置里,后续每个分支都要重复修 bug。
  • 多个 App 之间的代码结构、资源路径、依赖链路和运行时行为过于接近,很难解释它们的独立性。
  • 产品身份容易混用,例如 API appId、Firebase、权益/订阅商品、法务链接、签名 profile 没有完全对应当前产品。

我现在更倾向的做法是:先把稳定业务语义抽成一个足够薄的 Core,再从 Core 派生多个 Variant。Core 复用的是业务合同和业务效果,Variant 负责具体实现路径和产品身份。

架构命名

我把这种做法称为 Core-Variant Architecture(核心-变体架构)

这里的 Core 指稳定的业务核心,包括 contracts(能力接口)、models(业务模型)、workflow(业务流程)和 app controller。它负责定义业务需要什么,以及这些业务能力应该以什么语义被使用。

Variant 指具体产品变体,包括 UI、路由、状态管理、网络、存储、资源、SDK、权益/订阅配置、平台配置和产品身份。它负责决定当前产品具体怎么实现,以及如何独立打包、验证和维护。

一句话概括:

Core-Variant Architecture 复用的是业务能力和业务结果,不复用具体产品实现。

Core-Variant Architecture 架构示意图

为了贴近常见工程目录,下面会继续用 base 表示 Core,用 variant 表示具体变体。

核心思想

这套架构的核心不是“多做几层目录”,而是把两个问题拆开:

  1. 业务需要什么。
  2. 当前产品具体怎么实现。

base 只回答第一个问题。它定义稳定的业务能力、模型和流程,例如:

  • 用户可以提交一次处理任务。
  • 用户可以提供输入文件。
  • 用户可以查询任务状态。
  • 用户可以查看和删除处理记录。
  • 用户可以获取权益配置并完成校验。
  • 用户可以保存处理结果。

variant 回答第二个问题。它决定使用什么网络库、状态管理、路由、存储、资源加载、平台 SDK、分析 SDK、视觉组件和平台配置。

一个简化后的目录大概是这样:

lib/
  core/
    contracts/
    models/
    workflow/
  app/
    app_capabilities.dart
    processing/
    records/
    entitlements/
  apps/
    current/
  variants/
    api/
    storage/
    files/
  features/

core 是最稳定的部分,variants 是最可替换的部分,app 位于两者中间,负责把业务能力组织成页面可以调用的动作。

Base 应该放什么

base 适合放稳定业务语义,不适合放具体实现。

比如文件处理能力可以定义成一个 contract:

abstract interface class ProcessingCapability {
  Future<ProcessingSubmission> submitDocument(
    DocumentProcessingRequest request,
  );
}

final class DocumentProcessingRequest {
  const DocumentProcessingRequest({
    required this.localFilePath,
    required this.instructions,
    this.presetId,
  });

  final String localFilePath;
  final String instructions;
  final int? presetId;
}

这个接口表达的是“提交一个文档处理请求”。它没有暴露 Dio、Chopper、http、multipart、SDK 对象、Widget、路由对象,也没有暴露后端原始 JSON。

这点很重要。因为一旦 contract 里出现具体实现细节,未来每个 variant 都会被迫继承这种实现。

base 里通常可以放:

  • contracts:稳定的能力接口。
  • models:业务语义模型。
  • workflow:纯业务流程,前提是它不依赖 UI、SDK、网络库和平台插件。
  • app controllers:页面和 capability 之间的业务动作层。
  • AppCapabilities:当前 App 能力集合。
  • 边界测试:防止 core 或 feature 重新直接依赖具体实现。

base 里不应该放:

  • 网络 client。
  • SDK 调用。
  • 平台插件。
  • 状态管理框架。
  • 路由框架。
  • 具体产品名、bundle id、法务链接、权益/订阅商品 id。
  • 某个 variant 的资源加载策略。
  • 为了减少重复而共享的具体实现代码。

AppCapabilities 的作用

AppCapabilities 是一个很关键的组合点。它不是 service locator 的替代品,而是一份稳定业务能力的装配清单。

示例:

final class AppCapabilities {
  const AppCapabilities({
    required this.processing,
    required this.recordList,
    required this.recordDelete,
    required this.entitlementCatalog,
    required this.entitlementVerification,
    required this.filePicker,
    required this.fileSave,
  });

  final ProcessingCapability processing;
  final RecordListCapability recordList;
  final RecordDeleteCapability recordDelete;
  final EntitlementCatalogCapability entitlementCatalog;
  final EntitlementVerificationCapability entitlementVerification;
  final FilePickerContract filePicker;
  final FileSaveCapability fileSave;
}

页面不应该知道这些 capability 的具体实现来自哪里。页面最多只调用 app controller:

final result = await ProcessingController.current().submitDocumentFromFile(
  localFilePath: filePath,
  instructions: instructions,
  presetId: presetId,
);

当前产品的 composition root(组合根)再负责把能力装起来:

AppCapabilities buildCurrentCapabilities() {
  final api = ApiProcessingAdapter();

  return AppCapabilities(
    processing: api,
    recordList: ApiRecordAdapter(),
    recordDelete: ApiRecordAdapter(),
    entitlementCatalog: ApiEntitlementAdapter(),
    entitlementVerification: ApiEntitlementAdapter(),
    filePicker: PluginFilePicker(),
    fileSave: LocalFileSaveAdapter(),
  );
}

未来某个 variant 可以换成另一套 adapter,而页面和 core 不需要感知。

Variant 应该做什么

variant 的目标不是只换皮。一个成熟的 variant,应该在多个真实工程主轴上有自己的实现选择。

常见差异轴包括:

  • DI 和 composition root(组合根)。
  • 状态管理。
  • 路由。
  • 网络库和 DTO 解析方式。
  • 本地存储。
  • 文件选择、校验和保存策略。
  • 资源格式和资源加载方式。
  • 权益/订阅流程。
  • analytics/event pipeline(埋点和事件上报链路)。
  • CI、签名和导出链路。

换 display name、图标、主色调、启动图,这些只能算产品包装,不应该算架构差异。

一个 variant 的最低目标是:它能构造一套完整的 AppCapabilities,并且能独立解释自己的产品身份和实现路径。

业务一致不等于代码一致

这里有一个容易混淆的点:多个 variant 的业务效果可以一致,但代码实现不需要一致,也不应该被强制要求一致。

业务一致应该用验收场景定义,例如:

  • 新用户可以启动并完成初始化。
  • 用户可以选择输入文件。
  • 用户可以提交处理任务。
  • 用户可以看到任务进度。
  • 用户可以查看结果和处理记录。
  • 用户可以删除处理记录。
  • 用户可以加载权益配置、完成校验并刷新用户状态。
  • 用户可以查看隐私政策、服务条款、支持入口。

这些是业务效果。至于内部用 GetX 还是 Riverpod,用 Dio 还是 Chopper,用 SharedPreferences 还是 Hive,不应该影响业务验收。

所以这套架构的关键是:用同一组业务场景验收不同 variant,而不是强迫不同 variant 共享同一套实现。

渐进式迁移比一次性重写更稳

从普通项目抽 base 时,最稳的方式不是马上替换所有技术栈,而是先把旧实现包进 variant adapter。

例如旧代码可能是:

final result = await LegacyAction.submitProcessingJob(
  file: file,
  instructions: instructions,
);

迁移后先变成:

final result = await processing.submitDocument(
  DocumentProcessingRequest(
    localFilePath: file.path,
    instructions: instructions,
  ),
);

旧的 LegacyAction 不需要立刻删除,可以先藏在 adapter 后面:

final class ApiProcessingAdapter implements ProcessingCapability {
  @override
  Future<ProcessingSubmission> submitDocument(
    DocumentProcessingRequest request,
  ) async {
    final response = await LegacyAction.submitProcessingJob(
      file: File(request.localFilePath),
      instructions: request.instructions,
      presetId: request.presetId,
    );

    return ProcessingSubmission(taskId: response.taskId);
  }
}

这样 base 先能跑,业务行为也更容易对齐。后续 variant 再决定是否替换网络库、DTO、错误处理、缓存或文件传输实现。

边界测试非常重要

这种架构如果没有测试约束,很容易退化。开发者一着急,就会在页面里重新 import API action,或者在 core 里引入 Flutter、GetX、SDK、plugin。

建议至少保留几类架构测试:

  • lib/core 不 import Flutter、GetX、网络库、SDK、plugin、features、variants。
  • feature 和 app controller 不直接调用旧 action。
  • 非 composition root 不 import concrete variants。
  • 页面不直接使用 SDK 结果对象或 API DTO。
  • 当前 variant 能构造完整 AppCapabilities

测试可以很朴素,直接扫描源码:

test('core imports stay clean', () {
  final forbidden = [
    'package:flutter/',
    'package:get/',
    'package:dio/',
    'package:in_app_purchase/',
    'package:example_app/variants',
  ];

  for (final file in dartFiles('lib/core')) {
    final text = file.readAsStringSync();
    for (final pattern in forbidden) {
      expect(text.contains(pattern), isFalse, reason: file.path);
    }
  }
});

这种测试不复杂,但能防止架构边界在后续需求中慢慢失效。

最容易犯的错误

把 base 做得太厚

base 变厚通常是因为想复用代码。比如多个 variant 都要提交文件处理任务,于是把 multipart、token refresh、Dio client 都放进 base。

短期看减少了重复,长期看会让每个 variant 都被同一套实现绑住。

base 应该复用语义,不应该复用具体实现。

把 UI 差异误认为实现差异

换图标、换颜色、换几张资源,不等于新的工程实现。真正的 variant 差异,应该能从依赖、目录、composition root、资源策略、capability adapter、测试和 CI 中看出来。

在页面里继续写业务编排

页面应该处理输入、展示和局部 UI 状态。它不应该拼 API payload、解析后端 map、调用 SDK、处理权益凭证、写 token、判断后端状态码。

这些逻辑应该进入 app controller 或 variant adapter。

产品身份混用

这是多变体项目里风险最高的问题之一。一个产品里混进另一个产品的 Firebase、权益配置、legal URL、API appId 或 profile,会造成审核、账务、统计和线上排查的混乱。

产品身份应该有文档、有检查,也应该有 CI gate。

我会如何落地

如果从零开始做,我会按这个顺序:

  1. 冻结当前项目事实,列出现有业务能力和直接依赖点。
  2. 抽出 core contracts 和 core models。
  3. 建立 AppCapabilities 和 app controllers。
  4. 把旧实现包进 variants/current 或类似目录。
  5. 迁移页面调用点,每次只做一个 vertical slice。
  6. 增加边界测试。
  7. 写 base extraction summary。
  8. 从 base 切 variant 分支和 worktree。
  9. 先写 variant plan 和 identity matrix。
  10. 选择真实差异轴,逐步实现 capability adapter。
  11. 补 variant docs、preflight 和 parity tests。
  12. flutter testflutter analyzegit diff --check

每一步都应该能回答一个问题:这个改动是在稳定业务语义,还是在实现某个产品变体?如果回答不清楚,通常说明边界还没想明白。

什么时候应该停下来

不是所有逻辑都适合马上抽象。遇到这些情况,我会倾向于停下来确认,而不是猜:

  • 权益/订阅商品语义不清楚。
  • 后端接口含义不清楚。
  • token、签名、用户迁移、权益校验回调涉及线上状态。
  • 法务链接、隐私说明、支持入口没有最终值。
  • 需要改 bundle id、Team、profile、Firebase、entitlements(系统能力配置)。
  • 某个能力只服务一个产品,不确定是否应该进 base。

架构抽象的目标是降低长期风险,而不是制造新的不确定性。

总结

这套 Core-Variant 架构的核心价值,是把“稳定业务效果”和“具体工程实现”分开。

base 越薄,variant 越自由。
contract 越稳定,页面越不容易被实现细节污染。
identity 越清楚,产品越容易独立维护和解释。
测试越早建立,架构越不容易回退。

它不是一种追求层数的架构,也不是一种为了制造差异而制造差异的流程。它更像是一条工程纪律:业务语义可以共享,具体实现必须有边界;业务效果可以一致,产品身份和实现路径必须真实、清晰、可验证。