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 复用的是业务能力和业务结果,不复用具体产品实现。

为了贴近常见工程目录,下面会继续用 base 表示 Core,用 variant 表示具体变体。
核心思想
这套架构的核心不是“多做几层目录”,而是把两个问题拆开:
- 业务需要什么。
- 当前产品具体怎么实现。
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。
我会如何落地
如果从零开始做,我会按这个顺序:
- 冻结当前项目事实,列出现有业务能力和直接依赖点。
- 抽出 core contracts 和 core models。
- 建立
AppCapabilities和 app controllers。 - 把旧实现包进
variants/current或类似目录。 - 迁移页面调用点,每次只做一个 vertical slice。
- 增加边界测试。
- 写 base extraction summary。
- 从 base 切 variant 分支和 worktree。
- 先写 variant plan 和 identity matrix。
- 选择真实差异轴,逐步实现 capability adapter。
- 补 variant docs、preflight 和 parity tests。
- 跑
flutter test、flutter analyze、git diff --check。
每一步都应该能回答一个问题:这个改动是在稳定业务语义,还是在实现某个产品变体?如果回答不清楚,通常说明边界还没想明白。
什么时候应该停下来
不是所有逻辑都适合马上抽象。遇到这些情况,我会倾向于停下来确认,而不是猜:
- 权益/订阅商品语义不清楚。
- 后端接口含义不清楚。
- token、签名、用户迁移、权益校验回调涉及线上状态。
- 法务链接、隐私说明、支持入口没有最终值。
- 需要改 bundle id、Team、profile、Firebase、entitlements(系统能力配置)。
- 某个能力只服务一个产品,不确定是否应该进 base。
架构抽象的目标是降低长期风险,而不是制造新的不确定性。
总结
这套 Core-Variant 架构的核心价值,是把“稳定业务效果”和“具体工程实现”分开。
base 越薄,variant 越自由。
contract 越稳定,页面越不容易被实现细节污染。
identity 越清楚,产品越容易独立维护和解释。
测试越早建立,架构越不容易回退。
它不是一种追求层数的架构,也不是一种为了制造差异而制造差异的流程。它更像是一条工程纪律:业务语义可以共享,具体实现必须有边界;业务效果可以一致,产品身份和实现路径必须真实、清晰、可验证。