diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9cc4b71..6f363cb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,13 @@
本文件记录 `ShrinkEventBus` 在当前工作区中的包内变更。
+## [2.3.2] - 2026-11-06
+
+### Performance
+
+- 诊断观察者列表在订阅或退订时生成不可变快照,消除每次诊断派发的列表分配,保留逐个观察者的异常隔离。
+- 同步排队直接保存回调,移除额外闭包;主线程调度器复用泵送回调,保持延后事件的批次边界。
+
## [2.3.0] - 2026-11-06
### Added
diff --git a/DotNet~/ShrinkSDK.EventBus.csproj b/DotNet~/ShrinkSDK.EventBus.csproj
index d54d74a..f5df8a1 100644
--- a/DotNet~/ShrinkSDK.EventBus.csproj
+++ b/DotNet~/ShrinkSDK.EventBus.csproj
@@ -5,7 +5,7 @@
ShrinkEventBus.Runtime
ShrinkEventBus
ShrinkSDK.EventBus
- 2.3.1
+ 2.3.2
ShrinkSDK typed event bus runtime.
false
diff --git a/README.md b/README.md
index d5af685..d7e3084 100644
--- a/README.md
+++ b/README.md
@@ -166,23 +166,31 @@ public static class GlobalHandlers
}
```
-Unity ILPostProcessor 会生成直接调用桥和静态模块初始化注册;纯 .NET incremental generator 要求 subscriber 是 `partial`,并生成 ValueTask handler 绑定。生产路径不枚举程序集和方法,不使用 `MethodInfo.Invoke`、`DynamicInvoke` 或 `object[]` 参数。
+Unity ILPostProcessor 会生成直接调用桥和静态模块初始化注册;NuGet 包通过 `ShrinkSDK.CodeGen` 生成绑定。生产路径不枚举程序集和方法,不使用 `MethodInfo.Invoke`、`DynamicInvoke` 或 `object[]` 参数。
## Unity 与纯 .NET
-Unity 包依赖 UniTask:
+Unity 和正式 NuGet 包 `ShrinkSDK.EventBus` 均使用 UniTask,可直接 `await`:
```csharp
UniTask PostAsync(...)
```
-纯 .NET 实现在 `Tools~/DotNet/ShrinkEventBus.Core`:
+普通 C# 项目无需安装 Unity 或 Godot。使用 .NET 8 或更新 SDK 新建控制台项目后配置包源并安装:
-```csharp
-ValueTask PostAsync(...)
+```shell
+dotnet new console -n EventBusDemo
+cd EventBusDemo
+dotnet new nugetconfig
+dotnet nuget add source https://git.crash.work/api/packages/ShrinkSDK/nuget/index.json --name ShrinkSDK --configfile nuget.config
+dotnet add package ShrinkSDK.EventBus --version 2.3.2
```
-两边共享事件与特性契约,awaitable 和调度实现按宿主选择,不把 UniTask/ValueTask 写进事件数据。
+UniTask、Runtime.Abstractions 和构建时 CodeGen 会随包自动还原,不需要手动添加分析器或项目引用。完整可运行示例见 [Samples~/DotNetConsole](Samples~/DotNetConsole/Program.cs),可用它替换新项目的 `Program.cs` 后执行 `dotnet run -c Release`。
+
+控制台和后台服务建议显式创建 `Inline`、`DedicatedThread` 或 `TaskPool` Bus。`MainThread` 跨线程派发及 `PostDeferred` 需要宿主提供可用的主线程调度器;普通控制台没有 Unity/Godot 的帧循环。
+
+`Tools~/DotNet/ShrinkEventBus.Core` 是独立的 ValueTask 工具实现,不是上述正式 NuGet 包的运行时。事件数据不应依赖 UniTask/ValueTask。
## ECS/Burst
diff --git a/Runtime/EventBus.cs b/Runtime/EventBus.cs
index 0b3f4b3..cd3fbfd 100644
--- a/Runtime/EventBus.cs
+++ b/Runtime/EventBus.cs
@@ -57,6 +57,8 @@ namespace ShrinkEventBus
private static readonly IShrinkEventBus DefaultBus = CreateDefaultBus();
private static Action? _posted;
private static Action? _detailedPosted;
+ private static readonly object DetailedObserverGate = new();
+ private static Action[]? _detailedObserverSnapshot;
internal static event Action Posted
{
@@ -78,14 +80,41 @@ namespace ShrinkEventBus
internal static event Action DetailedPosted
{
- add => _detailedPosted += value;
- remove => _detailedPosted -= value;
+ add
+ {
+ lock (DetailedObserverGate)
+ {
+ _detailedPosted += value;
+ PublishDetailedObserverSnapshot();
+ }
+ }
+ remove
+ {
+ lock (DetailedObserverGate)
+ {
+ _detailedPosted -= value;
+ PublishDetailedObserverSnapshot();
+ }
+ }
+ }
+
+ private static void PublishDetailedObserverSnapshot()
+ {
+ var invocationList = _detailedPosted?.GetInvocationList();
+ Action[]? snapshot = null;
+ if (invocationList != null)
+ {
+ snapshot = new Action[invocationList.Length];
+ for (var i = 0; i < snapshot.Length; i++)
+ snapshot[i] = (Action)invocationList[i];
+ }
+ Volatile.Write(ref _detailedObserverSnapshot, snapshot);
}
internal static bool HasDetailedPostedObservers
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
- get => Volatile.Read(ref _detailedPosted) != null;
+ get => Volatile.Read(ref _detailedObserverSnapshot) != null;
}
public static IShrinkEventBus Default => DefaultBus;
@@ -212,15 +241,14 @@ namespace ShrinkEventBus
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static void NotifyDetailedPosted(ShrinkEventTrace trace)
{
- var observers = Volatile.Read(ref _detailedPosted);
+ var observers = Volatile.Read(ref _detailedObserverSnapshot);
if (observers == null)
return;
- var invocationList = observers.GetInvocationList();
- for (var i = 0; i < invocationList.Length; i++)
+ for (var i = 0; i < observers.Length; i++)
{
try
{
- ((Action)invocationList[i])(trace);
+ observers[i](trace);
}
catch (Exception exception)
{
diff --git a/Runtime/ShrinkBusSchedulers.cs b/Runtime/ShrinkBusSchedulers.cs
index 98dcb15..6c27050 100644
--- a/Runtime/ShrinkBusSchedulers.cs
+++ b/Runtime/ShrinkBusSchedulers.cs
@@ -59,15 +59,29 @@ namespace ShrinkEventBus
internal sealed class ShrinkSchedulerWorkItem
{
private readonly UniTaskCompletionSource? _completion;
+ private readonly Delegate _action;
+
+ public ShrinkSchedulerWorkItem(Action action)
+ {
+ _action = action ?? throw new ArgumentNullException(nameof(action));
+ }
public ShrinkSchedulerWorkItem(Func action, bool awaitable)
{
- Action = action ?? throw new ArgumentNullException(nameof(action));
+ _action = action ?? throw new ArgumentNullException(nameof(action));
if (awaitable)
_completion = new UniTaskCompletionSource();
}
- public Func Action { get; }
+ public UniTask Invoke()
+ {
+ if (_action is Action synchronousAction)
+ {
+ synchronousAction();
+ return UniTask.CompletedTask;
+ }
+ return ((Func)_action)();
+ }
public UniTask Completion => _completion?.Task ?? UniTask.CompletedTask;
public void Complete() => _completion?.TrySetResult();
@@ -169,7 +183,7 @@ namespace ShrinkEventBus
{
try
{
- await item.Action();
+ await item.Invoke();
item.Complete();
}
catch (OperationCanceledException ex)
@@ -187,7 +201,7 @@ namespace ShrinkEventBus
{
try
{
- item.Action().GetAwaiter().GetResult();
+ item.Invoke().GetAwaiter().GetResult();
item.Complete();
}
catch (OperationCanceledException ex)
@@ -232,6 +246,7 @@ namespace ShrinkEventBus
private readonly MainThreadDispatcherContract _dispatcher;
private readonly ShrinkSDK.Runtime.IShrinkDeferredMainThreadDispatcher? _deferredDispatcher;
private readonly object _gate = new();
+ private readonly Action _pump;
private int _pumpScheduled;
private int _disposed;
private bool _discardPending;
@@ -241,6 +256,7 @@ namespace ShrinkEventBus
_queue = new ShrinkSchedulerQueue(name, options);
_dispatcher = ShrinkEventBusRuntime.MainThreadDispatcher;
_deferredDispatcher = _dispatcher as ShrinkSDK.Runtime.IShrinkDeferredMainThreadDispatcher;
+ _pump = () => PumpAsync().Forget();
}
public bool IsOnSchedulerThread => _dispatcher.IsMainThread;
@@ -269,11 +285,7 @@ namespace ShrinkEventBus
lock (_gate)
{
if (_disposed != 0) return ShrinkDeferredPostStatus.Stopped;
- var item = new ShrinkSchedulerWorkItem(() =>
- {
- action();
- return UniTask.CompletedTask;
- }, awaitable: false);
+ var item = new ShrinkSchedulerWorkItem(action);
if (!_queue.TryEnqueue(item)) return ShrinkDeferredPostStatus.QueueFull;
return SchedulePump() ? ShrinkDeferredPostStatus.Accepted : ShrinkDeferredPostStatus.SchedulerRejected;
}
@@ -333,8 +345,8 @@ namespace ShrinkEventBus
try
{
accepted = _deferredDispatcher != null
- ? _deferredDispatcher.TryPostDeferred(() => PumpAsync().Forget())
- : _dispatcher.TryPost(() => PumpAsync().Forget());
+ ? _deferredDispatcher.TryPostDeferred(_pump)
+ : _dispatcher.TryPost(_pump);
}
catch
{
@@ -398,11 +410,7 @@ namespace ShrinkEventBus
{
if (Volatile.Read(ref _disposed) != 0)
return false;
- var item = new ShrinkSchedulerWorkItem(() =>
- {
- action();
- return UniTask.CompletedTask;
- }, awaitable: false);
+ var item = new ShrinkSchedulerWorkItem(action);
if (!_queue.TryEnqueue(item))
return false;
ScheduleWorkers();
@@ -503,11 +511,7 @@ namespace ShrinkEventBus
return true;
}
- var item = new ShrinkSchedulerWorkItem(() =>
- {
- action();
- return UniTask.CompletedTask;
- }, awaitable: false);
+ var item = new ShrinkSchedulerWorkItem(action);
if (!_queue.TryEnqueue(item))
return false;
_signal.Set();
diff --git a/Samples~/DotNetConsole/Program.cs b/Samples~/DotNetConsole/Program.cs
new file mode 100644
index 0000000..c749768
--- /dev/null
+++ b/Samples~/DotNetConsole/Program.cs
@@ -0,0 +1,40 @@
+using ShrinkEventBus;
+
+using var host = new ShrinkEventBusHost();
+var bus = host.CreateBus(ShrinkBusKey.Game, ShrinkBusOptions.Inline());
+var subscriber = new OrderSubscriber();
+using var binding = host.Attach(subscriber);
+var diagnostics = 0;
+using var observation = EventBus.ObserveDiagnostics(_ => diagnostics++);
+
+bus.Post(new OrderPlaced(2));
+await bus.PostAsync(new OrderPlaced(3));
+if (subscriber.Total != 5 || diagnostics != 2)
+ throw new InvalidOperationException("Generated subscription or diagnostics failed.");
+
+var workerKey = new ShrinkBusKey("worker", "orders");
+var worker = host.CreateBus(workerKey, ShrinkBusOptions.DedicatedThread());
+var workerSubscriber = new OrderSubscriber();
+using var workerBinding = host.Attach(workerSubscriber, workerKey);
+await worker.PostAsync(new OrderPlaced(7));
+if (workerSubscriber.Total != 7)
+ throw new InvalidOperationException("Dedicated-thread delivery failed.");
+
+binding.Dispose();
+bus.Post(new OrderPlaced(10));
+if (subscriber.Total != 5)
+ throw new InvalidOperationException("Unsubscribe failed.");
+
+await host.ShutdownAsync();
+Console.WriteLine("EVENTBUS_CONSOLE_PASS: sync, async, diagnostics, worker, unsubscribe");
+
+public readonly record struct OrderPlaced(int Amount) : IShrinkEvent;
+
+[ShrinkEventSubscriber]
+public sealed partial class OrderSubscriber
+{
+ public int Total { get; private set; }
+
+ [ShrinkSubscribe]
+ private void OnOrder(OrderPlaced order) => Total += order.Amount;
+}
diff --git a/Tests/ShrinkEventBusDeferredDiagnosticsTests.cs b/Tests/ShrinkEventBusDeferredDiagnosticsTests.cs
index bac92a3..bd5a0ee 100644
--- a/Tests/ShrinkEventBusDeferredDiagnosticsTests.cs
+++ b/Tests/ShrinkEventBusDeferredDiagnosticsTests.cs
@@ -73,6 +73,35 @@ namespace ShrinkEventBus.Tests
Assert.AreEqual(1, secondObserverCalls);
}
+ [Test]
+ public void ObserveDiagnostics_MutationsApplyToNextDispatch()
+ {
+ var calls = new List();
+ IDisposable second = null;
+ IDisposable added = null;
+ using var first = EventBus.ObserveDiagnostics(_ =>
+ {
+ calls.Add("first");
+ second.Dispose();
+ if (added == null)
+ added = EventBus.ObserveDiagnostics(__ => calls.Add("added"));
+ });
+ second = EventBus.ObserveDiagnostics(_ => calls.Add("second"));
+ try
+ {
+ EventBus.NotifyDetailedPosted(default);
+ CollectionAssert.AreEqual(new[] { "first", "second" }, calls);
+ calls.Clear();
+ EventBus.NotifyDetailedPosted(default);
+ CollectionAssert.AreEqual(new[] { "first", "added" }, calls);
+ }
+ finally
+ {
+ second.Dispose();
+ added?.Dispose();
+ }
+ }
+
[Test]
public void PostDeferred_DoesNotDispatchBeforePump()
{
diff --git a/package.json b/package.json
index 28fc84c..3bfe68d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "com.cneicy.shrink-eventbus",
- "version": "2.3.1",
+ "version": "2.3.2",
"displayName": "ShrinkEventBus",
"description": "多 Bus、特性强类型注册、UniTask 调度与低分配发布的 Unity 事件总线。",
"unity": "2022.3",