feat(sdk): migrate to EventBus 2.0

Replace the legacy EventBase runtime with generated multi-bus bindings and explicit scheduling. Migrate app, data, network, demo, and mod consumers; add generated network-event registration and owner-scoped mod content overrides.
This commit is contained in:
2026-08-26 01:15:48 +08:00
parent 67d32795c4
commit ad5a7b68a3
129 changed files with 5071 additions and 5584 deletions
@@ -11,8 +11,10 @@ namespace ExampleGreedMod
{
public override void OnRegisterContent(ShrinkModContext context)
{
context.GetRegistry<IReplacedPersonMod>(ReplacedPersonModBridge.RegistryName)
.Register(context.ModInfo.ModId, context.ModInfo.ModId, new ExampleGreedRules());
context.RegisterContent(
ReplacedPersonModBridge.RegistryName,
"rules",
new ExampleGreedRules());
}
public override void OnReady(ShrinkModContext context)
@@ -477,6 +477,6 @@ namespace ReplacedPerson.Runtime
private static ReplacedCommandResult Reject(string code, string detail) => new() { ErrorCode = code, Message = detail };
private static void Publish(string status, string detail) =>
EventBus.TriggerEvent(new ReplacedNetworkStatusEvent { Status = status, Detail = detail });
EventBus.Post(new ReplacedNetworkStatusEvent { Status = status, Detail = detail });
}
}
@@ -5,7 +5,7 @@ using ShrinkEventBus;
namespace ReplacedPerson.Runtime
{
public sealed class ReplacedMatchPhaseEvent : EventBase
public sealed class ReplacedMatchPhaseEvent : IShrinkEvent
{
public int Round { get; set; }
public ReplacedMatchPhase MatchPhase { get; set; }
@@ -13,39 +13,39 @@ namespace ReplacedPerson.Runtime
public string StateHash { get; set; } = string.Empty;
}
public sealed class ReplacedCardsCommittedEvent : EventBase
public sealed class ReplacedCardsCommittedEvent : IShrinkEvent
{
public int PlayerIndex { get; set; }
public string[] NormalCardIds { get; set; } = System.Array.Empty<string>();
public string[] EndCardIds { get; set; } = System.Array.Empty<string>();
}
public sealed class ReplacedDiceEvent : EventBase
public sealed class ReplacedDiceEvent : IShrinkEvent
{
public int Slot { get; set; }
public int PlayerOneRoll { get; set; }
public int PlayerTwoRoll { get; set; }
}
public sealed class ReplacedDamageEvent : EventBase
public sealed class ReplacedDamageEvent : IShrinkEvent
{
public int TargetPlayer { get; set; }
public int Amount { get; set; }
public int RemainingHealth { get; set; }
}
public sealed class ReplacedRewardEvent : EventBase
public sealed class ReplacedRewardEvent : IShrinkEvent
{
public string RewardId { get; set; } = string.Empty;
}
public sealed class ReplacedSaveEvent : EventBase
public sealed class ReplacedSaveEvent : IShrinkEvent
{
public string Operation { get; set; } = string.Empty;
public bool Success { get; set; }
}
public sealed class ReplacedNetworkStatusEvent : EventBase
public sealed class ReplacedNetworkStatusEvent : IShrinkEvent
{
public string Status { get; set; } = string.Empty;
public string Detail { get; set; } = string.Empty;
@@ -69,7 +69,7 @@ namespace ReplacedPerson.Runtime
var result = Match.Apply(command);
if (result.Accepted && !result.Duplicate)
{
EventBus.TriggerEvent(new ReplacedCardsCommittedEvent
EventBus.Post(new ReplacedCardsCommittedEvent
{
PlayerIndex = 0,
NormalCardIds = submission.NormalCardIds.ToArray(),
@@ -87,7 +87,7 @@ namespace ReplacedPerson.Runtime
{
if (!Content.Current.Cards.ContainsKey(cardId)) throw new ArgumentException("Unknown card: " + cardId);
_saveData.CollectedCards.Add(cardId);
EventBus.TriggerEvent(new ReplacedRewardEvent { RewardId = cardId });
EventBus.Post(new ReplacedRewardEvent { RewardId = cardId });
Changed?.Invoke();
}
@@ -106,11 +106,11 @@ namespace ReplacedPerson.Runtime
try
{
await ShrinkSave.SaveSlotAsync(slot, new SaveOptions { SlotName = "被替代之人" });
EventBus.TriggerEvent(new ReplacedSaveEvent { Operation = "save", Success = true });
EventBus.Post(new ReplacedSaveEvent { Operation = "save", Success = true });
}
catch
{
EventBus.TriggerEvent(new ReplacedSaveEvent { Operation = "save", Success = false });
EventBus.Post(new ReplacedSaveEvent { Operation = "save", Success = false });
throw;
}
}
@@ -120,12 +120,12 @@ namespace ReplacedPerson.Runtime
try
{
await ShrinkSave.LoadSlotAsync(slot);
EventBus.TriggerEvent(new ReplacedSaveEvent { Operation = "load", Success = true });
EventBus.Post(new ReplacedSaveEvent { Operation = "load", Success = true });
Changed?.Invoke();
}
catch
{
EventBus.TriggerEvent(new ReplacedSaveEvent { Operation = "load", Success = false });
EventBus.Post(new ReplacedSaveEvent { Operation = "load", Success = false });
throw;
}
}
@@ -171,7 +171,7 @@ namespace ReplacedPerson.Runtime
var before = Match.State.Clone();
var result = Match.Apply(command);
if (!result.Accepted) throw new InvalidOperationException("AI produced illegal command: " + result.ErrorCode);
EventBus.TriggerEvent(new ReplacedCardsCommittedEvent
EventBus.Post(new ReplacedCardsCommittedEvent
{
PlayerIndex = 1,
NormalCardIds = submission.NormalCardIds.ToArray(),
@@ -194,7 +194,7 @@ namespace ReplacedPerson.Runtime
{
if (Match == null) return;
var state = after ?? Match.State;
EventBus.TriggerEvent(new ReplacedMatchPhaseEvent
EventBus.Post(new ReplacedMatchPhaseEvent
{
Round = state.Round,
MatchPhase = state.Phase,
@@ -204,7 +204,7 @@ namespace ReplacedPerson.Runtime
if (before == null || !SameResolution(before.LastResolution, state.LastResolution))
foreach (var slot in state.LastResolution)
{
EventBus.TriggerEvent(new ReplacedDiceEvent
EventBus.Post(new ReplacedDiceEvent
{
Slot = slot.SlotIndex,
PlayerOneRoll = slot.PlayerOneRoll,
@@ -216,7 +216,7 @@ namespace ReplacedPerson.Runtime
{
var damage = Math.Max(0, before.Players[i].Health - state.Players[i].Health);
if (damage > 0)
EventBus.TriggerEvent(new ReplacedDamageEvent { TargetPlayer = i, Amount = damage, RemainingHealth = state.Players[i].Health });
EventBus.Post(new ReplacedDamageEvent { TargetPlayer = i, Amount = damage, RemainingHealth = state.Players[i].Health });
}
}
@@ -246,7 +246,7 @@ namespace ReplacedPerson.Runtime
var target = reward.StartsWith("ornament.", StringComparison.Ordinal) ? _saveData.Ornaments : _saveData.CollectedCards;
if (target.Contains(reward)) continue;
target.Add(reward);
EventBus.TriggerEvent(new ReplacedRewardEvent { RewardId = reward });
EventBus.Post(new ReplacedRewardEvent { RewardId = reward });
}
_saveData.ChapterProgress = Math.Max(_saveData.ChapterProgress, ActiveEnemyId == "greed.full" ? 2 : 1);
_saveData.RecentReplay = Match.Replay.Select(value => value.Clone()).ToList();
@@ -54,7 +54,7 @@ namespace ShrinkApp
{
HasFailed = true;
LastError = ex.Message;
EventBus.TriggerEvent(new ShrinkAppStartFailedEvent
EventBus.Post(new ShrinkAppStartFailedEvent
{
ErrorMessage = ex.Message,
FailedModuleId = _startedModules.LastOrDefault() ?? string.Empty
@@ -75,7 +75,7 @@ namespace ShrinkApp
return;
_publishedStartedEvent = true;
EventBus.TriggerEvent(new ShrinkAppStartedEvent
EventBus.Post(new ShrinkAppStartedEvent
{
ModuleIds = _startedModules.ToArray()
});
@@ -168,12 +168,12 @@ namespace ShrinkApp
public ShrinkAppHostingMode hostingMode = ShrinkAppHostingMode.ClassicHost;
}
public sealed class ShrinkAppStartedEvent : EventBase
public sealed class ShrinkAppStartedEvent : IShrinkEvent
{
public IReadOnlyList<string> ModuleIds { get; set; } = Array.Empty<string>();
}
public sealed class ShrinkAppStartFailedEvent : EventBase
public sealed class ShrinkAppStartFailedEvent : IShrinkEvent
{
public string ErrorMessage { get; set; } = string.Empty;
public string FailedModuleId { get; set; } = string.Empty;
+1 -1
View File
@@ -5,7 +5,7 @@
"description": "Shrink 系列统一宿主层,提供模块安装器、服务容器与统一启动流程。",
"unity": "2022.3",
"dependencies": {
"com.cneicy.shrink-eventbus": "1.3.0",
"com.cneicy.shrink-eventbus": "2.0.0",
"com.cneicy.shrink-shared-codegen": "0.1.0",
"com.cysharp.unitask": "2.5.10"
},
@@ -8,6 +8,16 @@ namespace ShrinkCommand.Integration
{
public static class ShrinkCommandEventBusBridge
{
[ShrinkEventSubscriber(OwnerId = "shrink.command", DefaultBus = "game")]
private sealed class CommandRequestSubscriber
{
[ShrinkSubscribe(Priority = ShrinkEventPriority.Lowest)]
private UniTask OnExecuteRequest(ShrinkCommandExecuteRequestEvent eventArgs)
{
return HandleExecuteRequestAsync(eventArgs);
}
}
private sealed class ServiceRegistration
{
public string ServiceName = ShrinkCommandConstants.DefaultServiceName;
@@ -20,7 +30,7 @@ namespace ShrinkCommand.Integration
private static readonly object SyncRoot = new();
private static readonly Dictionary<string, ServiceRegistration> RegisteredByName = new(StringComparer.OrdinalIgnoreCase);
private static readonly Dictionary<ShrinkCommandService, ServiceRegistration> RegisteredByService = new();
private static bool _initialized;
private static IDisposable? _requestHandlerBinding;
[UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetStaticState()
@@ -37,7 +47,8 @@ namespace ShrinkCommand.Integration
RegisteredByService.Clear();
RegisteredByName.Clear();
_initialized = false;
_requestHandlerBinding?.Dispose();
_requestHandlerBinding = null;
}
}
@@ -113,17 +124,16 @@ namespace ShrinkCommand.Integration
Source = source
};
await EventBus.TriggerEventAsync(requestEvent);
await EventBus.PostAsync(requestEvent);
return requestEvent.ExecutionResult ?? ShrinkCommandExecutionResult.Failure("没有命令桥处理该请求。");
}
private static void EnsureInitialized()
{
if (_initialized)
if (_requestHandlerBinding != null)
return;
_initialized = true;
EventBus.RegisterEvent<ShrinkCommandExecuteRequestEvent>(HandleExecuteRequestAsync, EventPriority.LOWEST);
_requestHandlerBinding = EventBus.Attach(new CommandRequestSubscriber());
}
private static async UniTask HandleExecuteRequestAsync(ShrinkCommandExecuteRequestEvent eventArgs)
@@ -159,7 +169,7 @@ namespace ShrinkCommand.Integration
private static void PublishExecuting(ServiceRegistration registration, ShrinkCommandExecutingInfo info)
{
EventBus.TriggerEvent(new ShrinkCommandExecutingEvent
EventBus.Post(new ShrinkCommandExecutingEvent
{
ServiceName = registration.ServiceName,
RawInput = info.RawInput,
@@ -174,7 +184,7 @@ namespace ShrinkCommand.Integration
{
var commandPath = info.Command?.Path ?? string.Empty;
var arguments = new Dictionary<string, string>(info.Arguments, StringComparer.OrdinalIgnoreCase);
EventBus.TriggerEvent(new ShrinkCommandExecutedEvent
EventBus.Post(new ShrinkCommandExecutedEvent
{
ServiceName = registration.ServiceName,
RawInput = info.RawInput,
@@ -189,7 +199,7 @@ namespace ShrinkCommand.Integration
if (info.Result.IsSuccess)
return;
EventBus.TriggerEvent(new ShrinkCommandFailedEvent
EventBus.Post(new ShrinkCommandFailedEvent
{
ServiceName = registration.ServiceName,
RawInput = info.RawInput,
@@ -4,17 +4,20 @@ using ShrinkEventBus;
namespace ShrinkCommand.Integration
{
[Cancelable]
public sealed class ShrinkCommandExecuteRequestEvent : EventBase
public sealed class ShrinkCommandExecuteRequestEvent : IShrinkCancelableEvent
{
private bool _isCanceled;
public string ServiceName { get; set; } = ShrinkCommandConstants.DefaultServiceName;
public string RawInput { get; set; } = string.Empty;
public IShrinkCommandSource? Source { get; set; }
public bool IsHandled { get; set; }
public ShrinkCommandExecutionResult? ExecutionResult { get; set; }
public bool IsCanceled => _isCanceled;
public void SetCanceled(bool value) => _isCanceled = value;
}
public sealed class ShrinkCommandExecutingEvent : EventBase
public sealed class ShrinkCommandExecutingEvent : IShrinkEvent
{
public string ServiceName { get; set; } = ShrinkCommandConstants.DefaultServiceName;
public string RawInput { get; set; } = string.Empty;
@@ -24,7 +27,7 @@ namespace ShrinkCommand.Integration
public Dictionary<string, string> Arguments { get; set; } = new();
}
public sealed class ShrinkCommandExecutedEvent : EventBase
public sealed class ShrinkCommandExecutedEvent : IShrinkEvent
{
public string ServiceName { get; set; } = ShrinkCommandConstants.DefaultServiceName;
public string RawInput { get; set; } = string.Empty;
@@ -36,7 +39,7 @@ namespace ShrinkCommand.Integration
public string Message { get; set; } = string.Empty;
}
public sealed class ShrinkCommandFailedEvent : EventBase
public sealed class ShrinkCommandFailedEvent : IShrinkEvent
{
public string ServiceName { get; set; } = ShrinkCommandConstants.DefaultServiceName;
public string RawInput { get; set; } = string.Empty;
@@ -7,7 +7,7 @@
"dependencies": {
"com.cneicy.shrink-command": "0.2.0",
"com.cneicy.shrink-context-core": "0.1.0",
"com.cneicy.shrink-eventbus": "1.3.0"
"com.cneicy.shrink-eventbus": "2.0.0"
},
"keywords": ["command", "eventbus", "integration", "bridge"],
"author": {
@@ -214,7 +214,7 @@ namespace ShrinkContext.AppAdapter
$"waiting={WaitingModuleIds.Count} disabled={_disabled.Count} " +
$"modules=[{string.Join(", ", active)}]");
EventBus.TriggerEvent(new ShrinkAppStartedEvent
EventBus.Post(new ShrinkAppStartedEvent
{
ModuleIds = active.ToArray()
});
@@ -19,6 +19,15 @@ namespace ShrinkContext.AppAdapter.Tests
/// </summary>
public class ShrinkAppLoaderHostTests
{
[ShrinkEventSubscriber(DefaultBus = "game")]
private sealed class AppStartedProbe
{
public ShrinkAppStartedEvent? Value { get; private set; }
[ShrinkSubscribe]
private void OnStarted(ShrinkAppStartedEvent value) => Value = value;
}
private ShrinkAppSettings _settings = null!;
[SetUp]
@@ -46,8 +55,8 @@ namespace ShrinkContext.AppAdapter.Tests
new FakeInstaller("app.a", "a"),
new FakeInstaller("app.b", "b") { DependsOn = new[] { "app.a" } });
ShrinkAppStartedEvent? started = null;
using (var subscription = EventBus.SubscribeEvent<ShrinkAppStartedEvent>(e => started = e))
var probe = new AppStartedProbe();
using (EventBus.Attach(probe))
{
TestAwait.Run(host.StartAsync());
}
@@ -55,8 +64,8 @@ namespace ShrinkContext.AppAdapter.Tests
Assert.IsTrue(host.IsRunning);
Assert.IsTrue(host.IsModuleActive("app.a"));
Assert.IsTrue(host.IsModuleActive("app.b"), "依赖模块在提供者激活后自动激活");
Assert.IsNotNull(started);
CollectionAssert.AreEquivalent(new[] { "app.a", "app.b" }, started!.ModuleIds);
Assert.IsNotNull(probe.Value);
CollectionAssert.AreEquivalent(new[] { "app.a", "app.b" }, probe.Value!.ModuleIds);
}
[Test]
@@ -7,48 +7,25 @@ using ShrinkEventBus;
namespace ShrinkContext.EventBusAdapter
{
/// <summary>
/// 把 ShrinkEventBus 的订阅/注册包装为可逆效应
/// <c>IShrinkEventSubscription.Dispose</c> 天然就是订阅的逆操作——
/// 组件在 apply 里订阅,停用时随上下文自动退订,无需手写卸载路径。
/// 把生成的 ShrinkEventBus 绑定包装为可逆效应
/// </summary>
public static class ShrinkEventBusEffects
{
/// <summary>订阅事件即效应:ctx 卸载或手动 Dispose 句柄时自动退订。</summary>
public static ShrinkEffectHandle EffectSubscribe<TEvent>(
this ShrinkCtx ctx,
Action<TEvent> handler,
EventPriority priority = EventPriority.NORMAL,
bool receiveCanceled = false)
where TEvent : EventBase
{
if (ctx == null)
throw new ArgumentNullException(nameof(ctx));
if (handler == null)
throw new ArgumentNullException(nameof(handler));
var subscription = EventBus.SubscribeEvent(handler, priority, receiveCanceled);
return ctx.EffectInverse(() =>
{
subscription.Dispose();
return UniTask.CompletedTask;
});
}
/// <summary>
/// 实例对象整体注册即效应:扫描对象上的 [EventSubscribe] 方法(含未织入的非 MonoBehaviour 类),
/// 逆操作为 Unregister。
/// 激活生成的 [ShrinkSubscribe] 绑定,并在 Context 回滚时统一释放。
/// </summary>
public static ShrinkEffectHandle EffectRegister(this ShrinkCtx ctx, object target)
public static ShrinkEffectHandle EffectAttach(this ShrinkCtx ctx, object target,
ShrinkBusKey? defaultBus = null)
{
if (ctx == null)
throw new ArgumentNullException(nameof(ctx));
if (target == null)
throw new ArgumentNullException(nameof(target));
EventBus.Register(target);
var binding = EventBus.Attach(target, defaultBus);
return ctx.EffectInverse(() =>
{
EventBus.Unregister(target);
binding.Dispose();
return UniTask.CompletedTask;
});
}
@@ -1,5 +1,4 @@
#nullable enable
using System;
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using NUnit.Framework;
@@ -9,7 +8,7 @@ using UnityEngine;
namespace ShrinkContext.EventBusAdapter.Tests
{
public sealed class PingEvent : EventBase
public sealed class PingEvent : IShrinkEvent
{
public int Value { get; set; }
}
@@ -33,64 +32,74 @@ namespace ShrinkContext.EventBusAdapter.Tests
public void TearDown()
{
_runtime.ShutdownAsync().GetAwaiter().GetResult();
EventBus.UnregisterAllEvents();
}
[Test]
public void EffectSubscribe_ReceivesEvents_AndAutoUnsubscribesOnContextDispose()
public void EffectAttach_ReceivesEvents_AndAutoUnsubscribesOnContextDispose()
{
var received = new List<int>();
_ctx.EffectSubscribe<PingEvent>(e => received.Add(e.Value));
var subscriber = new InstanceSubscriber(received);
_ctx.EffectAttach(subscriber);
EventBus.TriggerEvent(new PingEvent { Value = 1 });
EventBus.TriggerEvent(new PingEvent { Value = 2 });
EventBus.Post(new PingEvent { Value = 1 });
EventBus.Post(new PingEvent { Value = 2 });
CollectionAssert.AreEqual(new[] { 1, 2 }, received);
_ctx.DisposeAsync().GetAwaiter().GetResult();
EventBus.TriggerEvent(new PingEvent { Value = 3 });
EventBus.Post(new PingEvent { Value = 3 });
CollectionAssert.AreEqual(new[] { 1, 2 }, received,
"上下文回滚后订阅必须自动退订(IShrinkEventSubscription.Dispose 即逆操作)");
"上下文回滚后 EventBus binding 必须通过 IDisposable.Dispose 自动退订");
}
[Test]
public void EffectSubscribe_ManualDispose_OnlyUnsubscribesOwn()
public void EffectAttach_ManualDispose_OnlyUnsubscribesOwn()
{
var first = new List<int>();
var second = new List<int>();
_ctx.EffectSubscribe<PingEvent>(e => first.Add(e.Value));
var handle = _ctx.EffectSubscribe<PingEvent>(e => second.Add(e.Value));
_ctx.EffectAttach(new InstanceSubscriber(first));
var handle = _ctx.EffectAttach(new InstanceSubscriber(second));
handle.DisposeAsync().GetAwaiter().GetResult();
EventBus.TriggerEvent(new PingEvent { Value = 7 });
EventBus.Post(new PingEvent { Value = 7 });
CollectionAssert.AreEqual(new[] { 7 }, first, "手动退订只影响自己的订阅");
Assert.IsEmpty(second);
}
[Test]
public void EffectRegister_ObjectSubscribers_AutoUnregisterOnContextDispose()
public void EffectAttach_ObjectSubscribers_AutoUnregisterOnContextDispose()
{
var subscriber = new InstanceSubscriber();
_ctx.EffectRegister(subscriber);
var values = new List<int>();
var subscriber = new InstanceSubscriber(values);
_ctx.EffectAttach(subscriber);
EventBus.TriggerEvent(new PingEvent { Value = 5 });
EventBus.Post(new PingEvent { Value = 5 });
Assert.AreEqual(5, subscriber.LastValue);
_ctx.DisposeAsync().GetAwaiter().GetResult();
EventBus.TriggerEvent(new PingEvent { Value = 6 });
EventBus.Post(new PingEvent { Value = 6 });
Assert.AreEqual(5, subscriber.LastValue, "实例注册随上下文回滚自动注销");
}
[ShrinkEventSubscriber(DefaultBus = "game")]
private sealed class InstanceSubscriber
{
private readonly List<int> _values;
public InstanceSubscriber(List<int> values)
{
_values = values;
}
public int LastValue { get; private set; }
[EventSubscribe]
[ShrinkSubscribe]
public void OnPing(PingEvent e)
{
LastValue = e.Value;
_values.Add(e.Value);
}
}
}
@@ -6,7 +6,7 @@
"unity": "2022.3",
"dependencies": {
"com.cneicy.shrink-context-core": "0.1.0",
"com.cneicy.shrink-eventbus": "1.3.0",
"com.cneicy.shrink-eventbus": "2.0.0",
"com.cysharp.unitask": "2.5.10"
},
"keywords": [
@@ -19,4 +19,4 @@
"name": "cneicy",
"url": "https://github.com/cneicy"
}
}
}
@@ -54,13 +54,13 @@ namespace ShrinkDataSaver.Integration
}
private static void HandleSettingsChanged(string key, object value) =>
EventBus.TriggerEvent(new SettingsChangedEvent { Key = key, Value = value });
EventBus.Post(new SettingsChangedEvent { Key = key, Value = value });
private static void HandleSaveStarted(SaveStartedEventArgs args) =>
EventBus.TriggerEvent(new SaveStartedEvent { SlotIndex = args.SlotIndex, Timestamp = args.Timestamp });
EventBus.Post(new SaveStartedEvent { SlotIndex = args.SlotIndex, Timestamp = args.Timestamp });
private static void HandleSaveCompleted(SaveCompletedEventArgs args) =>
EventBus.TriggerEvent(new SaveCompletedEvent
EventBus.Post(new SaveCompletedEvent
{
SlotIndex = args.SlotIndex,
ModuleNames = args.ModuleNames,
@@ -68,17 +68,17 @@ namespace ShrinkDataSaver.Integration
});
private static void HandleSaveFailed(SaveFailedEventArgs args) =>
EventBus.TriggerEvent(new SaveFailedEvent
EventBus.Post(new SaveFailedEvent
{
SlotIndex = args.SlotIndex,
ErrorMessage = args.ErrorMessage
});
private static void HandleLoadStarted(LoadStartedEventArgs args) =>
EventBus.TriggerEvent(new LoadStartedEvent { SlotIndex = args.SlotIndex, Timestamp = args.Timestamp });
EventBus.Post(new LoadStartedEvent { SlotIndex = args.SlotIndex, Timestamp = args.Timestamp });
private static void HandleLoadCompleted(LoadCompletedEventArgs args) =>
EventBus.TriggerEvent(new LoadCompletedEvent
EventBus.Post(new LoadCompletedEvent
{
SlotIndex = args.SlotIndex,
ModuleNames = args.ModuleNames,
@@ -87,14 +87,14 @@ namespace ShrinkDataSaver.Integration
});
private static void HandleLoadFailed(LoadFailedEventArgs args) =>
EventBus.TriggerEvent(new LoadFailedEvent
EventBus.Post(new LoadFailedEvent
{
SlotIndex = args.SlotIndex,
ErrorMessage = args.ErrorMessage
});
private static void HandleMigrationCompleted(MigrationCompletedEventArgs args) =>
EventBus.TriggerEvent(new MigrationCompletedEvent
EventBus.Post(new MigrationCompletedEvent
{
SlotIndex = args.SlotIndex,
FromVersion = args.FromVersion,
@@ -102,6 +102,6 @@ namespace ShrinkDataSaver.Integration
});
private static void HandleSlotDeleted(SlotDeletedEventArgs args) =>
EventBus.TriggerEvent(new SlotDeletedEvent { SlotIndex = args.SlotIndex });
EventBus.Post(new SlotDeletedEvent { SlotIndex = args.SlotIndex });
}
}
@@ -3,39 +3,39 @@ using ShrinkEventBus;
namespace ShrinkDataSaver.Integration
{
public class SettingsChangedEvent : EventBase
public class SettingsChangedEvent : IShrinkEvent
{
public string Key { get; internal set; }
public object Value { get; internal set; }
public T Get<T>() => (T)Convert.ChangeType(Value, typeof(T));
}
public class SaveStartedEvent : EventBase
public class SaveStartedEvent : IShrinkEvent
{
public int SlotIndex { get; internal set; }
public long Timestamp { get; internal set; }
}
public class SaveCompletedEvent : EventBase
public class SaveCompletedEvent : IShrinkEvent
{
public int SlotIndex { get; internal set; }
public string[] ModuleNames { get; internal set; }
public long Timestamp { get; internal set; }
}
public class SaveFailedEvent : EventBase
public class SaveFailedEvent : IShrinkEvent
{
public int SlotIndex { get; internal set; }
public string ErrorMessage { get; internal set; }
}
public class LoadStartedEvent : EventBase
public class LoadStartedEvent : IShrinkEvent
{
public int SlotIndex { get; internal set; }
public long Timestamp { get; internal set; }
}
public class LoadCompletedEvent : EventBase
public class LoadCompletedEvent : IShrinkEvent
{
public int SlotIndex { get; internal set; }
public string[] ModuleNames { get; internal set; }
@@ -43,21 +43,21 @@ namespace ShrinkDataSaver.Integration
public long Timestamp { get; internal set; }
}
public class LoadFailedEvent : EventBase
public class LoadFailedEvent : IShrinkEvent
{
public int SlotIndex { get; internal set; }
public string ErrorMessage { get; internal set; }
}
public class MigrationCompletedEvent : EventBase
public class MigrationCompletedEvent : IShrinkEvent
{
public int SlotIndex { get; internal set; }
public int FromVersion { get; internal set; }
public int ToVersion { get; internal set; }
}
public class SlotDeletedEvent : EventBase
public class SlotDeletedEvent : IShrinkEvent
{
public int SlotIndex { get; internal set; }
}
}
}
@@ -1,155 +1,39 @@
# ShrinkDataSaver.Integration.EventBus
[ShrinkDataSaver](https://github.com/cneicy/ShrinkDataSaver) 与 [ShrinkEventBus](https://github.com/cneicy/ShrinkEventBus) 的桥接层
把 ShrinkDataSaver 的设置、保存、加载、迁移和删除生命周期发布为 ShrinkEventBus 2.0 事件
ContextLoader 下由 `ShrinkDataSaverEventBusComponent` 注入 `shrink.service.datasaver` 后接桥,依赖撤回时拆桥;独立使用时调用 `DataSaverEventBusBridge.Register()` / `Unregister()`仅安装程序集不会再产生全局副作用。
ContextLoader 通过 `ShrinkDataSaverEventBusComponent` 管理桥生命周期;独立宿主调用 `DataSaverEventBusBridge.Register()`,退出时调用 `Unregister()`
## ✨ 特性概览
事件均实现 `IShrinkEvent`
| 特性 | 说明 |
|------|------|
| 🌉 **可逆桥接** | 由 Cordis 组件或显式 API 管理注册与注销 |
| 📡 **完整事件覆盖** | 9 种事件类型,覆盖设置变更 + 存档保存/加载/删除/迁移全生命周期 |
| 🔒 **类型安全** | 所有事件继承 `EventBase`,支持 `[EventBusSubscriber]` 自动注册 |
| 📦 **丰富载荷** | 事件携带 SlotIndex、ModuleNames、Version、Timestamp、ErrorMessage 等完整数据 |
- `SettingsChangedEvent`
- `SaveStartedEvent` / `SaveCompletedEvent` / `SaveFailedEvent`
- `LoadStartedEvent` / `LoadCompletedEvent` / `LoadFailedEvent`
- `MigrationCompletedEvent`
- `SlotDeletedEvent`
## 📦 依赖
- [ShrinkDataSaver](https://github.com/cneicy/ShrinkDataSaver) `2.0.0+`
- [ShrinkEventBus](https://github.com/cneicy/ShrinkEventBus) `1.0.0+`
## ⚙️ 安装
在项目的 `Packages/manifest.json` 中添加:
```json
{
"dependencies": {
"com.cysharp.unitask": "https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask",
"com.cneicy.shrink-datasaver": "https://github.com/cneicy/ShrinkDataSaver.git",
"com.cneicy.shrink-datasaver-integration-eventbus": "https://github.com/cneicy/ShrinkDataSaver.Integration.EventBus.git"
}
}
```
或通过 Package Manager → `+``Add package from git URL` 输入:
```
https://github.com/cneicy/ShrinkDataSaver.Integration.EventBus.git
```
## 📡 事件类型
| 事件类 | 对应原生事件 | 载荷字段 |
|--------|-------------|----------|
| `SettingsChangedEvent` | `ShrinkSettings.OnChanged` | Key, Value |
| `SaveStartedEvent` | `ShrinkSave.OnSaveStarted` | SlotIndex, Timestamp |
| `SaveCompletedEvent` | `ShrinkSave.OnSaveCompleted` | SlotIndex, ModuleNames[], Timestamp |
| `SaveFailedEvent` | `ShrinkSave.OnSaveFailed` | SlotIndex, ErrorMessage |
| `LoadStartedEvent` | `ShrinkSave.OnLoadStarted` | SlotIndex, Timestamp |
| `LoadCompletedEvent` | `ShrinkSave.OnLoadCompleted` | SlotIndex, ModuleNames[], Version, Timestamp |
| `LoadFailedEvent` | `ShrinkSave.OnLoadFailed` | SlotIndex, ErrorMessage |
| `MigrationCompletedEvent` | `ShrinkSave.OnMigrationCompleted` | SlotIndex, FromVersion, ToVersion |
| `SlotDeletedEvent` | `ShrinkSave.OnDeleteCompleted` | SlotIndex |
## 🚀 使用示例
### 使用 `[EventBusSubscriber]` 自动注册(推荐)
订阅只使用 2.0 特性:
```csharp
[EventBusSubscriber]
public class SaveUIManager : MonoBehaviour
[ShrinkEventSubscriber(DefaultBus = "game")]
public sealed class SaveUiHandlers
{
[EventSubscribe(EventPriority.NORMAL)]
private void OnSaveCompleted(SaveCompletedEvent e)
[ShrinkSubscribe]
private void OnSaved(SaveCompletedEvent value)
{
Debug.Log($"槽位 {e.SlotIndex} 保存成功,模块: {string.Join(", ", e.ModuleNames)}");
ShowSaveIndicator();
Debug.Log($"slot {value.SlotIndex} saved");
}
[EventSubscribe(EventPriority.NORMAL)]
private void OnLoadCompleted(LoadCompletedEvent e)
[ShrinkSubscribe(Priority = ShrinkEventPriority.Low)]
private void OnLoadFailed(LoadFailedEvent value)
{
Debug.Log($"槽位 {e.SlotIndex} 加载完成 (v{e.Version})");
TransitionToGame();
}
[EventSubscribe(EventPriority.NORMAL)]
private void OnSaveFailed(SaveFailedEvent e)
{
ShowErrorDialog($"保存失败: {e.ErrorMessage}");
}
[EventSubscribe(EventPriority.NORMAL)]
private void OnLoadFailed(LoadFailedEvent e)
{
ShowErrorDialog($"加载失败: {e.ErrorMessage}");
}
[EventSubscribe(EventPriority.NORMAL)]
private void OnMigration(MigrationCompletedEvent e)
{
Debug.Log($"存档已从 v{e.FromVersion} 迁移到 v{e.ToVersion}");
}
[EventSubscribe(EventPriority.NORMAL)]
private void OnSettingsChanged(SettingsChangedEvent e)
{
if (e.Key == "MasterVolume")
ApplyVolume(e.Get<float>());
Debug.LogError(value.ErrorMessage);
}
}
using var binding = EventBus.Attach(new SaveUiHandlers());
```
### 手动注册
桥接方向是 DataSaver -> EventBus,发布使用 `EventBus.Post(...)`。没有手工 Delegate 注册或旧 Trigger API。
```csharp
public class AnalyticsTracker : IDisposable
{
public AnalyticsTracker()
{
EventBus.RegisterEvent<SaveCompletedEvent>(OnSave, EventPriority.LOWEST);
EventBus.RegisterEvent<LoadCompletedEvent>(OnLoad, EventPriority.LOWEST);
}
private void OnSave(SaveCompletedEvent e)
=> Analytics.Track("save", new { slot = e.SlotIndex, modules = e.ModuleNames.Length });
private void OnLoad(LoadCompletedEvent e)
=> Analytics.Track("load", new { slot = e.SlotIndex, version = e.Version });
public void Dispose()
=> EventBus.UnregisterAllEventsForObject(this);
}
```
## 🏗️ 架构
```
ShrinkDataSaver.Integration.EventBus/
├── DataSaverEvents.cs 9 个 EventBase 子类(事件定义)
├── DataSaverEventBusBridge.cs 显式、可注销的静态桥接器
├── ShrinkDataSaverEventBusComponent.cs 按服务键管理桥生命周期
└── ShrinkDataSaver.Integration.EventBus.asmdef
```
**桥接原理:**
```
ShrinkDataSaver 原生事件 (Action<XxxEventArgs>)
└─ ShrinkDataSaverEventBusComponent / 显式 Register
└─ EventBus.TriggerEvent(new XxxEvent { ... })
└─ ShrinkEventBus 分发到所有订阅者
```
桥接器是单向的:DataSaver → EventBus。业务代码只需订阅 EventBus 事件,无需直接引用 ShrinkDataSaver 的原生事件。
## ⚠️ 注意事项
- **生命周期**ContextLoader 组合根负责接桥;Standalone 必须在 DataSaver 初始化后显式 `Register()`,并在退出时 `Unregister()`
- **不影响原生事件**:桥接是附加行为,ShrinkDataSaver 的原生 C# 事件仍然正常触发,两种订阅方式可并存。
- **SettingsChangedEvent.Get\<T\>()**:提供泛型辅助方法获取强类型值,内部使用 `Convert.ChangeType`
## 📄 License
[MIT](LICENSE)
`SettingsChangedEvent.Get<T>()` 内部使用 `Convert.ChangeType`,只应在设置值确实可转换时调用。
@@ -11,7 +11,7 @@
"com.cneicy.shrink-datasaver": "2.2.0",
"com.cneicy.shrink-context-core": "0.1.0",
"com.cysharp.unitask": "2.5.10",
"com.cneicy.shrink-eventbus": "1.3.0"
"com.cneicy.shrink-eventbus": "2.0.0"
},
"keywords": ["save", "eventbus", "integration", "bridge"],
"author": {
+8 -6
View File
@@ -279,25 +279,25 @@ if (meta.IsEncrypted)
> 详细文档见 [ShrinkDataSaver.Integration.EventBus](https://github.com/cneicy/ShrinkDataSaver.Integration.EventBus)
项目中同时包含 `ShrinkDataSaver.Integration.EventBus` 时,桥接器会在游戏启动时自动初始化,将所有原生事件映射到 EventBus
项目中同时包含 `ShrinkDataSaver.Integration.EventBus` 时,由 Context 组件或宿主显式管理桥接生命周期,将原生事件发布到指定的 ShrinkEventBus 2.0 Bus
```csharp
[EventBusSubscriber]
public class SaveUIManager : MonoBehaviour
[ShrinkEventSubscriber(DefaultBus = "game")]
public sealed partial class SaveUIManager : MonoBehaviour
{
[EventSubscribe(EventPriority.NORMAL)]
[ShrinkSubscribe]
private void OnSaveCompleted(SaveCompletedEvent e)
{
ShowSaveIndicator(e.SlotIndex, e.ModuleNames);
}
[EventSubscribe(EventPriority.NORMAL)]
[ShrinkSubscribe]
private void OnLoadFailed(LoadFailedEvent e)
{
ShowErrorDialog(e.ErrorMessage);
}
[EventSubscribe(EventPriority.NORMAL)]
[ShrinkSubscribe]
private void OnSettingsChanged(SettingsChangedEvent e)
{
if (e.Key == "MasterVolume")
@@ -306,6 +306,8 @@ public class SaveUIManager : MonoBehaviour
}
```
普通 C# 对象通过 `EventBus.Attach(target)` 接入;MonoBehaviour 可使用 `ShrinkMonoEventScope`,也可在 `OnEnable`/`OnDisable` 中自行持有并释放 binding。桥接器本身由 `ShrinkDataSaverEventBusComponent``DataSaverEventBusBridge.Register()`/`Unregister()` 管理。
**EventBus 事件类型完整列表:**
`SettingsChangedEvent` · `SaveStartedEvent` · `SaveCompletedEvent` · `SaveFailedEvent` · `LoadStartedEvent` · `LoadCompletedEvent` · `LoadFailedEvent` · `MigrationCompletedEvent` · `SlotDeletedEvent`
@@ -2,6 +2,26 @@
本文件记录 `ShrinkEventBus` 在当前工作区中的包内变更。
## [2.0.0] - 2026-08-23
### Added
- 新增统一 `IShrinkEvent` 契约、`ShrinkEventSubscriber` / `ShrinkSubscribe` 特性与 `Post` / `PostAsync` 入口。
- 新增命名多 BusGame、Scene、Mod、World、Server 及自定义 `ShrinkBusKey`
- Bus 创建时可选择 Inline、Unity MainThread、DedicatedThread 或 TaskPool 调度器,并支持有界队列、溢出策略、最大并发与关闭排空。
- Unity 中异步 handler 与调度优先使用 UniTask;新增 `ShrinkMonoEventScope` 管理普通 MonoBehaviour 的绑定生命周期。
- ILPostProcessor 为特性标记的普通 C# / Mono 类型生成 `IShrinkGeneratedSubscriber` 强类型绑定,发布热路径不使用反射调用。
- 新增可选 `com.cneicy.shrink-eventbus-entities` 包,Burst Job 通过 NativeQueue writer/playback 进入同一 Bus。
- 新增 `DotNet/ShrinkEventBus.Core` ValueTask 运行时与 Roslyn incremental generator,纯 .NET 宿主无需 Unity 或 UniTask。
- EventBus 可视化调试器使用 UI Toolkit 重写,新增 Bus 导航、实时指标、分发耗时、线程/执行模式/结果详情、问题筛选、跟随和 CSV 导出;详细采样只在窗口主动采集时启用。
- 新增独立 Play Mode benchmark,覆盖 0/1/8/30 handler、取消、异步和生成绑定开销。
- Inline Bus 使用专用运行时实现,非取消全同步 channel 在订阅变化时生成有序 multicast dispatcher`ShrinkPostResult` 压缩为 32-bit 状态,保持 API 不变。
### Breaking
- 删除 `EventBase``EventBusSubscriber` / `EventSubscribe``RegisterEvent` / `SubscribeEvent` / `TriggerEvent`、手工 Delegate 注册和旧反射通道。
- 所有事件改为实现 `IShrinkEvent`;取消和结果分别实现 `IShrinkCancelableEvent``IShrinkResultEvent<TResult>`
## [1.3.0] - 2026-06-12
### Breaking
@@ -16,13 +16,6 @@ namespace ShrinkEventBus.CodeGen
public sealed class EventBusILPostProcessor : ILPostProcessor
{
private const string RuntimeAssemblyName = "ShrinkEventBus.Runtime";
private static readonly string[] SharedCoverageAssemblyNames =
{
"ShrinkCommand.Runtime",
"ShrinkNetwork.Runtime",
"ShrinkApp.Core.Runtime"
};
public override ILPostProcessor GetInstance() => this;
public override bool WillProcess(ICompiledAssembly compiledAssembly)
@@ -30,9 +23,6 @@ namespace ShrinkEventBus.CodeGen
if (!ReferencesAssembly(compiledAssembly, RuntimeAssemblyName))
return false;
if (ShouldDeferToSharedCodeGen(compiledAssembly))
return false;
return true;
}
@@ -47,54 +37,28 @@ namespace ShrinkEventBus.CodeGen
try
{
var subscriberType = FindType(module, "ShrinkEventBus.EventBusSubscriberAttribute", RuntimeAssemblyName);
var subscribeAttributeType = FindType(module, "ShrinkEventBus.EventSubscribeAttribute", RuntimeAssemblyName);
var eventBusType = FindType(module, "ShrinkEventBus.EventBus", RuntimeAssemblyName);
var staticRegistryCtor = FindTypeArrayConstructor(
module,
"ShrinkEventBus.EventBusStaticRegistryAttribute",
RuntimeAssemblyName);
if (subscriberType == null || subscribeAttributeType == null || eventBusType == null)
var generatedSubscriberType = FindType(module, "ShrinkEventBus.ShrinkEventSubscriberAttribute", RuntimeAssemblyName);
var generatedSubscribeType = FindType(module, "ShrinkEventBus.ShrinkSubscribeAttribute", RuntimeAssemblyName);
if (generatedSubscriberType == null || generatedSubscribeType == null)
return GetResult(assemblyDefinition, diagnostics);
var eventBusTypeDef = eventBusType.Resolve();
var autoRegisterMethod = eventBusTypeDef?.Methods.FirstOrDefault(method =>
method.Name == "AutoRegister" &&
method.IsStatic &&
method.Parameters.Count == 1);
var unregisterMethod = eventBusTypeDef?.Methods.FirstOrDefault(method =>
method.Name == "UnregisterInstance" &&
method.IsStatic &&
method.Parameters.Count == 1);
if (autoRegisterMethod == null || unregisterMethod == null)
return GetResult(assemblyDefinition, diagnostics);
foreach (var type in GetAllTypes(module.Types)
.Where(type => !type.IsInterface && !type.IsAbstract)
.Where(type => HasAttribute(type, generatedSubscriberType))
.Where(type => type.Methods.Any(method =>
!method.IsStatic && HasAttribute(method, generatedSubscribeType))))
{
InjectGeneratedBinding(type, module, generatedSubscribeType);
}
var autoRegisterMethodRef = module.ImportReference(autoRegisterMethod);
var unregisterMethodRef = module.ImportReference(unregisterMethod);
var subscriberTypes = module.Types
.Where(type => HasAttribute(type, subscriberType))
.Where(InheritsFromMonoBehaviour)
.Where(type => HasInstanceSubscribeMethod(type, subscribeAttributeType))
var staticSubscriberTypes = GetAllTypes(module.Types)
.Where(type => HasAttribute(type, generatedSubscriberType))
.Where(type => type.Methods.Any(method =>
method.IsStatic && HasAttribute(method, generatedSubscribeType)))
.ToArray();
if (staticSubscriberTypes.Length > 0)
InjectStaticBootstrap(module, staticSubscriberTypes, generatedSubscribeType);
foreach (var type in subscriberTypes)
{
InjectAutoRegister(type, module, autoRegisterMethodRef);
InjectAutoUnregister(type, module, unregisterMethodRef);
}
if (staticRegistryCtor != null)
{
var staticSubscriberTypes = module.Types
.Where(type => HasAttribute(type, subscriberType))
.Where(type => type.Methods.Any(method =>
method.IsStatic && HasAttribute(method, subscribeAttributeType)))
.Select(type => module.ImportReference(type))
.ToArray();
if (staticSubscriberTypes.Length > 0)
AddAssemblyTypeArrayAttribute(module, staticRegistryCtor, staticSubscriberTypes);
}
}
catch (Exception ex)
{
@@ -108,25 +72,6 @@ namespace ShrinkEventBus.CodeGen
return GetResult(assemblyDefinition, diagnostics);
}
private static bool ShouldDeferToSharedCodeGen(ICompiledAssembly compiledAssembly)
{
if (!IsSharedCodeGenAvailable())
return false;
return SharedCoverageAssemblyNames.Any(name => ReferencesAssembly(compiledAssembly, name));
}
private static bool IsSharedCodeGenAvailable()
{
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
if (string.Equals(assembly.GetName().Name, "Unity.ShrinkShared.CodeGen", StringComparison.Ordinal))
return true;
}
return Type.GetType("ShrinkShared.CodeGen.ShrinkRegistryILPostProcessor, Unity.ShrinkShared.CodeGen", false) != null;
}
private static bool ReferencesAssembly(ICompiledAssembly compiledAssembly, string assemblyName)
{
return compiledAssembly.References.Any(reference =>
@@ -138,178 +83,409 @@ namespace ShrinkEventBus.CodeGen
return provider.CustomAttributes.Any(attribute => attribute.AttributeType.FullName == expectedAttributeType.FullName);
}
private static bool HasInstanceSubscribeMethod(TypeDefinition type, TypeReference subscribeAttributeType)
private static IEnumerable<TypeDefinition> GetAllTypes(IEnumerable<TypeDefinition> roots)
{
var current = type;
while (current != null && current.Name != "MonoBehaviour")
foreach (var type in roots)
{
if (current.Methods.Any(method => !method.IsStatic && HasAttribute(method, subscribeAttributeType)))
return true;
var baseTypeRef = current.BaseType;
if (baseTypeRef == null)
return false;
TypeDefinition? resolvedBase;
try
{
resolvedBase = baseTypeRef.Resolve();
}
catch
{
return true;
}
if (resolvedBase == null)
return true;
current = resolvedBase;
yield return type;
foreach (var nested in GetAllTypes(type.NestedTypes))
yield return nested;
}
return false;
}
private static bool InheritsFromMonoBehaviour(TypeDefinition type)
private static void InjectGeneratedBinding(TypeDefinition type, ModuleDefinition module,
TypeReference subscribeAttributeType)
{
var current = type.BaseType;
while (current != null)
{
if (current.Name == "MonoBehaviour")
return true;
var generatedInterface = FindType(module,
"ShrinkEventBus.IShrinkGeneratedSubscriber", RuntimeAssemblyName)
?? throw new InvalidOperationException("IShrinkGeneratedSubscriber was not found.");
if (type.Interfaces.Any(item => item.InterfaceType.FullName == generatedInterface.FullName))
return;
try
var resolverType = FindType(module, "ShrinkEventBus.IShrinkBusResolver", RuntimeAssemblyName)
?? throw new InvalidOperationException("IShrinkBusResolver was not found.");
var busKeyType = FindType(module, "ShrinkEventBus.ShrinkBusKey", RuntimeAssemblyName)
?? throw new InvalidOperationException("ShrinkBusKey was not found.");
var bindingType = FindType(module, "ShrinkEventBus.ShrinkEventBinding", RuntimeAssemblyName)
?? throw new InvalidOperationException("ShrinkEventBinding was not found.");
var bindingHelperType = FindType(module, "ShrinkEventBus.ShrinkGeneratedBinding", RuntimeAssemblyName)
?? throw new InvalidOperationException("ShrinkGeneratedBinding was not found.");
var priorityType = FindType(module, "ShrinkEventBus.ShrinkEventPriority", RuntimeAssemblyName)
?? throw new InvalidOperationException("ShrinkEventPriority was not found.");
var asyncHandlerType = FindType(module, "ShrinkEventBus.ShrinkAsyncEventHandler`1", RuntimeAssemblyName)
?? throw new InvalidOperationException("ShrinkAsyncEventHandler was not found.");
var nullableBusKey = new GenericInstanceType(module.ImportReference(typeof(Nullable<>)));
nullableBusKey.GenericArguments.Add(busKeyType);
var disposableType = module.ImportReference(typeof(IDisposable));
var bindingDefinition = bindingType.Resolve()
?? throw new InvalidOperationException("ShrinkEventBinding could not be resolved.");
var bindingCtor = module.ImportReference(bindingDefinition.Methods.Single(method =>
method.IsConstructor && method.Parameters.Count == 0));
var bindingAdd = module.ImportReference(bindingDefinition.Methods.Single(method =>
method.Name == "Add" && method.Parameters.Count == 1));
var helperDefinition = bindingHelperType.Resolve()
?? throw new InvalidOperationException("ShrinkGeneratedBinding could not be resolved.");
var syncSubscribe = module.ImportReference(helperDefinition.Methods.Single(method =>
method.Name == "Subscribe" && method.HasGenericParameters));
var asyncSubscribe = module.ImportReference(helperDefinition.Methods.Single(method =>
method.Name == "SubscribeAsync" && method.HasGenericParameters));
var legacyAsyncSubscribe = module.ImportReference(helperDefinition.Methods.Single(method =>
method.Name == "SubscribeAsyncLegacy" && method.HasGenericParameters));
var interfaceDefinition = generatedInterface.Resolve()
?? throw new InvalidOperationException("IShrinkGeneratedSubscriber could not be resolved.");
var interfaceMethod = module.ImportReference(interfaceDefinition.Methods.Single(method =>
method.Name == "AttachGenerated"));
var generatedMethod = new MethodDefinition(
"ShrinkEventBus.IShrinkGeneratedSubscriber.AttachGenerated",
MethodAttributes.Private | MethodAttributes.Final | MethodAttributes.HideBySig |
MethodAttributes.NewSlot | MethodAttributes.Virtual,
disposableType);
generatedMethod.Parameters.Add(new ParameterDefinition("resolver", ParameterAttributes.None, resolverType));
generatedMethod.Parameters.Add(new ParameterDefinition("defaultBus", ParameterAttributes.Optional,
nullableBusKey));
generatedMethod.Overrides.Add(interfaceMethod);
generatedMethod.Body.InitLocals = true;
var bindingLocal = new VariableDefinition(bindingType);
generatedMethod.Body.Variables.Add(bindingLocal);
var il = generatedMethod.Body.GetILProcessor();
il.Emit(OpCodes.Newobj, bindingCtor);
il.Emit(OpCodes.Stloc, bindingLocal);
var classDefaultBus = ReadStringProperty(
type.CustomAttributes.First(attribute =>
attribute.AttributeType.FullName == "ShrinkEventBus.ShrinkEventSubscriberAttribute"),
"DefaultBus");
foreach (var handler in type.Methods.Where(method =>
!method.IsStatic && HasAttribute(method, subscribeAttributeType)).ToArray())
{
EmitGeneratedSubscription(il, module, type, handler,
handler.CustomAttributes.First(attribute =>
attribute.AttributeType.FullName == subscribeAttributeType.FullName),
classDefaultBus, bindingLocal, bindingAdd, syncSubscribe, asyncSubscribe,
legacyAsyncSubscribe, asyncHandlerType);
}
il.Emit(OpCodes.Ldloc, bindingLocal);
il.Emit(OpCodes.Ret);
type.Interfaces.Add(new InterfaceImplementation(generatedInterface));
type.Methods.Add(generatedMethod);
}
private static void EmitGeneratedSubscription(ILProcessor il, ModuleDefinition module,
TypeDefinition ownerType, MethodDefinition handler, CustomAttribute attribute,
string classDefaultBus, VariableDefinition bindingLocal, MethodReference bindingAdd,
MethodReference syncSubscribe, MethodReference asyncSubscribe,
MethodReference legacyAsyncSubscribe, TypeReference asyncHandlerType)
{
if (handler.Parameters.Count is < 1 or > 2)
throw new InvalidOperationException(
$"[ShrinkSubscribe] method {handler.FullName} must have one event parameter and an optional CancellationToken.");
var eventType = module.ImportReference(handler.Parameters[0].ParameterType);
var eventInterface = FindType(module, "ShrinkEventBus.IShrinkEvent", RuntimeAssemblyName)
?? throw new InvalidOperationException("IShrinkEvent was not found.");
if (!ImplementsInterface(eventType, eventInterface.FullName))
throw new InvalidOperationException(
$"[ShrinkSubscribe] method {handler.FullName} event parameter must implement IShrinkEvent.");
var bus = ReadStringProperty(attribute, "Bus");
if (string.IsNullOrWhiteSpace(bus))
bus = classDefaultBus;
var priority = ReadIntProperty(attribute, "Priority", 2);
var numericPriority = ReadIntProperty(attribute, "NumericPriority", 0);
var receiveCanceled = ReadBoolProperty(attribute, "ReceiveCanceled", false);
MethodReference openSubscribe;
TypeReference delegateType;
if (handler.ReturnType.FullName == module.TypeSystem.Void.FullName && handler.Parameters.Count == 1)
{
openSubscribe = syncSubscribe;
delegateType = MakeGenericType(module, typeof(Action<>), eventType);
}
else if (handler.ReturnType.FullName == "Cysharp.Threading.Tasks.UniTask" &&
handler.Parameters.Count == 1)
{
openSubscribe = legacyAsyncSubscribe;
var uniTaskType = FindType(module, "Cysharp.Threading.Tasks.UniTask", "UniTask")
?? module.ImportReference(handler.ReturnType);
delegateType = MakeGenericType(module, typeof(Func<,>), eventType, uniTaskType);
}
else if (handler.ReturnType.FullName == "Cysharp.Threading.Tasks.UniTask" &&
handler.Parameters.Count == 2 &&
handler.Parameters[1].ParameterType.FullName == typeof(System.Threading.CancellationToken).FullName)
{
openSubscribe = asyncSubscribe;
var closedAsyncHandler = new GenericInstanceType(asyncHandlerType);
closedAsyncHandler.GenericArguments.Add(eventType);
delegateType = closedAsyncHandler;
}
else
{
throw new InvalidOperationException(
$"Unsupported [ShrinkSubscribe] signature: {handler.FullName}. Use void(T), UniTask(T), or UniTask(T, CancellationToken).");
}
var closedSubscribe = new GenericInstanceMethod(openSubscribe);
closedSubscribe.GenericArguments.Add(eventType);
var delegateCtor = MakeDelegateConstructor(module, delegateType);
il.Emit(OpCodes.Ldloc, bindingLocal);
il.Emit(OpCodes.Ldarg_1);
il.Emit(OpCodes.Ldarg_2);
il.Emit(OpCodes.Ldstr, bus ?? string.Empty);
il.Emit(OpCodes.Ldarg_0);
il.Emit(OpCodes.Ldarg_0);
il.Emit(OpCodes.Ldftn, module.ImportReference(handler));
il.Emit(OpCodes.Newobj, delegateCtor);
il.Emit(OpCodes.Ldc_I4, priority);
il.Emit(OpCodes.Ldc_I4, numericPriority);
il.Emit(receiveCanceled ? OpCodes.Ldc_I4_1 : OpCodes.Ldc_I4_0);
il.Emit(OpCodes.Call, closedSubscribe);
il.Emit(OpCodes.Callvirt, bindingAdd);
}
private static void InjectStaticBootstrap(ModuleDefinition module,
IReadOnlyList<TypeDefinition> subscriberTypes, TypeReference subscribeAttributeType)
{
var registryType = FindType(module, "ShrinkEventBus.ShrinkStaticBindingRegistry", RuntimeAssemblyName)
?? throw new InvalidOperationException("ShrinkStaticBindingRegistry was not found.");
var asyncHandlerType = FindType(module, "ShrinkEventBus.ShrinkAsyncEventHandler`1", RuntimeAssemblyName)
?? throw new InvalidOperationException("ShrinkAsyncEventHandler was not found.");
var registryDefinition = registryType.Resolve()
?? throw new InvalidOperationException("ShrinkStaticBindingRegistry could not be resolved.");
var syncRegister = module.ImportReference(registryDefinition.Methods.Single(method =>
method.Name == "Register" && method.HasGenericParameters));
var asyncRegister = module.ImportReference(registryDefinition.Methods.Single(method =>
method.Name == "RegisterAsync" && method.HasGenericParameters));
var legacyAsyncRegister = module.ImportReference(registryDefinition.Methods.Single(method =>
method.Name == "RegisterAsyncLegacy" && method.HasGenericParameters));
var bootstrap = new TypeDefinition("ShrinkEventBus.Generated",
"ShrinkGeneratedStaticBindings",
TypeAttributes.Abstract | TypeAttributes.Sealed | TypeAttributes.NotPublic,
module.TypeSystem.Object);
module.Types.Add(bootstrap);
var register = new MethodDefinition("Register",
MethodAttributes.Assembly | MethodAttributes.Static | MethodAttributes.HideBySig,
module.TypeSystem.Void);
bootstrap.Methods.Add(register);
var il = register.Body.GetILProcessor();
foreach (var type in subscriberTypes)
{
var classDefaultBus = ReadStringProperty(
type.CustomAttributes.First(attribute =>
attribute.AttributeType.FullName == "ShrinkEventBus.ShrinkEventSubscriberAttribute"),
"DefaultBus");
foreach (var handler in type.Methods.Where(method =>
method.IsStatic && HasAttribute(method, subscribeAttributeType)).ToArray())
{
current = current.Resolve()?.BaseType;
}
catch
{
break;
EmitStaticGeneratedSubscription(il, module, handler,
handler.CustomAttributes.First(attribute =>
attribute.AttributeType.FullName == subscribeAttributeType.FullName),
classDefaultBus, syncRegister, asyncRegister,
legacyAsyncRegister, asyncHandlerType);
}
}
return false;
il.Emit(OpCodes.Ret);
InjectModuleInitializer(module, register);
}
private static void InjectAutoRegister(TypeDefinition type, ModuleDefinition module, MethodReference autoRegisterMethodRef)
private static void EmitStaticGeneratedSubscription(ILProcessor il, ModuleDefinition module,
MethodDefinition handler, CustomAttribute attribute, string classDefaultBus,
MethodReference syncRegister, MethodReference asyncRegister,
MethodReference legacyAsyncRegister, TypeReference asyncHandlerType)
{
var awake = type.Methods.FirstOrDefault(method => method.Name == "Awake" && !method.IsStatic);
if (awake == null)
{
var baseAwakeRef = FindBaseMethodReference(type, "Awake", module);
if (baseAwakeRef != null)
{
awake = new MethodDefinition("Awake",
MethodAttributes.Family | MethodAttributes.HideBySig | MethodAttributes.Virtual,
module.TypeSystem.Void);
var il = awake.Body.GetILProcessor();
il.Emit(OpCodes.Ldarg_0);
il.Emit(OpCodes.Call, baseAwakeRef);
il.Emit(OpCodes.Ldarg_0);
il.Emit(OpCodes.Call, autoRegisterMethodRef);
il.Emit(OpCodes.Ret);
type.Methods.Add(awake);
return;
}
if (handler.Parameters.Count is < 1 or > 2)
throw new InvalidOperationException(
$"[ShrinkSubscribe] method {handler.FullName} must have one event parameter and an optional CancellationToken.");
awake = new MethodDefinition("Awake",
MethodAttributes.Private | MethodAttributes.HideBySig,
var eventType = module.ImportReference(handler.Parameters[0].ParameterType);
var eventInterface = FindType(module, "ShrinkEventBus.IShrinkEvent", RuntimeAssemblyName)
?? throw new InvalidOperationException("IShrinkEvent was not found.");
if (!ImplementsInterface(eventType, eventInterface.FullName))
throw new InvalidOperationException(
$"[ShrinkSubscribe] method {handler.FullName} event parameter must implement IShrinkEvent.");
var bus = ReadStringProperty(attribute, "Bus");
if (string.IsNullOrWhiteSpace(bus))
bus = classDefaultBus;
var priority = ReadIntProperty(attribute, "Priority", 2);
var numericPriority = ReadIntProperty(attribute, "NumericPriority", 0);
var receiveCanceled = ReadBoolProperty(attribute, "ReceiveCanceled", false);
MethodReference openRegister;
TypeReference delegateType;
if (handler.ReturnType.FullName == module.TypeSystem.Void.FullName && handler.Parameters.Count == 1)
{
openRegister = syncRegister;
delegateType = MakeGenericType(module, typeof(Action<>), eventType);
}
else if (handler.ReturnType.FullName == "Cysharp.Threading.Tasks.UniTask" &&
handler.Parameters.Count == 1)
{
openRegister = legacyAsyncRegister;
var uniTaskType = FindType(module, "Cysharp.Threading.Tasks.UniTask", "UniTask")
?? module.ImportReference(handler.ReturnType);
delegateType = MakeGenericType(module, typeof(Func<,>), eventType, uniTaskType);
}
else if (handler.ReturnType.FullName == "Cysharp.Threading.Tasks.UniTask" &&
handler.Parameters.Count == 2 &&
handler.Parameters[1].ParameterType.FullName == typeof(System.Threading.CancellationToken).FullName)
{
openRegister = asyncRegister;
var closedAsyncHandler = new GenericInstanceType(asyncHandlerType);
closedAsyncHandler.GenericArguments.Add(eventType);
delegateType = closedAsyncHandler;
}
else
{
throw new InvalidOperationException(
$"Unsupported static [ShrinkSubscribe] signature: {handler.FullName}.");
}
var closedRegister = new GenericInstanceMethod(openRegister);
closedRegister.GenericArguments.Add(eventType);
var delegateCtor = MakeDelegateConstructor(module, delegateType);
var handlerBridge = CreateStaticHandlerBridge(module, handler);
il.Emit(OpCodes.Ldstr, bus ?? string.Empty);
il.Emit(OpCodes.Ldnull);
il.Emit(OpCodes.Ldftn, module.ImportReference(handlerBridge));
il.Emit(OpCodes.Newobj, delegateCtor);
il.Emit(OpCodes.Ldc_I4, priority);
il.Emit(OpCodes.Ldc_I4, numericPriority);
il.Emit(receiveCanceled ? OpCodes.Ldc_I4_1 : OpCodes.Ldc_I4_0);
il.Emit(OpCodes.Call, closedRegister);
}
private static MethodDefinition CreateStaticHandlerBridge(ModuleDefinition module,
MethodDefinition handler)
{
var bridge = new MethodDefinition(
$"ShrinkEventBus.GeneratedStaticHandler_{handler.MetadataToken.ToInt32():X8}",
MethodAttributes.Assembly | MethodAttributes.Static | MethodAttributes.HideBySig,
module.ImportReference(handler.ReturnType));
for (var i = 0; i < handler.Parameters.Count; i++)
{
var parameter = handler.Parameters[i];
bridge.Parameters.Add(new ParameterDefinition(parameter.Name, parameter.Attributes,
module.ImportReference(parameter.ParameterType)));
}
var il = bridge.Body.GetILProcessor();
for (var i = 0; i < bridge.Parameters.Count; i++)
il.Emit(OpCodes.Ldarg, bridge.Parameters[i]);
il.Emit(OpCodes.Call, module.ImportReference(handler));
il.Emit(OpCodes.Ret);
handler.DeclaringType.Methods.Add(bridge);
return bridge;
}
private static void InjectModuleInitializer(ModuleDefinition module, MethodReference register)
{
var moduleType = module.Types.First(type => type.Name == "<Module>");
var initializer = moduleType.Methods.FirstOrDefault(method => method.Name == ".cctor");
if (initializer == null)
{
initializer = new MethodDefinition(".cctor",
MethodAttributes.Private | MethodAttributes.Static |
MethodAttributes.HideBySig | MethodAttributes.SpecialName |
MethodAttributes.RTSpecialName,
module.TypeSystem.Void);
var retIl = awake.Body.GetILProcessor();
retIl.Emit(OpCodes.Ret);
type.Methods.Add(awake);
initializer.Body.GetILProcessor().Emit(OpCodes.Ret);
moduleType.Methods.Add(initializer);
}
var processor = awake.Body.GetILProcessor();
var instructions = new List<Instruction>
{
processor.Create(OpCodes.Ldarg_0),
processor.Create(OpCodes.Call, autoRegisterMethodRef),
processor.Create(OpCodes.Nop)
};
instructions.Reverse();
instructions.ForEach(instruction => processor.Body.Instructions.Insert(0, instruction));
var processor = initializer.Body.GetILProcessor();
processor.InsertBefore(initializer.Body.Instructions[0],
processor.Create(OpCodes.Call, register));
}
private static void InjectAutoUnregister(TypeDefinition type, ModuleDefinition module, MethodReference unregisterMethodRef)
private static GenericInstanceType MakeGenericType(ModuleDefinition module, Type openType,
params TypeReference[] arguments)
{
var onDestroy = type.Methods.FirstOrDefault(method => method.Name == "OnDestroy" && !method.IsStatic);
if (onDestroy == null)
{
var baseOnDestroyRef = FindBaseMethodReference(type, "OnDestroy", module);
if (baseOnDestroyRef != null)
{
onDestroy = new MethodDefinition("OnDestroy",
MethodAttributes.Family | MethodAttributes.HideBySig | MethodAttributes.Virtual,
module.TypeSystem.Void);
var il = onDestroy.Body.GetILProcessor();
il.Emit(OpCodes.Ldarg_0);
il.Emit(OpCodes.Call, unregisterMethodRef);
il.Emit(OpCodes.Ldarg_0);
il.Emit(OpCodes.Call, baseOnDestroyRef);
il.Emit(OpCodes.Ret);
type.Methods.Add(onDestroy);
return;
}
onDestroy = new MethodDefinition("OnDestroy",
MethodAttributes.Private | MethodAttributes.HideBySig,
module.TypeSystem.Void);
var retIl = onDestroy.Body.GetILProcessor();
retIl.Emit(OpCodes.Ret);
type.Methods.Add(onDestroy);
}
var processor = onDestroy.Body.GetILProcessor();
var instructions = new List<Instruction>
{
processor.Create(OpCodes.Ldarg_0),
processor.Create(OpCodes.Call, unregisterMethodRef),
processor.Create(OpCodes.Nop)
};
instructions.Reverse();
instructions.ForEach(instruction => processor.Body.Instructions.Insert(0, instruction));
var result = new GenericInstanceType(module.ImportReference(openType));
foreach (var argument in arguments)
result.GenericArguments.Add(argument);
return result;
}
private static MethodReference? FindBaseMethodReference(TypeDefinition type, string methodName, ModuleDefinition module)
private static MethodReference MakeDelegateConstructor(ModuleDefinition module, TypeReference delegateType)
{
var ctor = new MethodReference(".ctor", module.TypeSystem.Void, delegateType)
{
HasThis = true,
CallingConvention = MethodCallingConvention.Default
};
ctor.Parameters.Add(new ParameterDefinition(module.TypeSystem.Object));
ctor.Parameters.Add(new ParameterDefinition(module.TypeSystem.IntPtr));
return ctor;
}
private static bool ImplementsInterface(TypeReference type, string interfaceFullName)
{
TypeDefinition? current;
try
{
var baseTypeRef = type.BaseType;
while (baseTypeRef != null)
{
var baseTypeDef = baseTypeRef.Resolve();
if (baseTypeDef == null)
break;
var method = baseTypeDef.Methods.FirstOrDefault(candidate =>
candidate.Name == methodName && !candidate.IsStatic && candidate.IsVirtual);
if (method != null)
{
if (baseTypeRef is GenericInstanceType genericInstance)
{
var methodRef = new MethodReference(
method.Name,
module.ImportReference(method.ReturnType),
module.ImportReference(genericInstance))
{
HasThis = method.HasThis,
ExplicitThis = method.ExplicitThis,
CallingConvention = method.CallingConvention
};
return methodRef;
}
return module.ImportReference(method);
}
baseTypeRef = baseTypeDef.BaseType;
}
current = type.Resolve();
}
catch
{
return false;
}
return null;
while (current != null)
{
if (current.Interfaces.Any(item => item.InterfaceType.FullName == interfaceFullName))
return true;
try
{
current = current.BaseType?.Resolve();
}
catch
{
return false;
}
}
return false;
}
private static string ReadStringProperty(CustomAttribute attribute, string name)
{
foreach (var property in attribute.Properties)
{
if (property.Name == name)
return property.Argument.Value as string ?? string.Empty;
}
return string.Empty;
}
private static int ReadIntProperty(CustomAttribute attribute, string name, int defaultValue)
{
foreach (var property in attribute.Properties)
{
if (property.Name == name)
return Convert.ToInt32(property.Argument.Value);
}
return defaultValue;
}
private static bool ReadBoolProperty(CustomAttribute attribute, string name, bool defaultValue)
{
foreach (var property in attribute.Properties)
{
if (property.Name == name)
return Convert.ToBoolean(property.Argument.Value);
}
return defaultValue;
}
private static TypeReference? FindType(ModuleDefinition module, string fullName, string assemblyName)
@@ -318,28 +494,6 @@ namespace ShrinkEventBus.CodeGen
return resolved == null ? null : module.ImportReference(resolved);
}
private static MethodReference? FindTypeArrayConstructor(ModuleDefinition module, string fullName, string assemblyName)
{
var typeRef = FindType(module, fullName, assemblyName);
var typeDef = typeRef?.Resolve();
var ctor = typeDef?.Methods.FirstOrDefault(method =>
method.IsConstructor &&
method.Parameters.Count == 1 &&
method.Parameters[0].ParameterType.IsArray &&
method.Parameters[0].ParameterType.GetElementType().FullName == module.ImportReference(typeof(Type)).FullName);
return ctor == null ? null : module.ImportReference(ctor);
}
private static void AddAssemblyTypeArrayAttribute(ModuleDefinition module, MethodReference ctor, TypeReference[] types)
{
var attribute = new CustomAttribute(ctor);
var typeTypeRef = module.ImportReference(typeof(Type));
attribute.ConstructorArguments.Add(new CustomAttributeArgument(
module.ImportReference(typeof(Type[])),
types.Select(type => new CustomAttributeArgument(typeTypeRef, type)).ToArray()));
module.Assembly.CustomAttributes.Add(attribute);
}
private static AssemblyDefinition AssemblyDefinitionFor(ICompiledAssembly compiledAssembly)
{
var assemblyResolver = new PostProcessorAssemblyResolver(compiledAssembly);
@@ -8,7 +8,7 @@ using Unity.CompilationPipeline.Common.ILPostProcessing;
namespace ShrinkEventBus.CodeGen
{
internal sealed class PostProcessorAssemblyResolver : IAssemblyResolver
public sealed class PostProcessorAssemblyResolver : IAssemblyResolver
{
private readonly string[] _references;
private readonly Dictionary<string, AssemblyDefinition> _cache = new();
@@ -6,7 +6,7 @@ using Mono.Cecil;
namespace ShrinkEventBus.CodeGen
{
internal sealed class PostProcessorReflectionImporter : DefaultReflectionImporter
public sealed class PostProcessorReflectionImporter : DefaultReflectionImporter
{
private const string CoreLibName = "System.Private.CoreLib";
private readonly AssemblyNameReference? _corlib;
@@ -27,7 +27,7 @@ namespace ShrinkEventBus.CodeGen
}
}
internal sealed class PostProcessorReflectionImporterProvider : IReflectionImporterProvider
public sealed class PostProcessorReflectionImporterProvider : IReflectionImporterProvider
{
public IReflectionImporter GetReflectionImporter(ModuleDefinition module)
{
File diff suppressed because it is too large Load Diff
+117 -575
View File
@@ -1,629 +1,171 @@
# ShrinkEventBus
# ShrinkEventBus 2.0
一个为 Unity C# 项目设计的高性能、类型安全事件总线系统。支持优先级调度、编译期自动注册、同步/异步混合处理,以及完整的 MonoBehaviour 生命周期管理
ShrinkEventBus 2.0 是面向 Unity、普通 C# 宿主和 Entities/Burst 生产端的统一事件总线。运行时只有一种事件模型、一个订阅入口和一组发布 API;不同 Bus 只表达生命周期、所有权与调度策略
## ✨ 特性概览
| 特性 | 说明 |
|------|------|
| 🔒 **类型安全** | 基于泛型的强类型事件,编译期检查,无装箱开销 |
| ⚡ **高性能热路径** | 注册期预编译 invoker(无反射调用、无装箱),派发走缓存快照数组,监听者快照零拷贝挂接 |
| 🤖 **零侵入自动注册** | 标记 `[EventBusSubscriber]` 即可,ILPostProcessor 编译期自动织入注册与反注册逻辑,动态创建的对象也无需手写任何代码 |
| 🧾 **静态订阅清单** | 静态 `[EventBusSubscriber]` 现可通过编译期注册表收口,避免默认总线启动时全域扫描所有类型 |
| 🎯 **双重优先级** | 支持枚举优先级与数字优先级组合,精确控制执行顺序 |
| 🔄 **同步 & 异步** | 统一支持 `Action`(同步)与 `UniTask`(异步)两种 handler 形式 |
| 🧵 **线程安全** | 注册/注销操作全程加锁保护 |
| 📦 **对象池** | 内置 `EventPool<T>`,高频事件零 GC |
| 🔍 **调试友好** | Editor 事件查看器实时追踪订阅者与触发日志 |
| 🧩 **可实例化总线** | 除默认静态 `EventBus` 外,也可以用 Builder 创建独立 bus,并按需要配置异常策略、事件类型约束、分 phase 分发 |
| 🌳 **父事件监听** | 监听父事件类型时,子事件触发也会命中父事件监听器,便于做 Pre/Post 家族事件和统一监控 |
## 👓 Benchmark
[Benchmark结果](Benchmark.txt)
运行时仓库里自带 `EventBusBenchmark` 组件,当前会分别覆盖这些场景:
- 无订阅者 / 单订阅者 / 多订阅者同步触发
- 父事件监听子事件
-`EventPriority` 分 phase 分发
- 单订阅者异步触发
- 手工 delegate 注册 / 注销
- `object / Type / MethodInfo` 扫描注册 / 注销
- `EventPool<T>``new`
- 已取消事件跳过
如果你在评估这次 `IShrinkEventBus`、继承监听和严格注册带来的成本变化,优先看这个组件的输出,而不是只看 `Benchmark.txt` 里的旧样本。
## 📦 依赖
- Unity 2022.3+
- [UniTask](https://github.com/Cysharp/UniTask) `2.x`
## ⚙️ 安装
在项目的 `Packages/manifest.json` 中添加:
```json
{
"dependencies": {
"com.cysharp.unitask": "https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask",
"com.cneicy.shrink-eventbus": "https://github.com/cneicy/ShrinkEventBus.git"
}
}
```
或通过 Package Manager → `+``Add package from git URL` 输入:
```
https://github.com/cneicy/ShrinkEventBus.git
```
> ⚠️ **自动织入依赖说明**:当前工作区同时支持两层织入。若当前程序集命中共享管线 `ShrinkShared.CodeGen` 的覆盖范围(例如同时引用 `ShrinkCommand.Runtime` / `ShrinkNetwork.Runtime` / `ShrinkApp.Core.Runtime`),则优先由共享管线处理;其余只引用 `ShrinkEventBus.Runtime` 的程序集由模块内 `CodeGen/` 本地 ILPostProcessor 兜底。独立安装本包但未带上 `CodeGen/` 或共享管线时,没有编译期织入,请改用手动接入:MonoBehaviour 在 `Awake`/`OnDestroy` 中调用 `EventBus.AutoRegister(this)` / `EventBus.UnregisterInstance(this)`,或使用 `SubscribeEvent` 句柄。
## 🚀 快速上手
### 第一步:定义事件
所有事件必须继承 `EventBase`,通过 Attribute 声明附加能力:
## 核心契约
```csharp
// 普通事件
public class PlayerDiedEvent : EventBase
public interface IShrinkEvent { }
public interface IShrinkCancelableEvent : IShrinkEvent
{
public int PlayerId { get; set; }
public string Cause { get; set; }
bool IsCanceled { get; }
void SetCanceled(bool value);
}
// 可取消事件
[Cancelable]
public class PlayerMoveEvent : EventBase
public interface IShrinkResultEvent<TResult> : IShrinkEvent
{
public Vector3 OldPosition { get; set; }
public Vector3 NewPosition { get; set; }
}
// 有返回结果的事件
[HasResult]
public class ItemPickupEvent : EventBase
{
public string ItemId { get; set; }
public GameObject Picker { get; set; }
TResult Result { get; }
void SetResult(TResult result);
}
```
### 第二步:订阅事件
事件可以是 class、struct 或 unmanaged struct,不需要继承 SDK 基类。
在 MonoBehaviour 上标记 `[EventBusSubscriber]`,用 `[EventSubscribe]` 标记处理方法。
**无论是场景初始时存在的对象,还是运行时动态 `Instantiate` 的对象,都会在 `Awake` 时自动完成注册,销毁时自动清理,无需手写任何注册代码。**
## 多 Bus
```csharp
[EventBusSubscriber]
public class UIManager : MonoBehaviour
var gameBus = EventBus.GetOrCreateBus(
ShrinkBusKey.Game,
ShrinkBusOptions.MainThread());
var modBus = EventBus.CreateBus(
ShrinkBusKey.Mod("com.example.mod"),
ShrinkBusOptions.DedicatedThread());
var workerBus = EventBus.CreateBus(
new ShrinkBusKey("worker", "pathfinding"),
ShrinkBusOptions.TaskPool(maxConcurrency: 4));
```
标准 Key 包括 `Game``Server``Scene(id)``Mod(id)``World(id)`。调度器在 Bus 创建时确定,之后不可修改:
| Scheduler | 语义 |
|---|---|
| `Inline` | 在发布线程立即执行 |
| `MainThread` | Unity PlayerLoop 主线程;已在主线程时直调 |
| `DedicatedThread` | 有界队列、专属线程、Ordered 串行 |
| `TaskPool` | 有界队列、显式最大并发;Parallel 忽略优先级顺序 |
`Post` 表示投递:同线程同步完成,跨线程入队后返回。`PostAsync` 等待全部 handler 完成,并传播取消与异常。需要结果或最终取消状态时使用 `PostAsync`
## 唯一订阅入口
```csharp
[ShrinkEventSubscriber(
OwnerId = "com.example.mod",
DefaultBus = "mod:com.example.mod")]
public sealed class PlayerHandlers
{
// 同步处理
[EventSubscribe(EventPriority.NORMAL)]
private void OnPlayerDied(PlayerDiedEvent evt)
[ShrinkSubscribe(Priority = ShrinkEventPriority.High)]
private void OnJoined(PlayerJoinedEvent value)
{
ShowDeathScreen(evt.PlayerId);
}
// 异步处理(UniTask
[EventSubscribe(EventPriority.HIGH)]
private async UniTask OnItemPickup(ItemPickupEvent evt)
[ShrinkSubscribe]
private async UniTask OnLoaded(
PlayerLoadedEvent value,
CancellationToken cancellationToken)
{
await PlayPickupAnimation(evt.ItemId);
await UniTask.Yield(cancellationToken);
}
}
```
### 第三步:触发事件
Bus 解析顺序:方法 `Bus`、类型 `DefaultBus``Attach` 传入默认 Bus、否则 `game`
普通对象由宿主管理生命周期:
```csharp
// 同步触发
EventBus.TriggerEvent(new PlayerDiedEvent { PlayerId = 1, Cause = "Fall" });
using var binding = EventBus.Attach(new PlayerHandlers());
```
// 异步触发(顺序等待每个 handler)
await EventBus.TriggerEventAsync(new PlayerMoveEvent
MonoBehaviour 不需要继承 SDK 基类。可自行在 `OnEnable/OnDisable` 中 Attach/Dispose,也可以在 GameObject 上添加 `ShrinkMonoEventScope`,由它统一绑定同对象或子层级中的生成 subscriber。
静态类型同样只使用特性:
```csharp
[ShrinkEventSubscriber(DefaultBus = "game")]
public static class GlobalHandlers
{
OldPosition = transform.position,
NewPosition = targetPos
});
// 使用对象池(高频场景推荐)
using var evt = EventPool<PlayerDiedEvent>.Get();
evt.PlayerId = 1;
EventBus.TriggerEvent(evt);
// using 块结束时自动归还到池中
```
## 🖼️ 追踪图形化
菜单栏 → `ShrinkSDK``事件总线``事件查看器`
![订阅者全览](img1.png)
![实时触发日志](img2.png)
实时触发日志页现已支持关键词过滤、按“有监听者 / 无监听者”筛选,以及“折叠同类事件”聚合查看,适合排查高频事件刷屏场景。
---
## 📖 核心概念
### 自动注册机制
ShrinkEventBus 通过 **ILPostProcessor** 在编译期自动处理完整的生命周期管理。当 Unity 编译代码时,所有标记了 `[EventBusSubscriber]` 且自身或基类链上存在实例 `[EventSubscribe]` 方法的 MonoBehaviour 子类会被自动识别,并在其 `Awake``OnDestroy` 方法中分别织入注册与反注册逻辑(没有任何实例订阅方法的类型会被跳过,不织入也不报错)。
当前织入策略如下:
- 命中共享管线 `ShrinkShared.CodeGen` 覆盖范围的程序集,优先由共享管线处理。
- 其余只引用 `ShrinkEventBus.Runtime` 的程序集,由模块内 `CodeGen/Editor/EventBusILPostProcessor.cs` 本地处理。
- 因此 ShrinkSDK 工作区内的统一 CodeGen 与独立 `ShrinkEventBus` 业务程序集可以同时兼容,且不会双重织入。
织入规则如下:
- 类**自身已有** `Awake`/`OnDestroy`:在方法头部插入,用户自己负责 `base` 调用
- 类**没有**,但**基类有虚方法**:生成 `protected override` 并自动调用 `base` 方法,`Awake` 顺序为 `base.Awake() → AutoRegister``OnDestroy` 顺序为 `UnregisterInstance → base.OnDestroy()`
- 类**没有**,基类**也没有**:生成私有方法并插入
这意味着:
- 场景初始加载的对象 → `Awake` 执行时自动注册
- 运行时 `Instantiate` 的对象 → `Awake` 执行时自动注册
- GameObject 销毁时 → `OnDestroy` 执行时自动反注册,无内存泄漏
**整个过程对业务代码完全透明,类里不需要写任何注册相关的代码。**
### 实例化总线与 Builder
默认情况下,项目继续使用全局静态门面 `EventBus`。如果你需要更清晰的模块边界,也可以创建独立 bus:
```csharp
var gameplayBus = EventBus.CreateBus(builder => builder
.AllowPerPhaseDispatch()
.SetExceptionHandlingMode(ShrinkEventExceptionHandlingMode.LogAndThrow));
gameplayBus.Register(new GameplaySubscribers());
gameplayBus.TriggerEvent(new PlayerDiedEvent { PlayerId = 1, Cause = "Fall" });
```
当前 Builder 支持的重点配置:
- `SetExceptionHandlingMode(...)`
- `SetExceptionHandler(...)`
- `AllowPerPhaseDispatch()`
- `CheckTypesOnDispatch()`
- `MarkerInterface<TMarker>()`
- `ClassChecker(...)`
- `StartShutdown()`
如果你只是想继续沿用旧习惯,直接用静态 `EventBus` 即可;它内部就是一个默认的 `IShrinkEventBus` 实例。
### 手动注册的新边界
除了 `AutoRegister(this)` / `[EventBusSubscriber]` 这条 Unity 友好的自动接入路径,现在也支持更显式的手动注册:
```csharp
// 扫描实例上的 [EventSubscribe] 方法
EventBus.Register(mySubscriberInstance);
// 扫描某个类型上的 static [EventSubscribe] 方法
EventBus.Register(typeof(GlobalEventHooks));
// 只注册某一个 static [EventSubscribe] 方法
EventBus.Register(typeof(GlobalEventHooks).GetMethod("OnPlayerDied",
BindingFlags.Static | BindingFlags.NonPublic));
```
和旧版本相比,手动注册现在会更严格:
- 方法必须带 `[EventSubscribe]`
- 只能有一个参数
- 参数必须继承 `EventBase`
- 返回值只能是 `void``UniTask`
- 实例注册只接受实例方法,类型/方法注册只接受静态方法
这样做的目的是把“为什么没触发”尽量提前到注册阶段暴露,而不是静默吞掉。
### 优先级系统
`EventPriority` 枚举定义了六个优先级档位,数值越小越先执行:
```
HIGHEST(0) → HIGH(1) → NORMAL(2) → LOW(3) → LOWEST(4) → MONITOR(5)
```
同一优先级档位内,可用数字优先级进一步细排(数字越大越先执行):
```csharp
// 枚举优先级
[EventSubscribe(EventPriority.HIGH)]
private void Handler(SomeEvent evt) { }
// 数字优先级(自动映射到枚举档位;手动注册时必须显式传入数字)
EventBus.RegisterEvent<SomeEvent>(Handler, priority: 75); // 映射为 HIGH
// 手动注册时混合使用
EventBus.RegisterEvent<SomeEvent>(Handler, EventPriority.HIGH, receiveCanceled: false);
```
数字到枚举的映射规则:
| 数字范围 | 枚举档位 |
|---------|---------|
| ≥ 100 | HIGHEST |
| ≥ 50 | HIGH |
| ≥ 0 | NORMAL |
| ≥ -50 | LOW |
| < -50 | LOWEST |
> 1.3.0 起:数字 `0` 映射到 `NORMAL`(与枚举重载默认值一致);int 重载不再提供默认值,不带优先级的 `RegisterEvent(handler)` 调用唯一解析到枚举重载(NORMAL)。
**推荐的优先级分工:**
```
HIGHEST — 权限校验、合法性检查
HIGH — 核心业务逻辑、数值计算
NORMAL — 默认行为、状态变更
LOW — UI 更新、音效、特效
LOWEST — 收尾清理
MONITOR — 日志、统计、监控(通常配合 receiveCanceled: true
```
如果你创建的 bus 开启了 `AllowPerPhaseDispatch()`,也可以只分发某一个 phase
```csharp
gameplayBus.TriggerEvent(EventPriority.HIGH, evt);
await gameplayBus.TriggerEventAsync(EventPriority.MONITOR, evt);
```
这个模式主要适合做框架级流水线控制;普通业务仍推荐直接走完整分发。
### 父事件监听
现在监听父事件时,子事件触发也会命中父事件监听器:
```csharp
public class DamageEvent : EventBase
{
public int Value { get; set; }
}
public sealed class CriticalDamageEvent : DamageEvent
{
public bool IsCritical { get; set; }
}
EventBus.RegisterEvent<DamageEvent>(OnAnyDamage, EventPriority.MONITOR, receiveCanceled: true);
EventBus.TriggerEvent(new CriticalDamageEvent { Value = 42, IsCritical = true });
```
这很适合做统一日志、统一权限检查、事件族级别的监控和桥接。
### 事件取消与结果
```csharp
// 取消事件(需标记 [Cancelable]
[EventSubscribe(EventPriority.HIGHEST)]
private void ValidateMove(PlayerMoveEvent evt)
{
if (!IsValidPosition(evt.NewPosition))
evt.SetCanceled(true); // 后续未设置 receiveCanceled: true 的 handler 将跳过
}
// 监控处理器可以接收已取消的事件
[EventSubscribe(EventPriority.MONITOR, receiveCanceled: true)]
private void LogMove(PlayerMoveEvent evt)
{
Debug.Log($"移动 {(evt.IsCanceled ? "" : "")}");
}
// 触发方检查取消状态
var moveEvent = new PlayerMoveEvent { ... };
await EventBus.TriggerEventAsync(moveEvent);
if (!moveEvent.IsCanceled)
transform.position = moveEvent.NewPosition;
```
```csharp
// 设置结果(需标记 [HasResult]
[EventSubscribe(EventPriority.HIGH)]
private void CheckPermission(ItemPickupEvent evt)
{
evt.SetResult(player.HasSpace ? EventResult.ALLOW : EventResult.DENY);
}
// 触发方读取结果
var pickupEvent = new ItemPickupEvent { ... };
EventBus.TriggerEvent(pickupEvent);
bool success = pickupEvent.Result switch
{
EventResult.ALLOW => true,
EventResult.DENY => false,
EventResult.DEFAULT => DefaultPickupLogic()
};
```
### 注册方式对比
| 方式 | 适用场景 | 自动反注册 |
|------|---------|-----------|
| `[EventBusSubscriber]` + `[EventSubscribe]` | MonoBehaviour(推荐) | ✅ ILP 织入 OnDestroy,随 GameObject 销毁自动清理 |
| `EventBus.SubscribeEvent(...)` 手动订阅 | 非 MonoBehaviour 类、Lambda | ✅ `Dispose()` 即可精准清理 |
| `EventBus.RegisterEvent(...)` 手动注册 | 兼容旧代码 | ❌ 需手动调用 `UnregisterEvent` |
| `EventBus.AutoRegister(this)` | 特殊场景下手动触发 | ❌ 需手动调用 `UnregisterInstance` |
**手动注册示例(非 MonoBehaviour):**
```csharp
public class InventorySystem : IDisposable
{
private readonly IShrinkEventSubscription _itemPickupSubscription;
public InventorySystem()
{
_itemPickupSubscription = EventBus.SubscribeEvent<ItemPickupEvent>(OnItemPickup, EventPriority.NORMAL);
}
private void OnItemPickup(ItemPickupEvent evt) { /* ... */ }
public void Dispose()
{
_itemPickupSubscription.Dispose();
}
[ShrinkSubscribe]
private static void OnStartup(GameStartupEvent value) { }
}
```
---
Unity ILPostProcessor 会生成直接调用桥和静态模块初始化注册;纯 .NET incremental generator 要求 subscriber 是 `partial`,并生成 ValueTask handler 绑定。生产路径不枚举程序集和方法,不使用 `MethodInfo.Invoke``DynamicInvoke``object[]` 参数。
## 🔧 API 参考
## Unity 与纯 .NET
### EventBus(静态门面)
#### 注册 / 注销
Unity 包依赖 UniTask
```csharp
// 同步 handler
EventBus.RegisterEvent<TEvent>(Action<TEvent> handler, EventPriority priority, bool receiveCanceled);
EventBus.RegisterEvent<TEvent>(Action<TEvent> handler, int priority);
EventBus.SubscribeEvent<TEvent>(Action<TEvent> handler, EventPriority priority, bool receiveCanceled);
EventBus.SubscribeEvent<TEvent>(Action<TEvent> handler, int priority);
// 异步 handlerUniTask
EventBus.RegisterEvent<TEvent>(Func<TEvent, UniTask> handler, EventPriority priority, bool receiveCanceled);
EventBus.SubscribeEvent<TEvent>(Func<TEvent, UniTask> handler, EventPriority priority, bool receiveCanceled);
// 注销
EventBus.UnregisterEvent<TEvent>(Action<TEvent> handler);
EventBus.UnregisterEvent<TEvent>(Func<TEvent, UniTask> handler);
EventBus.UnregisterAllEventsForObject(object target); // 注销某实例的全部 handler
EventBus.ClearAllSubscribersForEvent<TEvent>(); // 清空某事件的全部订阅者
EventBus.UnregisterAllEvents(); // 全部清空(谨慎使用)
UniTask<ShrinkPostResult> PostAsync<TEvent>(...)
```
#### 触发
纯 .NET 实现在 `DotNet/ShrinkEventBus.Core`
```csharp
// 同步触发:只同步等待 sync handlerasync handler 会基于事件快照 fire-and-forget
bool handled = EventBus.TriggerEvent<TEvent>(TEvent eventArgs);
// 异步触发:顺序 await 每个 handler
bool handled = await EventBus.TriggerEventAsync<TEvent>(TEvent eventArgs);
ValueTask<ShrinkPostResult> PostAsync<TEvent>(...)
```
> ⚠️ `TriggerEvent` 中遇到 async handler 时,不会等待其完成,而是对当前事件做一份快照后异步执行。如果你需要让 async handler 参与最终状态(如 `IsCanceled` / `Result` / 后续字段改写),请使用 `TriggerEventAsync`
两边共享事件与特性契约,awaitable 和调度实现按宿主选择,不把 UniTask/ValueTask 写进事件数据
#### 查询
## ECS/Burst
`com.cneicy.shrink-eventbus-entities` 提供:
```csharp
EventBus.IsInstanceRegistered(object target);
EventBus.GetRegisteredInstanceCount();
EventBus.GetRegisteredEventTypeCount();
EventBus.GetEventSubscribers<TEvent>(); // 返回 EventHandlerInfo[]
EventBus.GetListenerList<TEvent>(); // 无订阅者时返回 null
EventBus.GetActiveSubscriptionsSnapshot();// 返回 IDisposable 订阅快照
ShrinkEcsEventWriter<TEvent>
ShrinkEcsEventQueue<TEvent>.Playback(IShrinkEventBus bus)
```
### EventPool\<T\>
Burst Job 只写 unmanaged 事实事件到 NativeQueuePlayback 在托管/ECS 系统阶段进入相同 Bus。取消、结果或等待异步完成的事件不应从 Burst Job 直接发布。
## 队列与关闭
`ShrinkBusOptions` 配置队列容量、溢出策略、最大并发、关闭排空和超时。关键业务默认使用 `Reject``DropNewest/DropOldest` 只适用于明确允许丢失的数据;`Wait` 只由 `PostAsync` 使用。
## UI Toolkit 调试器
菜单:`ShrinkSDK/事件总线/事件查看器`
窗口使用密集的 Bus 导航、事件流和事件详情三段布局,提供 Bus/Scheduler/DispatchMode/队列/溢出策略/handler 数量、实时速率、平均分发耗时、同步或异步执行方式、实际线程、`ShrinkPostResult`、问题筛选、跟随、搜索、CSV 导出与最多 5000 条环形保留。详细采样只在窗口开启且 `Capture` 为启用状态时生效,暂停或关闭窗口后不计时、不保留事件对象。
它只观察静态 `EventBus` 宿主管理的 Bus,不捕获独立 `ShrinkEventBusHost`;性能基准仍使用独立 Host,避免 UI 采样进入被测热路径。
## Benchmark
实现位于 `Benchmark/ShrinkEventBusBenchmark.cs`,入口:
```csharp
// 从池中取出(自动重置状态)
var evt = EventPool<MyEvent>.Get();
// 手动归还
EventPool<MyEvent>.Release(evt);
// 推荐:配合 using 自动归还
using var evt = EventPool<MyEvent>.Get();
EventBus.TriggerEvent(evt);
// 作用域结束时调用 Dispose() → 自动归还
ShrinkEventBusBenchmark.Run(
iterations: 1_000_000,
massIterations: 10_000,
registrationIterations: 5_000);
```
> ⚠️ 归还后不要再访问 `evt` 的属性,对象已被重置并放回池中。
2026-08-24 在 Unity `2022.3.62f3`、Windows Editor Play Mode、独立 Inline Host 下三轮中位数(全局调试采样不观察该 Host):
### EventBase 关键成员
| 场景 | 2.0 ops/s | 1.3.0 基线 |
|---|---:|---:|
| 0 handler | 129,282,649 | 8,971,378 |
| 1 handler | 67,249,586 | 6,132,242 |
| 8 handlers, struct | 35,024,798 | 无同口径数据 |
| 8 handlers, class | 25,321,199 | MessagePipe 图示 25,639,260 |
| 30 handlers | 10,823,612 | 258,805 |
| canceled skips 10 | 9,029,517 | 1,894,776 |
| 1 async handler | 5,909,446 | 1,168,634 |
| generated Attach + Dispose | 674,291 | instance scan 76,353 |
```csharp
evt.EventId // Guid,每次派发唯一(懒生成,首次访问时分配)
evt.EventTime // 事件创建时间(UTC
evt.IsCancelable // 是否支持取消(由 [Cancelable] 决定)
evt.HasResult // 是否支持结果(由 [HasResult] 决定)
evt.IsCanceled // 是否已被取消
evt.Result // 当前结果(EventResult 枚举)
evt.Phase // 当前执行到的优先级阶段
evt.CurrentHandler // 当前正在执行的 handler 信息
evt.GetSubscribers() // 获取本次派发的 handler 快照拷贝(调试用)
```
同步 struct `Post` 热路径实测为 `0 B / 10,000,000 ops`;全局门面在没有 Network bridge 对象观察者时也不会为 struct 事件装箱。MessagePipe 图来自不同机器和 .NET benchmark,class 结果只能说明已达到同一吞吐量级,不能作为跨设备胜负结论。当前 Windows x64 Development IL2CPP Player 已构建成功;仍应在目标平台 Player/设备上复测吞吐。
---
## 设计边界
## 🏗️ 架构说明
- handler 不单独选择线程;需要不同线程亲和性时发布到另一个 Bus。
- Parallel 分发不承诺优先级顺序。
- 同步 `Post` 遇到异步 handler 时是 fire-and-forget;需要等待使用 `PostAsync`
- 外部 Mod DLL 必须携带生成合同;没有合同的生产 DLL 不自动反射注册。
- Editor 诊断是可选观察层,不参与正式无诊断热路径。
```
ShrinkEventBus
├── Runtime/
│ ├── EventBus 静态门面,内部是一个默认 IShrinkEventBus 实例
│ ├── ShrinkEventBusInstance 总线实现:注册、派发、异常策略、phase 分发
│ ├── ShrinkEventBusBuilder 实例总线的构建与配置入口
│ ├── ListenerList 按 phase 分桶的有序 handler 列表,带快照缓存与父链合并
│ ├── EventHandlerInfo 单个 handler 的元信息(优先级、预编译 invoker、调试信息)
│ ├── EventBase 所有事件的基类,携带生命周期状态与派发快照
│ ├── EventPool<T> 对象池,高频事件减少 GC
│ ├── EventCloneUtility 同步路径上 async handler 的事件快照克隆
│ ├── EventBusRegHelper 反射扫描 & handler 注册逻辑
│ └── EventAutoRegHelper 运行时初始化,确保 IsInitialized 状态正确
├── Editor/
│ └── EventBusViewerWindow 事件查看器,实时显示订阅者与触发日志
└── (织入)ShrinkShared.CodeGen / CodeGen 共享 ILPostProcessor 优先,本地 ILPostProcessor 兜底
向 [EventBusSubscriber] 类注入 AwakeAutoRegister
与 OnDestroyUnregisterInstance
```
**热路径(`TriggerEvent`)工作流:**
```
TriggerEvent(evt)
└─ 取该事件类型的 ListenerList // 总线级字典 + 共享锁,每类型常数开销
└─ GetHandlers() // 返回缓存快照数组(脏时才重建),无拷贝
├─ 快照数组引用挂到事件对象上(一次赋值,供 GetSubscribers 调试)
└─ 遍历 handlers[]
├─ 跳过已取消 & 不接收取消的 handler
├─ Action<T> → 经预编译 invoker 直接调用
└─ Func<T, UniTask> → 克隆事件快照后 .Forget()(同步路径)
```
**自动注册完整流程:**
```
【编译期】若当前程序集命中 ShrinkShared.CodeGen 覆盖范围,则由共享 ILPostProcessor 扫描;
否则由 CodeGen/EventBusILPostProcessor.cs 本地扫描
└─ 找到标记了 [EventBusSubscriber] 且存在实例 [EventSubscribe] 方法的 MonoBehaviour 子类
├─ 在 Awake 头部织入 EventBus.AutoRegister(this)
└─ 在 OnDestroy 头部织入 EventBus.UnregisterInstance(this)
(类无对应方法时自动生成,有虚基类方法时自动调用 base)
※ 只引用 ShrinkEventBus.Runtime 的纯业务程序集目前不在织入范围内,需手动 AutoRegister
【运行时 - 默认静态总线启动】
└─ 读取编译期静态订阅清单,注册 static [EventSubscribe] 方法
【运行时 - 动态创建】Instantiate(prefab)
└─ Unity 调用新对象的 Awake(已含织入代码)→ 自动注册
【运行时 - 销毁】GameObject.Destroy
└─ OnDestroy(已含织入代码)→ UnregisterInstance → 自动反注册
```
---
## ✅ 最佳实践
**事件设计:尽量让属性只读**
```csharp
// ✅ 推荐:构造时传入,防止 handler 间意外修改输入数据
public class OrderPlacedEvent : EventBase
{
public string OrderId { get; }
public decimal Amount { get; }
public OrderPlacedEvent(string orderId, decimal amount)
{
OrderId = orderId;
Amount = amount;
}
}
// ❌ 避免:公开可写属性,handler 间耦合风险高
public class BadEvent : EventBase
{
public object Payload { get; set; }
}
```
**高频事件一定要用对象池**
```csharp
// ✅ 每帧触发的伤害/移动事件
using var dmgEvt = EventPool<DamageEvent>.Get();
dmgEvt.Value = damage;
EventBus.TriggerEvent(dmgEvt);
// ❌ 每帧 new,会产生大量 GC
EventBus.TriggerEvent(new DamageEvent { Value = damage });
```
**非 MonoBehaviour 类一定要手动清理**
```csharp
public void Dispose()
{
EventBus.UnregisterAllEventsForObject(this);
}
```
**异步 handler 中谨慎触发新事件**
`TriggerEventAsync` 的 handler 内部再次 `await TriggerEventAsync`,链条过深时调用栈难以追踪,建议把二次触发拆到外部或改用消息队列。
---
## ⚠️ 注意事项
- **`TriggerEvent` 不等待异步 handler**:同步路径中的 UniTask handler 会基于事件快照异步执行,执行结果和异常不会传回调用方,对原事件对象的改动也不会回写。需要等待并拿到最终状态时请使用 `TriggerEventAsync`
- **同步路径中的 async 快照是浅拷贝**:事件对象本身会复制一份,但如果载荷里挂着可变引用类型(如 `List<>``Dictionary<>`、自定义引用对象),内部成员仍然是共享引用。高风险数据建议改成不可变载荷,或统一走 `TriggerEventAsync`
- **EventPool 归还后不要再使用**`Release` 后对象会立即 `ResetInternal()`,继续访问属性将得到默认值。
- **不要在 handler 内直接注册/注销 handler**:可能影响当前正在遍历的 handler 快照,会产生语义上的不确定性。
- **静态 handler 永远不会自动注销**:静态方法注册后持续存活直到显式调用 `UnregisterEvent`,不要在静态 handler 里持有场景对象引用。
- **`[EventBusSubscriber]` 仅对 MonoBehaviour 生效自动注册**:非 MonoBehaviour 类标记该 Attribute 无任何效果,请使用手动注册。
- **标了 `[EventBusSubscriber]` 但没有实例 `[EventSubscribe]` 方法的类**:编译期不会织入;若通过 `AutoRegister` 手动接入,会输出警告并跳过(不抛异常)。显式 `Register()` 对此仍严格抛错。
- **int 数字优先级重载必须显式传值**:1.3.0 起 int 重载不再有默认值;数字 `0` 映射 `NORMAL`
- **ILPostProcessor 织入发生在编译期**:修改代码后需要重新编译才能使注入生效,热重载场景下请注意这一点。
- **继承泛型基类(如 `Singleton<T>`)时无需额外处理**:ILP 会正确识别泛型基类中的虚方法并生成 `protected override`,自动调用 `base.Awake()``base.OnDestroy()`
---
## 🐛 常见问题排查
**事件没有被任何 handler 接收**
1. 检查订阅类是否有 `[EventBusSubscriber]`
2. 检查方法是否有 `[EventSubscribe]`,且签名为 `void/UniTask Method(TEvent evt)`
3. 确认代码在标记 `[EventBusSubscriber]` 后重新编译过(ILPostProcessor 需要编译期运行)
4. 确认没有在 `Awake` 之前就触发事件
```csharp
// 调试:主动检查注册状态
Debug.Log(EventBus.IsInstanceRegistered(this));
Debug.Log($"订阅者数量: {EventBus.GetEventSubscribers<MyEvent>().Length}");
```
**怀疑内存泄漏**
```csharp
// 检查是否有 handler 持有意外引用
var handlers = EventBus.GetEventSubscribers<MyEvent>();
foreach (var h in handlers)
Debug.Log($"{h.DisplayDeclaringType.Name}.{h.DisplayMethodName} | target: {h.Target}");
```
**Editor 下想追踪事件流**
打开事件查看器:菜单栏 → `ShrinkSDK``事件总线``事件查看器`
也可以通过代码追踪:
```csharp
EventBus.EnableDebugRecord = true;
EventBus.TriggerEvent(evt);
foreach (var h in evt.GetSubscribers())
Debug.Log($"[{h.Priority}] {h.DisplayDeclaringType.Name}.{h.DisplayMethodName}");
```
---
## 📄 License
[MIT](LICENSE)
架构审计与来源说明见 `Docs/JustAnyProjectArchitectureAudit.md` 和根目录 `DESIGN.md`
@@ -1,3 +1,5 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("ShrinkNetwork.Integration.EventBus")]
[assembly: InternalsVisibleTo("ShrinkEventBus.Editor")]
[assembly: InternalsVisibleTo("ShrinkEventBus.Tests")]
@@ -1,42 +0,0 @@
using System;
namespace ShrinkEventBus
{
[AttributeUsage(AttributeTargets.Class)]
public class CancelableAttribute : Attribute { }
[AttributeUsage(AttributeTargets.Class)]
public class HasResultAttribute : Attribute { }
[AttributeUsage(AttributeTargets.Class)]
public class EventBusSubscriberAttribute : Attribute { }
[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = true)]
public class EventSubscribeAttribute : Attribute
{
public EventPriority Priority { get; set; }
public bool ReceiveCanceled { get; set; }
public int NumericPriority { get; set; }
public EventSubscribeAttribute()
{
Priority = EventPriority.NORMAL;
ReceiveCanceled = false;
NumericPriority = 0;
}
public EventSubscribeAttribute(EventPriority priority, bool receiveCanceled = false)
{
Priority = priority;
ReceiveCanceled = receiveCanceled;
NumericPriority = 0;
}
public EventSubscribeAttribute(int priority)
{
NumericPriority = priority;
Priority = PriorityHelper.ConvertToEventPriority(priority);
ReceiveCanceled = false;
}
}
}
@@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: 9bb9d82b04a04f3085b9e68ab4f1ba60
timeCreated: 1760098802
@@ -1,36 +0,0 @@
using UnityEngine;
namespace ShrinkEventBus
{
public static class EventAutoRegHelper
{
private static readonly object InitLock = new();
public static bool IsInitialized { get; private set; }
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
private static void InitializeAfterSceneLoad()
{
EnsureInitialized();
}
public static void EnsureInitialized()
{
if (IsInitialized) return;
lock (InitLock)
{
if (IsInitialized) return;
IsInitialized = true;
}
}
public static void Cleanup()
{
lock (InitLock)
{
IsInitialized = false;
}
EventBus.UnregisterAllEvents();
}
}
}
@@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: f4d79f65274145cab7614950a64a92f5
timeCreated: 1760099405
@@ -1,146 +0,0 @@
#nullable enable
using System;
using System.Collections.Concurrent;
using System.Reflection;
using System.Runtime.Serialization;
namespace ShrinkEventBus
{
public abstract class EventBase : IDisposable
{
private sealed class EventMetadata
{
public bool IsCancelable;
public bool HasResult;
}
private static readonly ConcurrentDictionary<Type, EventMetadata> MetadataCache = new();
private EventHandlerInfo[]? _dispatchSnapshot;
private Guid? _eventId;
private bool _isCanceled;
private EventResult _result = EventResult.DEFAULT;
[IgnoreDataMember]
public EventHandlerInfo? CurrentHandler { get; internal set; }
[IgnoreDataMember]
public DateTime EventTime { get; private set; } = DateTime.UtcNow;
[IgnoreDataMember]
public Guid EventId => _eventId ??= Guid.NewGuid();
[IgnoreDataMember]
public bool IsCancelable { get; }
[IgnoreDataMember]
public bool HasResult { get; }
[IgnoreDataMember]
public EventPriority? Phase { get; private set; }
internal bool IsInPool { get; set; }
internal Action<EventBase>? ReleaseAction { get; set; }
protected EventBase()
{
var metadata = GetOrCreateMetadata(GetType());
IsCancelable = metadata.IsCancelable;
HasResult = metadata.HasResult;
Setup();
}
protected virtual void Setup() { }
protected virtual void OnReset() { }
internal void ResetInternal()
{
_isCanceled = false;
_result = EventResult.DEFAULT;
CurrentHandler = null;
Phase = null;
EventTime = DateTime.UtcNow;
_eventId = null;
_dispatchSnapshot = null;
OnReset();
}
internal void PrepareForDispatch()
{
CurrentHandler = null;
Phase = null;
EventTime = DateTime.UtcNow;
_eventId = null;
_dispatchSnapshot = null;
}
internal void SetListenerSnapshot(EventHandlerInfo[]? handlers)
{
_dispatchSnapshot = handlers is { Length: > 0 } ? handlers : null;
}
[IgnoreDataMember]
public bool IsCanceled
{
get => _isCanceled;
set
{
if (!IsCancelable)
throw new UnsupportedOperationException(
$"Event {GetType().Name} is not cancelable. Mark it with [Cancelable] to allow cancellation.");
_isCanceled = value;
}
}
[IgnoreDataMember]
public EventResult Result
{
get => _result;
set
{
if (!HasResult)
throw new InvalidOperationException(
$"Event {GetType().Name} does not support results. Mark it with [HasResult] to allow setting a result.");
_result = value;
}
}
internal void SetPhase(EventPriority value)
{
if (Phase == value) return;
if (Phase != null && Phase.Value.CompareTo(value) > 0)
throw new ArgumentException(
$"Event phase cannot move backwards from {Phase.Value} to {value}.", nameof(value));
Phase = value;
}
public void SetCanceled(bool canceled) => IsCanceled = canceled;
public void SetResult(EventResult result) => Result = result;
public EventHandlerInfo[] GetSubscribers()
{
var snapshot = _dispatchSnapshot;
if (snapshot == null || snapshot.Length == 0)
return Array.Empty<EventHandlerInfo>();
var copy = new EventHandlerInfo[snapshot.Length];
Array.Copy(snapshot, copy, snapshot.Length);
return copy;
}
public void Dispose()
{
ReleaseAction?.Invoke(this);
}
private static EventMetadata GetOrCreateMetadata(Type eventType)
{
return MetadataCache.GetOrAdd(eventType, static type => new EventMetadata
{
IsCancelable = type.GetCustomAttribute<CancelableAttribute>() != null,
HasResult = type.GetCustomAttribute<HasResultAttribute>() != null
});
}
}
}
@@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: 0b978c5174cf44d893a41950e7a504e3
timeCreated: 1760098714
+163 -96
View File
@@ -1,129 +1,196 @@
#nullable enable
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using Cysharp.Threading.Tasks;
namespace ShrinkEventBus
{
internal readonly struct ShrinkEventTrace
{
public ShrinkEventTrace(DateTime timestampUtc, Type eventType, ShrinkBusKey busKey,
ShrinkBusOptions options, int threadId, long elapsedTimestampTicks,
ShrinkPostResult result, bool isAsync)
{
TimestampUtc = timestampUtc;
EventType = eventType;
BusKey = busKey;
Scheduler = options.Scheduler;
DispatchMode = options.DispatchMode;
ThreadId = threadId;
ElapsedTimestampTicks = elapsedTimestampTicks;
Result = result;
IsAsync = isAsync;
}
public DateTime TimestampUtc { get; }
public Type EventType { get; }
public ShrinkBusKey BusKey { get; }
public ShrinkBusSchedulerKind Scheduler { get; }
public ShrinkDispatchMode DispatchMode { get; }
public int ThreadId { get; }
public long ElapsedTimestampTicks { get; }
public ShrinkPostResult Result { get; }
public bool IsAsync { get; }
}
public static class EventBus
{
private sealed class GlobalBusResolver : IShrinkBusResolver
{
public IShrinkEventBus GetBus(ShrinkBusKey key)
{
if (TryGetBus(key, out var bus))
return bus;
throw new KeyNotFoundException($"Bus '{key}' is not registered.");
}
public bool TryGetBus(ShrinkBusKey key, out IShrinkEventBus bus) =>
EventBus.TryGetBus(key, out bus);
}
private static readonly ConcurrentDictionary<ShrinkBusKey, IShrinkEventBus> Buses = new();
internal static readonly IShrinkBusResolver Resolver = new GlobalBusResolver();
private static readonly IShrinkEventBus DefaultBus = CreateDefaultBus();
private static Action<IShrinkEvent, Type, ShrinkBusKey>? _posted;
private static Action<ShrinkEventTrace>? _detailedPosted;
internal static event Action<IShrinkEvent, Type, ShrinkBusKey> Posted
{
add
{
_posted += value;
}
remove
{
_posted -= value;
}
}
internal static bool HasPostedObservers
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get => Volatile.Read(ref _posted) != null;
}
internal static event Action<ShrinkEventTrace> DetailedPosted
{
add => _detailedPosted += value;
remove => _detailedPosted -= value;
}
internal static bool HasDetailedPostedObservers
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get => Volatile.Read(ref _detailedPosted) != null;
}
public static IShrinkEventBus Default => DefaultBus;
public static event Action<EventBase, Type> OnEventTriggered
public static IShrinkEventBus CreateBus(ShrinkBusKey key, ShrinkBusOptions options)
{
add => DefaultBus.OnEventTriggered += value;
remove => DefaultBus.OnEventTriggered -= value;
if (options == null)
throw new ArgumentNullException(nameof(options));
var bus = BuildBus(key, options);
if (!Buses.TryAdd(key, bus))
{
bus.Dispose();
throw new InvalidOperationException($"Bus '{key}' is already registered.");
}
ShrinkStaticBindingRegistry.AttachForBus(key, Resolver);
return bus;
}
#if UNITY_EDITOR
public static bool EnableDebugRecord
public static IShrinkEventBus GetOrCreateBus(ShrinkBusKey key, ShrinkBusOptions options)
{
get => DefaultBus.EnableDebugRecord;
set => DefaultBus.EnableDebugRecord = value;
if (options == null)
throw new ArgumentNullException(nameof(options));
var bus = Buses.GetOrAdd(key, busKey => BuildBus(busKey, options));
ShrinkStaticBindingRegistry.AttachForBus(key, Resolver);
return bus;
}
public static event Action<EventBase, string, string, EventHandlerInfo[]> OnEventTriggeredForEditor
{
add => DefaultBus.OnEventTriggeredForEditor += value;
remove => DefaultBus.OnEventTriggeredForEditor -= value;
}
#endif
public static bool TryGetBus(ShrinkBusKey key, out IShrinkEventBus bus) =>
Buses.TryGetValue(key, out bus!);
public static ShrinkEventBusBuilder Builder() => new();
public static IShrinkEventBus CreateBus(Action<ShrinkEventBusBuilder>? configure = null)
public static bool RemoveBus(ShrinkBusKey key)
{
var builder = new ShrinkEventBusBuilder();
configure?.Invoke(builder);
return builder.Build();
if (key == ShrinkBusKey.Game || !Buses.TryRemove(key, out var bus))
return false;
ShrinkStaticBindingRegistry.DetachBus(key);
bus.Dispose();
return true;
}
public static void Start() => DefaultBus.Start();
public static void AutoRegister(object target) => DefaultBus.AutoRegister(target);
public static void Register(object target) => DefaultBus.Register(target);
public static void Unregister(object target) => DefaultBus.Unregister(target);
public static ShrinkPostResult Post<TEvent>(in TEvent eventData)
where TEvent : IShrinkEvent => DefaultBus.Post(in eventData);
public static void RegisterEvent<TEvent>(Func<TEvent, UniTask> handler, int priority)
where TEvent : EventBase => DefaultBus.RegisterEvent(handler, priority);
public static UniTask<ShrinkPostResult> PostAsync<TEvent>(TEvent eventData,
CancellationToken cancellationToken = default)
where TEvent : IShrinkEvent => DefaultBus.PostAsync(eventData, cancellationToken);
public static void RegisterEvent<TEvent>(Action<TEvent> handler,
EventPriority priority = EventPriority.NORMAL, bool receiveCanceled = false)
where TEvent : EventBase => DefaultBus.RegisterEvent(handler, priority, receiveCanceled);
public static IDisposable Attach(object target, ShrinkBusKey? defaultBus = null)
{
if (target == null)
throw new ArgumentNullException(nameof(target));
if (target is not IShrinkGeneratedSubscriber generated)
throw new InvalidOperationException(
$"Type {target.GetType().FullName} has no generated [ShrinkSubscribe] binding.");
return generated.AttachGenerated(Resolver, defaultBus ?? ShrinkBusKey.Game);
}
public static void RegisterEvent<TEvent>(Action<TEvent> handler, int priority)
where TEvent : EventBase => DefaultBus.RegisterEvent(handler, priority);
public static void RegisterEvent<TEvent>(Func<TEvent, UniTask> handler,
EventPriority priority = EventPriority.NORMAL, bool receiveCanceled = false)
where TEvent : EventBase => DefaultBus.RegisterEvent(handler, priority, receiveCanceled);
public static IShrinkEventSubscription SubscribeEvent<TEvent>(Action<TEvent> handler,
EventPriority priority = EventPriority.NORMAL, bool receiveCanceled = false)
where TEvent : EventBase => DefaultBus.SubscribeEvent(handler, priority, receiveCanceled);
public static IShrinkEventSubscription SubscribeEvent<TEvent>(Action<TEvent> handler, int priority)
where TEvent : EventBase => DefaultBus.SubscribeEvent(handler, priority);
public static IShrinkEventSubscription SubscribeEvent<TEvent>(Func<TEvent, UniTask> handler,
EventPriority priority = EventPriority.NORMAL, bool receiveCanceled = false)
where TEvent : EventBase => DefaultBus.SubscribeEvent(handler, priority, receiveCanceled);
public static IShrinkEventSubscription SubscribeEvent<TEvent>(Func<TEvent, UniTask> handler, int priority)
where TEvent : EventBase => DefaultBus.SubscribeEvent(handler, priority);
public static void UnregisterEvent<TEvent>(Func<TEvent, UniTask> handler) where TEvent : EventBase =>
DefaultBus.UnregisterEvent(handler);
public static void UnregisterEvent<TEvent>(Action<TEvent> handler) where TEvent : EventBase =>
DefaultBus.UnregisterEvent(handler);
public static void ClearAllSubscribersForEvent<TEvent>() where TEvent : EventBase =>
DefaultBus.ClearAllSubscribersForEvent<TEvent>();
public static void UnregisterAllEventsForObject(object targetObject) =>
DefaultBus.UnregisterAllEventsForObject(targetObject);
public static void UnregisterInstance(object targetObject) => DefaultBus.Unregister(targetObject);
public static void UnregisterAllEvents() => DefaultBus.UnregisterAllEvents();
public static UniTask<bool> TriggerEventAsync<TEvent>(TEvent eventArgs) where TEvent : EventBase =>
DefaultBus.TriggerEventAsync(eventArgs);
public static UniTask<bool> TriggerEventAsync<TEvent>(EventPriority phase, TEvent eventArgs)
where TEvent : EventBase => DefaultBus.TriggerEventAsync(phase, eventArgs);
public static bool TriggerEvent<TEvent>(TEvent eventArgs) where TEvent : EventBase =>
DefaultBus.TriggerEvent(eventArgs);
public static bool TriggerEvent<TEvent>(EventPriority phase, TEvent eventArgs) where TEvent : EventBase =>
DefaultBus.TriggerEvent(phase, eventArgs);
public static EventHandlerInfo[] GetEventSubscribers<TEvent>() where TEvent : EventBase =>
DefaultBus.GetEventSubscribers<TEvent>();
public static ListenerList GetListenerList<TEvent>() where TEvent : EventBase =>
DefaultBus.GetListenerList<TEvent>();
public static IReadOnlyDictionary<Type, EventHandlerInfo[]> GetAllSubscribersSnapshot() =>
DefaultBus.GetAllSubscribersSnapshot();
public static IReadOnlyList<ShrinkEventSubscriptionSnapshot> GetActiveSubscriptionsSnapshot() =>
DefaultBus.GetActiveSubscriptionsSnapshot();
public static bool IsInstanceRegistered(object target) => DefaultBus.IsInstanceRegistered(target);
public static int GetRegisteredInstanceCount() => DefaultBus.GetRegisteredInstanceCount();
public static int GetRegisteredEventTypeCount() => DefaultBus.GetRegisteredEventTypeCount();
internal static IReadOnlyList<(ShrinkBusKey Key, ShrinkBusOptions Options, int Subscribers)> Snapshot()
{
var snapshot = new List<(ShrinkBusKey, ShrinkBusOptions, int)>(Buses.Count);
foreach (var pair in Buses)
{
var subscribers = pair.Value is ShrinkEventBusInstance instance
? instance.SubscriberCount
: 0;
snapshot.Add((pair.Key, pair.Value.Options, subscribers));
}
return snapshot;
}
private static IShrinkEventBus CreateDefaultBus()
{
var bus = (ShrinkEventBusInstance)new ShrinkEventBusBuilder()
.SetExceptionHandlingMode(ShrinkEventExceptionHandlingMode.LogAndContinue)
.AllowPerPhaseDispatch()
.Build();
EventBusRegHelper.RegStaticEventHandler(bus);
var bus = BuildBus(ShrinkBusKey.Game, ShrinkBusOptions.MainThread());
Buses[ShrinkBusKey.Game] = bus;
ShrinkStaticBindingRegistry.AttachForBus(ShrinkBusKey.Game, Resolver);
return bus;
}
private static IShrinkEventBus BuildBus(ShrinkBusKey key, ShrinkBusOptions options) =>
new ShrinkEventBusBuilder()
.WithKey(key)
.WithOptions(options)
.WithPostObserver(NotifyPostedObject)
.Build();
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void NotifyPostedObject(IShrinkEvent eventData, Type eventType, ShrinkBusKey key)
{
_posted?.Invoke(eventData, eventType, key);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static void NotifyDetailedPosted(ShrinkEventTrace trace)
{
var observers = Volatile.Read(ref _detailedPosted);
if (observers == null)
return;
try
{
observers(trace);
}
catch (Exception exception)
{
UnityEngine.Debug.LogException(exception);
}
}
}
}
@@ -1,519 +0,0 @@
using System;
using System.Diagnostics;
using System.Reflection;
using System.Text;
using Cysharp.Threading.Tasks;
using UnityEngine;
using Debug = UnityEngine.Debug;
namespace ShrinkEventBus.Runtime
{
/// <summary>
/// ShrinkEventBus 性能测试。
/// 挂在任意 GameObject 上,运行后在 Console 查看结果。
/// 在进行测试时不要打开事件查看器的大量实时追踪。
/// </summary>
public sealed class EventBusBenchmark : MonoBehaviour
{
[Header("同步触发测试")] public int iterations = 1_000_000;
[Header("大批量订阅者测试")] public int subscriberCount = 30;
public int massIterations = 10_000;
[Header("异步触发测试")] public int asyncIterations = 1_000;
[Header("注册 / 注销测试")] public int registerIterations = 5_000;
public int scannedRegisterIterations = 1_000;
[Header("取消事件测试")] public int cancelIterations = 100_000;
public class BenchmarkEvent : EventBase
{
public int Value { get; set; }
}
public sealed class DerivedBenchmarkEvent : BenchmarkEvent
{
public bool IsDerived { get; set; }
}
[Cancelable]
public class CancelableBenchmarkEvent : EventBase
{
public int Value { get; set; }
}
private sealed class BenchmarkInstanceSubscriber
{
public int Recorded;
[EventSubscribe(EventPriority.NORMAL)]
private void OnBenchmark(BenchmarkEvent evt)
{
Recorded = evt.Value;
}
}
private static class BenchmarkStaticSubscriber
{
public static int Recorded;
[EventSubscribe(EventPriority.NORMAL)]
private static void OnBenchmark(BenchmarkEvent evt)
{
Recorded = evt.Value;
}
}
private static class BenchmarkMethodSubscriber
{
public static int Recorded;
[EventSubscribe(EventPriority.NORMAL)]
private static void OnBenchmark(BenchmarkEvent evt)
{
Recorded = evt.Value;
}
}
private sealed class PhaseSubscriber
{
public int Sum;
[EventSubscribe(EventPriority.HIGH)]
private void OnHigh(BenchmarkEvent evt)
{
Sum += evt.Value + 1;
}
[EventSubscribe(EventPriority.NORMAL)]
private void OnNormal(BenchmarkEvent evt)
{
Sum += evt.Value + 10;
}
[EventSubscribe(EventPriority.LOW)]
private void OnLow(BenchmarkEvent evt)
{
Sum += evt.Value + 100;
}
}
private readonly StringBuilder _report = new();
private void Start()
{
RunAllBenchmarks().Forget();
}
private async UniTaskVoid RunAllBenchmarks()
{
_report.Clear();
_report.AppendLine("╔══════════════════════════════════════════════════╗");
_report.AppendLine("║ ShrinkEventBus Benchmark Report ║");
_report.AppendLine(
$"║ iter={iterations,8} mass={massIterations,6} reg={registerIterations,5} scan={scannedRegisterIterations,4} ║");
_report.AppendLine("╚══════════════════════════════════════════════════╝");
_report.AppendLine(" 基准对象:独立实例 busAllowPerPhaseDispatch 已开启)");
Log("开始:无订阅者触发");
Bench_TriggerEvent_NoSubscriber();
await UniTask.Yield();
Log("开始:单订阅者同步触发");
Bench_TriggerEvent_SingleSubscriber();
await UniTask.Yield();
Log("开始:父事件监听子事件");
Bench_TriggerEvent_InheritedSubscriber();
await UniTask.Yield();
Log($"开始:{subscriberCount} 订阅者同步触发");
Bench_TriggerEvent_MassSubscribers();
await UniTask.Yield();
Log("开始:按 phase 分发");
Bench_TriggerEvent_PerPhase();
await UniTask.Yield();
Log("开始:异步触发");
await Bench_TriggerEventAsync();
await UniTask.Yield();
Log("开始:手工 delegate 注册/注销");
Bench_RegisterUnregister_ManualDelegate();
await UniTask.Yield();
Log("开始:实例扫描注册/注销");
Bench_RegisterUnregister_InstanceScan();
await UniTask.Yield();
Log("开始:静态类型扫描注册/注销");
Bench_RegisterUnregister_StaticScan();
await UniTask.Yield();
Log("开始:MethodInfo 注册/注销");
Bench_RegisterUnregister_MethodScan();
await UniTask.Yield();
Log("开始:EventPool vs new");
Bench_EventPool_vs_New();
await UniTask.Yield();
Log("开始:已取消事件跳过");
Bench_CanceledEvent_Skip();
_report.AppendLine("\n══════════════════════════════════════════════════");
_report.AppendLine(" 全部测试完成");
Debug.Log(_report.ToString());
}
private void Bench_TriggerEvent_NoSubscriber()
{
var bus = CreateBenchmarkBus();
var evt = new BenchmarkEvent { Value = 1 };
Warmup(() => bus.TriggerEvent(evt), 500);
var sw = Stopwatch.StartNew();
for (var i = 0; i < iterations; i++)
bus.TriggerEvent(evt);
sw.Stop();
Record("TriggerEvent × 无订阅者(基线)", sw.Elapsed.TotalMilliseconds, iterations);
Log("完成:无订阅者触发");
}
private void Bench_TriggerEvent_SingleSubscriber()
{
var bus = CreateBenchmarkBus();
var dummy = 0;
void Handler(BenchmarkEvent evt)
{
dummy = evt.Value;
}
bus.RegisterEvent<BenchmarkEvent>(Handler, EventPriority.NORMAL);
var evt = new BenchmarkEvent { Value = 1 };
Warmup(() => bus.TriggerEvent(evt), 500);
var sw = Stopwatch.StartNew();
for (var i = 0; i < iterations; i++)
bus.TriggerEvent(evt);
sw.Stop();
Record("TriggerEvent × 单订阅者同步", sw.Elapsed.TotalMilliseconds, iterations);
Log("完成:单订阅者同步触发");
bus.UnregisterEvent<BenchmarkEvent>(Handler);
GC.KeepAlive(dummy);
}
private void Bench_TriggerEvent_InheritedSubscriber()
{
var bus = CreateBenchmarkBus();
var dummy = 0;
void Handler(BenchmarkEvent evt)
{
dummy = evt.Value;
}
bus.RegisterEvent<BenchmarkEvent>(Handler, EventPriority.NORMAL);
var evt = new DerivedBenchmarkEvent { Value = 1, IsDerived = true };
Warmup(() => bus.TriggerEvent(evt), 500);
var sw = Stopwatch.StartNew();
for (var i = 0; i < iterations; i++)
bus.TriggerEvent(evt);
sw.Stop();
Record("TriggerEvent × 父事件监听子事件", sw.Elapsed.TotalMilliseconds, iterations);
Log("完成:父事件监听子事件");
bus.UnregisterEvent<BenchmarkEvent>(Handler);
GC.KeepAlive(dummy);
}
private void Bench_TriggerEvent_MassSubscribers()
{
var bus = CreateBenchmarkBus();
var dummy = 0;
var handlers = new Action<BenchmarkEvent>[subscriberCount];
for (var i = 0; i < subscriberCount; i++)
{
var captured = i;
handlers[i] = evt => { dummy = captured + evt.Value; };
bus.RegisterEvent<BenchmarkEvent>(handlers[i], EventPriority.NORMAL);
}
var evt = new BenchmarkEvent { Value = 1 };
Warmup(() => bus.TriggerEvent(evt), 100);
var sw = Stopwatch.StartNew();
for (var i = 0; i < massIterations; i++)
bus.TriggerEvent(evt);
sw.Stop();
Record($"TriggerEvent × {subscriberCount} 订阅者同步", sw.Elapsed.TotalMilliseconds, massIterations);
Log($"完成:{subscriberCount} 订阅者同步触发");
foreach (var handler in handlers)
bus.UnregisterEvent<BenchmarkEvent>(handler);
GC.KeepAlive(dummy);
}
private void Bench_TriggerEvent_PerPhase()
{
var bus = CreateBenchmarkBus();
var subscriber = new PhaseSubscriber();
bus.Register(subscriber);
var evt = new BenchmarkEvent { Value = 1 };
Warmup(() => bus.TriggerEvent(EventPriority.HIGH, evt), 500);
var sw = Stopwatch.StartNew();
for (var i = 0; i < iterations; i++)
bus.TriggerEvent(EventPriority.HIGH, evt);
sw.Stop();
Record("TriggerEvent(Phase=HIGH) × 3 phase 监听", sw.Elapsed.TotalMilliseconds, iterations);
Log("完成:按 phase 分发");
bus.Unregister(subscriber);
GC.KeepAlive(subscriber.Sum);
}
private async UniTask Bench_TriggerEventAsync()
{
var bus = CreateBenchmarkBus();
var dummy = 0;
async UniTask Handler(BenchmarkEvent evt)
{
dummy = evt.Value;
await UniTask.CompletedTask;
}
bus.RegisterEvent<BenchmarkEvent>(Handler, EventPriority.NORMAL);
var evt = new BenchmarkEvent { Value = 1 };
await WarmupAsync(() => bus.TriggerEventAsync(evt), 10);
const int perFrame = 10;
var totalMs = 0.0;
var ran = 0;
while (ran < asyncIterations)
{
await UniTask.Yield();
var count = Math.Min(perFrame, asyncIterations - ran);
var sw = Stopwatch.StartNew();
for (var i = 0; i < count; i++)
await bus.TriggerEventAsync(evt);
sw.Stop();
totalMs += sw.Elapsed.TotalMilliseconds;
ran += count;
}
Record("TriggerEventAsync × 单订阅者异步", totalMs, asyncIterations);
Log("完成:异步触发");
bus.UnregisterEvent<BenchmarkEvent>(Handler);
GC.KeepAlive(dummy);
}
private void Bench_RegisterUnregister_ManualDelegate()
{
var bus = CreateBenchmarkBus();
var dummy = 0;
var handlers = new Action<BenchmarkEvent>[registerIterations];
for (var i = 0; i < registerIterations; i++)
{
var captured = i;
handlers[i] = evt => { dummy = captured; };
}
var swReg = Stopwatch.StartNew();
for (var i = 0; i < registerIterations; i++)
bus.RegisterEvent<BenchmarkEvent>(handlers[i], EventPriority.NORMAL);
swReg.Stop();
Record("RegisterEvent(delegate)", swReg.Elapsed.TotalMilliseconds, registerIterations);
var swUnreg = Stopwatch.StartNew();
for (var i = 0; i < registerIterations; i++)
bus.UnregisterEvent<BenchmarkEvent>(handlers[i]);
swUnreg.Stop();
Record("UnregisterEvent(delegate)", swUnreg.Elapsed.TotalMilliseconds, registerIterations);
Log("完成:手工 delegate 注册/注销");
GC.KeepAlive(dummy);
}
private void Bench_RegisterUnregister_InstanceScan()
{
var bus = CreateBenchmarkBus();
var subscribers = new BenchmarkInstanceSubscriber[scannedRegisterIterations];
for (var i = 0; i < subscribers.Length; i++)
subscribers[i] = new BenchmarkInstanceSubscriber();
var sw = Stopwatch.StartNew();
for (var i = 0; i < subscribers.Length; i++)
{
var subscriber = subscribers[i];
bus.Register(subscriber);
bus.Unregister(subscriber);
}
sw.Stop();
Record("Register/Unregister(instance scan)", sw.Elapsed.TotalMilliseconds, subscribers.Length);
Log("完成:实例扫描注册/注销");
}
private void Bench_RegisterUnregister_StaticScan()
{
var bus = CreateBenchmarkBus();
var sw = Stopwatch.StartNew();
for (var i = 0; i < scannedRegisterIterations; i++)
{
bus.Register(typeof(BenchmarkStaticSubscriber));
bus.Unregister(typeof(BenchmarkStaticSubscriber));
}
sw.Stop();
Record("Register/Unregister(static type scan)", sw.Elapsed.TotalMilliseconds, scannedRegisterIterations);
Log("完成:静态类型扫描注册/注销");
}
private void Bench_RegisterUnregister_MethodScan()
{
var bus = CreateBenchmarkBus();
var method = typeof(BenchmarkMethodSubscriber).GetMethod("OnBenchmark",
BindingFlags.Static | BindingFlags.NonPublic);
if (method == null)
throw new MissingMethodException(typeof(BenchmarkMethodSubscriber).FullName, "OnBenchmark");
var sw = Stopwatch.StartNew();
for (var i = 0; i < scannedRegisterIterations; i++)
{
bus.Register(method);
bus.Unregister(method);
}
sw.Stop();
Record("Register/Unregister(MethodInfo)", sw.Elapsed.TotalMilliseconds, scannedRegisterIterations);
Log("完成:MethodInfo 注册/注销");
}
private void Bench_EventPool_vs_New()
{
var bus = CreateBenchmarkBus();
void Handler(BenchmarkEvent evt)
{
}
bus.RegisterEvent<BenchmarkEvent>(Handler, EventPriority.NORMAL);
for (var i = 0; i < 64; i++)
EventPool<BenchmarkEvent>.Release(EventPool<BenchmarkEvent>.Get());
GC.Collect();
var gcBefore = GC.CollectionCount(0);
var swNew = Stopwatch.StartNew();
for (var i = 0; i < iterations; i++)
{
var evt = new BenchmarkEvent { Value = i };
bus.TriggerEvent(evt);
}
swNew.Stop();
var gcNew = GC.CollectionCount(0) - gcBefore;
Record($"TriggerEvent × new() [GC Gen0={gcNew,3}]", swNew.Elapsed.TotalMilliseconds, iterations);
GC.Collect();
gcBefore = GC.CollectionCount(0);
var swPool = Stopwatch.StartNew();
for (var i = 0; i < iterations; i++)
{
using var evt = EventPool<BenchmarkEvent>.Get();
evt.Value = i;
bus.TriggerEvent(evt);
}
swPool.Stop();
var gcPool = GC.CollectionCount(0) - gcBefore;
Record($"TriggerEvent × Pool.Get() [GC Gen0={gcPool,3}]", swPool.Elapsed.TotalMilliseconds, iterations);
Log("完成:EventPool vs new");
bus.UnregisterEvent<BenchmarkEvent>(Handler);
}
private void Bench_CanceledEvent_Skip()
{
var bus = CreateBenchmarkBus();
var dummy = 0;
var handlers = new Action<CancelableBenchmarkEvent>[10];
for (var i = 0; i < handlers.Length; i++)
{
handlers[i] = evt => { dummy = evt.Value; };
bus.RegisterEvent<CancelableBenchmarkEvent>(handlers[i], EventPriority.NORMAL);
}
var evt = new CancelableBenchmarkEvent { Value = 1 };
evt.SetCanceled(true);
Warmup(() => bus.TriggerEvent(evt), 500);
var sw = Stopwatch.StartNew();
for (var i = 0; i < cancelIterations; i++)
bus.TriggerEvent(evt);
sw.Stop();
Record("TriggerEvent × 已取消事件跳过(10 订阅者)", sw.Elapsed.TotalMilliseconds, cancelIterations);
Log("完成:已取消事件跳过");
foreach (var handler in handlers)
bus.UnregisterEvent<CancelableBenchmarkEvent>(handler);
GC.KeepAlive(dummy);
}
private static IShrinkEventBus CreateBenchmarkBus()
{
return EventBus.CreateBus(builder => builder.AllowPerPhaseDispatch());
}
private static void Warmup(Action action, int count)
{
for (var i = 0; i < count; i++)
action();
}
private static async UniTask WarmupAsync(Func<UniTask<bool>> action, int count)
{
for (var i = 0; i < count; i++)
await action();
}
private static void Log(string msg)
{
Debug.Log($"[Benchmark] {msg}");
}
private void Record(string label, double totalMs, int count)
{
var perOp = totalMs / count * 1000.0;
var throughput = totalMs <= 0.0001 ? 0 : count / (totalMs / 1000.0);
_report.AppendLine($"\n ▶ {label}");
_report.AppendLine($" 总耗时 : {totalMs,10:F3} ms");
_report.AppendLine($" 单次 : {perOp,10:F4} μs/op");
_report.AppendLine($" 吞吐量 : {throughput,10:F0} ops/s");
}
}
}
@@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: 9619a4dc431b4b2c8268c32a9e56f72f
timeCreated: 1772913062
@@ -1,53 +0,0 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
namespace ShrinkEventBus
{
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
public sealed class EventBusStaticRegistryAttribute : Attribute
{
public EventBusStaticRegistryAttribute(params Type[] subscriberTypes)
{
SubscriberTypes = subscriberTypes ?? Array.Empty<Type>();
}
public Type[] SubscriberTypes { get; }
}
internal static class EventBusGeneratedRegistry
{
public static IReadOnlyList<Type> GetStaticSubscriberTypes()
{
var types = new List<Type>();
var seen = new HashSet<Type>();
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
object[] attributes;
try
{
attributes = assembly.GetCustomAttributes(typeof(EventBusStaticRegistryAttribute), false);
}
catch
{
continue;
}
foreach (var attribute in attributes.OfType<EventBusStaticRegistryAttribute>())
{
foreach (var subscriberType in attribute.SubscriberTypes ?? Array.Empty<Type>())
{
if (subscriberType == null || !seen.Add(subscriberType))
continue;
types.Add(subscriberType);
}
}
}
return types;
}
}
}
@@ -1,193 +0,0 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Runtime.CompilerServices;
using Cysharp.Threading.Tasks;
using UnityEngine;
namespace ShrinkEventBus
{
internal static class EventBusRegHelper
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void RegStaticEventHandler(ShrinkEventBusInstance bus)
{
if (bus == null)
throw new ArgumentNullException(nameof(bus));
RegisterStaticSubscriberTypes(bus, EventBusGeneratedRegistry.GetStaticSubscriberTypes());
}
public static void RegisterStaticSubscriberTypes(ShrinkEventBusInstance bus, IEnumerable<Type> subscriberTypes)
{
if (bus == null)
throw new ArgumentNullException(nameof(bus));
if (subscriberTypes == null)
throw new ArgumentNullException(nameof(subscriberTypes));
foreach (var type in subscriberTypes)
{
try
{
if (type == null || type.GetCustomAttributes(typeof(EventBusSubscriberAttribute), false).Length == 0)
continue;
RegisterTarget(bus, type, requireSubscriberAttribute: true, lenientWhenNoMethods: true);
}
catch (Exception ex)
{
_ = ex;
#if UNITY_EDITOR
Debug.LogWarning($"[EventBus] Static registration failed for {type?.FullName}: {ex.Message}");
#endif
}
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void RegisterTarget(ShrinkEventBusInstance bus, object target, bool requireSubscriberAttribute,
bool lenientWhenNoMethods = false)
{
if (bus == null)
throw new ArgumentNullException(nameof(bus));
if (target == null)
throw new ArgumentNullException(nameof(target));
switch (target)
{
case MethodInfo method:
RegisterMethod(bus, null, method, requireSubscriberAttribute: false, isStaticRegistration: true);
return;
case Type staticType:
RegisterDeclaredMethods(bus, null, staticType, requireSubscriberAttribute,
isStaticRegistration: true, lenientWhenNoMethods);
return;
default:
RegisterDeclaredMethods(bus, target, target.GetType(), requireSubscriberAttribute,
isStaticRegistration: false, lenientWhenNoMethods);
return;
}
}
private static void RegisterDeclaredMethods(ShrinkEventBusInstance bus, object? target, Type type,
bool requireSubscriberAttribute, bool isStaticRegistration, bool lenientWhenNoMethods)
{
if (requireSubscriberAttribute &&
type.GetCustomAttributes(typeof(EventBusSubscriberAttribute), false).Length == 0)
{
throw new InvalidOperationException(
$"Type {type.FullName} must declare [EventBusSubscriber] before it can be auto-registered.");
}
var flags = (isStaticRegistration ? BindingFlags.Static : BindingFlags.Instance) |
BindingFlags.Public | BindingFlags.NonPublic;
var methods = type.GetMethods(flags);
var foundMethods = 0;
foreach (var method in methods)
{
var hasSubscribeAttribute = method.GetCustomAttributes(typeof(EventSubscribeAttribute), false).Length > 0;
if (!hasSubscribeAttribute)
continue;
if (method.IsStatic != isStaticRegistration)
{
var expected = isStaticRegistration ? "static" : "instance";
throw new InvalidOperationException(
$"Method {method} is annotated with [EventSubscribe] but does not match the expected {expected} registration mode.");
}
RegisterMethod(bus, target, method, requireSubscriberAttribute: false, isStaticRegistration);
foundMethods++;
}
if (foundMethods == 0)
{
var message =
$"Type {type.FullName} has no [EventSubscribe] methods for {(isStaticRegistration ? "static" : "instance")} registration.";
if (lenientWhenNoMethods)
{
Debug.LogWarning($"[EventBus] {message} Auto-registration skipped.");
return;
}
throw new InvalidOperationException(message);
}
}
private static void RegisterMethod(ShrinkEventBusInstance bus, object? target, MethodInfo method,
bool requireSubscriberAttribute, bool isStaticRegistration)
{
if (method == null)
throw new ArgumentNullException(nameof(method));
if (!method.IsDefined(typeof(EventSubscribeAttribute), false))
throw new InvalidOperationException($"Method {method} is not annotated with [EventSubscribe].");
if (method.IsStatic != isStaticRegistration)
{
var expected = isStaticRegistration ? "static" : "instance";
throw new InvalidOperationException(
$"Method {method} is annotated with [EventSubscribe] but does not match the expected {expected} registration mode.");
}
if (requireSubscriberAttribute && method.DeclaringType != null &&
method.DeclaringType.GetCustomAttributes(typeof(EventBusSubscriberAttribute), false).Length == 0)
{
throw new InvalidOperationException(
$"Type {method.DeclaringType.FullName} must declare [EventBusSubscriber] before it can be auto-registered.");
}
var subscribeAttr = (EventSubscribeAttribute)method.GetCustomAttributes(typeof(EventSubscribeAttribute), false)[0];
var parameters = method.GetParameters();
if (parameters.Length != 1)
{
throw new InvalidOperationException(
$"Method {method} has [EventSubscribe] but declares {parameters.Length} parameters. Event handlers must declare exactly one EventBase parameter.");
}
var parameterType = parameters[0].ParameterType;
if (!typeof(EventBase).IsAssignableFrom(parameterType))
{
throw new InvalidOperationException(
$"Method {method} has [EventSubscribe] but parameter {parameterType.FullName} does not inherit from EventBase.");
}
ProcessMethodRegistration(bus, target, method, subscribeAttr, parameterType);
}
private static void ProcessMethodRegistration(ShrinkEventBusInstance bus, object? target, MethodInfo method,
EventSubscribeAttribute subscribeAttr, Type parameterType)
{
string scope = target == null ? "Static" : "Instance";
string typeName = target == null ? method.DeclaringType?.Name ?? "Unknown" : target.GetType().Name;
if (method.ReturnType == typeof(UniTask))
{
var funcType = typeof(Func<,>).MakeGenericType(parameterType, typeof(UniTask));
var handlerDelegate = target == null
? Delegate.CreateDelegate(funcType, method)
: Delegate.CreateDelegate(funcType, target, method);
bus.RegisterEventInternal(parameterType, handlerDelegate, subscribeAttr.Priority,
subscribeAttr.NumericPriority, subscribeAttr.ReceiveCanceled,
$"{scope} {typeName}.{method.Name} (UniTask)", method);
return;
}
if (method.ReturnType == typeof(void))
{
var actionType = typeof(Action<>).MakeGenericType(parameterType);
var actionDelegate = target == null
? Delegate.CreateDelegate(actionType, method)
: Delegate.CreateDelegate(actionType, target, method);
bus.RegisterEventInternal(parameterType, actionDelegate, subscribeAttr.Priority,
subscribeAttr.NumericPriority, subscribeAttr.ReceiveCanceled,
$"{scope} {typeName}.{method.Name} (Sync)", method);
return;
}
throw new InvalidOperationException(
$"Method {method} has [EventSubscribe] but return type {method.ReturnType.FullName} is unsupported. Use void or UniTask.");
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: a237a407a3a42394eba27bc8e4dbdce2
@@ -1,75 +0,0 @@
using System;
using System.Collections.Generic;
using System.Reflection;
namespace ShrinkEventBus
{
internal static class EventCloneUtility
{
private static readonly object CacheLock = new();
private static readonly Dictionary<Type, FieldInfo[]> FieldCache = new();
public static TEvent CloneForDetachedDispatch<TEvent>(TEvent source) where TEvent : EventBase
{
if (source == null)
throw new ArgumentNullException(nameof(source));
if (Activator.CreateInstance(source.GetType()) is not TEvent clone)
throw new InvalidOperationException(
$"Cannot clone event type {source.GetType().FullName}. A public parameterless constructor is required.");
// 先固化 EventId,让克隆与原事件共享同一个派发标识
_ = source.EventId;
CopyFields(source, clone);
clone.ReleaseAction = null;
clone.IsInPool = false;
return clone;
}
private static void CopyFields(EventBase source, EventBase target)
{
var fields = GetCopyableFields(source.GetType());
for (var i = 0; i < fields.Length; i++)
fields[i].SetValue(target, fields[i].GetValue(source));
}
private static FieldInfo[] GetCopyableFields(Type type)
{
lock (CacheLock)
{
if (FieldCache.TryGetValue(type, out var cached))
return cached;
var fields = new List<FieldInfo>();
var currentType = type;
while (currentType != null && currentType != typeof(object))
{
var declaredFields = currentType.GetFields(BindingFlags.Instance | BindingFlags.Public |
BindingFlags.NonPublic | BindingFlags.DeclaredOnly);
for (var i = 0; i < declaredFields.Length; i++)
{
var field = declaredFields[i];
if (field.IsStatic)
continue;
if (ShouldSkipField(field))
continue;
fields.Add(field);
}
currentType = currentType.BaseType;
}
cached = fields.ToArray();
FieldCache[type] = cached;
return cached;
}
}
private static bool ShouldSkipField(FieldInfo field)
{
return field.Name is "<ReleaseAction>k__BackingField"
or "<IsInPool>k__BackingField";
}
}
}
@@ -1,141 +0,0 @@
using System;
using System.Reflection;
using Cysharp.Threading.Tasks;
#pragma warning disable CS8632 // 只能在 "#nullable" 注释上下文内的代码中使用可为 null 的引用类型的注释。
namespace ShrinkEventBus
{
public interface IMethodWrapper
{
MethodInfo OriginalMethod { get; }
}
public class EventHandlerInfo
{
public long SubscriptionId { get; }
public Delegate Handler { get; }
internal Action<EventBase>? SyncInvoker { get; }
internal Func<EventBase, UniTask>? AsyncInvoker { get; }
public EventPriority Priority { get; }
public int NumericPriority { get; }
public bool ReceiveCanceled { get; }
public object? Target { get; }
public MethodInfo Method { get; }
public string DebugInfo { get; }
public Type DeclaringType { get; }
public string MethodName { get; }
public MethodInfo? OriginalMethod { get; }
public string OriginalMethodName { get; }
public Type? OriginalDeclaringType { get; }
public DateTime RegisteredAtUtc { get; }
private EventHandlerInfo(long subscriptionId, Delegate handler, Action<EventBase>? syncInvoker,
Func<EventBase, UniTask>? asyncInvoker,
EventPriority priority, int numericPriority, bool receiveCanceled, string debugInfo = "",
MethodInfo? originalMethod = null)
{
SubscriptionId = subscriptionId;
Handler = handler;
SyncInvoker = syncInvoker;
AsyncInvoker = asyncInvoker;
Priority = priority;
NumericPriority = numericPriority;
ReceiveCanceled = receiveCanceled;
Target = handler.Target;
Method = handler.Method;
DebugInfo = debugInfo;
DeclaringType = Method.DeclaringType ?? typeof(object);
MethodName = Method.Name;
OriginalMethod = ExtractOriginalMethodFromWrapper(handler) ?? originalMethod;
if (OriginalMethod != null)
{
OriginalMethodName = OriginalMethod.Name;
OriginalDeclaringType = OriginalMethod.DeclaringType;
}
else
{
OriginalMethodName = MethodName;
OriginalDeclaringType = DeclaringType;
}
RegisteredAtUtc = DateTime.UtcNow;
}
private static MethodInfo? ExtractOriginalMethodFromWrapper(Delegate handler)
{
if (handler.Target is IMethodWrapper wrapper)
{
return wrapper.OriginalMethod;
}
return null;
}
public string DisplayMethodName => OriginalMethodName;
public Type DisplayDeclaringType => OriginalDeclaringType ?? DeclaringType;
public bool MatchesMethod(MethodInfo method)
{
return method != null && (ReferenceEquals(Method, method) || ReferenceEquals(OriginalMethod, method));
}
public bool MatchesDeclaringType(Type declaringType)
{
return declaringType != null &&
(DeclaringType == declaringType || OriginalDeclaringType == declaringType);
}
public static EventHandlerInfo Create(long subscriptionId, Delegate handler, Type eventType,
EventPriority priority, int numericPriority, bool receiveCanceled, string debugInfo = "",
MethodInfo? originalMethod = null)
{
if (handler == null)
throw new System.ArgumentNullException(nameof(handler));
if (eventType == null)
throw new System.ArgumentNullException(nameof(eventType));
Action<EventBase>? syncInvoker = null;
Func<EventBase, UniTask>? asyncInvoker = null;
if (handler.Method.ReturnType == typeof(void))
syncInvoker = CreateSyncInvoker(eventType, handler);
else if (handler.Method.ReturnType == typeof(UniTask))
asyncInvoker = CreateAsyncInvoker(eventType, handler);
else
throw new System.ArgumentException(
$"Unsupported event handler return type {handler.Method.ReturnType.FullName} for {handler.Method}.");
return new EventHandlerInfo(subscriptionId, handler, syncInvoker, asyncInvoker, priority, numericPriority,
receiveCanceled, debugInfo, originalMethod);
}
private static Action<EventBase> CreateSyncInvoker(Type eventType, Delegate handler)
{
var factory = typeof(EventHandlerInfo).GetMethod(nameof(CreateSyncInvokerGeneric),
BindingFlags.NonPublic | BindingFlags.Static)!.MakeGenericMethod(eventType);
return (Action<EventBase>)factory.Invoke(null, new object[] { handler })!;
}
private static Func<EventBase, UniTask> CreateAsyncInvoker(Type eventType, Delegate handler)
{
var factory = typeof(EventHandlerInfo).GetMethod(nameof(CreateAsyncInvokerGeneric),
BindingFlags.NonPublic | BindingFlags.Static)!.MakeGenericMethod(eventType);
return (Func<EventBase, UniTask>)factory.Invoke(null, new object[] { handler })!;
}
private static Action<EventBase> CreateSyncInvokerGeneric<TEvent>(Delegate handler) where TEvent : EventBase
{
var typedHandler = (Action<TEvent>)handler;
return eventArgs => typedHandler((TEvent)eventArgs);
}
private static Func<EventBase, UniTask> CreateAsyncInvokerGeneric<TEvent>(Delegate handler)
where TEvent : EventBase
{
var typedHandler = (Func<TEvent, UniTask>)handler;
return eventArgs => typedHandler((TEvent)eventArgs);
}
}
}
@@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: 19972a32cbf54c7f94d6214fde5635c2
timeCreated: 1760098840
@@ -1,47 +0,0 @@
using System;
using System.Collections.Generic;
namespace ShrinkEventBus
{
public static class EventPool<T> where T : EventBase, new()
{
private const int MaxPoolSize = 128;
private static readonly Stack<T> Pool = new(32);
private static readonly object Lock = new();
private static readonly Action<EventBase> CachedReleaseAction = e => Release((T)e);
public static T Get()
{
lock (Lock)
{
if (Pool.Count > 0)
{
var evt = Pool.Pop();
evt.IsInPool = false;
return evt;
}
}
var newEvt = new T();
newEvt.ReleaseAction = CachedReleaseAction;
return newEvt;
}
public static void Release(T evt)
{
if (evt == null)
return;
lock (Lock)
{
if (evt.IsInPool || Pool.Count >= MaxPoolSize)
return;
evt.ResetInternal();
evt.IsInPool = true;
Pool.Push(evt);
}
}
}
}
@@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: c59d817792dd40f08ffed2f1a8d65cb8
timeCreated: 1772891540
@@ -1,27 +0,0 @@
namespace ShrinkEventBus
{
public enum EventPriority
{
HIGHEST = 0,
HIGH = 1,
NORMAL = 2,
LOW = 3,
LOWEST = 4,
MONITOR = 5
}
internal static class PriorityHelper
{
public static EventPriority ConvertToEventPriority(int numericPriority)
{
return numericPriority switch
{
>= 100 => EventPriority.HIGHEST,
>= 50 => EventPriority.HIGH,
>= 0 => EventPriority.NORMAL,
>= -50 => EventPriority.LOW,
_ => EventPriority.LOWEST
};
}
}
}
@@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: c77c5178c66948f29a7c87c9ae203004
timeCreated: 1760098774
@@ -1,20 +0,0 @@
using System;
namespace ShrinkEventBus
{
public class UnsupportedOperationException : InvalidOperationException
{
public UnsupportedOperationException() : base()
{
}
public UnsupportedOperationException(string message) : base(message)
{
}
public UnsupportedOperationException(string message, Exception innerException)
: base(message, innerException)
{
}
}
}
@@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: 893fdc450e7a4a8e8e1b675f0ce084a7
timeCreated: 1760098825
@@ -1,109 +1,23 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Threading;
using Cysharp.Threading.Tasks;
namespace ShrinkEventBus
{
public interface IShrinkEventSubscription : IDisposable
public interface IShrinkEventBus : IDisposable
{
long SubscriptionId { get; }
bool IsDisposed { get; }
}
ShrinkBusKey Key { get; }
ShrinkBusOptions Options { get; }
public readonly struct ShrinkEventSubscriptionSnapshot
{
public ShrinkEventSubscriptionSnapshot(long subscriptionId, Type eventType, EventPriority priority,
int numericPriority, bool receiveCanceled, object target, string targetTypeName, string methodName,
string debugInfo, DateTime registeredAtUtc)
{
SubscriptionId = subscriptionId;
EventType = eventType ?? throw new ArgumentNullException(nameof(eventType));
Priority = priority;
NumericPriority = numericPriority;
ReceiveCanceled = receiveCanceled;
Target = target;
TargetTypeName = targetTypeName ?? string.Empty;
MethodName = methodName ?? string.Empty;
DebugInfo = debugInfo ?? string.Empty;
RegisteredAtUtc = registeredAtUtc;
}
ShrinkPostResult Post<TEvent>(in TEvent eventData)
where TEvent : IShrinkEvent;
public long SubscriptionId { get; }
public Type EventType { get; }
public EventPriority Priority { get; }
public int NumericPriority { get; }
public bool ReceiveCanceled { get; }
public object Target { get; }
public string TargetTypeName { get; }
public string MethodName { get; }
public string DebugInfo { get; }
public DateTime RegisteredAtUtc { get; }
public bool IsStaticHandler => Target == null;
}
UniTask<ShrinkPostResult> PostAsync<TEvent>(TEvent eventData,
CancellationToken cancellationToken = default)
where TEvent : IShrinkEvent;
public interface IShrinkEventBus
{
event Action<EventBase, Type> OnEventTriggered;
#if UNITY_EDITOR
event Action<EventBase, string, string, EventHandlerInfo[]> OnEventTriggeredForEditor;
bool EnableDebugRecord { get; set; }
#endif
bool IsStarted { get; }
void Start();
void AutoRegister(object target);
void Register(object target);
void Unregister(object target);
void RegisterEvent<TEvent>(Action<TEvent> handler, EventPriority priority = EventPriority.NORMAL,
bool receiveCanceled = false) where TEvent : EventBase;
void RegisterEvent<TEvent>(Action<TEvent> handler, int priority) where TEvent : EventBase;
void RegisterEvent<TEvent>(Func<TEvent, UniTask> handler, EventPriority priority = EventPriority.NORMAL,
bool receiveCanceled = false) where TEvent : EventBase;
void RegisterEvent<TEvent>(Func<TEvent, UniTask> handler, int priority) where TEvent : EventBase;
IShrinkEventSubscription SubscribeEvent<TEvent>(Action<TEvent> handler,
EventPriority priority = EventPriority.NORMAL, bool receiveCanceled = false) where TEvent : EventBase;
IShrinkEventSubscription SubscribeEvent<TEvent>(Action<TEvent> handler, int priority)
where TEvent : EventBase;
IShrinkEventSubscription SubscribeEvent<TEvent>(Func<TEvent, UniTask> handler,
EventPriority priority = EventPriority.NORMAL, bool receiveCanceled = false) where TEvent : EventBase;
IShrinkEventSubscription SubscribeEvent<TEvent>(Func<TEvent, UniTask> handler, int priority)
where TEvent : EventBase;
void UnregisterEvent<TEvent>(Action<TEvent> handler) where TEvent : EventBase;
void UnregisterEvent<TEvent>(Func<TEvent, UniTask> handler) where TEvent : EventBase;
void ClearAllSubscribersForEvent<TEvent>() where TEvent : EventBase;
void UnregisterAllEventsForObject(object targetObject);
void UnregisterAllEvents();
bool TriggerEvent<TEvent>(TEvent eventArgs) where TEvent : EventBase;
bool TriggerEvent<TEvent>(EventPriority phase, TEvent eventArgs) where TEvent : EventBase;
UniTask<bool> TriggerEventAsync<TEvent>(TEvent eventArgs) where TEvent : EventBase;
UniTask<bool> TriggerEventAsync<TEvent>(EventPriority phase, TEvent eventArgs) where TEvent : EventBase;
EventHandlerInfo[] GetEventSubscribers<TEvent>() where TEvent : EventBase;
ListenerList GetListenerList<TEvent>() where TEvent : EventBase;
IReadOnlyDictionary<Type, EventHandlerInfo[]> GetAllSubscribersSnapshot();
IReadOnlyList<ShrinkEventSubscriptionSnapshot> GetActiveSubscriptionsSnapshot();
bool IsInstanceRegistered(object target);
int GetRegisteredInstanceCount();
int GetRegisteredEventTypeCount();
}
public interface IShrinkEventExceptionHandler
{
void HandleException(IShrinkEventBus bus, EventBase eventArgs, EventHandlerInfo[] listeners, int index,
Exception exception);
}
public enum ShrinkEventExceptionHandlingMode
{
LogAndContinue,
Throw,
LogAndThrow
IDisposable Attach(object target);
}
}
@@ -1,262 +0,0 @@
#nullable enable
using System;
using System.Collections.Generic;
namespace ShrinkEventBus
{
public class ListenerList
{
private static readonly EventPriority[] Priorities = (EventPriority[])Enum.GetValues(typeof(EventPriority));
private readonly object _lock;
private readonly List<EventHandlerInfo>[] _priorityBuckets;
private readonly ListenerList? _parent;
private List<ListenerList>? _children;
private EventHandlerInfo[] _snapshot = Array.Empty<EventHandlerInfo>();
private readonly EventHandlerInfo[]?[] _phaseSnapshots = new EventHandlerInfo[Priorities.Length][];
private bool _dirty;
public ListenerList(ListenerList? parent = null) : this(parent, null)
{
}
// 同一条父子链必须共用一把锁,否则脏标记传播与快照重建会产生竞态
internal ListenerList(ListenerList? parent, object? sharedLock)
{
_lock = sharedLock ?? parent?._lock ?? new object();
_priorityBuckets = new List<EventHandlerInfo>[Priorities.Length];
for (var i = 0; i < _priorityBuckets.Length; i++)
_priorityBuckets[i] = new List<EventHandlerInfo>();
_parent = parent;
_parent?.AddChild(this);
_dirty = true;
}
public int LocalCount
{
get
{
lock (_lock)
{
var count = 0;
foreach (var list in _priorityBuckets)
count += list.Count;
return count;
}
}
}
public int Count => GetHandlers().Length;
public void Add(EventHandlerInfo info)
{
if (info == null)
throw new ArgumentNullException(nameof(info));
lock (_lock)
{
var list = _priorityBuckets[(int)info.Priority];
var index = BinarySearchInsertIndex(list, info.NumericPriority);
list.Insert(index, info);
MarkDirty();
}
}
public bool Remove(Delegate handler)
{
if (handler == null)
return false;
lock (_lock)
{
foreach (var list in _priorityBuckets)
{
for (var i = 0; i < list.Count; i++)
{
if (!list[i].Handler.Equals(handler))
continue;
list.RemoveAt(i);
MarkDirty();
return true;
}
}
return false;
}
}
public int RemoveWhere(Predicate<EventHandlerInfo> predicate)
{
if (predicate == null)
return 0;
lock (_lock)
{
var removedCount = 0;
foreach (var list in _priorityBuckets)
{
for (var i = list.Count - 1; i >= 0; i--)
{
if (!predicate(list[i]))
continue;
list.RemoveAt(i);
removedCount++;
}
}
if (removedCount > 0)
MarkDirty();
return removedCount;
}
}
public void RemoveTarget(object target)
{
if (target == null)
return;
lock (_lock)
{
var removed = false;
foreach (var list in _priorityBuckets)
{
for (var i = list.Count - 1; i >= 0; i--)
{
if (!ReferenceEquals(list[i].Target, target))
continue;
list.RemoveAt(i);
removed = true;
}
}
if (removed)
MarkDirty();
}
}
public EventHandlerInfo[] GetHandlers()
{
lock (_lock)
{
if (_dirty)
RebuildSnapshot();
return _snapshot;
}
}
public EventHandlerInfo[] GetHandlers(EventPriority priority)
{
lock (_lock)
{
if (_dirty)
RebuildSnapshot();
return _phaseSnapshots[(int)priority] ?? Array.Empty<EventHandlerInfo>();
}
}
public void Clear()
{
lock (_lock)
{
foreach (var list in _priorityBuckets)
list.Clear();
MarkDirty();
}
}
private void AddChild(ListenerList child)
{
if (child == null)
return;
lock (_lock)
{
_children ??= new List<ListenerList>(2);
_children.Add(child);
}
}
private void MarkDirty()
{
_dirty = true;
_snapshot = Array.Empty<EventHandlerInfo>();
Array.Clear(_phaseSnapshots, 0, _phaseSnapshots.Length);
if (_children == null)
return;
for (var i = 0; i < _children.Count; i++)
_children[i].MarkDirty();
}
private void RebuildSnapshot()
{
var merged = new List<EventHandlerInfo>();
for (var i = 0; i < Priorities.Length; i++)
{
var parentHandlers = _parent != null
? _parent.GetHandlers(Priorities[i])
: Array.Empty<EventHandlerInfo>();
var phaseSnapshot = MergeByNumericPriority(_priorityBuckets[i], parentHandlers);
_phaseSnapshots[i] = phaseSnapshot;
merged.AddRange(phaseSnapshot);
}
_snapshot = merged.ToArray();
_dirty = false;
}
// 两侧均已按 NumericPriority 降序排列;平局时本类型 handler 在前
private static EventHandlerInfo[] MergeByNumericPriority(List<EventHandlerInfo> own,
EventHandlerInfo[] parentHandlers)
{
if (parentHandlers.Length == 0)
return own.ToArray();
if (own.Count == 0)
return parentHandlers;
var result = new EventHandlerInfo[own.Count + parentHandlers.Length];
int i = 0, j = 0, k = 0;
while (i < own.Count && j < parentHandlers.Length)
{
result[k++] = parentHandlers[j].NumericPriority > own[i].NumericPriority
? parentHandlers[j++]
: own[i++];
}
while (i < own.Count)
result[k++] = own[i++];
while (j < parentHandlers.Length)
result[k++] = parentHandlers[j++];
return result;
}
private static int BinarySearchInsertIndex(List<EventHandlerInfo> list, int numericPriority)
{
var lo = 0;
var hi = list.Count;
while (lo < hi)
{
var mid = (lo + hi) >> 1;
var cmp = numericPriority.CompareTo(list[mid].NumericPriority);
if (cmp > 0)
hi = mid;
else
lo = mid + 1;
}
return lo;
}
}
}
@@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: 864eb3e7bf8f443c86472995e408c580
timeCreated: 1760098877
@@ -0,0 +1,546 @@
#nullable enable
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Threading;
using Cysharp.Threading.Tasks;
namespace ShrinkEventBus
{
internal interface IShrinkBusScheduler : IDisposable
{
bool IsOnSchedulerThread { get; }
bool TryPost(Action action);
UniTask PostAsync(Func<UniTask> action, CancellationToken cancellationToken);
UniTask ShutdownAsync(bool drain, TimeSpan timeout);
}
internal sealed class ShrinkQueueFullException : InvalidOperationException
{
public ShrinkQueueFullException(string schedulerName)
: base($"Scheduler queue '{schedulerName}' is full.")
{
}
}
internal static class ShrinkBusSchedulerFactory
{
public static IShrinkBusScheduler Create(ShrinkBusOptions options, string name)
{
return options.Scheduler switch
{
ShrinkBusSchedulerKind.MainThread => new ShrinkMainThreadScheduler(name, options),
ShrinkBusSchedulerKind.DedicatedThread => new ShrinkDedicatedThreadScheduler(name, options),
ShrinkBusSchedulerKind.TaskPool => new ShrinkTaskPoolScheduler(name, options),
_ => new ShrinkInlineScheduler()
};
}
}
internal sealed class ShrinkSchedulerWorkItem
{
private readonly UniTaskCompletionSource? _completion;
public ShrinkSchedulerWorkItem(Func<UniTask> action, bool awaitable)
{
Action = action ?? throw new ArgumentNullException(nameof(action));
if (awaitable)
_completion = new UniTaskCompletionSource();
}
public Func<UniTask> Action { get; }
public UniTask Completion => _completion?.Task ?? UniTask.CompletedTask;
public void Complete() => _completion?.TrySetResult();
public void Fail(Exception exception) => _completion?.TrySetException(exception);
public void Cancel(CancellationToken cancellationToken) => _completion?.TrySetCanceled(cancellationToken);
public void Drop(string schedulerName) =>
_completion?.TrySetException(new ShrinkQueueFullException(schedulerName));
}
internal sealed class ShrinkSchedulerQueue : IDisposable
{
private readonly ConcurrentQueue<ShrinkSchedulerWorkItem> _queue = new();
private readonly SemaphoreSlim _slots;
private readonly ShrinkQueueOverflowPolicy _overflowPolicy;
private readonly string _name;
private int _disposed;
public ShrinkSchedulerQueue(string name, ShrinkBusOptions options)
{
_name = name;
_overflowPolicy = options.OverflowPolicy;
_slots = new SemaphoreSlim(options.QueueCapacity, options.QueueCapacity);
}
public bool IsEmpty => _queue.IsEmpty;
public bool TryEnqueue(ShrinkSchedulerWorkItem item)
{
if (Volatile.Read(ref _disposed) != 0)
return false;
if (!_slots.Wait(0))
{
if (_overflowPolicy != ShrinkQueueOverflowPolicy.DropOldest ||
!_queue.TryDequeue(out var dropped))
return false;
dropped.Drop(_name);
_slots.Release();
if (!_slots.Wait(0))
return false;
}
_queue.Enqueue(item);
return true;
}
public async UniTask EnqueueAsync(ShrinkSchedulerWorkItem item, CancellationToken cancellationToken)
{
if (Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(_name);
if (_overflowPolicy == ShrinkQueueOverflowPolicy.Wait)
{
await _slots.WaitAsync(cancellationToken).AsUniTask(useCurrentSynchronizationContext: false);
if (Volatile.Read(ref _disposed) != 0)
{
_slots.Release();
throw new ObjectDisposedException(_name);
}
_queue.Enqueue(item);
return;
}
if (!TryEnqueue(item))
throw new ShrinkQueueFullException(_name);
}
public bool TryDequeue(out ShrinkSchedulerWorkItem item)
{
if (!_queue.TryDequeue(out item!))
return false;
_slots.Release();
return true;
}
public void DropPending()
{
while (_queue.TryDequeue(out var item))
{
_slots.Release();
item.Drop(_name);
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
return;
DropPending();
_slots.Dispose();
}
}
internal static class ShrinkSchedulerWorkItemRunner
{
public static async UniTask RunAsync(ShrinkSchedulerWorkItem item)
{
try
{
await item.Action();
item.Complete();
}
catch (OperationCanceledException ex)
{
item.Cancel(ex.CancellationToken);
}
catch (Exception ex)
{
item.Fail(ex);
ShrinkEventDiagnostics.LogException(ex);
}
}
public static void RunBlocking(ShrinkSchedulerWorkItem item)
{
try
{
item.Action().GetAwaiter().GetResult();
item.Complete();
}
catch (OperationCanceledException ex)
{
item.Cancel(ex.CancellationToken);
}
catch (Exception ex)
{
item.Fail(ex);
ShrinkEventDiagnostics.LogException(ex);
}
}
}
internal sealed class ShrinkInlineScheduler : IShrinkBusScheduler
{
public bool IsOnSchedulerThread => true;
public bool TryPost(Action action)
{
action();
return true;
}
public UniTask PostAsync(Func<UniTask> action, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return action();
}
public UniTask ShutdownAsync(bool drain, TimeSpan timeout) => UniTask.CompletedTask;
public void Dispose() { }
}
internal sealed class ShrinkMainThreadScheduler : IShrinkBusScheduler
{
private readonly ShrinkSchedulerQueue _queue;
private int _pumpScheduled;
private int _disposed;
public ShrinkMainThreadScheduler(string name, ShrinkBusOptions options)
{
_queue = new ShrinkSchedulerQueue(name, options);
}
public bool IsOnSchedulerThread => PlayerLoopHelper.IsMainThread;
public bool TryPost(Action action)
{
if (Volatile.Read(ref _disposed) != 0)
return false;
if (IsOnSchedulerThread)
{
action();
return true;
}
var item = new ShrinkSchedulerWorkItem(() =>
{
action();
return UniTask.CompletedTask;
}, awaitable: false);
if (!_queue.TryEnqueue(item))
return false;
SchedulePump();
return true;
}
public async UniTask PostAsync(Func<UniTask> action, CancellationToken cancellationToken)
{
if (Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(ShrinkMainThreadScheduler));
cancellationToken.ThrowIfCancellationRequested();
if (IsOnSchedulerThread)
{
await action();
return;
}
var item = new ShrinkSchedulerWorkItem(action, awaitable: true);
await _queue.EnqueueAsync(item, cancellationToken);
SchedulePump();
await item.Completion.AttachExternalCancellation(cancellationToken);
}
public async UniTask ShutdownAsync(bool drain, TimeSpan timeout)
{
Interlocked.Exchange(ref _disposed, 1);
if (!drain)
_queue.DropPending();
else
await ShrinkSchedulerShutdown.WaitUntilDrainedAsync(
_queue, () => Volatile.Read(ref _pumpScheduled) != 0, timeout);
_queue.Dispose();
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
return;
_queue.Dispose();
}
private void SchedulePump()
{
if (Interlocked.Exchange(ref _pumpScheduled, 1) != 0)
return;
UniTask.Void(PumpAsync);
}
private async UniTaskVoid PumpAsync()
{
await UniTask.SwitchToMainThread();
try
{
while (_queue.TryDequeue(out var item))
await ShrinkSchedulerWorkItemRunner.RunAsync(item);
}
finally
{
Interlocked.Exchange(ref _pumpScheduled, 0);
if (!_queue.IsEmpty && Volatile.Read(ref _disposed) == 0)
SchedulePump();
}
}
}
internal sealed class ShrinkTaskPoolScheduler : IShrinkBusScheduler
{
private readonly ShrinkSchedulerQueue _queue;
private readonly int _maxConcurrency;
private int _workers;
private int _disposed;
public ShrinkTaskPoolScheduler(string name, ShrinkBusOptions options)
{
_queue = new ShrinkSchedulerQueue(name, options);
_maxConcurrency = options.DispatchMode == ShrinkDispatchMode.Ordered ? 1 : options.MaxConcurrency;
}
public bool IsOnSchedulerThread => false;
public bool TryPost(Action action)
{
if (Volatile.Read(ref _disposed) != 0)
return false;
var item = new ShrinkSchedulerWorkItem(() =>
{
action();
return UniTask.CompletedTask;
}, awaitable: false);
if (!_queue.TryEnqueue(item))
return false;
ScheduleWorkers();
return true;
}
public async UniTask PostAsync(Func<UniTask> action, CancellationToken cancellationToken)
{
if (Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(ShrinkTaskPoolScheduler));
var item = new ShrinkSchedulerWorkItem(action, awaitable: true);
await _queue.EnqueueAsync(item, cancellationToken);
ScheduleWorkers();
await item.Completion.AttachExternalCancellation(cancellationToken);
}
public async UniTask ShutdownAsync(bool drain, TimeSpan timeout)
{
Interlocked.Exchange(ref _disposed, 1);
if (!drain)
_queue.DropPending();
else
await ShrinkSchedulerShutdown.WaitUntilDrainedAsync(
_queue, () => Volatile.Read(ref _workers) != 0, timeout);
_queue.Dispose();
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
return;
_queue.Dispose();
}
private void ScheduleWorkers()
{
while (!_queue.IsEmpty)
{
var current = Volatile.Read(ref _workers);
if (current >= _maxConcurrency ||
Interlocked.CompareExchange(ref _workers, current + 1, current) != current)
return;
UniTask.Void(WorkerAsync);
}
}
private async UniTaskVoid WorkerAsync()
{
await UniTask.SwitchToThreadPool();
try
{
while (_queue.TryDequeue(out var item))
await ShrinkSchedulerWorkItemRunner.RunAsync(item);
}
finally
{
Interlocked.Decrement(ref _workers);
if (!_queue.IsEmpty && Volatile.Read(ref _disposed) == 0)
ScheduleWorkers();
}
}
}
internal sealed class ShrinkDedicatedThreadScheduler : IShrinkBusScheduler
{
private readonly ShrinkSchedulerQueue _queue;
private readonly AutoResetEvent _signal = new(false);
private readonly ManualResetEventSlim _stopped = new(false);
private readonly Thread _thread;
private int _disposed;
private int _threadId;
private int _shutdownTimedOut;
private int _waitHandlesDisposed;
public ShrinkDedicatedThreadScheduler(string name, ShrinkBusOptions options)
{
_queue = new ShrinkSchedulerQueue(name, options);
_thread = new Thread(Run)
{
IsBackground = true,
Name = $"ShrinkBus:{name}"
};
_thread.Start();
}
public bool IsOnSchedulerThread => Thread.CurrentThread.ManagedThreadId == Volatile.Read(ref _threadId);
public bool TryPost(Action action)
{
if (Volatile.Read(ref _disposed) != 0)
return false;
if (IsOnSchedulerThread)
{
action();
return true;
}
var item = new ShrinkSchedulerWorkItem(() =>
{
action();
return UniTask.CompletedTask;
}, awaitable: false);
if (!_queue.TryEnqueue(item))
return false;
_signal.Set();
return true;
}
public async UniTask PostAsync(Func<UniTask> action, CancellationToken cancellationToken)
{
if (Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(ShrinkDedicatedThreadScheduler));
if (IsOnSchedulerThread)
{
await action();
return;
}
var item = new ShrinkSchedulerWorkItem(action, awaitable: true);
await _queue.EnqueueAsync(item, cancellationToken);
_signal.Set();
await item.Completion.AttachExternalCancellation(cancellationToken);
}
public async UniTask ShutdownAsync(bool drain, TimeSpan timeout)
{
Interlocked.Exchange(ref _disposed, 1);
if (!drain)
_queue.DropPending();
_signal.Set();
var waitMs = timeout == Timeout.InfiniteTimeSpan
? Timeout.Infinite
: Math.Max(0, (int)Math.Min(int.MaxValue, timeout.TotalMilliseconds));
var stopped = await UniTask.RunOnThreadPool(() => _stopped.Wait(waitMs));
if (!stopped)
{
_queue.DropPending();
Volatile.Write(ref _shutdownTimedOut, 1);
try
{
if (_stopped.IsSet)
DisposeWaitHandles();
}
catch (ObjectDisposedException)
{
}
return;
}
DisposeWaitHandles();
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
return;
_queue.DropPending();
Volatile.Write(ref _shutdownTimedOut, 1);
_signal.Set();
}
private void Run()
{
Volatile.Write(ref _threadId, Thread.CurrentThread.ManagedThreadId);
try
{
while (Volatile.Read(ref _disposed) == 0 || !_queue.IsEmpty)
{
if (!_queue.TryDequeue(out var item))
{
_signal.WaitOne(50);
continue;
}
ShrinkSchedulerWorkItemRunner.RunBlocking(item);
}
}
finally
{
_stopped.Set();
if (Volatile.Read(ref _shutdownTimedOut) != 0)
DisposeWaitHandles();
}
}
private void DisposeWaitHandles()
{
if (Interlocked.Exchange(ref _waitHandlesDisposed, 1) != 0)
return;
_queue.Dispose();
_signal.Dispose();
_stopped.Dispose();
}
}
internal static class ShrinkEventDiagnostics
{
public static void LogException(Exception exception)
{
#if UNITY_5_3_OR_NEWER
UnityEngine.Debug.LogException(exception);
#else
Trace.TraceError(exception.ToString());
#endif
}
}
internal static class ShrinkSchedulerShutdown
{
public static async UniTask WaitUntilDrainedAsync(ShrinkSchedulerQueue queue,
Func<bool> hasActiveWork, TimeSpan timeout)
{
var started = Stopwatch.StartNew();
while (!queue.IsEmpty || hasActiveWork())
{
if (timeout != Timeout.InfiniteTimeSpan && started.Elapsed >= timeout)
{
queue.DropPending();
return;
}
await UniTask.Delay(1, ignoreTimeScale: true);
}
}
}
}
@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 04809187aa0ce3b43a4df12b048a1c98
guid: 8d5885f09c9a4de89cd95de44433bb17
MonoImporter:
externalObjects: {}
serializedVersion: 2
@@ -0,0 +1,47 @@
using System;
namespace ShrinkEventBus
{
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public sealed class ShrinkEventSubscriberAttribute : Attribute
{
public string OwnerId { get; set; } = string.Empty;
public string DefaultBus { get; set; } = string.Empty;
}
[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = false)]
public sealed class ShrinkSubscribeAttribute : Attribute
{
public string Bus { get; set; } = string.Empty;
public ShrinkEventPriority Priority { get; set; } = ShrinkEventPriority.Normal;
public int NumericPriority { get; set; }
public bool ReceiveCanceled { get; set; }
public ShrinkSubscribeAttribute() { }
public ShrinkSubscribeAttribute(ShrinkEventPriority priority, bool receiveCanceled = false)
{
Priority = priority;
ReceiveCanceled = receiveCanceled;
}
public ShrinkSubscribeAttribute(int priority)
{
NumericPriority = priority;
Priority = priority switch
{
>= 100 => ShrinkEventPriority.Highest,
>= 50 => ShrinkEventPriority.High,
>= 0 => ShrinkEventPriority.Normal,
>= -50 => ShrinkEventPriority.Low,
_ => ShrinkEventPriority.Lowest
};
}
}
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)]
public sealed class ShrinkEventAttribute : Attribute
{
public ShrinkDispatchMode Dispatch { get; set; } = ShrinkDispatchMode.Ordered;
}
}
@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 876564c111495b2489870b29ab75da5e
guid: 32e8ae06929942288c57cd8c89b3d3a8
MonoImporter:
externalObjects: {}
serializedVersion: 2
@@ -0,0 +1,293 @@
#nullable enable
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using Cysharp.Threading.Tasks;
namespace ShrinkEventBus
{
public interface IShrinkBusResolver
{
IShrinkEventBus GetBus(ShrinkBusKey key);
bool TryGetBus(ShrinkBusKey key, out IShrinkEventBus bus);
}
public interface IShrinkGeneratedSubscriber
{
IDisposable AttachGenerated(IShrinkBusResolver resolver, ShrinkBusKey? defaultBus = null);
}
public sealed class ShrinkEventBinding : IDisposable
{
private readonly List<IDisposable> _items = new();
private bool _disposed;
public void Add(IDisposable subscription)
{
if (subscription == null)
throw new ArgumentNullException(nameof(subscription));
if (_disposed)
{
subscription.Dispose();
throw new ObjectDisposedException(nameof(ShrinkEventBinding));
}
_items.Add(subscription);
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
for (var i = _items.Count - 1; i >= 0; i--)
_items[i].Dispose();
_items.Clear();
}
}
public static class ShrinkStaticBindingRegistry
{
private sealed class Entry
{
public long Id;
public ShrinkBusKey Key;
public Func<IShrinkBusResolver, IDisposable> Factory = null!;
}
private static readonly object Gate = new();
private static readonly List<Entry> Entries = new();
private static readonly Dictionary<long, IDisposable> Bindings = new();
private static long _nextId;
public static void Register(ShrinkBusKey key,
Func<IShrinkBusResolver, IDisposable> factory)
{
if (factory == null)
throw new ArgumentNullException(nameof(factory));
lock (Gate)
{
var entry = new Entry
{
Id = Interlocked.Increment(ref _nextId),
Key = key,
Factory = factory
};
Entries.Add(entry);
TryAttachLocked(entry, EventBus.Resolver);
}
}
public static void Register<TEvent>(string bus, Action<TEvent> handler,
ShrinkEventPriority priority, int numericPriority, bool receiveCanceled)
where TEvent : IShrinkEvent
{
var key = ShrinkBusKey.Parse(bus);
Register(key, resolver => ShrinkGeneratedBinding.Subscribe(
resolver, null, bus, null, handler, priority, numericPriority, receiveCanceled));
}
public static void RegisterAsync<TEvent>(string bus, ShrinkAsyncEventHandler<TEvent> handler,
ShrinkEventPriority priority, int numericPriority, bool receiveCanceled)
where TEvent : IShrinkEvent
{
var key = ShrinkBusKey.Parse(bus);
Register(key, resolver => ShrinkGeneratedBinding.SubscribeAsync(
resolver, null, bus, null, handler, priority, numericPriority, receiveCanceled));
}
public static void RegisterAsyncLegacy<TEvent>(string bus, Func<TEvent, UniTask> handler,
ShrinkEventPriority priority, int numericPriority, bool receiveCanceled)
where TEvent : IShrinkEvent
{
var key = ShrinkBusKey.Parse(bus);
Register(key, resolver => ShrinkGeneratedBinding.SubscribeAsyncLegacy(
resolver, null, bus, null, handler, priority, numericPriority, receiveCanceled));
}
internal static void AttachForBus(ShrinkBusKey key, IShrinkBusResolver resolver)
{
lock (Gate)
{
for (var i = 0; i < Entries.Count; i++)
{
if (Entries[i].Key == key)
TryAttachLocked(Entries[i], resolver);
}
}
}
internal static void DetachBus(ShrinkBusKey key)
{
lock (Gate)
{
for (var i = 0; i < Entries.Count; i++)
{
var entry = Entries[i];
if (entry.Key != key || !Bindings.TryGetValue(entry.Id, out var binding))
continue;
Bindings.Remove(entry.Id);
binding.Dispose();
}
}
}
private static void TryAttachLocked(Entry entry, IShrinkBusResolver resolver)
{
if (Bindings.ContainsKey(entry.Id) || !resolver.TryGetBus(entry.Key, out _))
return;
Bindings.Add(entry.Id, entry.Factory(resolver));
}
}
public sealed class ShrinkEventBusHost : IShrinkBusResolver, IDisposable
{
private readonly ConcurrentDictionary<ShrinkBusKey, IShrinkEventBus> _buses = new();
private bool _disposed;
public IEnumerable<IShrinkEventBus> Buses => _buses.Values;
public IShrinkEventBus CreateBus(ShrinkBusKey key, ShrinkBusOptions options)
{
if (_disposed)
throw new ObjectDisposedException(nameof(ShrinkEventBusHost));
if (options == null)
throw new ArgumentNullException(nameof(options));
var bus = new ShrinkEventBusBuilder()
.WithKey(key)
.WithOptions(options)
.Build();
if (!_buses.TryAdd(key, bus))
{
if (bus is IDisposable disposable)
disposable.Dispose();
throw new InvalidOperationException($"Bus '{key}' is already registered.");
}
return bus;
}
public IShrinkEventBus GetOrCreateBus(ShrinkBusKey key, Func<ShrinkBusOptions> optionsFactory)
{
if (_disposed)
throw new ObjectDisposedException(nameof(ShrinkEventBusHost));
if (optionsFactory == null)
throw new ArgumentNullException(nameof(optionsFactory));
return _buses.GetOrAdd(key, busKey =>
new ShrinkEventBusBuilder()
.WithKey(busKey)
.WithOptions(optionsFactory())
.Build());
}
public IShrinkEventBus GetBus(ShrinkBusKey key)
{
if (_buses.TryGetValue(key, out var bus))
return bus;
throw new KeyNotFoundException($"Bus '{key}' is not registered.");
}
public bool TryGetBus(ShrinkBusKey key, out IShrinkEventBus bus) => _buses.TryGetValue(key, out bus!);
public IDisposable Attach(object target, ShrinkBusKey? defaultBus = null)
{
if (target == null)
throw new ArgumentNullException(nameof(target));
if (target is IShrinkGeneratedSubscriber generated)
return generated.AttachGenerated(this, defaultBus);
var bus = GetBus(defaultBus ?? ShrinkBusKey.Game);
return bus.Attach(target);
}
public bool RemoveBus(ShrinkBusKey key)
{
if (!_buses.TryRemove(key, out var bus))
return false;
if (bus is IDisposable disposable)
disposable.Dispose();
return true;
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
foreach (var bus in _buses.Values)
{
if (bus is IDisposable disposable)
disposable.Dispose();
}
_buses.Clear();
}
public async UniTask ShutdownAsync()
{
if (_disposed)
return;
_disposed = true;
foreach (var bus in _buses.Values)
{
if (bus is ShrinkEventBusInstance instance)
await instance.ShutdownAsync();
else
bus.Dispose();
}
_buses.Clear();
}
}
public static class ShrinkGeneratedBinding
{
public static IShrinkBusResolver RuntimeResolver => EventBus.Resolver;
public static IDisposable Subscribe<TEvent>(IShrinkBusResolver resolver, ShrinkBusKey? defaultBus,
string? configuredBus, object? owner, Action<TEvent> handler, ShrinkEventPriority priority,
int numericPriority, bool receiveCanceled) where TEvent : IShrinkEvent
{
var bus = ResolveBus(resolver, defaultBus, configuredBus);
if (bus is not ShrinkEventBusInstance instance)
throw new InvalidOperationException("Generated bindings require the built-in ShrinkEventBus implementation.");
return instance.SubscribeGenerated(handler,
new ShrinkSubscribeDescriptor(configuredBus, priority, numericPriority, receiveCanceled));
}
public static IDisposable SubscribeAsync<TEvent>(IShrinkBusResolver resolver, ShrinkBusKey? defaultBus,
string? configuredBus, object? owner, ShrinkAsyncEventHandler<TEvent> handler,
ShrinkEventPriority priority, int numericPriority, bool receiveCanceled) where TEvent : IShrinkEvent
{
var bus = ResolveBus(resolver, defaultBus, configuredBus);
if (bus is not ShrinkEventBusInstance instance)
throw new InvalidOperationException("Generated bindings require the built-in ShrinkEventBus implementation.");
return instance.SubscribeGenerated(handler,
new ShrinkSubscribeDescriptor(configuredBus, priority, numericPriority, receiveCanceled));
}
public static IDisposable SubscribeAsyncLegacy<TEvent>(IShrinkBusResolver resolver,
ShrinkBusKey? defaultBus, string? configuredBus, object? owner,
Func<TEvent, UniTask> handler, ShrinkEventPriority priority,
int numericPriority, bool receiveCanceled) where TEvent : IShrinkEvent
{
if (handler == null)
throw new ArgumentNullException(nameof(handler));
return SubscribeAsync<TEvent>(resolver, defaultBus, configuredBus, owner,
(eventData, _) => handler(eventData), priority, numericPriority, receiveCanceled);
}
private static IShrinkEventBus ResolveBus(IShrinkBusResolver resolver, ShrinkBusKey? defaultBus,
string? configuredBus)
{
if (resolver == null)
throw new ArgumentNullException(nameof(resolver));
var key = !string.IsNullOrWhiteSpace(configuredBus)
? ShrinkBusKey.Parse(configuredBus)
: defaultBus ?? ShrinkBusKey.Game;
return resolver.GetBus(key);
}
}
}
@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 11b7dddad0d215b47a7249e74a574105
guid: f19286886fb64b3ba7bfbd010c540913
MonoImporter:
externalObjects: {}
serializedVersion: 2
@@ -1,76 +1,36 @@
#nullable enable
using System;
namespace ShrinkEventBus
{
public sealed class ShrinkEventBusBuilder
{
internal IShrinkEventExceptionHandler? ExceptionHandler { get; private set; }
internal ShrinkEventExceptionHandlingMode ExceptionHandlingMode { get; private set; } =
ShrinkEventExceptionHandlingMode.LogAndContinue;
internal Action<Type>? EventClassChecker { get; private set; }
internal bool StartShutdownEnabled { get; private set; }
internal bool CheckTypesOnDispatchEnabled { get; private set; }
internal bool AllowPerPhaseDispatchEnabled { get; private set; }
internal ShrinkBusKey Key { get; private set; } = ShrinkBusKey.Game;
internal ShrinkBusOptions Options { get; private set; } = ShrinkBusOptions.Inline();
internal Action<IShrinkEvent, Type, ShrinkBusKey>? PostObserver { get; private set; }
public ShrinkEventBusBuilder SetExceptionHandler(IShrinkEventExceptionHandler handler)
public ShrinkEventBusBuilder WithKey(ShrinkBusKey key)
{
ExceptionHandler = handler ?? throw new ArgumentNullException(nameof(handler));
Key = key;
return this;
}
public ShrinkEventBusBuilder SetExceptionHandlingMode(ShrinkEventExceptionHandlingMode mode)
public ShrinkEventBusBuilder WithOptions(ShrinkBusOptions options)
{
ExceptionHandlingMode = mode;
Options = (options ?? throw new ArgumentNullException(nameof(options))).CloneValidated();
return this;
}
public ShrinkEventBusBuilder StartShutdown()
public IShrinkEventBus Build() => Options.Scheduler == ShrinkBusSchedulerKind.Inline
? new ShrinkInlineEventBusInstance(this)
: new ShrinkEventBusInstance(this);
internal ShrinkEventBusBuilder WithPostObserver(
Action<IShrinkEvent, Type, ShrinkBusKey> observer)
{
StartShutdownEnabled = true;
PostObserver = observer ?? throw new ArgumentNullException(nameof(observer));
return this;
}
public ShrinkEventBusBuilder CheckTypesOnDispatch()
{
CheckTypesOnDispatchEnabled = true;
return this;
}
public ShrinkEventBusBuilder AllowPerPhaseDispatch()
{
AllowPerPhaseDispatchEnabled = true;
return this;
}
public ShrinkEventBusBuilder ClassChecker(Action<Type> checker)
{
if (checker == null)
throw new ArgumentNullException(nameof(checker));
EventClassChecker = EventClassChecker == null
? checker
: EventClassChecker + checker;
return this;
}
public ShrinkEventBusBuilder MarkerInterface<TMarker>() where TMarker : class
{
var markerType = typeof(TMarker);
if (!markerType.IsInterface)
throw new InvalidOperationException($"Marker type {markerType.FullName} must be an interface.");
return ClassChecker(eventType =>
{
if (!markerType.IsAssignableFrom(eventType))
throw new ArgumentException(
$"This bus only accepts events assignable to {markerType.FullName}, but got {eventType.FullName}.");
});
}
public IShrinkEventBus Build()
{
return new ShrinkEventBusInstance(this);
}
}
}
@@ -0,0 +1,272 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using Cysharp.Threading.Tasks;
namespace ShrinkEventBus
{
public delegate UniTask ShrinkAsyncEventHandler<in TEvent>(TEvent eventData,
CancellationToken cancellationToken) where TEvent : IShrinkEvent;
internal interface IShrinkEventChannel
{
int Count { get; }
bool RemoveSubscription(long subscriptionId);
void Clear();
void DetachSlot(int slot);
}
internal static class ShrinkEventChannelSlots<TEvent> where TEvent : IShrinkEvent
{
private static readonly object Gate = new();
private static ShrinkEventChannel<TEvent>?[] _slots =
Array.Empty<ShrinkEventChannel<TEvent>?>();
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static ShrinkEventChannel<TEvent>? Get(int slot)
{
var snapshot = _slots;
return (uint)slot < (uint)snapshot.Length ? snapshot[slot] : null;
}
public static void Set(int slot, ShrinkEventChannel<TEvent> channel)
{
lock (Gate)
{
var current = _slots;
var length = current.Length;
if (length <= slot)
{
length = Math.Max(4, length);
while (length <= slot)
length *= 2;
}
var next = new ShrinkEventChannel<TEvent>?[length];
Array.Copy(current, next, current.Length);
next[slot] = channel;
Volatile.Write(ref _slots, next);
}
}
public static void Clear(int slot, ShrinkEventChannel<TEvent> channel)
{
lock (Gate)
{
var current = _slots;
if ((uint)slot >= (uint)current.Length || !ReferenceEquals(current[slot], channel))
return;
var next = (ShrinkEventChannel<TEvent>?[])current.Clone();
next[slot] = null;
Volatile.Write(ref _slots, next);
}
}
}
internal sealed class ShrinkEventChannel<TEvent> : IShrinkEventChannel
where TEvent : IShrinkEvent
{
private static readonly bool SupportsCancellation =
typeof(IShrinkCancelableEvent).IsAssignableFrom(typeof(TEvent));
private sealed class HandlerEntry
{
public long SubscriptionId;
public long RegistrationOrder;
public Action<TEvent>? SyncHandler;
public ShrinkAsyncEventHandler<TEvent>? AsyncHandler;
public ShrinkSubscribeDescriptor Descriptor;
}
private readonly object _gate = new();
private readonly List<HandlerEntry> _entries = new();
private HandlerEntry[] _snapshot = Array.Empty<HandlerEntry>();
private Action<TEvent>? _syncDispatcher;
public int Count => _snapshot.Length;
public void Add(long subscriptionId, long registrationOrder, Action<TEvent> handler,
ShrinkSubscribeDescriptor descriptor)
{
if (handler == null)
throw new ArgumentNullException(nameof(handler));
lock (_gate)
{
_entries.Add(new HandlerEntry
{
SubscriptionId = subscriptionId,
RegistrationOrder = registrationOrder,
SyncHandler = handler,
Descriptor = descriptor
});
RebuildSnapshot();
}
}
public void Add(long subscriptionId, long registrationOrder,
ShrinkAsyncEventHandler<TEvent> handler, ShrinkSubscribeDescriptor descriptor)
{
if (handler == null)
throw new ArgumentNullException(nameof(handler));
lock (_gate)
{
_entries.Add(new HandlerEntry
{
SubscriptionId = subscriptionId,
RegistrationOrder = registrationOrder,
AsyncHandler = handler,
Descriptor = descriptor
});
RebuildSnapshot();
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public ShrinkPostResult Post(in TEvent eventData)
{
var syncDispatcher = _syncDispatcher;
if (syncDispatcher != null)
{
syncDispatcher(eventData);
return new ShrinkPostResult(true, true, false);
}
var snapshot = _snapshot;
var handled = false;
for (var i = 0; i < snapshot.Length; i++)
{
var entry = snapshot[i];
if (IsCanceled(eventData) && !entry.Descriptor.ReceiveCanceled)
continue;
if (entry.SyncHandler != null)
entry.SyncHandler(eventData);
else if (entry.AsyncHandler != null)
entry.AsyncHandler(eventData, CancellationToken.None).Forget(ShrinkEventDiagnostics.LogException);
else
continue;
handled = true;
}
return ShrinkPostResult.Completed(handled, IsCanceled(eventData));
}
public async UniTask<ShrinkPostResult> PostAsync(TEvent eventData, ShrinkDispatchMode dispatchMode,
CancellationToken cancellationToken)
{
var snapshot = _snapshot;
var handled = false;
if (dispatchMode == ShrinkDispatchMode.Parallel)
{
var tasks = new List<UniTask>(snapshot.Length);
for (var i = 0; i < snapshot.Length; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var entry = snapshot[i];
if (IsCanceled(eventData) && !entry.Descriptor.ReceiveCanceled)
continue;
if (entry.SyncHandler != null)
entry.SyncHandler(eventData);
else if (entry.AsyncHandler != null)
tasks.Add(entry.AsyncHandler(eventData, cancellationToken));
else
continue;
handled = true;
}
if (tasks.Count > 0)
await UniTask.WhenAll(tasks);
}
else
{
for (var i = 0; i < snapshot.Length; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var entry = snapshot[i];
if (IsCanceled(eventData) && !entry.Descriptor.ReceiveCanceled)
continue;
if (entry.SyncHandler != null)
entry.SyncHandler(eventData);
else if (entry.AsyncHandler != null)
await entry.AsyncHandler(eventData, cancellationToken);
else
continue;
handled = true;
}
}
return ShrinkPostResult.Completed(handled, IsCanceled(eventData));
}
public bool RemoveSubscription(long subscriptionId)
{
lock (_gate)
{
var removed = _entries.RemoveAll(entry => entry.SubscriptionId == subscriptionId) > 0;
RebuildSnapshot();
return removed;
}
}
public void Clear()
{
lock (_gate)
{
_entries.Clear();
_snapshot = Array.Empty<HandlerEntry>();
_syncDispatcher = null;
}
}
public void DetachSlot(int slot) => ShrinkEventChannelSlots<TEvent>.Clear(slot, this);
private void RebuildSnapshot()
{
_entries.Sort(static (left, right) =>
{
var leftOrder = left.Descriptor.NumericPriority == 0
? (int)left.Descriptor.Priority * 1000
: -left.Descriptor.NumericPriority;
var rightOrder = right.Descriptor.NumericPriority == 0
? (int)right.Descriptor.Priority * 1000
: -right.Descriptor.NumericPriority;
var priority = leftOrder.CompareTo(rightOrder);
return priority != 0
? priority
: left.RegistrationOrder.CompareTo(right.RegistrationOrder);
});
_snapshot = _entries.ToArray();
Action<TEvent>? dispatcher = null;
if (!SupportsCancellation)
{
for (var i = 0; i < _snapshot.Length; i++)
{
var handler = _snapshot[i].SyncHandler;
if (handler == null)
{
dispatcher = null;
break;
}
dispatcher += handler;
}
}
Volatile.Write(ref _syncDispatcher, dispatcher);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool IsCanceled(TEvent eventData)
{
if (!SupportsCancellation)
return false;
return ((IShrinkCancelableEvent)(object)eventData!).IsCanceled;
}
}
}
@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: fc534f0e8598cab47b393ccffc90ad98
guid: 5ffae3efb7664efcb82f2bbefbdd5a4e
MonoImporter:
externalObjects: {}
serializedVersion: 2
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,213 @@
#nullable enable
using System;
using System.Runtime.CompilerServices;
namespace ShrinkEventBus
{
/// <summary>Marker shared by managed, Unity and ECS event payloads.</summary>
public interface IShrinkEvent
{
}
public interface IShrinkCancelableEvent : IShrinkEvent
{
bool IsCanceled { get; }
void SetCanceled(bool value);
}
public interface IShrinkResultEvent<TResult> : IShrinkEvent
{
TResult Result { get; }
void SetResult(TResult result);
}
public enum ShrinkEventPriority
{
Highest = 0,
High = 1,
Normal = 2,
Low = 3,
Lowest = 4,
Monitor = 5
}
public enum ShrinkDispatchMode
{
Ordered = 0,
Parallel = 1
}
public enum ShrinkBusSchedulerKind
{
Inline = 0,
MainThread = 1,
DedicatedThread = 2,
TaskPool = 3
}
public enum ShrinkQueueOverflowPolicy
{
Reject = 0,
DropNewest = 1,
DropOldest = 2,
Wait = 3
}
public enum ShrinkPostFailure
{
None = 0,
BusStopped = 1,
QueueFull = 2,
Canceled = 3,
HandlerException = 4,
InvalidEvent = 5
}
public readonly struct ShrinkPostResult
{
private const int AcceptedMask = 1 << 0;
private const int HandledMask = 1 << 1;
private const int CanceledMask = 1 << 2;
private const int FailureShift = 8;
private readonly int _value;
public ShrinkPostResult(bool accepted, bool handled, bool canceled,
ShrinkPostFailure failure = ShrinkPostFailure.None)
{
_value = (accepted ? AcceptedMask : 0) |
(handled ? HandledMask : 0) |
(canceled ? CanceledMask : 0) |
((int)failure << FailureShift);
}
public bool Accepted => (_value & AcceptedMask) != 0;
public bool Handled => (_value & HandledMask) != 0;
public bool Canceled => (_value & CanceledMask) != 0;
public ShrinkPostFailure Failure => (ShrinkPostFailure)((uint)_value >> FailureShift);
public bool Succeeded => Accepted && Failure == ShrinkPostFailure.None;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static ShrinkPostResult Rejected(ShrinkPostFailure failure) =>
new(false, false, false, failure);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static ShrinkPostResult Completed(bool handled, bool canceled = false) =>
new(true, handled, canceled);
}
public readonly struct ShrinkBusKey : IEquatable<ShrinkBusKey>
{
public ShrinkBusKey(string scope, string name)
{
if (string.IsNullOrWhiteSpace(scope))
throw new ArgumentException("Bus scope must not be empty.", nameof(scope));
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Bus name must not be empty.", nameof(name));
Scope = scope.Trim().ToLowerInvariant();
Name = name.Trim();
}
public string Scope { get; }
public string Name { get; }
public string Id => Scope == "game" && Name == "default" ? "game" : $"{Scope}:{Name}";
public static ShrinkBusKey Game => new("game", "default");
public static ShrinkBusKey Server => new("server", "default");
public static ShrinkBusKey Scene(string sceneId) => new("scene", sceneId);
public static ShrinkBusKey Mod(string modId) => new("mod", modId);
public static ShrinkBusKey World(string worldId) => new("world", worldId);
public static ShrinkBusKey Parse(string? value)
{
if (string.IsNullOrWhiteSpace(value) || string.Equals(value.Trim(), "game", StringComparison.OrdinalIgnoreCase))
return Game;
var normalized = value.Trim();
var separator = normalized.IndexOf(':');
return separator <= 0 || separator == normalized.Length - 1
? new ShrinkBusKey("custom", normalized)
: new ShrinkBusKey(normalized.Substring(0, separator), normalized.Substring(separator + 1));
}
public bool Equals(ShrinkBusKey other) =>
string.Equals(Scope, other.Scope, StringComparison.OrdinalIgnoreCase) &&
string.Equals(Name, other.Name, StringComparison.Ordinal);
public override bool Equals(object? obj) => obj is ShrinkBusKey other && Equals(other);
public override int GetHashCode() => HashCode.Combine(Scope.ToLowerInvariant(), Name);
public override string ToString() => Id;
public static bool operator ==(ShrinkBusKey left, ShrinkBusKey right) => left.Equals(right);
public static bool operator !=(ShrinkBusKey left, ShrinkBusKey right) => !left.Equals(right);
}
public sealed class ShrinkBusOptions
{
public ShrinkBusSchedulerKind Scheduler { get; set; } = ShrinkBusSchedulerKind.Inline;
public ShrinkDispatchMode DispatchMode { get; set; } = ShrinkDispatchMode.Ordered;
public int QueueCapacity { get; set; } = 4096;
public int MaxConcurrency { get; set; } = Math.Max(1, Environment.ProcessorCount);
public ShrinkQueueOverflowPolicy OverflowPolicy { get; set; } = ShrinkQueueOverflowPolicy.Reject;
public bool DrainOnShutdown { get; set; } = true;
public TimeSpan ShutdownTimeout { get; set; } = TimeSpan.FromSeconds(5);
public static ShrinkBusOptions Inline(ShrinkDispatchMode mode = ShrinkDispatchMode.Ordered) =>
new() { Scheduler = ShrinkBusSchedulerKind.Inline, DispatchMode = mode };
public static ShrinkBusOptions MainThread(int queueCapacity = 4096) =>
new() { Scheduler = ShrinkBusSchedulerKind.MainThread, QueueCapacity = queueCapacity };
public static ShrinkBusOptions DedicatedThread(int queueCapacity = 4096) =>
new() { Scheduler = ShrinkBusSchedulerKind.DedicatedThread, QueueCapacity = queueCapacity };
public static ShrinkBusOptions TaskPool(int maxConcurrency = 0, int queueCapacity = 4096) =>
new()
{
Scheduler = ShrinkBusSchedulerKind.TaskPool,
DispatchMode = ShrinkDispatchMode.Parallel,
QueueCapacity = queueCapacity,
MaxConcurrency = maxConcurrency > 0 ? maxConcurrency : Math.Max(1, Environment.ProcessorCount)
};
internal ShrinkBusOptions CloneValidated()
{
if (QueueCapacity <= 0)
throw new ArgumentOutOfRangeException(nameof(QueueCapacity));
if (MaxConcurrency <= 0)
throw new ArgumentOutOfRangeException(nameof(MaxConcurrency));
if (ShutdownTimeout < TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(ShutdownTimeout));
if (OverflowPolicy == ShrinkQueueOverflowPolicy.Wait && Scheduler == ShrinkBusSchedulerKind.Inline)
throw new InvalidOperationException("Inline buses do not have a queue and cannot use Wait overflow policy.");
return new ShrinkBusOptions
{
Scheduler = Scheduler,
DispatchMode = DispatchMode,
QueueCapacity = QueueCapacity,
MaxConcurrency = MaxConcurrency,
OverflowPolicy = OverflowPolicy,
DrainOnShutdown = DrainOnShutdown,
ShutdownTimeout = ShutdownTimeout
};
}
}
public readonly struct ShrinkSubscribeDescriptor
{
public ShrinkSubscribeDescriptor(string? bus, ShrinkEventPriority priority,
int numericPriority, bool receiveCanceled)
{
Bus = bus ?? string.Empty;
Priority = priority;
NumericPriority = numericPriority;
ReceiveCanceled = receiveCanceled;
}
public string Bus { get; }
public ShrinkEventPriority Priority { get; }
public int NumericPriority { get; }
public bool ReceiveCanceled { get; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 04b409c00e3144fd8d9f3efb7c2901b7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,71 @@
#nullable enable
using System;
using UnityEngine;
namespace ShrinkEventBus
{
/// <summary>
/// Optional Unity lifecycle host for generated event bindings. It does not require
/// the target component to inherit from an SDK base class.
/// </summary>
[DisallowMultipleComponent]
public sealed class ShrinkMonoEventScope : MonoBehaviour
{
[SerializeField] private string bus = "game";
[SerializeField] private bool includeChildren;
private IDisposable? _binding;
private ShrinkEventBinding? _bindingGroup;
public string BusId
{
get => bus;
set => bus = value ?? string.Empty;
}
private void OnEnable()
{
RefreshBindings();
}
private void OnDisable()
{
ReleaseBindings();
}
public void RefreshBindings()
{
ReleaseBindings();
var key = ShrinkBusKey.Parse(bus);
if (!EventBus.TryGetBus(key, out var targetBus))
targetBus = EventBus.GetOrCreateBus(key, ShrinkBusOptions.MainThread());
var components = includeChildren
? GetComponentsInChildren<MonoBehaviour>(true)
: GetComponents<MonoBehaviour>();
for (var i = 0; i < components.Length; i++)
TryAttach(components[i], targetBus);
}
public void ReleaseBindings()
{
_binding?.Dispose();
_binding = null;
_bindingGroup = null;
}
private void TryAttach(MonoBehaviour target, IShrinkEventBus targetBus)
{
if (target == null || ReferenceEquals(target, this))
return;
if (target is not IShrinkGeneratedSubscriber)
return;
var binding = targetBus.Attach(target);
if (_bindingGroup == null)
_bindingGroup = new ShrinkEventBinding();
_bindingGroup.Add(binding);
_binding = _bindingGroup;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5fe48da79b2f4a10a338e497f4ce5ae1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,15 @@
{
"name": "ShrinkEventBus.PlayMode.Tests",
"rootNamespace": "ShrinkEventBus.PlayMode.Tests",
"references": [
"ShrinkEventBus.Runtime",
"UniTask"
],
"optionalUnityReferences": ["TestAssemblies"],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"autoReferenced": false,
"defineConstraints": ["UNITY_INCLUDE_TESTS"]
}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: b03fa714413c4af5ad4140e7a409ef2f
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,48 @@
#nullable enable
using System.Collections;
using Cysharp.Threading.Tasks;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
namespace ShrinkEventBus.PlayMode.Tests
{
internal readonly struct MonoLifecycleEvent : IShrinkEvent
{
public MonoLifecycleEvent(int value) => Value = value;
public int Value { get; }
}
[ShrinkEventSubscriber(DefaultBus = "game")]
internal sealed class MonoLifecycleTarget : MonoBehaviour
{
public int Sum { get; private set; }
[ShrinkSubscribe]
private void OnEvent(MonoLifecycleEvent value) => Sum += value.Value;
}
public sealed class ShrinkMonoEventScopePlayModeTests
{
[UnityTest]
public IEnumerator OnEnableAttachesAndOnDisableReleases()
{
var gameObject = new GameObject("ShrinkMonoEventScope-PlayMode");
gameObject.SetActive(false);
var target = gameObject.AddComponent<MonoLifecycleTarget>();
gameObject.AddComponent<ShrinkMonoEventScope>();
gameObject.SetActive(true);
yield return null;
yield return EventBus.PostAsync(new MonoLifecycleEvent(3)).ToCoroutine();
Assert.AreEqual(3, target.Sum);
gameObject.SetActive(false);
yield return null;
yield return EventBus.PostAsync(new MonoLifecycleEvent(5)).ToCoroutine();
Assert.AreEqual(3, target.Sum);
Object.Destroy(gameObject);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f42b5f6f2249483b9978f0fa729d479b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,200 +0,0 @@
using System;
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using NUnit.Framework;
namespace ShrinkEventBus.Tests
{
[TestFixture]
public sealed class EventBusDispatchTests
{
private class PlainEvent : EventBase
{
}
private sealed class DerivedPlainEvent : PlainEvent
{
}
[Cancelable]
private sealed class CancelableEvent : EventBase
{
}
[HasResult]
private sealed class ResultEvent : EventBase
{
}
[Test]
public void TriggerEvent_ReturnsFalseWithoutSubscribers_TrueWhenHandled()
{
var bus = EventBus.CreateBus();
Assert.IsFalse(bus.TriggerEvent(new PlainEvent()));
bus.RegisterEvent<PlainEvent>(_ => { }, EventPriority.NORMAL);
Assert.IsTrue(bus.TriggerEvent(new PlainEvent()));
}
[Test]
public void CanceledEvent_SkipsHandlersUnlessReceiveCanceled()
{
var bus = EventBus.CreateBus();
var order = new List<string>();
bus.RegisterEvent<CancelableEvent>(evt =>
{
order.Add("canceler");
evt.SetCanceled(true);
}, EventPriority.HIGHEST);
bus.RegisterEvent<CancelableEvent>(_ => order.Add("skipped"), EventPriority.NORMAL);
bus.RegisterEvent<CancelableEvent>(evt => order.Add($"monitor:{evt.IsCanceled}"),
EventPriority.MONITOR, receiveCanceled: true);
var evt = new CancelableEvent();
bus.TriggerEvent(evt);
Assert.IsTrue(evt.IsCanceled);
CollectionAssert.AreEqual(new[] { "canceler", "monitor:True" }, order);
}
[Test]
public void SetCanceled_OnNonCancelableEvent_ThrowsWithMessage()
{
var evt = new PlainEvent();
var ex = Assert.Throws<UnsupportedOperationException>(() => evt.SetCanceled(true));
Assert.IsFalse(string.IsNullOrWhiteSpace(ex.Message));
}
[Test]
public void SetResult_OnEventWithoutResult_ThrowsWithMessage()
{
var evt = new PlainEvent();
var ex = Assert.Throws<InvalidOperationException>(() => evt.SetResult(EventResult.ALLOW));
Assert.IsFalse(string.IsNullOrWhiteSpace(ex.Message));
}
[Test]
public void SetResult_OnResultEvent_PersistsResult()
{
var bus = EventBus.CreateBus();
bus.RegisterEvent<ResultEvent>(evt => evt.SetResult(EventResult.DENY), EventPriority.HIGH);
var evt = new ResultEvent();
bus.TriggerEvent(evt);
Assert.AreEqual(EventResult.DENY, evt.Result);
}
[Test]
public void ParentSubscriber_ReceivesDerivedEvent()
{
var bus = EventBus.CreateBus();
var hits = 0;
bus.RegisterEvent<PlainEvent>(_ => hits++, EventPriority.NORMAL);
bus.TriggerEvent(new DerivedPlainEvent());
Assert.AreEqual(1, hits);
}
[Test]
public void ParentSubscriber_RegisteredAfterChildWasTriggered_StillReceivesChild()
{
var bus = EventBus.CreateBus();
// 先触发子事件,让子类型的监听列表先于父监听器创建(覆盖脏传播路径)
bus.TriggerEvent(new DerivedPlainEvent());
var hits = 0;
bus.RegisterEvent<PlainEvent>(_ => hits++, EventPriority.NORMAL);
bus.TriggerEvent(new DerivedPlainEvent());
Assert.AreEqual(1, hits);
}
[Test]
public void PerPhaseDispatch_OnlyRunsRequestedPhase()
{
var bus = EventBus.CreateBus(builder => builder.AllowPerPhaseDispatch());
var order = new List<string>();
bus.RegisterEvent<PlainEvent>(_ => order.Add("high"), EventPriority.HIGH);
bus.RegisterEvent<PlainEvent>(_ => order.Add("normal"), EventPriority.NORMAL);
bus.TriggerEvent(EventPriority.HIGH, new PlainEvent());
CollectionAssert.AreEqual(new[] { "high" }, order);
}
[Test]
public void PerPhaseDispatch_ThrowsWhenNotEnabled()
{
var bus = EventBus.CreateBus();
Assert.Throws<InvalidOperationException>(() => bus.TriggerEvent(EventPriority.HIGH, new PlainEvent()));
}
[Test]
public void TriggerEventAsync_RunsHandlersSequentiallyByPriority()
{
var bus = EventBus.CreateBus();
var order = new List<string>();
async UniTask AsyncHandler(PlainEvent evt)
{
order.Add("async-high");
await UniTask.CompletedTask;
}
bus.RegisterEvent<PlainEvent>(AsyncHandler, EventPriority.HIGH, receiveCanceled: false);
bus.RegisterEvent<PlainEvent>(_ => order.Add("sync-normal"), EventPriority.NORMAL);
var handled = bus.TriggerEventAsync(new PlainEvent()).GetAwaiter().GetResult();
Assert.IsTrue(handled);
CollectionAssert.AreEqual(new[] { "async-high", "sync-normal" }, order);
}
[Test]
public void TriggerEventAsync_AsyncHandlerCancellation_SkipsLaterHandlers()
{
var bus = EventBus.CreateBus();
var order = new List<string>();
async UniTask CancelingHandler(CancelableEvent evt)
{
evt.SetCanceled(true);
order.Add("async-canceler");
await UniTask.CompletedTask;
}
bus.RegisterEvent<CancelableEvent>(CancelingHandler, EventPriority.HIGHEST, receiveCanceled: false);
bus.RegisterEvent<CancelableEvent>(_ => order.Add("skipped"), EventPriority.NORMAL);
bus.TriggerEventAsync(new CancelableEvent()).GetAwaiter().GetResult();
CollectionAssert.AreEqual(new[] { "async-canceler" }, order);
}
[Test]
public void GetSubscribers_ReturnsDefensiveCopyOfDispatchSnapshot()
{
var bus = EventBus.CreateBus();
bus.RegisterEvent<PlainEvent>(_ => { }, EventPriority.NORMAL);
var evt = new PlainEvent();
bus.TriggerEvent(evt);
var first = evt.GetSubscribers();
Assert.AreEqual(1, first.Length);
first[0] = null;
var second = evt.GetSubscribers();
Assert.IsNotNull(second[0]);
}
}
}
@@ -1,120 +0,0 @@
using System.Collections.Generic;
using NUnit.Framework;
namespace ShrinkEventBus.Tests
{
[TestFixture]
public sealed class EventBusPriorityTests
{
private class BaseEvent : EventBase
{
}
private sealed class DerivedEvent : BaseEvent
{
}
[Test]
public void EnumPriority_ExecutesPhasesInOrder()
{
var bus = EventBus.CreateBus();
var order = new List<string>();
bus.RegisterEvent<BaseEvent>(_ => order.Add("LOW"), EventPriority.LOW);
bus.RegisterEvent<BaseEvent>(_ => order.Add("MONITOR"), EventPriority.MONITOR);
bus.RegisterEvent<BaseEvent>(_ => order.Add("HIGHEST"), EventPriority.HIGHEST);
bus.RegisterEvent<BaseEvent>(_ => order.Add("NORMAL"), EventPriority.NORMAL);
bus.TriggerEvent(new BaseEvent());
CollectionAssert.AreEqual(new[] { "HIGHEST", "NORMAL", "LOW", "MONITOR" }, order);
}
[Test]
public void NumericPriority_HigherNumberRunsFirstWithinPhase()
{
var bus = EventBus.CreateBus();
var order = new List<string>();
// 5 与 10 都映射到 NORMAL 档
bus.RegisterEvent<BaseEvent>(_ => order.Add("p5"), 5);
bus.RegisterEvent<BaseEvent>(_ => order.Add("p10"), 10);
bus.TriggerEvent(new BaseEvent());
CollectionAssert.AreEqual(new[] { "p10", "p5" }, order);
}
[Test]
public void NumericPriority_TieKeepsRegistrationOrder()
{
var bus = EventBus.CreateBus();
var order = new List<string>();
bus.RegisterEvent<BaseEvent>(_ => order.Add("first"), 0);
bus.RegisterEvent<BaseEvent>(_ => order.Add("second"), 0);
bus.TriggerEvent(new BaseEvent());
CollectionAssert.AreEqual(new[] { "first", "second" }, order);
}
[Test]
public void NumericPriorityZero_MapsToNormalPhase()
{
var bus = EventBus.CreateBus();
bus.RegisterEvent<BaseEvent>(_ => { }, 0);
var subscribers = bus.GetEventSubscribers<BaseEvent>();
Assert.AreEqual(1, subscribers.Length);
Assert.AreEqual(EventPriority.NORMAL, subscribers[0].Priority);
}
[Test]
public void RegisterEvent_WithoutPriority_ResolvesToNormal()
{
var bus = EventBus.CreateBus();
void Handler(BaseEvent evt)
{
}
// 不带优先级的调用应唯一解析到枚举重载(NORMAL),不再有重载二义性
bus.RegisterEvent<BaseEvent>(Handler);
var subscribers = bus.GetEventSubscribers<BaseEvent>();
Assert.AreEqual(1, subscribers.Length);
Assert.AreEqual(EventPriority.NORMAL, subscribers[0].Priority);
}
[Test]
public void ParentAndChildHandlers_MergeByNumericPriorityWithinPhase()
{
var bus = EventBus.CreateBus();
var order = new List<string>();
bus.RegisterEvent<BaseEvent>(_ => order.Add("parent10"), 10);
bus.RegisterEvent<DerivedEvent>(_ => order.Add("child5"), 5);
bus.RegisterEvent<DerivedEvent>(_ => order.Add("child1"), 1);
bus.TriggerEvent(new DerivedEvent());
CollectionAssert.AreEqual(new[] { "parent10", "child5", "child1" }, order);
}
[Test]
public void ParentAndChildHandlers_TiePrefersChild()
{
var bus = EventBus.CreateBus();
var order = new List<string>();
bus.RegisterEvent<BaseEvent>(_ => order.Add("parent0"), 0);
bus.RegisterEvent<DerivedEvent>(_ => order.Add("child0"), 0);
bus.TriggerEvent(new DerivedEvent());
CollectionAssert.AreEqual(new[] { "child0", "parent0" }, order);
}
}
}
@@ -1,228 +0,0 @@
using System;
using System.Reflection;
using System.Text.RegularExpressions;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
namespace ShrinkEventBus.Tests
{
[TestFixture]
public sealed class EventBusRegistrationTests
{
private class ProbeEvent : EventBase
{
}
private sealed class PooledEvent : EventBase
{
public int Value { get; set; }
protected override void OnReset()
{
Value = 0;
}
}
private sealed class InstanceSubscriber
{
public int Hits;
[EventSubscribe(EventPriority.NORMAL)]
private void OnProbe(ProbeEvent evt)
{
Hits++;
}
}
private static class StaticSubscriber
{
public static int Hits;
[EventSubscribe(EventPriority.NORMAL)]
private static void OnProbe(ProbeEvent evt)
{
Hits++;
}
}
private sealed class InvalidSignatureSubscriber
{
[EventSubscribe(EventPriority.NORMAL)]
private void OnProbe(ProbeEvent evt, int extra)
{
}
}
[EventBusSubscriber]
private sealed class EmptySubscriber
{
}
private sealed class UnattributedSubscriber
{
[EventSubscribe(EventPriority.NORMAL)]
private void OnProbe(ProbeEvent evt)
{
}
}
[Test]
public void Register_InstanceScan_RegistersAndUnregisters()
{
var bus = EventBus.CreateBus();
var subscriber = new InstanceSubscriber();
bus.Register(subscriber);
Assert.IsTrue(bus.IsInstanceRegistered(subscriber));
bus.TriggerEvent(new ProbeEvent());
Assert.AreEqual(1, subscriber.Hits);
bus.Unregister(subscriber);
Assert.IsFalse(bus.IsInstanceRegistered(subscriber));
bus.TriggerEvent(new ProbeEvent());
Assert.AreEqual(1, subscriber.Hits);
}
[Test]
public void Register_StaticTypeScan_RegistersAndUnregisters()
{
var bus = EventBus.CreateBus();
StaticSubscriber.Hits = 0;
bus.Register(typeof(StaticSubscriber));
bus.TriggerEvent(new ProbeEvent());
Assert.AreEqual(1, StaticSubscriber.Hits);
bus.Unregister(typeof(StaticSubscriber));
bus.TriggerEvent(new ProbeEvent());
Assert.AreEqual(1, StaticSubscriber.Hits);
}
[Test]
public void Register_MethodInfo_RegistersSingleHandler()
{
var bus = EventBus.CreateBus();
StaticSubscriber.Hits = 0;
var method = typeof(StaticSubscriber).GetMethod("OnProbe",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.IsNotNull(method);
bus.Register(method);
bus.TriggerEvent(new ProbeEvent());
Assert.AreEqual(1, StaticSubscriber.Hits);
bus.Unregister(method);
bus.TriggerEvent(new ProbeEvent());
Assert.AreEqual(1, StaticSubscriber.Hits);
}
[Test]
public void Register_InvalidHandlerSignature_Throws()
{
var bus = EventBus.CreateBus();
Assert.Throws<InvalidOperationException>(() => bus.Register(new InvalidSignatureSubscriber()));
}
[Test]
public void AutoRegister_TypeWithoutSubscriberAttribute_Throws()
{
var bus = EventBus.CreateBus();
Assert.Throws<InvalidOperationException>(() => bus.AutoRegister(new UnattributedSubscriber()));
}
[Test]
public void AutoRegister_SubscriberWithoutHandlers_WarnsInsteadOfThrowing()
{
var bus = EventBus.CreateBus();
LogAssert.Expect(LogType.Warning, new Regex(@"has no \[EventSubscribe\] methods"));
Assert.DoesNotThrow(() => bus.AutoRegister(new EmptySubscriber()));
}
[Test]
public void GetEventSubscribers_ReturnsDefensiveCopy()
{
var bus = EventBus.CreateBus();
bus.RegisterEvent<ProbeEvent>(_ => { }, EventPriority.NORMAL);
var first = bus.GetEventSubscribers<ProbeEvent>();
Assert.AreEqual(1, first.Length);
first[0] = null;
var second = bus.GetEventSubscribers<ProbeEvent>();
Assert.IsNotNull(second[0]);
}
[Test]
public void UnregisterAllEventsForObject_RemovesScannedHandlers()
{
var bus = EventBus.CreateBus();
var subscriber = new InstanceSubscriber();
bus.Register(subscriber);
bus.UnregisterAllEventsForObject(subscriber);
Assert.IsFalse(bus.IsInstanceRegistered(subscriber));
bus.TriggerEvent(new ProbeEvent());
Assert.AreEqual(0, subscriber.Hits);
bus.Register(subscriber);
bus.TriggerEvent(new ProbeEvent());
Assert.AreEqual(1, subscriber.Hits);
}
[Test]
public void EventPool_ReleaseResetsAndReusesInstance()
{
var evt = EventPool<PooledEvent>.Get();
evt.Value = 42;
EventPool<PooledEvent>.Release(evt);
var reused = EventPool<PooledEvent>.Get();
Assert.AreSame(evt, reused);
Assert.AreEqual(0, reused.Value);
EventPool<PooledEvent>.Release(reused);
}
[Test]
public void EventPool_DoubleRelease_DoesNotDuplicatePoolEntry()
{
var evt = EventPool<PooledEvent>.Get();
EventPool<PooledEvent>.Release(evt);
EventPool<PooledEvent>.Release(evt);
var first = EventPool<PooledEvent>.Get();
var second = EventPool<PooledEvent>.Get();
Assert.AreSame(evt, first);
Assert.AreNotSame(evt, second);
EventPool<PooledEvent>.Release(first);
EventPool<PooledEvent>.Release(second);
}
[Test]
public void EventPool_DisposeReturnsToPool()
{
PooledEvent captured;
using (var evt = EventPool<PooledEvent>.Get())
{
captured = evt;
evt.Value = 7;
}
var reused = EventPool<PooledEvent>.Get();
Assert.AreSame(captured, reused);
Assert.AreEqual(0, reused.Value);
EventPool<PooledEvent>.Release(reused);
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 4570cac2813143c40b9a8edeadf19057
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,94 +0,0 @@
using NUnit.Framework;
using UnityEngine;
namespace ShrinkEventBus.Tests
{
[TestFixture]
public sealed class EventBusSubscriptionTests
{
private sealed class SampleEvent : EventBase
{
public int Value { get; set; }
}
[SetUp]
public void SetUp()
{
EventBus.UnregisterAllEvents();
}
[TearDown]
public void TearDown()
{
EventBus.UnregisterAllEvents();
}
[Test]
public void SubscribeEvent_Dispose_RemovesOnlyOwnedSubscription()
{
var firstHits = 0;
var secondHits = 0;
using var first = EventBus.SubscribeEvent<SampleEvent>(_ => firstHits++);
using var second = EventBus.SubscribeEvent<SampleEvent>(_ => secondHits++);
EventBus.TriggerEvent(new SampleEvent());
Assert.AreEqual(1, firstHits);
Assert.AreEqual(1, secondHits);
first.Dispose();
EventBus.TriggerEvent(new SampleEvent());
Assert.AreEqual(1, firstHits);
Assert.AreEqual(2, secondHits);
}
[Test]
public void ActiveSubscriptionSnapshot_ContainsExpectedMetadata()
{
using var subscription = EventBus.SubscribeEvent<SampleEvent>(_ => { }, EventPriority.HIGH, true);
var snapshots = EventBus.GetActiveSubscriptionsSnapshot();
Assert.AreEqual(1, snapshots.Count);
Assert.AreEqual(subscription.SubscriptionId, snapshots[0].SubscriptionId);
Assert.AreEqual(typeof(SampleEvent), snapshots[0].EventType);
Assert.AreEqual(EventPriority.HIGH, snapshots[0].Priority);
Assert.IsTrue(snapshots[0].ReceiveCanceled);
Assert.IsFalse(string.IsNullOrWhiteSpace(snapshots[0].MethodName));
}
[Test]
public void RegisterEvent_StillWorksWithoutDisposableSubscription()
{
var hits = 0;
void Handler(SampleEvent evt)
{
hits += evt.Value;
}
EventBus.RegisterEvent<SampleEvent>(Handler);
EventBus.TriggerEvent(new SampleEvent { Value = 3 });
Assert.AreEqual(3, hits);
EventBus.UnregisterEvent<SampleEvent>(Handler);
EventBus.TriggerEvent(new SampleEvent { Value = 5 });
Assert.AreEqual(3, hits);
}
[Test]
public void ActiveSubscriptionSnapshot_EmptyAfterDispose()
{
var subscription = EventBus.SubscribeEvent<SampleEvent>(_ => Debug.Log("noop"));
subscription.Dispose();
var snapshots = EventBus.GetActiveSubscriptionsSnapshot();
Assert.AreEqual(0, snapshots.Count);
Assert.IsTrue(subscription.IsDisposed);
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: f5ea531619ceaeb46af4f56454624ef7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,440 @@
#nullable enable
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading;
using Cysharp.Threading.Tasks;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
namespace ShrinkEventBus.Tests
{
internal readonly struct GeneratedPingEvent : IShrinkEvent
{
public GeneratedPingEvent(int value) => Value = value;
public int Value { get; }
}
[ShrinkEventSubscriber(DefaultBus = "game")]
internal sealed class CodeGeneratedTarget
{
public int Sum { get; private set; }
[ShrinkSubscribe]
private void OnPing(GeneratedPingEvent evt) => Sum += evt.Value;
}
[ShrinkEventSubscriber(DefaultBus = "game")]
internal static class StaticCodeGeneratedTarget
{
public static int Sum { get; set; }
[ShrinkSubscribe]
private static void OnPing(GeneratedPingEvent evt) => Sum += evt.Value;
}
[ShrinkEventSubscriber(DefaultBus = "mod:late-static")]
internal static class DelayedStaticCodeGeneratedTarget
{
public static int Sum { get; set; }
[ShrinkSubscribe]
private static void OnPing(GeneratedPingEvent evt) => Sum += evt.Value;
}
[ShrinkEventSubscriber(DefaultBus = "game")]
internal sealed class MonoCodeGeneratedTarget : MonoBehaviour
{
public int Sum { get; private set; }
[ShrinkSubscribe]
private void OnPing(GeneratedPingEvent evt) => Sum += evt.Value;
}
public sealed class ShrinkEventBusV2Tests
{
private readonly struct PingEvent : IShrinkEvent
{
public PingEvent(int value) => Value = value;
public int Value { get; }
}
private readonly struct DiagnosticEvent : IShrinkEvent
{
}
private sealed class CancelEvent : IShrinkCancelableEvent
{
public bool IsCanceled { get; private set; }
public void SetCanceled(bool value) => IsCanceled = value;
}
private sealed class ResultEvent : IShrinkResultEvent<int>
{
public int Result { get; private set; }
public void SetResult(int result) => Result = result;
}
private sealed class GeneratedTarget : IShrinkGeneratedSubscriber
{
public int Sum;
public IDisposable AttachGenerated(IShrinkBusResolver resolver, ShrinkBusKey? defaultBus = null)
{
var binding = new ShrinkEventBinding();
binding.Add(ShrinkGeneratedBinding.Subscribe<PingEvent>(resolver, defaultBus, string.Empty,
this, OnPing, ShrinkEventPriority.Normal, 0, false));
return binding;
}
private void OnPing(PingEvent evt) => Sum += evt.Value;
}
[Test]
public void MultipleBusesAreIsolated()
{
using var host = new ShrinkEventBusHost();
var game = host.CreateBus(ShrinkBusKey.Game, ShrinkBusOptions.Inline());
var mod = host.CreateBus(ShrinkBusKey.Mod("sample"), ShrinkBusOptions.Inline());
var gameHits = 0;
var modHits = 0;
using var gameBinding = ShrinkGeneratedBinding.Subscribe<PingEvent>(host, ShrinkBusKey.Game,
string.Empty, this, evt => gameHits += evt.Value, ShrinkEventPriority.Normal, 0, false);
using var modBinding = ShrinkGeneratedBinding.Subscribe<PingEvent>(host, ShrinkBusKey.Mod("sample"),
string.Empty, this, evt => modHits += evt.Value, ShrinkEventPriority.Normal, 0, false);
game.Post(new PingEvent(2));
mod.Post(new PingEvent(3));
Assert.AreEqual(2, gameHits);
Assert.AreEqual(3, modHits);
}
[Test]
public void GeneratedTargetAttachDisposesAllHandlers()
{
using var host = new ShrinkEventBusHost();
var game = host.CreateBus(ShrinkBusKey.Game, ShrinkBusOptions.Inline());
var target = new GeneratedTarget();
var binding = host.Attach(target, ShrinkBusKey.Game);
game.Post(new PingEvent(4));
binding.Dispose();
game.Post(new PingEvent(7));
Assert.AreEqual(4, target.Sum);
}
[Test]
public void AttributeOnlyTargetUsesIlGeneratedBinding()
{
using var host = new ShrinkEventBusHost();
var game = host.CreateBus(ShrinkBusKey.Game, ShrinkBusOptions.Inline());
var target = new CodeGeneratedTarget();
using (host.Attach(target, ShrinkBusKey.Game))
game.Post(new GeneratedPingEvent(9));
game.Post(new GeneratedPingEvent(4));
Assert.AreEqual(9, target.Sum);
}
[Test]
public void StaticAttributeSubscriberUsesModuleInitializerBinding()
{
StaticCodeGeneratedTarget.Sum = 0;
EventBus.Post(new GeneratedPingEvent(11));
Assert.AreEqual(11, StaticCodeGeneratedTarget.Sum);
}
[Test]
public void StaticSubscriberAttachesWhenNamedBusIsCreatedLater()
{
var key = ShrinkBusKey.Mod("late-static");
EventBus.RemoveBus(key);
DelayedStaticCodeGeneratedTarget.Sum = 0;
var bus = EventBus.CreateBus(key, ShrinkBusOptions.Inline());
bus.Post(new GeneratedPingEvent(13));
Assert.AreEqual(13, DelayedStaticCodeGeneratedTarget.Sum);
Assert.IsTrue(EventBus.RemoveBus(key));
}
[UnityTest]
public IEnumerator MonoScopeRefreshAndReleaseControlGeneratedBindings()
{
var gameObject = new GameObject("ShrinkEventBusV2-MonoScope");
gameObject.SetActive(false);
var target = gameObject.AddComponent<MonoCodeGeneratedTarget>();
var scope = gameObject.AddComponent<ShrinkMonoEventScope>();
Assert.IsInstanceOf<IShrinkGeneratedSubscriber>(target);
scope.RefreshBindings();
yield return null;
yield return EventBus.PostAsync(new GeneratedPingEvent(5)).ToCoroutine();
Assert.AreEqual(5, target.Sum);
scope.ReleaseBindings();
yield return EventBus.PostAsync(new GeneratedPingEvent(7)).ToCoroutine();
Assert.AreEqual(5, target.Sum);
UnityEngine.Object.DestroyImmediate(gameObject);
}
[Test]
public void CanceledEventsSkipNormalHandlersButReachMonitors()
{
using var host = new ShrinkEventBusHost();
var game = host.CreateBus(ShrinkBusKey.Game, ShrinkBusOptions.Inline());
var normal = 0;
var monitor = 0;
using var first = ShrinkGeneratedBinding.Subscribe<CancelEvent>(host, ShrinkBusKey.Game,
string.Empty, this, evt => evt.SetCanceled(true), ShrinkEventPriority.Highest, 0, false);
using var second = ShrinkGeneratedBinding.Subscribe<CancelEvent>(host, ShrinkBusKey.Game,
string.Empty, this, _ => normal++, ShrinkEventPriority.Normal, 0, false);
using var third = ShrinkGeneratedBinding.Subscribe<CancelEvent>(host, ShrinkBusKey.Game,
string.Empty, this, _ => monitor++, ShrinkEventPriority.Monitor, 0, true);
var result = game.Post(new CancelEvent());
Assert.IsTrue(result.Canceled);
Assert.AreEqual(0, normal);
Assert.AreEqual(1, monitor);
}
[Test]
public void PriorityAndNumericPriorityAreStable()
{
using var host = new ShrinkEventBusHost();
var game = host.CreateBus(ShrinkBusKey.Game, ShrinkBusOptions.Inline());
var order = new List<string>();
using var normal = ShrinkGeneratedBinding.Subscribe<PingEvent>(host, ShrinkBusKey.Game,
string.Empty, this, _ => order.Add("normal"), ShrinkEventPriority.Normal, 0, false);
using var high = ShrinkGeneratedBinding.Subscribe<PingEvent>(host, ShrinkBusKey.Game,
string.Empty, this, _ => order.Add("high"), ShrinkEventPriority.High, 0, false);
using var numeric = ShrinkGeneratedBinding.Subscribe<PingEvent>(host, ShrinkBusKey.Game,
string.Empty, this, _ => order.Add("numeric"), ShrinkEventPriority.Normal, 75, false);
game.Post(new PingEvent(1));
CollectionAssert.AreEqual(new[] { "numeric", "high", "normal" }, order);
}
[Test]
public void ResultEventUsesTypedResultContract()
{
using var host = new ShrinkEventBusHost();
var game = host.CreateBus(ShrinkBusKey.Game, ShrinkBusOptions.Inline());
using var binding = ShrinkGeneratedBinding.Subscribe<ResultEvent>(host, ShrinkBusKey.Game,
string.Empty, this, value => value.SetResult(42),
ShrinkEventPriority.Normal, 0, false);
var value = new ResultEvent();
var result = game.Post(value);
Assert.IsTrue(result.Handled);
Assert.AreEqual(42, value.Result);
}
[Test]
public void HandlerExceptionPropagatesFromPost()
{
using var host = new ShrinkEventBusHost();
var game = host.CreateBus(ShrinkBusKey.Game, ShrinkBusOptions.Inline());
using var binding = ShrinkGeneratedBinding.Subscribe<PingEvent>(host, ShrinkBusKey.Game,
string.Empty, this, _ => throw new InvalidOperationException("expected"),
ShrinkEventPriority.Normal, 0, false);
var exception = Assert.Throws<InvalidOperationException>(() => game.Post(new PingEvent(1)));
Assert.AreEqual("expected", exception!.Message);
}
[UnityTest]
public IEnumerator PostAsyncCancellationReturnsCanceledFailure()
{
using var host = new ShrinkEventBusHost();
var bus = host.CreateBus(new ShrinkBusKey("worker", "cancellation"),
ShrinkBusOptions.DedicatedThread());
using var source = new CancellationTokenSource();
source.Cancel();
ShrinkPostResult result = default;
yield return bus.PostAsync(new PingEvent(1), source.Token)
.ContinueWith(value => result = value)
.ToCoroutine();
Assert.AreEqual(ShrinkPostFailure.Canceled, result.Failure);
}
[UnityTest]
public IEnumerator DedicatedThreadShutdownDrainsAcceptedWork()
{
var host = new ShrinkEventBusHost();
var key = new ShrinkBusKey("worker", "drain");
var bus = host.CreateBus(key, ShrinkBusOptions.DedicatedThread(queueCapacity: 4));
var hits = 0;
using var binding = ShrinkGeneratedBinding.Subscribe<PingEvent>(host, key,
string.Empty, this, _ => Interlocked.Increment(ref hits),
ShrinkEventPriority.Normal, 0, false);
Assert.IsTrue(bus.Post(new PingEvent(1)).Accepted);
Assert.IsTrue(bus.Post(new PingEvent(2)).Accepted);
yield return host.ShutdownAsync().ToCoroutine();
Assert.AreEqual(2, Volatile.Read(ref hits));
}
[Test]
public void DedicatedThreadRejectsWhenBoundedQueueIsFull()
{
using var host = new ShrinkEventBusHost();
var key = new ShrinkBusKey("worker", "bounded");
var options = ShrinkBusOptions.DedicatedThread(queueCapacity: 1);
options.OverflowPolicy = ShrinkQueueOverflowPolicy.Reject;
var bus = host.CreateBus(key, options);
using var entered = new ManualResetEventSlim(false);
using var release = new ManualResetEventSlim(false);
var hits = 0;
using var binding = ShrinkGeneratedBinding.Subscribe<PingEvent>(host, key, string.Empty,
this, _ =>
{
Interlocked.Increment(ref hits);
entered.Set();
release.Wait(TimeSpan.FromSeconds(2));
}, ShrinkEventPriority.Normal, 0, false);
Assert.IsTrue(bus.Post(new PingEvent(1)).Accepted);
Assert.IsTrue(entered.Wait(TimeSpan.FromSeconds(2)));
Assert.IsTrue(bus.Post(new PingEvent(2)).Accepted);
var rejected = bus.Post(new PingEvent(3));
release.Set();
Assert.IsFalse(rejected.Accepted);
Assert.AreEqual(ShrinkPostFailure.QueueFull, rejected.Failure);
Assert.IsTrue(SpinWait.SpinUntil(() => Volatile.Read(ref hits) == 2, TimeSpan.FromSeconds(2)));
}
[UnityTest]
public IEnumerator TaskPoolHonorsMaximumConcurrency()
{
using var host = new ShrinkEventBusHost();
var key = new ShrinkBusKey("worker", "parallel");
var bus = host.CreateBus(key, ShrinkBusOptions.TaskPool(maxConcurrency: 2, queueCapacity: 8));
var active = 0;
var maxActive = 0;
using var binding = ShrinkGeneratedBinding.SubscribeAsync<PingEvent>(host, key, string.Empty,
this, async (_, _) =>
{
var current = Interlocked.Increment(ref active);
UpdateMaximum(ref maxActive, current);
Thread.Sleep(30);
Interlocked.Decrement(ref active);
await UniTask.CompletedTask;
}, ShrinkEventPriority.Normal, 0, false);
var posts = new List<UniTask<ShrinkPostResult>>();
for (var i = 0; i < 4; i++)
posts.Add(bus.PostAsync(new PingEvent(i)));
yield return UniTask.WhenAll(posts).ToCoroutine();
Assert.AreEqual(2, maxActive);
}
[Test]
public void WarmStructPostDoesNotAllocate()
{
using var host = new ShrinkEventBusHost();
var game = host.CreateBus(ShrinkBusKey.Game, ShrinkBusOptions.Inline());
var sum = 0;
using var binding = ShrinkGeneratedBinding.Subscribe<PingEvent>(host, ShrinkBusKey.Game,
string.Empty, this, evt => sum += evt.Value, ShrinkEventPriority.Normal, 0, false);
for (var i = 0; i < 100; i++)
game.Post(new PingEvent(i));
var before = GC.GetAllocatedBytesForCurrentThread();
for (var i = 0; i < 10_000; i++)
game.Post(new PingEvent(i));
var allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.AreEqual(0, allocated);
Assert.Greater(sum, 0);
}
[Test]
public void GlobalDetailedObserverReportsDispatchMetadata()
{
var captured = false;
var trace = default(ShrinkEventTrace);
void Capture(ShrinkEventTrace value)
{
trace = value;
captured = true;
}
EventBus.DetailedPosted += Capture;
try
{
EventBus.Post(new DiagnosticEvent());
}
finally
{
EventBus.DetailedPosted -= Capture;
}
Assert.IsTrue(captured);
Assert.AreEqual(typeof(DiagnosticEvent), trace.EventType);
Assert.AreEqual(ShrinkBusKey.Game, trace.BusKey);
Assert.AreEqual(ShrinkBusSchedulerKind.MainThread, trace.Scheduler);
Assert.IsTrue(trace.Result.Accepted);
Assert.GreaterOrEqual(trace.ElapsedTimestampTicks, 0L);
}
[Test]
public void GlobalStructPostDoesNotAllocateWithoutObservers()
{
var value = new DiagnosticEvent();
for (var i = 0; i < 100; i++)
EventBus.Post(in value);
var before = GC.GetAllocatedBytesForCurrentThread();
for (var i = 0; i < 10_000; i++)
EventBus.Post(in value);
var allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.AreEqual(0, allocated);
}
[UnityTest]
public IEnumerator DedicatedThreadPostAsyncRunsOnDedicatedThread()
{
using var host = new ShrinkEventBusHost();
var bus = host.CreateBus(new ShrinkBusKey("worker", "tests"),
ShrinkBusOptions.DedicatedThread());
var callerThread = Thread.CurrentThread.ManagedThreadId;
var handlerThread = callerThread;
using var binding = ShrinkGeneratedBinding.Subscribe<PingEvent>(host,
new ShrinkBusKey("worker", "tests"), string.Empty, this,
_ => handlerThread = Thread.CurrentThread.ManagedThreadId,
ShrinkEventPriority.Normal, 0, false);
yield return bus.PostAsync(new PingEvent(1)).ToCoroutine();
Assert.AreNotEqual(callerThread, handlerThread);
}
private static void UpdateMaximum(ref int location, int candidate)
{
while (true)
{
var current = Volatile.Read(ref location);
if (candidate <= current || Interlocked.CompareExchange(ref location, candidate, current) == current)
return;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f11f5ea66801460ca4ec08d35763d627
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+2 -2
View File
@@ -1,8 +1,8 @@
{
"name": "com.cneicy.shrink-eventbus",
"version": "1.3.0",
"version": "2.0.0",
"displayName": "ShrinkEventBus",
"description": "高性能、类型安全的 Unity 事件总线,支持优先级调度与自动注册。",
"description": "多 Bus、特性强类型注册、UniTask 调度与低分配发布的 Unity 事件总线。",
"unity": "2022.3",
"documentationUrl": "https://github.com/cneicy/ShrinkEventBus",
"changelogUrl": "https://github.com/cneicy/ShrinkEventBus/blob/main/CHANGELOG.md",
@@ -2,17 +2,20 @@
本文件记录 `ShrinkModFramework` 在当前工作区中的包内变更。
## [Unreleased]
## [0.2.0] - 2026-08-24
### Added
- 新增 `ShrinkModContextHost` / `ShrinkModComponentSource`,把模组生命周期、Registry、Network handler 与 Harmony lease 纳入可逆组件事务。
- 新增真实外部 DLL fixture 测试,覆盖失败替换恢复、损坏文件、依赖缺失、watcher 去抖与删除卸载。
- 注册表新增 owner namespace、显式优先级覆盖、同优先级冲突拒绝和覆盖候选诊断;owner 卸载时会恢复下一个覆盖或基础项。
- `ShrinkModContext` 新增 `RegisterContent` / `OverrideContent`,模组不再手工传入 owner;查询改为 `IReadOnlyShrinkModRegistry<T>`
### Changed
- `ShrinkModLoader` 默认使用 ContextHost;外部 DLL 以 SHA-256 作为 revision,同内容幂等。
- watcher 监听新增、修改、删除与重命名,并把 burst 文件事件折叠为一次主线程组合提交。
- 移除允许任意完整键的 `ShrinkModRegistry.Register(owner, key, value)`;基础内容必须通过 owner namespace 注册。
### Fixed
@@ -82,10 +82,11 @@ Application.persistentDataPath/Mods
- `[ShrinkMod]` 模组声明
- `ShrinkModBase` 生命周期
- `EventBusSubscriber` 实例自动接入
- `[ShrinkEventSubscriber]` / `[ShrinkSubscribe]` 生成式实例接入 Mod Bus
- `ShrinkCommandSubscriber` 实例命令自动接入
- `ShrinkNetworkSubscriber` 实例网络处理自动接入
- `context.GetRegistry(...)` 注册内容
- `context.RegisterContent(...)` 使用 ModId namespace 注册内容
- `context.OverrideContent(...)` 以显式优先级覆盖已有内容
- `context.RegisterNetworkHandler(...)` 走模组网络抽象层
## 注意
@@ -94,6 +95,7 @@ Application.persistentDataPath/Mods
- 当前模板默认依赖当前仓库根目录下的:
- `ShrinkModFramework.Runtime.csproj`
- `ShrinkEventBus.Runtime.csproj`
- `DotNet/ShrinkEventBus.Generator/ShrinkEventBus.Generator.csproj`(作为 Roslyn analyzer
- `ShrinkCommand.Runtime.csproj`
- `ShrinkNetwork.Runtime.csproj`
- 如果你把模板工程移到别处,需要同步修正 `__PROJECT_NAME__.csproj` 里的 `ProjectReference`
@@ -16,6 +16,9 @@
<ItemGroup>
<ProjectReference Include="__SDK_ROOT_RELATIVE__\ShrinkModFramework.Runtime.csproj" />
<ProjectReference Include="__SDK_ROOT_RELATIVE__\ShrinkEventBus.Runtime.csproj" />
<ProjectReference Include="__SDK_ROOT_RELATIVE__\DotNet\ShrinkEventBus.Generator\ShrinkEventBus.Generator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
<ProjectReference Include="__SDK_ROOT_RELATIVE__\ShrinkCommand.Runtime.csproj" />
<ProjectReference Include="__SDK_ROOT_RELATIVE__\ShrinkNetwork.Runtime.csproj" />
</ItemGroup>
@@ -5,7 +5,7 @@ using ShrinkNetwork;
namespace __ROOT_NAMESPACE__
{
public sealed class __PROJECT_NAME__Event : EventBase
public sealed class __PROJECT_NAME__Event : IShrinkEvent
{
public string Message { get; set; } = string.Empty;
}
@@ -8,10 +8,10 @@ using ShrinkNetwork;
namespace __ROOT_NAMESPACE__
{
[ShrinkMod("__MOD_ID__", "__DISPLAY_NAME__", "1.0.0")]
[EventBusSubscriber]
[ShrinkEventSubscriber(OwnerId = "__MOD_ID__", DefaultBus = "mod:__MOD_ID__")]
[ShrinkCommandSubscriber]
[ShrinkNetworkSubscriber]
public sealed class __PROJECT_NAME__Mod : ShrinkModBase
public sealed partial class __PROJECT_NAME__Mod : ShrinkModBase
{
private readonly RuntimeState _state = new();
@@ -22,11 +22,7 @@ namespace __ROOT_NAMESPACE__
public override void OnRegisterContent(ShrinkModContext context)
{
var registry = context.GetRegistry<string>("example.items");
registry.Register(
context.ModInfo.ModId,
context.ModInfo.ModId + ":example_item",
"__DISPLAY_NAME__ Item");
context.RegisterContent("example.items", "example_item", "__DISPLAY_NAME__ Item");
}
public override void OnInitialize(ShrinkModContext context)
@@ -38,7 +34,7 @@ namespace __ROOT_NAMESPACE__
});
}
[EventSubscribe]
[ShrinkSubscribe]
private void OnExampleEvent(__PROJECT_NAME__Event evt)
{
_state.LastEventMessage = evt.Message ?? string.Empty;
@@ -5,7 +5,7 @@ using ShrinkNetwork;
namespace __ROOT_NAMESPACE__
{
public sealed class __PROJECT_NAME__Event : EventBase
public sealed class __PROJECT_NAME__Event : IShrinkEvent
{
public string Message { get; set; } = string.Empty;
}
@@ -8,10 +8,10 @@ using ShrinkNetwork;
namespace __ROOT_NAMESPACE__
{
[ShrinkMod("__MOD_ID__", "__DISPLAY_NAME__", "1.0.0")]
[EventBusSubscriber]
[ShrinkEventSubscriber(OwnerId = "__MOD_ID__", DefaultBus = "mod:__MOD_ID__")]
[ShrinkCommandSubscriber]
[ShrinkNetworkSubscriber]
public sealed class __PROJECT_NAME__Mod : ShrinkModBase
public sealed partial class __PROJECT_NAME__Mod : ShrinkModBase
{
private readonly RuntimeState _state = new();
@@ -22,11 +22,7 @@ namespace __ROOT_NAMESPACE__
public override void OnRegisterContent(ShrinkModContext context)
{
var registry = context.GetRegistry<string>("example.items");
registry.Register(
context.ModInfo.ModId,
context.ModInfo.ModId + ":example_item",
"__DISPLAY_NAME__ Item");
context.RegisterContent("example.items", "example_item", "__DISPLAY_NAME__ Item");
}
public override void OnInitialize(ShrinkModContext context)
@@ -38,7 +34,7 @@ namespace __ROOT_NAMESPACE__
});
}
[EventSubscribe]
[ShrinkSubscribe]
private void OnExampleEvent(__PROJECT_NAME__Event evt)
{
_state.LastEventMessage = evt.Message ?? string.Empty;
@@ -20,6 +20,7 @@ public static class ShrinkModSdkExporter
{
"ShrinkModFramework.Runtime",
"ShrinkEventBus.Runtime",
"ShrinkEventBus.Generator",
"ShrinkCommand.Runtime",
"ShrinkCommand.Integration.EventBus",
"ShrinkCommand.Integration.Network",
@@ -43,6 +44,7 @@ public static class ShrinkModSdkExporter
{
var projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
BuildSolution(projectRoot);
BuildEventBusGenerator(projectRoot);
var defaultOutputRoot = Path.Combine(projectRoot, DefaultOutputRootRelativePath);
Directory.CreateDirectory(defaultOutputRoot);
@@ -109,6 +111,30 @@ public static class ShrinkModSdkExporter
}
}
private static void BuildEventBusGenerator(string projectRoot)
{
var startInfo = new ProcessStartInfo
{
FileName = "dotnet",
Arguments = "build .\\DotNet\\ShrinkEventBus.Generator\\ShrinkEventBus.Generator.csproj -c Release /nr:false",
WorkingDirectory = projectRoot,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(startInfo);
if (process == null)
throw new InvalidOperationException("无法启动 ShrinkEventBus.Generator 构建。");
var stdout = process.StandardOutput.ReadToEnd();
var stderr = process.StandardError.ReadToEnd();
process.WaitForExit();
if (process.ExitCode != 0)
throw new InvalidOperationException("构建 ShrinkEventBus.Generator 失败。\n" + stdout + "\n" + stderr);
}
private static void PrepareOutputDirectory(string outputRoot)
{
Directory.CreateDirectory(outputRoot);
@@ -135,6 +161,7 @@ public static class ShrinkModSdkExporter
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "Assembly-CSharp"),
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkModFramework.Runtime"),
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkEventBus.Runtime"),
Path.Combine(projectRoot, "DotNet", "ShrinkEventBus.Generator", "bin", "Release", "netstandard2.0"),
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkCommand.Runtime"),
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkCommand.Integration.EventBus"),
Path.Combine(projectRoot, "Temp", "Bin", "Debug", "ShrinkCommand.Integration.Network"),
@@ -181,7 +208,7 @@ public static class ShrinkModSdkExporter
builder.AppendLine("## 内容");
builder.AppendLine();
builder.AppendLine("- `Libs/`");
builder.AppendLine(" - 已编译好的框架运行时 DLL、当前游戏 DLL,以及模板需要的基础依赖");
builder.AppendLine(" - 已编译好的框架运行时 DLL、EventBus 代码生成器、当前游戏 DLL,以及模板需要的基础依赖");
builder.AppendLine("- `Templates/ExternalMod/SampleShrinkMod/`");
builder.AppendLine(" - 可直接复制一份开始写外部模组的模板工程");
builder.AppendLine();
@@ -266,6 +293,8 @@ public static class ShrinkModSdkExporter
- `src/`
- 示例模组代码
EventBus handler 只使用 `[ShrinkEventSubscriber]` / `[ShrinkSubscribe]`。项目已把 `Libs/ShrinkEventBus.Generator.dll` 作为 Roslyn analyzer 引入,因此 Attach/Post 路径不依赖运行时反射。
## 推荐构建方式
双击或在命令行运行:
@@ -367,6 +396,10 @@ cmd /k";
builder.AppendLine(" </Reference>");
}
builder.AppendLine(" </ItemGroup>");
builder.AppendLine();
builder.AppendLine(" <ItemGroup>");
builder.AppendLine(" <Analyzer Include=\"..\\..\\..\\Libs\\ShrinkEventBus.Generator.dll\" />");
builder.AppendLine(" </ItemGroup>");
builder.AppendLine("</Project>");
return builder.ToString();
@@ -381,7 +414,7 @@ using ShrinkNetwork;
namespace SampleShrinkMod
{
public sealed class SampleShrinkModEvent : EventBase
public sealed class SampleShrinkModEvent : IShrinkEvent
{
public string Message { get; set; } = string.Empty;
}
@@ -413,10 +446,10 @@ using ShrinkNetwork;
namespace SampleShrinkMod
{
[ShrinkMod(""sample.shrink.mod"", ""Sample Shrink Mod"", ""1.0.0"")]
[EventBusSubscriber]
[ShrinkEventSubscriber(OwnerId = ""sample.shrink.mod"", DefaultBus = ""mod:sample.shrink.mod"")]
[ShrinkCommandSubscriber]
[ShrinkNetworkSubscriber]
public sealed class SampleShrinkModMod : ShrinkModBase
public sealed partial class SampleShrinkModMod : ShrinkModBase
{
private readonly RuntimeState _state = new();
@@ -427,11 +460,7 @@ namespace SampleShrinkMod
public override void OnRegisterContent(ShrinkModContext context)
{
var registry = context.GetRegistry<string>(""example.items"");
registry.Register(
context.ModInfo.ModId,
context.ModInfo.ModId + "":example_item"",
""Sample Shrink Mod Item"");
context.RegisterContent(""example.items"", ""example_item"", ""Sample Shrink Mod Item"");
}
public override void OnInitialize(ShrinkModContext context)
@@ -443,7 +472,7 @@ namespace SampleShrinkMod
});
}
[EventSubscribe]
[ShrinkSubscribe]
private void OnExampleEvent(SampleShrinkModEvent evt)
{
_state.LastEventMessage = evt.Message ?? string.Empty;
+38 -21
View File
@@ -125,8 +125,7 @@ public class DemoCoreMod : ShrinkModBase
{
public override void OnRegisterContent(ShrinkModContext context)
{
var itemRegistry = context.GetRegistry<string>("items");
itemRegistry.Register(context.ModInfo.ModId, "demo:iron_hammer", "Iron Hammer");
context.RegisterContent("items", "iron_hammer", "Iron Hammer");
}
public override void OnInitialize(ShrinkModContext context)
@@ -250,14 +249,27 @@ var snapshot = ShrinkModDiagnostics.CaptureExternalAssemblies(settings);
### 注册内容
```csharp
var itemRegistry = context.GetRegistry<string>("items");
itemRegistry.Register(context.ModInfo.ModId, "demo:sword", "Sword");
var key = context.RegisterContent("items", "sword", "Sword");
// key == "demo.core:sword"
```
基础注册不接受任意完整键。框架始终使用当前 `ModId` 生成 `<owner>:<localKey>`,避免模组误写其它 owner 的 namespace。
### 覆盖已有内容
```csharp
context.OverrideContent(
"items",
"core:iron_sword",
priority: 100,
value: "Overridden Sword");
```
### 读取内容
```csharp
if (itemRegistry.TryGet("demo:sword", out var itemName))
var itemRegistry = context.GetRegistry<string>("items");
if (itemRegistry.TryGet("demo.core:sword", out var itemName))
{
context.Log($"找到内容:{itemName}");
}
@@ -265,8 +277,12 @@ if (itemRegistry.TryGet("demo:sword", out var itemName))
规则:
- 一个注册表内的键必须唯一
- 每条注册项都记录归属模组
- 基础项的完整键固定为 `<ownerModId>:<localKey>`
- `context.GetRegistry<T>()` 只返回 `IReadOnlyShrinkModRegistry<T>`;写入只能经过 `RegisterContent` / `OverrideContent`
- 覆盖目标必须已经存在
- 优先级越高越先命中;同目标、同优先级直接报冲突
- 每条基础项和覆盖项都记录 owner;owner 卸载时自动撤回,并恢复下一个覆盖或基础值
- `Entries` 返回当前生效项,`BaseEntries` 返回未应用覆盖的基础项
- 注册表按类型和名称双重隔离
## 网络同步
@@ -345,7 +361,7 @@ ShrinkModNetworkManager.SetTransport(new MyTransport());
之后,框架会自动尝试把**模组实例本身**接入这些现有模块:
- 如果模组类带 `[EventBusSubscriber]`,自动调用 `EventBus.AutoRegister(modInstance)`
- 如果模组类带 `[ShrinkEventSubscriber]` 且携带生成绑定,自动 `Attach``ShrinkBusKey.Mod(modId)`,并在模组生命周期结束时释放 binding
- 如果模组类带 `[ShrinkCommandSubscriber]`,自动调用 `ShrinkCommandRuntime.Default.RegisterCommands(modInstance)`
- 如果模组类带 `[ShrinkNetworkSubscriber]`,自动调用 `ShrinkNetworkRuntime.Default.RegisterHandlers(modInstance)`
- 如果项目里存在 `ShrinkNetwork.Integration.EventBus`,会额外调用 `ShrinkNetworkEventBusBridge.RefreshBindings()`,把新模组里声明的网络事件类型补进桥接层
@@ -354,7 +370,7 @@ ShrinkModNetworkManager.SetTransport(new MyTransport());
- **项目内模组**和**运行时外部 DLL 模组**都可以把实例方法挂到 `EventBus / Command / Network`
- 模组作者不需要自己再写一遍宿主层胶水
- `ShrinkModFramework` 本身仍然没有硬引用这些包,保持可选依赖关系
- EventBus 是模组框架的正式依赖;Command、Network 及其桥接仍按安装情况接入
### 推荐写法
@@ -369,10 +385,10 @@ using ShrinkModFramework;
using ShrinkNetwork;
[ShrinkMod("demo.full", "Demo Full", "1.0.0")]
[EventBusSubscriber]
[ShrinkEventSubscriber(OwnerId = "demo.full", DefaultBus = "mod:demo.full")]
[ShrinkCommandSubscriber]
[ShrinkNetworkSubscriber]
public sealed class DemoFullMod : ShrinkModBase
public sealed partial class DemoFullMod : ShrinkModBase
{
private DemoSaveData _saveData = new();
@@ -390,7 +406,7 @@ public sealed class DemoFullMod : ShrinkModBase
});
}
[EventSubscribe]
[ShrinkSubscribe]
private void OnSaveCompleted(ShrinkDataSaver.Integration.SaveCompletedEvent evt)
{
// 这里只是示意:模组类实例会被框架自动接到 EventBus
@@ -426,15 +442,16 @@ public sealed class DemoFullMod : ShrinkModBase
推荐:
- 模组类本身带 `[EventBusSubscriber]`
- 实例方法上写 `[EventSubscribe]`
- `ShrinkModFramework` 自动 `AutoRegister`
- 模组类本身带 `[ShrinkEventSubscriber]` 并声明 `DefaultBus = "mod:<modId>"`
- 实例方法上写 `[ShrinkSubscribe]`
- 类型声明为 `partial`,由 ILPostProcessor 或 Roslyn incremental generator 生成强类型 binding
-`ShrinkModFramework` 自动 Attach 到模组 Bus,并随模组生命周期释放
注意:
- `ShrinkEventBus``MonoBehaviour` 零侵入自动注册依赖编译期织入
- 对**运行时外部 DLL**,不要假设它也会自动织入生命周期
- 外部 DLL 更稳的方式是“实例订阅 + 框架自动 `EventBus.AutoRegister(modInstance)`
- Unity 项目内类型由 ILPostProcessor 生成 binding;外部 DLL 使用导出 SDK 中的 `ShrinkEventBus.Generator` 分析器
- 外部 DLL 必须携带生成合同;正式运行时不会反射扫描没有生成合同的旧程序集
- 模组框架只负责把已生成的实例 binding Attach 到对应 Mod Bus,不会枚举方法或调用 `MethodInfo.Invoke`
#### 2. `ShrinkDataSaver`
@@ -503,7 +520,7 @@ public sealed class DemoFullMod : ShrinkModBase
推荐:
- 事件类型同时满足 `EventBase + IShrinkNetworkMessage`
- 事件类型同时满足 `IShrinkEvent + IShrinkNetworkMessage`
- 标记 `[ShrinkNetworkEvent] + [ShrinkNetworkMessage(...)]`
- 让模组监听或发布这些事件,而不是再写一层重复 DTO
@@ -522,10 +539,10 @@ public sealed class DemoFullMod : ShrinkModBase
- **项目内模组**
- 编译时就在 Unity 当前 AppDomain 里
- 更容易吃到各模块自己的静态扫描/自动注册能力
- 由 Unity ILPostProcessor 生成 EventBus binding
- **运行时外部 DLL 模组**
-`ShrinkModFramework` 后续动态加载进来的
- 更稳的是走“模组实例注册”而不是“静态全局扫描”
- 必须引用导出包里的 `ShrinkEventBus.Generator.dll` 生成同一份 binding 合同
所以当前推荐原则很明确:
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using Cysharp.Threading.Tasks;
using ShrinkContext;
using ShrinkEventBus;
namespace ShrinkModFramework
{
@@ -66,6 +67,16 @@ namespace ShrinkModFramework
instance.OnRegisterContent(modContext);
handle.State = ShrinkModState.ContentRegistered;
IDisposable eventBinding = null;
ctx.Effect(
() => eventBinding = ShrinkModOptionalRuntimeIntegration.TryAttachEventBusInstance(
instance.GetType(), instance, handle.Info.ModId, _host.VerboseLogging),
() =>
{
eventBinding?.Dispose();
EventBus.RemoveBus(ShrinkBusKey.Mod(handle.Info.ModId));
});
// 模组网络 handler 以 ModId 为归属键,可在失败或卸载时整体撤回。
ctx.Effect(() => { }, () => ShrinkModNetworkManager.UnregisterHandlers(handle.Info.ModId));
instance.OnInitialize(modContext);
@@ -54,7 +54,7 @@ namespace ShrinkModFramework
public IReadOnlyList<ShrinkModComponentSource> CurrentSources => _currentSources.ToArray();
internal ShrinkModRegistryManager RegistryManager { get; }
public ShrinkModRegistry<T> GetOrCreateRegistry<T>(string name) =>
public IReadOnlyShrinkModRegistry<T> GetOrCreateRegistry<T>(string name) =>
RegistryManager.GetOrCreateRegistry<T>(name);
public static string GetModKey(string modId)
@@ -50,7 +50,7 @@ namespace ShrinkModFramework
return _host.Mods;
}
public static ShrinkModRegistry<T> GetOrCreateRegistry<T>(string name) =>
public static IReadOnlyShrinkModRegistry<T> GetOrCreateRegistry<T>(string name) =>
Host.GetOrCreateRegistry<T>(name);
internal static void ResetForDomainReload()
@@ -27,11 +27,22 @@ namespace ShrinkModFramework
public IReadOnlyDictionary<string, ShrinkModHandle> LoadedMods => _loadedMods;
public bool IsContextManaged => _effectContext != null;
public ShrinkModRegistry<T> GetRegistry<T>(string name) => _registryManager.GetOrCreateRegistry<T>(name);
public IReadOnlyShrinkModRegistry<T> GetRegistry<T>(string name) =>
_registryManager.GetOrCreateRegistry<T>(name);
public bool TryGetRegistry<T>(string name, out ShrinkModRegistry<T> registry)
public bool TryGetRegistry<T>(string name, out IReadOnlyShrinkModRegistry<T> registry)
=> _registryManager.TryGetRegistry(name, out registry);
/// <summary>用当前 ModId 作为 namespace 注册内容,并返回最终完整键。</summary>
public string RegisterContent<T>(string registryName, string localKey, T value) =>
_registryManager.GetOrCreateMutableRegistry<T>(registryName)
.RegisterNamespaced(ModInfo.ModId, localKey, value);
/// <summary>以当前模组为 owner 覆盖已有内容;同目标、同优先级冲突会直接失败。</summary>
public void OverrideContent<T>(string registryName, string targetKey, int priority, T value) =>
_registryManager.GetOrCreateMutableRegistry<T>(registryName)
.RegisterOverride(ModInfo.ModId, targetKey, priority, value);
public bool IsModLoaded(string modId) => !string.IsNullOrWhiteSpace(modId) && _loadedMods.ContainsKey(modId);
public bool TryGetLoadedMod(string modId, out ShrinkModHandle handle)
@@ -1,3 +1,5 @@
using System;
namespace ShrinkModFramework
{
public sealed class ShrinkModHandle
@@ -6,6 +8,7 @@ namespace ShrinkModFramework
public IShrinkMod Instance { get; }
public ShrinkModState State { get; internal set; }
public long Generation { get; internal set; }
internal IDisposable EventBinding { get; set; }
internal ShrinkModHandle(ShrinkModInfo info, IShrinkMod instance)
{
@@ -1,21 +1,24 @@
using System;
using System.Linq;
using System.Reflection;
using ShrinkEventBus;
using UnityEngine;
namespace ShrinkModFramework
{
internal static class ShrinkModOptionalRuntimeIntegration
{
public static void RegisterModInstance(IShrinkMod modInstance, ShrinkModInfo modInfo, bool verboseLogging)
public static IDisposable RegisterModInstance(IShrinkMod modInstance, ShrinkModInfo modInfo,
bool verboseLogging)
{
if (modInstance == null || modInfo == null)
return;
return null;
var modType = modInstance.GetType();
TryRegisterEventBusInstance(modType, modInstance, modInfo.ModId, verboseLogging);
var eventBinding = TryAttachEventBusInstance(modType, modInstance, modInfo.ModId, verboseLogging);
TryRegisterCommandInstance(modType, modInstance, modInfo.ModId, verboseLogging);
TryRegisterNetworkInstance(modType, modInstance, modInfo.ModId, verboseLogging);
return eventBinding;
}
public static void RefreshGlobalBindings(bool verboseLogging)
@@ -23,29 +26,25 @@ namespace ShrinkModFramework
TryRefreshNetworkEventBusBridge(verboseLogging);
}
private static void TryRegisterEventBusInstance(Type modType, object modInstance, string modId, bool verboseLogging)
public static IDisposable TryAttachEventBusInstance(Type modType, object modInstance,
string modId, bool verboseLogging)
{
if (!HasAttribute(modType, "ShrinkEventBus.EventBusSubscriberAttribute", "ShrinkEventBus.Runtime"))
return;
if (modInstance is not IShrinkGeneratedSubscriber)
return null;
try
{
var eventBusType = FindType("ShrinkEventBus.EventBus", "ShrinkEventBus.Runtime");
var autoRegisterMethod = eventBusType?.GetMethod("AutoRegister",
BindingFlags.Public | BindingFlags.Static,
null,
new[] { typeof(object) },
null);
if (autoRegisterMethod == null)
return;
autoRegisterMethod.Invoke(null, new[] { modInstance });
var busKey = ShrinkBusKey.Mod(modId);
EventBus.GetOrCreateBus(busKey, ShrinkBusOptions.DedicatedThread());
var binding = EventBus.Attach(modInstance, busKey);
if (verboseLogging)
Debug.Log($"[ShrinkModFramework] 模组 {modId} 已接入 ShrinkEventBus 实例订阅。");
return binding;
}
catch (Exception ex)
{
Debug.LogWarning($"[ShrinkModFramework] 模组 {modId} 接入 ShrinkEventBus 失败:{ex.Message}");
return null;
}
}
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using ShrinkEventBus;
using UnityEngine;
namespace ShrinkModFramework
@@ -106,6 +107,9 @@ namespace ShrinkModFramework
{
foreach (var handle in newHandles)
{
handle.EventBinding?.Dispose();
handle.EventBinding = null;
EventBus.RemoveBus(ShrinkBusKey.Mod(handle.Info.ModId));
LoadedMods.Remove(handle.Info.ModId);
LoadSequence.Remove(handle);
}
@@ -123,7 +127,7 @@ namespace ShrinkModFramework
return false;
}
public static ShrinkModRegistry<T> GetOrCreateRegistry<T>(string name)
public static IReadOnlyShrinkModRegistry<T> GetOrCreateRegistry<T>(string name)
=> ShrinkModCordisRuntime.IsInitialized
? ShrinkModCordisRuntime.GetOrCreateRegistry<T>(name)
: RegistryManager.GetOrCreateRegistry<T>(name);
@@ -131,6 +135,7 @@ namespace ShrinkModFramework
internal static void ResetForTesting()
{
ShrinkModCordisRuntime.ResetForTesting();
DisposeLegacyEventBindings();
LoadedMods.Clear();
LoadSequence.Clear();
RegistryManager.Clear();
@@ -145,6 +150,7 @@ namespace ShrinkModFramework
internal static void ResetForDomainReload()
{
ShrinkModCordisRuntime.ResetForDomainReload();
DisposeLegacyEventBindings();
LoadedMods.Clear();
LoadSequence.Clear();
RegistryManager.Clear();
@@ -383,11 +389,22 @@ namespace ShrinkModFramework
private static void RegisterOptionalRuntimeIntegrations(IEnumerable<ShrinkModHandle> handles, bool verboseLogging)
{
foreach (var handle in handles)
ShrinkModOptionalRuntimeIntegration.RegisterModInstance(handle.Instance, handle.Info, verboseLogging);
handle.EventBinding = ShrinkModOptionalRuntimeIntegration.RegisterModInstance(
handle.Instance, handle.Info, verboseLogging);
ShrinkModOptionalRuntimeIntegration.RefreshGlobalBindings(verboseLogging);
}
private static void DisposeLegacyEventBindings()
{
foreach (var handle in LoadedMods.Values)
{
handle.EventBinding?.Dispose();
handle.EventBinding = null;
EventBus.RemoveBus(ShrinkBusKey.Mod(handle.Info.ModId));
}
}
private static void RunPhase(IEnumerable<ShrinkModHandle> handles, ShrinkModState targetState, bool verboseLogging,
Action<IShrinkMod, ShrinkModContext> callback, string phaseName)
{
@@ -4,14 +4,28 @@ using System.Linq;
namespace ShrinkModFramework
{
public interface IReadOnlyShrinkModRegistry<T>
{
string Name { get; }
IReadOnlyCollection<ShrinkRegistryEntry<T>> Entries { get; }
IReadOnlyCollection<ShrinkRegistryEntry<T>> BaseEntries { get; }
bool Contains(string key);
bool TryGet(string key, out T value);
bool TryGetEntry(string key, out ShrinkRegistryEntry<T> entry);
IReadOnlyList<ShrinkRegistryEntry<T>> GetOverrideCandidates(string key);
}
internal interface IShrinkOwnedRegistry
{
void RemoveOwnedEntries(string ownerModId);
}
public sealed class ShrinkModRegistry<T> : IShrinkOwnedRegistry
internal sealed class ShrinkModRegistry<T> : IReadOnlyShrinkModRegistry<T>, IShrinkOwnedRegistry
{
private readonly Dictionary<string, ShrinkRegistryEntry<T>> _entries = new();
private readonly Dictionary<string, ShrinkRegistryEntry<T>> _baseEntries =
new(StringComparer.Ordinal);
private readonly Dictionary<string, List<ShrinkRegistryEntry<T>>> _overrides =
new(StringComparer.Ordinal);
public string Name { get; }
@@ -23,27 +37,88 @@ namespace ShrinkModFramework
Name = name.Trim();
}
public IReadOnlyCollection<ShrinkRegistryEntry<T>> Entries => _entries.Values.ToArray();
/// <summary>当前生效项;存在覆盖时返回最高优先级的覆盖项。</summary>
public IReadOnlyCollection<ShrinkRegistryEntry<T>> Entries => _baseEntries.Keys
.OrderBy(key => key, StringComparer.Ordinal)
.Select(ResolveEntry)
.ToArray();
public void Register(string ownerModId, string key, T value)
/// <summary>不应用覆盖的基础注册项快照。</summary>
public IReadOnlyCollection<ShrinkRegistryEntry<T>> BaseEntries => _baseEntries.Values
.OrderBy(entry => entry.Key, StringComparer.Ordinal)
.ToArray();
private void RegisterBase(string ownerModId, string key, T value)
{
if (string.IsNullOrWhiteSpace(ownerModId))
throw new ArgumentException("归属模组 ID 不能为空。", nameof(ownerModId));
if (string.IsNullOrWhiteSpace(key))
throw new ArgumentException("注册键不能为空。", nameof(key));
key = key.Trim();
if (_entries.ContainsKey(key))
ownerModId = NormalizeOwner(ownerModId);
key = NormalizeKey(key);
if (_baseEntries.ContainsKey(key))
throw new InvalidOperationException($"注册表 '{Name}' 中已存在键 '{key}'。");
_entries.Add(key, new ShrinkRegistryEntry<T>(ownerModId.Trim(), key, value));
_baseEntries.Add(key, new ShrinkRegistryEntry<T>(ownerModId, key, value));
}
public bool Contains(string key) => !string.IsNullOrWhiteSpace(key) && _entries.ContainsKey(key.Trim());
/// <summary>
/// 使用 owner 作为 namespace 注册内容,并返回最终键。localKey 只能是局部名称,不能包含冒号。
/// </summary>
public string RegisterNamespaced(string ownerModId, string localKey, T value)
{
ownerModId = NormalizeOwner(ownerModId);
if (ownerModId.IndexOf(':') >= 0)
throw new ArgumentException("归属模组 ID 不能包含 ':'。", nameof(ownerModId));
if (string.IsNullOrWhiteSpace(localKey))
throw new ArgumentException("局部注册键不能为空。", nameof(localKey));
localKey = localKey.Trim();
if (localKey.IndexOf(':') >= 0)
throw new ArgumentException("局部注册键不能包含 ':'。", nameof(localKey));
var key = ownerModId + ":" + localKey;
RegisterBase(ownerModId, key, value);
return key;
}
/// <summary>
/// 为已有基础项注册显式覆盖。优先级越高越先命中;同一目标的相同优先级视为硬冲突。
/// </summary>
public void RegisterOverride(string ownerModId, string targetKey, int priority, T value)
{
ownerModId = NormalizeOwner(ownerModId);
targetKey = NormalizeKey(targetKey);
if (!_baseEntries.ContainsKey(targetKey))
throw new InvalidOperationException(
$"注册表 '{Name}' 的覆盖目标 '{targetKey}' 不存在。");
if (!_overrides.TryGetValue(targetKey, out var candidates))
{
candidates = new List<ShrinkRegistryEntry<T>>();
_overrides.Add(targetKey, candidates);
}
if (candidates.Any(candidate => candidate.Priority == priority))
throw new InvalidOperationException(
$"注册表 '{Name}' 的键 '{targetKey}' 在优先级 {priority} 存在覆盖冲突。");
candidates.Add(ShrinkRegistryEntry<T>.CreateOverride(ownerModId, targetKey, priority, value));
}
public IReadOnlyList<ShrinkRegistryEntry<T>> GetOverrideCandidates(string key)
{
if (string.IsNullOrWhiteSpace(key) || !_overrides.TryGetValue(key.Trim(), out var candidates))
return Array.Empty<ShrinkRegistryEntry<T>>();
return candidates
.OrderByDescending(candidate => candidate.Priority)
.ThenBy(candidate => candidate.OwnerModId, StringComparer.Ordinal)
.ToArray();
}
public bool Contains(string key) =>
!string.IsNullOrWhiteSpace(key) && _baseEntries.ContainsKey(key.Trim());
public bool TryGet(string key, out T value)
{
if (!string.IsNullOrWhiteSpace(key) && _entries.TryGetValue(key.Trim(), out var entry))
if (TryGetEntry(key, out var entry))
{
value = entry.Value;
return true;
@@ -55,8 +130,11 @@ namespace ShrinkModFramework
public bool TryGetEntry(string key, out ShrinkRegistryEntry<T> entry)
{
if (!string.IsNullOrWhiteSpace(key))
return _entries.TryGetValue(key.Trim(), out entry);
if (!string.IsNullOrWhiteSpace(key) && _baseEntries.ContainsKey(key.Trim()))
{
entry = ResolveEntry(key.Trim());
return true;
}
entry = default;
return false;
@@ -67,12 +145,52 @@ namespace ShrinkModFramework
if (string.IsNullOrWhiteSpace(ownerModId))
return;
var keys = _entries
ownerModId = ownerModId.Trim();
var keys = _baseEntries
.Where(pair => string.Equals(pair.Value.OwnerModId, ownerModId, StringComparison.Ordinal))
.Select(pair => pair.Key)
.ToArray();
foreach (var key in keys)
_entries.Remove(key);
_baseEntries.Remove(key);
var emptyOverrideTargets = new List<string>();
foreach (var pair in _overrides)
{
pair.Value.RemoveAll(candidate =>
string.Equals(candidate.OwnerModId, ownerModId, StringComparison.Ordinal));
if (pair.Value.Count == 0)
emptyOverrideTargets.Add(pair.Key);
}
foreach (var targetKey in emptyOverrideTargets)
_overrides.Remove(targetKey);
}
private ShrinkRegistryEntry<T> ResolveEntry(string key)
{
if (_overrides.TryGetValue(key, out var candidates) && candidates.Count > 0)
{
return candidates
.OrderByDescending(candidate => candidate.Priority)
.ThenBy(candidate => candidate.OwnerModId, StringComparer.Ordinal)
.First();
}
return _baseEntries[key];
}
private static string NormalizeOwner(string ownerModId)
{
if (string.IsNullOrWhiteSpace(ownerModId))
throw new ArgumentException("归属模组 ID 不能为空。", nameof(ownerModId));
return ownerModId.Trim();
}
private static string NormalizeKey(string key)
{
if (string.IsNullOrWhiteSpace(key))
throw new ArgumentException("注册键不能为空。", nameof(key));
return key.Trim();
}
}
@@ -81,12 +199,25 @@ namespace ShrinkModFramework
public string OwnerModId { get; }
public string Key { get; }
public T Value { get; }
public bool IsOverride { get; }
public int Priority { get; }
public ShrinkRegistryEntry(string ownerModId, string key, T value)
: this(ownerModId, key, value, false, 0)
{
}
private ShrinkRegistryEntry(string ownerModId, string key, T value, bool isOverride, int priority)
{
OwnerModId = ownerModId;
Key = key;
Value = value;
IsOverride = isOverride;
Priority = priority;
}
internal static ShrinkRegistryEntry<T> CreateOverride(
string ownerModId, string key, int priority, T value) =>
new(ownerModId, key, value, true, priority);
}
}
@@ -7,7 +7,10 @@ namespace ShrinkModFramework
{
private readonly Dictionary<string, object> _registries = new();
public ShrinkModRegistry<T> GetOrCreateRegistry<T>(string name)
public IReadOnlyShrinkModRegistry<T> GetOrCreateRegistry<T>(string name) =>
GetOrCreateMutableRegistry<T>(name);
internal ShrinkModRegistry<T> GetOrCreateMutableRegistry<T>(string name)
{
var compositeKey = BuildCompositeKey(typeof(T), name);
if (_registries.TryGetValue(compositeKey, out var existing))
@@ -18,7 +21,7 @@ namespace ShrinkModFramework
return registry;
}
public bool TryGetRegistry<T>(string name, out ShrinkModRegistry<T> registry)
public bool TryGetRegistry<T>(string name, out IReadOnlyShrinkModRegistry<T> registry)
{
var compositeKey = BuildCompositeKey(typeof(T), name);
if (_registries.TryGetValue(compositeKey, out var existing))
@@ -4,6 +4,7 @@
"references": [
"Newtonsoft.Json",
"ShrinkContext.Core.Runtime",
"ShrinkEventBus.Runtime",
"UniTask"
],
"includePlatforms": [],

Some files were not shown because too many files have changed in this diff Show More