mcpField
S
service-boundary-and-native

service-boundary-and-native

github.com/zakariaf开发63152 次安装5 周前更新
代码仓库

关于 service-boundary-and-native

面向 Flutter 开发的工程技能,指导将平台副作用隔离在注入式接口之后。重点包括类型化结果、Riverpod 依赖组装、确定性 Fake 和受控的原生边界。

主要功能

  1. 01用注入式接口隔离副作用
  2. 02以类型化结果表示失败
  3. 03通过 Riverpod Provider 组装依赖
  4. 04手写遵循契约的 Fake
  5. 05集中管理 MethodChannel
  6. 06检查 Flavor 组合根

适用场景

  1. 01设计可测试的 Flutter 服务边界
  2. 02避免功能代码直接依赖 SDK
  3. 03无需真实 SDK 测试失败路径
  4. 04管理原生通道和应用 Flavor

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/zakariaf/flutter-skills --skill service-boundary-and-native

安装后在 Claude Code 中输入/service-boundary-and-native确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

examples/
native_gateway.dart
service_boundary.dart
references/
multi-flavor.md
native-channels.md
service-interface.md
scripts/
check-flavor-graph.sh
check-service-boundaries.sh

详细介绍

服务边界与原生

每项平台能力——读取“当前时间”、分享、记录事件、获取远程标志、调用 MethodChannel——都会以注入接口的形式穿过应用的各层。接口只命名值类型;具体 SDK 只存在于一个类中;组合根(main)接入一个实际实现;测试接入一个确定性伪实现。**只抽象无法在测试中运行的部分,不多不少。**这条接缝能让功能保持纯粹、SDK 可替换,并让整个应用可以在无头环境中测试。

请根据当前任务阅读相应参考文档:

  • references/service-interface.md — 类型化结果与 bool/throw、穷尽性、@useResult、手写环境伪实现、箭头回调丢弃 Future 的漏洞、单一写入路径。
  • references/native-channels.md — MethodChannel 的放置位置、跨语言契约所有权表、版本化文件与 shared_preferences 的陷阱、原生快速路径的独立性、往返 integration_test。
  • references/multi-flavor.md — 逐行对应的组合根、优先使用构建风味而非运行时检测、按风味固定插件版本、禁止依赖的 CI 图门禁。

提交 PR 前运行 scripts/check-service-boundaries.sh 和 scripts/check-flavor-graph.sh。

不可妥协的规则

  1. **每个副作用都必须通过注入的接口实现,调用处绝不能直接依赖具体 SDK。**功能、通知器和仓库依赖接口;应用只在一个实时实现类中导入真正的 SDK。原因:只保留一个可替换的接缝,在一个地方隔离平台差异,并确保 SDK 绝不会触及纯代码。
  2. **只对无法在 flutter test 中运行的部分进行抽象。**一个接口 + 一个真实实现 + 一个伪实现 = 合理。一个接口 + 一个实现 + 没有伪实现 = 删除该接口。原因:为“对称”而设的接口毫无用处,只会掩盖真正的风险所在(不可测试的 I/O)。
  3. **接口签名只能引用值类型。**使用 DateTime、领域值对象或封装结果的密封类型;绝不使用插件类型,也不使用 dart:io 符号。原因:这样任何层都能引用接口名称,而 SDK 则被限制在一个类中。
  4. **每个方法都必须返回有类型的结果——失败属于控制流,不能吞掉异常。**将取消、空结果、目标不可用建模为封装结果的密封类型或 Result<T, F extends Failure>;绝不要抛出后又忘记处理异常。原因:Dart 不要求声明 throws,因此未处理的失败会悄无声息地消失。Result/Failure 的基础结构请参阅 error-handling-typed-results。
  5. **provider 在被覆盖之前必须抛出 UnimplementedError——provider 本身就是 DI。**不使用 get_it、服务定位器或第二个容器,也不在 provider 函数体中编写业务逻辑。原因:遗忘接线会导致明显的启动失败,而不是静默产生空数据或在导入时打开实时 SDK。
  6. 在组合根中为每个接口恰好接入一个实时实现。main(每种 flavor)是唯一可以构造实时 SDK 的地方;app.dart 和它下面的所有 widget 都不应了解接线细节。原因:组合根是唯一决定如何接入真实世界的接缝。
  7. **测试必须安装遵守契约的手写伪实现——优先使用伪实现,而不是 mock。**伪实现的失败路径必须能够触发;只会成功的伪实现是在美化理想路径。使用 implements(而非 extends),这样新增接口方法时就会导致构建失败。原因:通过的测试必须反映真实行为,包括它要防范的失败。
  8. **通过 clockProvider 注入时钟(来自 package:clock 的 Clock)——“现在”必须只有一个来源。**view、Notifier、repository 或纯核心中都不能直接使用 DateTime.now() / math.Random();功能代码读取 ref.read(clockProvider).now(),纯核心则接收 Clock 或使用环境中的 clock。绝不自定义 ClockService/SystemClock。原因:通过覆盖为 Clock.fixed(...),可以确定性地测试日期、连续记录天数和过期逻辑。注入的 Clock 请参阅 value-objects-money-and-units。
  9. **改变持久状态的服务必须接入唯一写入路径——先持久化,再发布。**改变持久状态的服务必须由仓库方法调用,并在任何状态重新发布之前完成提交(一个事务)。原因:流程中途崩溃时,绝不能留下“已确认但未持久化”的状态。
  10. **所有 MethodChannel 都必须位于同一个 lib/native/ 目录中;其他地方不得创建 MethodChannel。**无论功能运行得多好,只要在 widget、repository 或 Notifier 中创建 channel,就是缺陷。原因:平台通道是应用中最难测试的代码,必须集中在一处。
  11. **跨语言共享格式必须是带版本的契约,双方各有唯一负责人,并在同一个提交中修改双方实现,且由 integration_test 验证。**原因:跨越语言边界时,重命名键值不会被编译器、分析器或单元测试发现——只有真实的往返测试才能验证。

接口及其 Provider(Riverpod DI)

接口简洁、采用值类型,并返回类型化结果。Provider 则是一个轻量的抛出型连接层。

// lib/services/share_service.dart — value types only, no plugin symbol.
sealed class ShareResult { const ShareResult(); }
final class Shared extends ShareResult { const Shared(); }
final class ShareDismissed extends ShareResult { const ShareDismissed(); }
final class ShareUnavailable extends ShareResult {
  const ShareUnavailable(this.code); // a stable code, never a localized string
  final String code;
}

abstract interface class ShareService {
  Future<ShareResult> share(String text); // a dismiss/unavailable is data, not a throw
}
// lib/services/service_providers.dart — the Provider IS the DI. It throws until
// the composition root overrides it, so a forgotten wiring fails loudly at startup.
final shareServiceProvider = Provider<ShareService>(
  (ref) => throw UnimplementedError('override shareServiceProvider in main.dart'),
);

最基础且规范的边界是时间——到处读取时间,但绝不使用 DateTime.now()。唯一的时间类型是 package:clock 中的 Clock,通过 clockProvider 注入。与基于 SDK 的端口不同,时钟 Provider 可以安全地默认为真实时钟(const Clock() 不会开启 I/O,也不会访问 SDK),因此运行时无需覆盖;测试中则使用 Clock.fixed(...) 覆盖。不要定义自制的 ClockService/SystemClock。

import 'package:clock/clock.dart';

// The ONE time seam. Feature/Notifier code reads ref.watch(clockProvider).now();
// pure core takes a Clock param or uses the ambient `clock` (testable via withClock).
final clockProvider = Provider<Clock>((ref) => const Clock());

在组合根中使用一个真实实现

main 是唯一构造真实 SDK 的位置。具体 SDK 的导入只出现在真实实现类中。

// lib/main.dart — the composition root. app.dart below is wiring-blind.
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(ProviderScope(
    overrides: [
      shareServiceProvider.overrideWithValue(const OsShareService()), // the ONE live impl
      // clockProvider self-defaults to const Clock() — no override needed outside tests.
    ],
    child: const App(),
  ));
}

**多包(工作区)时:**接口放在共享基础包中;真实实现和 main 放在应用包中。对于单包应用,三者都是 lib/ 下的文件夹。参见 project-structure-and-packages。

多 Flavor 时:每个 Flavor 提供一个轻量的 main_<flavor>.dart,共用一个不区分 Flavor 的 app.dart。每个 main 都以相同顺序覆盖相同的 Provider 列表——只有每项右侧的实现不同——因此两个文件可以逐行对比。使用真实的构建 Flavor(Android 产品 Flavor + iOS scheme),绝不在运行时检测。参见 references/multi-flavor.md。

使用 Fake,而不是 Mock

手写 Fake 来 implements 接口,并模拟现实世界出错的情况。不使用 Mock 框架;不使用代码生成。

// lib/services/testing/fake_share_service.dart
final class FakeShareService implements ShareService {
  FakeShareService(this._next);
  final ShareResult _next;
  int calls = 0;
  factory FakeShareService.succeeding() => FakeShareService(const Shared());
  factory FakeShareService.unavailable() => FakeShareService(const ShareUnavailable('no_target'));

  @override
  Future<ShareResult> share(String text) async {
    calls++;
    return _next; // resolves instantly — no plugin, no OS sheet; failure path reachable
  }
}

时钟无需手写 Fake——package:clock 提供了 Clock.fixed(...)(纯代码可使用 withClock):

// test — no emulator, no network, no real SDK. ProviderContainer.test() auto-disposes.
test('sharing an unavailable target surfaces a code, never throws', () async {
  final container = ProviderContainer.test(overrides: [
    shareServiceProvider.overrideWithValue(FakeShareService.unavailable()),
    clockProvider.overrideWithValue(Clock.fixed(DateTime.utc(2026, 7, 21))),
  ]);
  final result = await container.read(shareServiceProvider).share('hello');
  expect(result, isA<ShareUnavailable>());
});

原生边界

MethodChannel 应隔离在 lib/native/ 中,由轻量的 Gateway 封装,并通过相同的可注入接口接缝暴露。(Gateway 指对特定插件/SDK 或 MethodChannel 的轻量封装;Service 指你定义的能力接口——参见 naming-conventions。)如果延迟要求如此,应确保原生端在 Dart 引擎未运行时也能工作。

// lib/native/item_widget_bridge.dart — the ONLY MethodChannel owner for this feature.
class ItemWidgetBridge {
  static const _channel = MethodChannel('app/item_widget');
  Future<void> publish(Map<String, Object?>? contractJson) =>
      _channel.invokeMethod('publish', contractJson);
}

// lib/native/item_widget_contract.dart — SOLE Dart owner of the shared format.
// Mirror: android/.../ItemWidgetContract.kt  (edit BOTH files in the same commit).
const int kItemWidgetContractVersion = 1;
Map<String, Object?> itemToContractJson(String title, DateTime updatedAtUtc) => {
      'v': kItemWidgetContractVersion, // a versioned file we own, not shared_preferences
      'title': title,
      'updatedAtUtc': updatedAtUtc.toIso8601String(),
    };

每条会更改镜像值的写入路径都必须在写入过程中重新发布,不能留待之后由人记得补上——删除/清除操作重新发布的要求与保存完全相同。参见 references/native-channels.md,了解所有权表、shared_preferences 的陷阱,以及用于验证镜像的往返测试。

反模式

  • **从功能模块、Notifier 或仓库调用插件/SDK。**真实 SDK 只允许在其真实实现中导入——绝不出现在 Widget、控制器或仓库的代码体内。
  • 在视图、Notifier、仓库或纯核心中访问 DateTime.now() / math.Random()。“现在”只有一个来源,即注入的时钟;其他任何方式都会破坏确定性时间测试。
  • Provider 默认使用真实服务而不是抛出异常——这会掩盖缺少覆盖的问题,还可能在导入时开启真实 I/O。
  • 使用 Mock 框架伪造边界,或使用总是成功、从不测试失败路径的 Fake。
  • onTap: () => service.doThing()——箭头闭包会将 Future 返回给 VoidCallback,导致 Future 及其错误被丢弃,且没有任何 lint 能发现此问题。应改为调用返回 void 的处理函数,并在其中使用 unawaited(...) 和 catchError。参见 references/service-interface.md。
  • 在 lib/native/ 外创建 MethodChannel,或只在语言边界的一侧重命名契约键,而不同一提交修改另一侧。
  • 在 Provider 中编写业务逻辑,或在 Riverpod 之外再使用第二个 DI 容器(get_it、package:provider)。
  • **在 Service 中编写面向用户的文案。**边界应返回带有稳定代码的类型化失败;功能层负责将其映射为本地化文案。
  • 在功能代码中使用 #if FLAVOR / String.fromEnvironment('FLAVOR') 分支,或让二进制在运行时检测其生态并切换 SDK——Flavor 只能在组合根处分化。

完成定义

  • 边界是一个 abstract interface class;其签名仅引用值类型,不包含插件类型或 dart:io 符号。
  • 该接口存在的唯一原因是它无法在测试中运行(有真实实现和 fake);不得仅为“对称”而添加接口。
  • 每个方法都返回类型明确的结果(sealed outcome / Result<T, F extends Failure>);取消、不可用和空值属于控制流,而不是抛出的异常;failure 携带稳定的代码,而不是本地化字符串。
  • Provider 在被覆盖前会抛出 UnimplementedError;使用方通过 ref.watch/ref.read 使用它;不使用 get_it,不创建第二个容器,Provider 中不包含逻辑。
  • 每个接口在 main 中(按 flavor)只接入一个有效实现;app.dart 不导入任何具体 SDK;多个 flavor 的 main 必须逐行一致。
  • 手写的 fake 使用 implements 实现该接口,遵守契约(可触发 failure 路径),并通过 overrideWithValue 安装,不使用 mock 框架。
  • 从 view、Notifier、repository 或纯核心代码中均无法访问 DateTime.now() / math.Random();时间只能通过注入的 Clock(clockProvider)传入,不得另行创建 ClockService。
  • 可变更状态的服务必须走唯一的写入路径:先持久化,再发布。
  • 所有 MethodChannel 都位于 lib/native/ 下;只有有效实现可以导入 SDK;scripts/check-service-boundaries.sh 检查通过。
  • 任何跨语言共享格式都必须有版本、由两端各自唯一负责,在同一个提交中修改两端,并由 integration_test 覆盖往返测试。
  • 如果存在 flavor,禁止依赖的 CI 图检查门禁(scripts/check-flavor-graph.sh)必须证明每个 flavor 都没有链接其禁止使用的 SDK。

相关技能

  • 有关 Notifier/AsyncNotifier 的规范,以及使用这些 Provider 时 watch/read/listen 的区分,请参阅 state-management-riverpod。
  • 有关所有边界方法都会返回的 sealed Result/Failure 主干,请参阅 error-handling-typed-results。
  • 有关 void 处理器模式(用于避免箭头回调丢弃 Future 的问题)以及 mounted/BuildContext 防护,请参阅 async-safety。
  • 有关承载这些覆盖项的组合根顺序(错误处理器、设置、DI 覆盖),请参阅 app-startup-and-bootstrap。
  • 有关单包与 workspace 布局中接口、有效实现和 main 的位置,请参阅 project-structure-and-packages。
  • 有关如何评估并固定每个有效实现背后插件的版本,请参阅 dependency-hygiene。
  • 有关优先使用 fake 而非 mock,以及验收门禁的做法,请参阅 testing-strategy。
  • 有关注入的 Clock(package:clock)以及这些签名所引用的规范值类型,请参阅 value-objects-money-and-units。
  • 有关 Service(自行定义的能力接口)与 Gateway(对特定插件/SDK 或 MethodChannel 的封装)的后缀规则,请参阅 naming-conventions。
  • 有关应用于广告/计费边界的策略(包括为何必须在初始化广告 SDK 前恢复 entitlement),请参阅 ads-and-iap-monetization。
  • 有关分享/文件选择器 Gateway 及其 fake,请参阅 data-export-and-restore;有关每个新插件附带的权限和商店声明,请参阅 release-and-store-shipping。

Provider / ChangeNotifier 附录

使用官方 Flutter 指南中的技术栈时,规则相同;只有接线机制有所不同。规则 1–4、7–11 完全相同。接口和类型化结果保持不变。

  • DI 在组合根处使用 package:provider,注入接口。 这里没有“先抛出异常,直到被覆盖”的惯用模式,因此应在根处提供真实实现,绝不要在其下方构造 SDK:
void main() {
  runApp(MultiProvider(
    providers: [
      Provider<Clock>.value(value: const Clock()), // package:clock, not a bespoke ClockService
      Provider<ShareService>.value(value: const OsShareService()),
    ],
    child: const App(),
  ));
}
  • ViewModel ChangeNotifier 通过构造函数接收接口,绝不访问全局对象,并基于不可变状态公开意图方法:
final class ShareViewModel extends ChangeNotifier {
  ShareViewModel(this._share, this._clock);
  final ShareService _share;
  final Clock _clock;
  // intent method; keeps the single write path and the injected clock
}
  • 测试时,将组件包装在 Provider<Interface>.value(value: FakeShareService...()) 中进行覆盖(或直接将假对象传入 ViewModel 构造函数)。使用同一个手写假对象,并覆盖相同的失败路径。
  • 不要再混用 get_it/injectable 作为第二个容器。 只使用一种 DI 机制;无论哪种方式,接口都是衔接点。

参考资料

评论

支持 Markdown