自动注册
目标是少写重复装配代码。它只能在模块边界内工作,必须有 Marker、生命周期声明和诊断输出。
五件事,五种责任
新版 Core 的关键不是“多加几个抽象”,而是把 Unity 项目最容易失控的五件事拆成五种机制: 自动注册只减少装配代码,构造注入暴露依赖,能力接口限制职责,Scope 管生命周期, 诊断图让自动化可见。
最新版不应该只是“一堆模块手动装起来”。它的全局概念是消费侧的 GameComposition,框架只把这份配方变成可运行、可诊断、可释放的 FlowScopeRuntime。
| 概念 | 是谁拥有 | 负责什么 | 不负责什么 |
|---|---|---|---|
| GameComposition | 消费侧 | 声明项目用了哪些模块、项目服务、首个 Feature、启动前置条件。 | 不执行 Feature 生命周期,不释放资源。 |
| CompositionBuilder | 框架提供,消费侧调用 | 收集模块安装、服务注册、自动扫描规则和诊断信息。 | 不决定项目业务启动顺序。 |
| FlowScopeRuntime | 框架创建,消费侧持有 | 持有 RootScope、FeatureHost、Diagnostics,作为运行时总句柄。 | 不变成全能 GContext,不允许业务随便从它 Resolve 一切。 |
| FeatureHost | 框架 | 执行 Feature 切换、Scope 创建、生命周期和失败清理。 | 不写业务逻辑,不决定首个 Feature 是谁。 |
它们不是并列名词,而是一条链。少写注册不等于隐藏依赖;自动化越多,诊断越要硬。
目标是少写重复装配代码。它只能在模块边界内工作,必须有 Marker、生命周期声明和诊断输出。
目标是让依赖可读、可测、可替换。不要把自动注册变成业务代码到处 Resolve。
目标是限制职责权限。对象实现什么能力,才获得什么扩展方法;接口必须能复用。
目标是明确谁拥有对象、谁释放对象。Feature 临时对象不能留在 Root。
目标是让自动注册可审查。启动时能看见每个服务来自哪个模块、依赖谁、生命周期是什么。
消费侧知道装了哪些模块,不让框架猜启动顺序。
扫描本模块 Assembly 和 Marker,减少重复注册。
类依赖在类型定义处暴露,不靠内部 Resolve。
资源、订阅、临时状态进入本次 Feature 生命周期。
自动化结果可见,启动失败能定位到模块和依赖链。
消费侧仍然保留 CompositionRoot,但不再把注册散落在 MonoBehaviour 里。项目写一份 GameComposition,Bootstrap 只负责构建 Runtime 和进入首个 Feature。
public sealed class GameBootstrap : MonoBehaviour
{
private FlowScopeRuntime _runtime;
private async void Start()
{
var composition = new FishingGameComposition(
addressablesBackend,
uiRoot,
savePath);
_runtime = FlowScopeRuntime.Build(composition);
await _runtime.Features.SwitchAsync<MainMenuFeature>(
FeatureSwitchRequest.Replace(),
destroyCancellationToken);
}
private async void OnDestroy()
{
if (_runtime != null)
await _runtime.DisposeAsync();
}
}
public sealed class FishingGameComposition : IGameComposition
{
private readonly IResourceBackend _resourceBackend;
private readonly Transform _uiRoot;
private readonly string _savePath;
public FishingGameComposition(
IResourceBackend resourceBackend,
Transform uiRoot,
string savePath)
{
_resourceBackend = resourceBackend;
_uiRoot = uiRoot;
_savePath = savePath;
}
public void Configure(ICompositionBuilder app)
{
app.UseRoot(new ServiceContainer());
app.Install(new ResourceModule(_resourceBackend));
app.Install(new UIModule(_uiRoot));
app.Install(new SaveModule(_savePath));
app.Install(new FishingModule());
app.Services.RegisterInstance<IPlayerData>(new PlayerData());
app.Services.RegisterFactory<IFishingApi>(
s => new FishingApi());
app.Features.UseDefaultSwitchPolicy(FeatureSwitchPolicy.ReplaceOnly);
}
}
下面不是完整实现,而是接口和关键路径预览。重点是职责分离:自动注册、依赖、权限、生命周期、诊断各管一件事。
public interface IGameComposition
{
void Configure(ICompositionBuilder app);
}
public interface ICompositionBuilder
{
IServiceRegistry Services { get; }
IFeatureRegistry Features { get; }
IDiagnosticOptions Diagnostics { get; }
void UseRoot(IServiceScope root);
void Install(IModule module);
}
public sealed class FlowScopeRuntime : IAsyncDisposable
{
public IServiceScope Root { get; }
public IFeatureHost Features { get; }
public IDependencyDiagnostics Diagnostics { get; }
public static FlowScopeRuntime Build(IGameComposition composition)
{
var builder = new CompositionBuilder();
composition.Configure(builder);
return builder.BuildRuntime();
}
}
public enum ServiceLifetime
{
Root,
Stage,
Feature
}
[AttributeUsage(AttributeTargets.Class)]
public sealed class FeatureServiceAttribute : Attribute
{
public ServiceLifetime Lifetime { get; }
public FeatureServiceAttribute(
ServiceLifetime lifetime = ServiceLifetime.Feature)
{
Lifetime = lifetime;
}
}
public interface IModule
{
void Install(IServiceRegistry services);
}
public interface IServiceRegistry
{
void RegisterInstance<T>(T instance);
void RegisterFactory<T>(Func<IServiceProvider, T> factory);
IModuleScanBuilder Scan(Assembly assembly);
}
public sealed class StartFishingUseCase
{
private readonly IFishingSession _session;
private readonly IPlayerWallet _wallet;
private readonly IFeatureNavigator _navigator;
public StartFishingUseCase(
IFishingSession session,
IPlayerWallet wallet,
IFeatureNavigator navigator)
{
_session = session;
_wallet = wallet;
_navigator = navigator;
}
public async ValueTask ExecuteAsync(CancellationToken ct)
{
if (!_wallet.CanPay(_session.EntryCost))
return;
await _navigator.SwitchAsync<FishingFeature>(
FeatureSwitchRequest.Replace(), ct);
}
}
public interface IFeatureContext
{
IServiceProvider Services { get; }
IFeatureLifetime Lifetime { get; }
IResourceGroup Resources { get; }
CancellationToken CancellationToken { get; }
}
public interface INavigableFeatureContext : IFeatureContext
{
IFeatureNavigator Navigator { get; }
}
public interface IHasFeatureContext<out TContext>
where TContext : IFeatureContext
{
TContext Context { get; }
}
public interface ICanUseResources :
IHasFeatureContext<IFeatureContext> {}
public interface ICanRequestNavigation :
IHasFeatureContext<INavigableFeatureContext> {}
public static class FeatureCapabilityExtensions
{
public static ValueTask<IResourceHandle<T>>
LoadOwnedAsync<T>(
this ICanUseResources self,
string key)
where T : class
{
return self.Context.Resources.LoadAsync<T>(
key,
self.Context.CancellationToken);
}
public static ValueTask SwitchToAsync<TFeature>(
this ICanRequestNavigation self,
FeatureSwitchRequest request)
where TFeature : IFeature
{
return self.Context.Navigator.SwitchAsync<TFeature>(
request,
self.Context.CancellationToken);
}
}
public sealed class FeatureHost : IFeatureHost
{
private readonly IServiceScope _root;
private ActiveFeature _active;
public async ValueTask SwitchAsync<TFeature>(
FeatureSwitchRequest request,
CancellationToken ct)
where TFeature : IFeature
{
if (_active != null)
await _active.ExitAndDisposeAsync(ct);
var scope = _root.CreateScope();
var lifetime = new FeatureLifetime();
var resources = scope.GetRequiredService<IResourceService>()
.CreateGroup();
var context = new FeatureContext(
scope, lifetime, resources, ct, request.Args);
var feature = scope.GetRequiredService<TFeature>();
await feature.LoadAsync(context);
await feature.EnterAsync(context);
_active = new ActiveFeature(feature, context, scope);
}
}
public sealed class MainMenuFeature :
IFeature,
ICanUseResources,
ICanRequestNavigation
{
public INavigableFeatureContext Context { get; private set; }
IFeatureContext IHasFeatureContext<IFeatureContext>.Context
=> Context;
public async ValueTask LoadAsync(IFeatureContext context)
{
Context = (INavigableFeatureContext)context;
var panel = await this.LoadOwnedAsync<GameObject>(
"UI/MainMenuPanel");
Context.Lifetime.Add(panel);
}
public ValueTask EnterAsync(IFeatureContext context)
=> ValueTask.CompletedTask;
public ValueTask ExitAsync(IFeatureContext context)
=> ValueTask.CompletedTask;
}
自动注册必须能回答三个问题:谁注册了它,它依赖谁,它属于哪个生命周期。
public interface IDependencyDiagnostics
{
RegistrationReport BuildRegistrationReport();
DependencyGraph BuildDependencyGraph(Type rootType);
}
public sealed record ServiceRegistration(
Type ServiceType,
Type ImplementationType,
ServiceLifetime Lifetime,
string ModuleName,
IReadOnlyList<Type> Dependencies);
public sealed record RegistrationReport(
IReadOnlyList<ServiceRegistration> Services,
IReadOnlyList<string> Warnings);
FishingModule
IFishingSession -> FishingSession [Feature]
depends on:
IPlayerData
IFishingApi
IFishingEconomy -> FishingEconomy [Feature]
depends on:
IPlayerWallet
Tables
MainMenuFeature
depends on:
IUIService
IResourceService
IFeatureNavigator
Warnings
EventScratchPanel resolves PlayerData from RootScope.
ScratchTicketManager registered as Root but implements IDisposable.
这套设计不是“自动注册万能”,也不是“能力接口万能”。每种机制只解决一个问题。
| 机制 | 负责 | 不负责 | 容易误用 |
|---|---|---|---|
| 自动注册 | 减少重复装配代码。 | 不负责说明业务依赖。 | 扫全项目、无 Marker、无生命周期、无诊断。 |
| 构造注入 | 暴露类的真实依赖。 | 不负责减少注册代码。 | 构造函数塞进万能 Context 或 IServiceProvider。 |
| 能力接口 | 限制对象能做什么。 | 不负责业务依赖注入。 | 让所有业务对象都实现框架接口。 |
| Scope | 创建、隔离、释放生命周期对象。 | 不负责业务分层。 | Feature 临时对象注册到 Root。 |
| 诊断图 | 让自动注册结果可见。 | 不替代架构边界。 | 只在出错时打印,不在启动报告里展示。 |
| 方案 | 消费侧感觉 | 好处 | 缺点 |
|---|---|---|---|
| 旧 GContext | 全局入口很方便,哪里都能 Resolve 和 Publish。 | 接入快,适合业务快速堆功能。 | 依赖隐藏、生命周期不清、事件命令混用、自动发现难追踪。 |
| 散装 Bootstrap | 消费侧能控制所有细节,但代码像一堆 new 和 Register。 | 启动顺序清楚,没有框架抢主控权。 | 缺少全局配方概念,项目越大越像脚本清单。 |
| 新版 GameComposition | 消费侧声明一份项目配方,Bootstrap 只 Build Runtime。 | 既有全局概念,又不接管 App 启动;模块显式,模块内自动;可输出诊断图。 | 需要维护 Composition 规范;小项目会觉得比直接 new 多一层;Builder API 设计不好会变成新 DSL 负担。 |
| GameComposition 成本 | 具体是什么意思 | 什么时候会出问题 | 规避方式 |
|---|---|---|---|
| 维护 Composition 规范 | 团队必须约定哪些东西写在 Composition,哪些东西写在 Module,哪些东西留在 Feature 内部。 | 如果所有人都往 Composition 塞注册、初始化、业务判断,它会变成新的总控脚本。 | Composition 只声明结构:安装模块、注册项目级服务、选择策略;业务初始化放 Feature 或项目 Bootstrap 步骤。 |
| 小项目多一层 | 只有一个场景、几个服务、没有复杂 Feature 切换时,GameComposition 会显得比直接 new 多绕一步。 | 原型项目、Game Jam、小 Demo、一次性工具页面,会被框架流程拖慢。 | 提供 SimpleBootstrap 模式:允许直接 new ServiceContainer + FeatureHost;GameComposition 作为推荐,不作为强制。 |
| Builder API 变 DSL 负担 | 如果 Builder 做太多语法糖,开发者需要学习一套框架专用语言,而不是普通 C#。 | 出现大量 `UseX().WithY().ForZ().When(...)` 链式调用,真实执行顺序反而不清楚。 | Builder 只保留少量动词:`UseRoot`、`Install`、`Register`、`UsePolicy`、`EnableDiagnostics`。复杂逻辑回到普通 C#。 |