关于 service-boundary-and-native
面向 Flutter 开发的工程技能,指导将平台副作用隔离在注入式接口之后。重点包括类型化结果、Riverpod 依赖组装、确定性 Fake 和受控的原生边界。
主要功能
- 01用注入式接口隔离副作用
- 02以类型化结果表示失败
- 03通过 Riverpod Provider 组装依赖
- 04手写遵循契约的 Fake
- 05集中管理 MethodChannel
- 06检查 Flavor 组合根
适用场景
- 01设计可测试的 Flutter 服务边界
- 02避免功能代码直接依赖 SDK
- 03无需真实 SDK 测试失败路径
- 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.dartservice_boundary.dartreferences/multi-flavor.mdnative-channels.mdservice-interface.mdscripts/check-flavor-graph.shcheck-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。
不可妥协的规则
- **每个副作用都必须通过注入的接口实现,调用处绝不能直接依赖具体 SDK。**功能、通知器和仓库依赖接口;应用只在一个实时实现类中导入真正的 SDK。原因:只保留一个可替换的接缝,在一个地方隔离平台差异,并确保 SDK 绝不会触及纯代码。
- **只对无法在
flutter test中运行的部分进行抽象。**一个接口 + 一个真实实现 + 一个伪实现 = 合理。一个接口 + 一个实现 + 没有伪实现 = 删除该接口。原因:为“对称”而设的接口毫无用处,只会掩盖真正的风险所在(不可测试的 I/O)。 - **接口签名只能引用值类型。**使用
DateTime、领域值对象或封装结果的密封类型;绝不使用插件类型,也不使用dart:io符号。原因:这样任何层都能引用接口名称,而 SDK 则被限制在一个类中。 - **每个方法都必须返回有类型的结果——失败属于控制流,不能吞掉异常。**将取消、空结果、目标不可用建模为封装结果的密封类型或
Result<T, F extends Failure>;绝不要抛出后又忘记处理异常。原因:Dart 不要求声明throws,因此未处理的失败会悄无声息地消失。Result/Failure的基础结构请参阅error-handling-typed-results。 - **provider 在被覆盖之前必须抛出
UnimplementedError——provider 本身就是 DI。**不使用get_it、服务定位器或第二个容器,也不在 provider 函数体中编写业务逻辑。原因:遗忘接线会导致明显的启动失败,而不是静默产生空数据或在导入时打开实时 SDK。 - 在组合根中为每个接口恰好接入一个实时实现。
main(每种 flavor)是唯一可以构造实时 SDK 的地方;app.dart和它下面的所有 widget 都不应了解接线细节。原因:组合根是唯一决定如何接入真实世界的接缝。 - **测试必须安装遵守契约的手写伪实现——优先使用伪实现,而不是 mock。**伪实现的失败路径必须能够触发;只会成功的伪实现是在美化理想路径。使用
implements(而非extends),这样新增接口方法时就会导致构建失败。原因:通过的测试必须反映真实行为,包括它要防范的失败。 - **通过
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。 - **改变持久状态的服务必须接入唯一写入路径——先持久化,再发布。**改变持久状态的服务必须由仓库方法调用,并在任何状态重新发布之前完成提交(一个事务)。原因:流程中途崩溃时,绝不能留下“已确认但未持久化”的状态。
- **所有 MethodChannel 都必须位于同一个
lib/native/目录中;其他地方不得创建 MethodChannel。**无论功能运行得多好,只要在 widget、repository 或 Notifier 中创建 channel,就是缺陷。原因:平台通道是应用中最难测试的代码,必须集中在一处。 - **跨语言共享格式必须是带版本的契约,双方各有唯一负责人,并在同一个提交中修改双方实现,且由
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 机制;无论哪种方式,接口都是衔接点。
参考资料
- Flutter — 应用架构指南 和 架构建议(强烈建议使用依赖注入)。
- Flutter — 构建风味版本(Android) 和 iOS 构建风味版本。
- Flutter — 编写自定义平台专属代码(MethodChannel)。
- Riverpod — 测试你的 providers 和 Riverpod 3.0 有哪些新变化。
- pub.dev —
clock(时间衔接点Clock/withClock)、provider、mocktail(仅用于一次性桩)、home_widget(仅用于数据桥接)。