ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

ObserverList引用方式与设计取舍:从静态属性到接口抽象

ObserverList引用方式与设计取舍:从静态属性到接口抽象 引用在第三方工具类中引用 ObserverList 的几种方式顺带聊聊设计上的取舍1. 为什么单独聊“引用”这件事ObserverList 本身是个很轻量的东西核心就是 add、remove、notify 三件套。但真正到了工程落地时大家遇到的第一道坎往往不是 ObserverList 怎么用而是“我在一个工具类里怎么把 ObserverList 引进来”这件事。第二道坎才是“引进来之后接口怎么设计才不别扭”。先说结论这两个问题其实是同一个问题。引用方式决定了你后面接口设计的自由度而接口设计又反过来限制你还能不能换引用方式。所以这文章不打算只给你贴三段代码就完事而是把几种做法放在一起对比说清楚每种方式的适用场景、坑在哪以及我自己在项目里踩过之后的选择。2. 先明确一个前提ObserverList 是什么、解决什么问题ObserverList 本质上是一个线程安全的观察者容器常见于事件总线、UI 通知、状态同步这类场景。它解决的问题很朴素当一个事件发生时可能有多个对象都关心这个事件但这些对象之间不应该互相持有引用否则就耦合死了。ObserverList 就是那个“中间人”帮你把“谁知道这件事”和“谁触发这件事”彻底分开。在 .NET 里常见的实现有两类自定义的 ObserverList 自己管 List 加锁基于事件 event EventHandler 的机制本质也是观察者容器。我在实践中更倾向于自己封装 ObserverList理由后面会说。这里先提一下因为后面所有引用方式都是围绕这个自定义类来展开的。3. 几种常见的引用方式3.1 直接作为静态属性挂在工具类上最直观的做法就是在工具类里定义一个静态属性public static class EventCenter { public static ObserverListSomeMessage MessageObservers { get; } new(); }然后在任何地方直接 EventCenter.MessageObservers.Add(...) 或 Notify(...)。优点很明显全局访问零成本任何模块都能用对中小型项目来说是最省事的方案。但这也是它的缺点——太自由了。一旦项目里出现“不知道谁在监听、也不知道谁在发送”的情况排查起来就是一场灾难。而且静态属性天然就是进程内单例如果以后要做多实例隔离比如多场景、多世界这套静态状态就会成为阻碍。我的建议是静态方案适合小项目、工具脚本、编辑器扩展不适合需要控制生命周期的大型架构。3.2 通过构造函数注入构造器注入这是目前我认为最“正确”的默认选择public class MyTool { private readonly ObserverListSomeMessage _observers; public MyTool(ObserverListSomeMessage observers) { _observers observers; } public void DoSomething() { _observers.Notify(new SomeMessage(...)); } }调用方负责创建 ObserverList传进来var observers new ObserverListSomeMessage(); var tool new MyTool(observers);构造函数注入的好处是依赖关系一目了然。任何人看到 MyTool 的构造函数就知道它依赖一个 ObserverList不会有隐藏的全局状态。测试也好做——直接 new 一个列表传进去不需要初始化任何静态环境。缺点就是稍微“啰嗦”一点尤其是当你的工具类依赖很多其他服务时构造函数会变得很长。但这不是坏味道恰恰是你在被迫正视复杂度。3.3 通过属性注入Setter 注入有些场景下构造函数注入不太合适。比如这个工具类是历史代码构造函数已经有大量参数不方便再改或者这个工具类是通过反射创建的不方便传参再或者 ObserverList 是可选的可能为 null。这时候可以用属性注入public class MyTool { public ObserverListSomeMessage? Observers { get; set; } public void DoSomething() { Observers?.Notify(new SomeMessage(...)); } }属性注入的灵活性很高但代价就是“可空性”。调用方可能忘记赋值导致运行时静默失败。我的习惯是属性注入只用于“有这个观察者更好没有也能跑”的可选依赖。如果是核心依赖必须用构造函数注入。3.4 通过接口抽象IEventBus/IMessageHub如果项目稍大我强烈建议在 ObserverList 外面再包一层接口。不直接暴露 ObserverList 类型而暴露一个更语义化的接口public interface IMessageBus { void SubscribeTMessage(ActionTMessage handler); void UnsubscribeTMessage(ActionTMessage handler); void PublishTMessage(TMessage message); }然后在实现类内部用 ObserverList 或字典管理public class MessageBus : IMessageBus { private readonly ObserverListobject _observerList new(); // 实现细节略 }引用方式就变成public class MyTool { private readonly IMessageBus _messageBus; public MyTool(IMessageBus messageBus) { _messageBus messageBus; } }这样做的好处非常明显。第一所有调用点依赖的是接口而不是具体类以后想换掉 ObserverList 的实现比如改成用 Channel 做异步分发完全不影响上层代码。第二可以写多个不同的 IMessageBus 实现比如测试用的内存版本、线上用的分布式版本这在做单元测试时特别香。3.5 服务定位器Service Locator还有一种比较传统的做法是通过一个全局的服务容器来获取 ObserverListvar observers ServiceLocator.GetObserverListSomeMessage();这种方法在老的 WPF/Prism 项目里比较常见。它确实是全局可访问的但代价是调用方和服务容器耦合在一起而且编译器无法帮你检查某个类型是否已经注册。我现在的态度是如果你的项目里已经有 DI 容器用 DI 容器本身来管理 ObserverList 的单例生命周期就够了不需要再套一层 Service Locator。4. 设计取舍接口设计比引用方式更重要引用方式只是第一步真正决定这个 ObserverList 好不好用的是你为它设计的 API。4.1 发布时是同步还是异步这是一个很容易被忽视但影响巨大的决策。同步 Notify调用 Publish 之后所有订阅者都会在同一个调用栈上立即执行。优点是逻辑简单、调试方便一个断点就能看到整个调用链。缺点是如果某个订阅者执行慢会阻塞后续所有订阅者甚至阻塞发布者本身。异步 Notify把消息投递到一个消息队列里由专门的线程或线程池处理。优点是发布者不会被阻塞适合高频消息。缺点是时序不可控而且如果订阅者里有 UI 操作还要再做线程切换。我自己在做工具类时默认用同步 Notify但会在 ObserverList 内部保留一个是否允许异步派发的开关留给将来性能优化时用。工程上永远不要一开始就搞异步分发后患无穷。4.2 弱引用还是强引用ObserverList 里存的是强引用还是弱引用直接决定了生命周期管理的难度。强引用默认订阅者对象会被 ObserverList 一直持有着如果忘记 Unsubscribe就会导致内存泄漏。这是绝大多数内存泄漏问题的根源。弱引用ObserverList 不会阻止垃圾回收回收订阅者对象但每次 Notify 的时候需要额外检查目标是否还活着。如果你做的是长生命周期的工具类我强烈建议考虑弱引用版本的 ObserverList。代价是每次通知时多一次条件判断但换来的是内存安全值。4.3 是否支持多线程并发ObserverList 的线程安全性取决于你用的 List 类型和是否加锁。我的经验是如果事件源只有主线程直接 List 都不用加锁如果有多个线程同时 Notify必须用锁或 ConcurrentBag 等线程安全集合。另外还要考虑“订阅者在 Notify 过程中被修改”的问题。比如正在遍历一个 List 通知所有订阅者的时候某个订阅者内部不小心调用了 Add 或 Remove。这个时候如果直接用 List就会抛出 InvalidOperationException。解决办法有两个用独立的快照数组在 Notify 时先把订阅者拷贝一份再遍历用 ReaderWriterLockSlim 控制读写并发。我自己用的是快照方案简单粗暴实测很稳。5. 我建议的落地组合附完整示例结合上面这些分析如果让我现在写一个新的项目我会这样组合ObserverList 内部用快照遍历 锁保护写操作对外暴露 IMessageBus 接口不直接暴露具体类型通过构造函数注入 IMessageBus 给工具类默认强引用大项目切换为弱引用。下面给一个最小可运行的示例框架public class SomeMessage { public string Content { get; set; } } public interface IMessageBus { void AddTMessage(ActionTMessage handler); void RemoveTMessage(ActionTMessage handler); void NotifyTMessage(TMessage message); } public class ObserverListTMessage : IMessageBus { private readonly object _syncLock new(); private ListActionTMessage _handlers new(); public void Add(ActionTMessage handler) { lock (_syncLock) { _handlers.Add(handler); } } public void Remove(ActionTMessage handler) { lock (_syncLock) { _handlers.Remove(handler); } } public void Notify(TMessage message) { ActionTMessage[] snapshot; lock (_syncLock) { snapshot _handlers.ToArray(); } foreach (var handler in snapshot) { handler(message); } } } public class MyTool { private readonly IMessageBus _messageBus; public MyTool(IMessageBus messageBus) { _messageBus messageBus; } public void Run() { _messageBus.Notify(new SomeMessage { Content Hello Observer }); } } // 组装 var observerList new ObserverListSomeMessage(); var tool new MyTool(observerList); observerList.Add(msg Console.WriteLine(msg.Content)); tool.Run();这套组合的好处是接口定义了行为ObserverList 提供了线程安全的实现工具类只依赖抽象测试时可以随意替换。看起来代码量稍微多一点但长期维护的收益远超这点前期成本。6. 常见问题与排查技巧附速查表6.1 问题一为什么我订阅的 handler 没有被调用最常见的原因是你在 Notify 之后才 Add。事件总线是即时派发不是持久化消息队列。如果某个订阅发生在 Notify 之后它永远收不到那条消息。排查时先确认代码执行顺序。还有个更隐蔽的原因你 Add 进去的 handler 和通知时用的 handler 不是同一个实例。尤其是用 lambda 表达式时每次 lambda 返回的 Action 实例都可能不同Remove 不掉就是这个问题。6.2 问题二内存泄漏怎么查先看是否有强引用没释放。最直接的办法是把 ObserverList 里存的订阅者改成弱引用然后抓 dump 看回收情况。如果回收正常说明原先确实是被 ObserverList 强引用住了。6.3 问题三多线程环境下运行不一致典型的错误是在 foreach 循环里直接修改集合。我们的快照方案已经避免了这个问题但如果你用别人的库请确认对方有没有做快照处理。否则就需要考虑 Clone 或 ToList。6.4 速查表症状可能原因排查步骤推荐解法订阅后无响应消息在订阅前已发布检查执行顺序加日志确保先订阅再发布Remove 无效handler 实例不同用强引用的静态方法替代 lambda缓存 handler 实例内存占用飙升强引用未释放抓 dump分析引用链换弱引用 ObserverListNotify 抛异常遍历时修改集合开启异常断点定位改为快照遍历杂乱消息导致性能差订阅者过多分析 Notify 耗时考虑按消息类型拆分列表7. 我的最终建议与心得如果你问我踩过这么多坑之后的核心体会是什么我会说ObserverList 的引用方式本质上是你对“模块间如何通信”这个问题的架构回答。静态全局最省事但牺牲了可控性构造注入最稳但要求你提前规划好依赖结构接口抽象最优雅但前期要多写几个类。我个人的最终选择是小项目直接用静态属性项目稍微变大立刻迁移到构造函数注入 接口抽象。迁移成本其实不高因为改动集中在组装层比如 Program.cs 里的 DI 注册业务代码基本不受影响。还有一个小技巧如果你不确定自己的设计是否合理就试着写一下单元测试。如果你发现写测试非常别扭十有八九是依赖关系设计出了问题。ObserverList 这种基础设施最忌讳的就是和具体业务逻辑绑得太死。最后再补充一点不要过度设计。如果你的项目真的只有一个地方用到了 ObserverList静态一把梭完全够用。架构是为解决问题服务的不是为了让你显得专业。等到第二处、第三处用到通知机制时再来考虑迁移也不迟。
返回列表