☰
Java自实现本地事件分发:事件总线设计与同步异步取舍
2026/11/3 12:05:28 网站建设 项目流程

本地事件分发这个能力,乍一听像是框架里才有的东西,其实只要项目体量稍微大一点,迟早会需要它。我用过的几个中小型 Java 服务,起初都是类与类之间直接调方法,改到最后发现下单流程里塞了二十多行"顺便通知一下积分""顺便写条消息""顺便刷新下缓存",一个方法改三次,每次都要重新回归全部下游逻辑。后来把事件总线引进来,主流程只负责发一条事件,谁关心谁自己订阅,改动范围立刻收窄到一个订阅类里。这篇就围绕"用 Java 自己实现一套本地事件分发"展开,讲清楚为什么要自己写、核心模型怎么设计、同步与异步怎么取舍、异常与顺序怎么兜底,以及上线之后怎么排查那些诡异的问题。

本地事件分发,说白了就是在同一个 JVM 进程内,把"某件事发生了"这个消息交给零个或多个感兴趣的接收者,发布方不关心也不等待具体是谁处理的。它和消息队列最大的区别就是不出进程,没有网络、没有序列化、没有中间件,代价是可靠性由你自己兜底。适合读这篇文章的人:正在被循环依赖和臃肿 Service 折磨的后端开发者、想给单体应用做模块解耦的架构同学,以及准备自己动手写一个轻量组件而不是直接引入重型框架的人。下面所有代码和方案都是我在实际项目里跑过的形态,不追求大而全,追求能抄走就用。

1. 先想清楚为什么不用现成的消息中间件

1.1 进程内通信和跨进程通信是两类问题

很多人一提"事件"就想到 MQ,其实这两者的适用场景差别很大。我把判断标准压成一句话:事件的消费者和生产者是否必须独立部署、独立扩容、独立故障隔离。如果答案是"是",那必须用跨进程的消息中间件;如果答案是"否",用本地事件分发就够了,而且简单得多。

举个具体例子。用户注册成功后要做四件事:发欢迎站内信、初始化积分账户、写一条操作审计日志、给推荐人加成长值。这四个动作全部在同一个应用里,用的是同一个数据库连接池,任何一个失败都不应该影响注册主流程的成功返回。这种场景就是本地事件分发的典型用法。如果你非要上 MQ,得额外维护 broker、处理消息序列化版本、考虑消息重复投递的幂等,运维和开发成本翻好几倍,而收益几乎为零——因为消费者本来就跟生产者同生共死。

我自己总结了一张对照表,每次评审方案时直接拿出来用:

维度本地事件分发跨进程消息中间件
通信边界同一 JVM 内跨进程、跨机器
可靠性内存态,进程挂则丢可持久化,支持重投
延迟纳秒到微秒级毫秒级起
事务一致性可与本地事务同生共死需要最终一致性设计
运维成本几乎为零需要部署与监控
适用规模单体、模块化单体微服务、跨团队

注意:本地事件分发不等于"不可靠"。进程内不丢,指的是同步分发时异常能立刻被感知;异步分发时队列在内存里,进程崩溃确实会丢,这一点在方案选型时必须跟业务方讲清楚,不要含混过去。

1.2 事件驱动真正解决的是依赖方向问题

代码腐烂往往不是因为功能复杂,而是因为依赖方向混乱。订单服务依赖积分服务,积分服务依赖消息服务,消息服务又反过来查订单——一圈下来谁也不敢动。事件驱动最核心的价值不是"异步",而是把双向依赖掰成单向。

没引入事件之前,调用方必须持有被调用方的引用,于是调用方知道被调用方的存在,被调用方的方法签名一改,调用方就得跟着改。引入事件之后,调用方只知道"有一个事件类型",订阅方只知道"我关心这个事件类型",双方通过事件这个中间契约通信,互相不持有引用。新增一个消费者时,主流程代码一行都不用改,这就是开闭原则落地最直接的样子。

我见过一个比较极端的反例:有个项目的下单方法里串行调用了十一个下游服务,每个都做了 try-catch 吞异常。结果某次上线后积分没加、优惠券没核销,日志里干干净净——因为异常全被吞了,谁也不知道。改造成事件分发之后,每个订阅方自己处理自己的异常并打日志,问题定位时间从半天缩短到十分钟。这就是解耦带来的可观测性红利。

1.3 自己写还是引框架,边界在哪

市面上有成熟的事件总线实现,功能齐全,但也带来一个问题:你引入了一个可能只用 20% 功能的依赖,还要跟着它的版本升级走。我自己动手写的原因有三个:一是核心逻辑不到三百行,维护成本可控;二是可以完全贴合自己项目的异常策略和线程模型;三是不用担心框架的内部实现变化导致行为漂移。

不过有几个信号出现时,我建议直接用成熟框架:需要事件持久化、需要跨节点广播、需要事件重放、需要复杂的路由规则(按标签、按优先级树形分发)。这些能力自己写会越写越大,最终变成半个中间件,得不偿失。判断标准很朴素:如果你的需求能用一张接口加一个 Map 表达,就自己写;如果开始需要配置文件描述路由,就换框架。

2. 把事件总线拆成四个最小零件

2.1 事件对象:不可变是第一原则

事件对象的唯一职责是承载"发生了什么",它必须是不可变的。原因很实在:同一个事件可能被多个订阅者同时拿到,如果某个订阅者顺手改了字段,后面的订阅者看到的数据就是脏的,而这种 bug 极难复现。

我用 Java 17 的 record 来定义,天然不可变,省掉一堆样板代码:

public record OrderCreatedEvent( Long orderId, Long userId, BigDecimal amount, Instant occurredAt ) { public OrderCreatedEvent { Objects.requireNonNull(orderId, "orderId"); Objects.requireNonNull(userId, "userId"); } }

如果你的项目还在 Java 8,用 final 字段加全参构造、只提供 getter 即可。关键是不要给事件加 setter,也不要往里塞可变集合。如果确实需要传列表,用List.copyOf()复制一份再放进去。

事件里该放什么?我的经验是放标识加必要快照,不要放整个实体对象。放实体对象有两个坏处:第一,实体通常是可变的,破坏了事件的不可变性;第二,实体可能带懒加载关联,异步处理时 session 已关闭,一访问就抛异常。所以正确做法是放 orderId 加上金额、用户 ID 这类计算需要的字段,订阅方需要更多数据时自己去查。

2.2 订阅者接口:泛型加一个方法就够

订阅者接口越简单越好,一个方法,一个泛型参数,避免设计成"万能处理器":

public interface EventListener<T> { void onEvent(T event); default Class<T> supportType() { ... } }

这里有个 Java 泛型的老坑必须提:运行时泛型被擦除,你没法直接通过T拿到 Class 对象。解决方案有两种,一种是让订阅者在构造时显式传 Class 进来,另一种是用 Spring 的GenericTypeResolver之类工具反射解析父接口的泛型参数。我自己写的时候选了更土但更可靠的办法——在注册时由调用方显式指定事件类型,虽然多写一个参数,但永远不会出意外:

public interface EventListener<T> { void onEvent(T event) throws Exception; } public final class EventBus { public <T> void subscribe(Class<T> eventType, EventListener<? super T> listener) { ... } }

EventListener<? super T>这个写法是有意为之的,它允许一个EventListener<Object>订阅所有具体类型的事件,也就是所谓的"通配订阅者",用来做统一日志或监控特别方便。如果写成EventListener<T>,这个能力就没了。

2.3 注册表:类型到监听器列表的映射

注册表本质就是一个ConcurrentHashMap<Class<?>, List<EventListener<?>>>,但细节决定成败。我踩过的坑集中在两点:并发注册和遍历时修改。

先说并发注册。应用启动阶段注册监听器通常是单线程的,但有些项目会做动态注册(比如插件式加载),这时候就可能并发写。用ConcurrentHashMap的computeIfAbsent可以保证原子性,但要注意返回的 List 本身也得是线程安全的。我一开始用了ArrayList,结果在某次压测中偶发ConcurrentModificationException,排查了半天才想起来是动态注册和事件分发撞在了一起。后来换成CopyOnWriteArrayList,问题消失。

为什么选CopyOnWriteArrayList而不是Collections.synchronizedList?因为注册表的读远多于写——事件分发的频率可能是注册频率的几万倍,而CopyOnWriteArrayList的读是无锁的,只在写时复制整个数组。写操作虽然贵,但发生得少,完全划算。不过要提醒一句:如果监听器数量上千且频繁动态增删,CopyOnWriteArrayList的复制成本会变得明显,那种场景就得换别的结构了,但绝大多数业务系统到不了这个量级。

遍历时修改的坑更隐蔽。分发事件的过程中,某个监听器触发了新的注册动作,如果直接遍历原始 List 就会抛异常。CopyOnWriteArrayList的迭代器是快照式的,天然规避了这个问题——它遍历的是创建迭代器那一刻的数组副本,新增的监听器下次分发才生效。这个语义我认为是合理的:一次事件分发应该有一个确定的接收者集合。

2.4 分发器:同步与异步的分水岭

分发器是整条链路的中枢,它的核心逻辑只有三步:按事件类型查监听器、依次调用、处理异常。但"依次调用"这四个字里藏着同步异步的选择,这是整个设计里最需要想清楚的地方。

同步分发的语义是:发布方法返回时,所有监听器都执行完毕。好处是简单、可预测、异常能立刻冒泡,能和调用方的事务绑在一起。坏处是任何一个慢监听器都会拖慢主流程,一个监听器抛异常会影响后续监听器(除非你显式捕获)。

异步分发的语义是:发布方法立刻返回,监听器在线程池里慢慢跑。好处是不拖主流程,坏处是异常拿不到、顺序不保证、进程崩溃会丢事件、还要处理线程池打满的降级策略。

我的实际做法是两种都支持,由订阅方自己声明,而不是全局一刀切。这个设计很关键,因为不同监听器的诉求完全不一样:发站内信可以异步,写审计日志最好同步(要保证和业务操作在同一个事务里),刷新本地缓存必须异步且要能容忍丢失。

3. 同步分发的实现细节与事务边界

3.1 主干代码,六十行讲完

同步分发的主干逻辑我写得尽量直白,不搞花活:

public final class EventBus { private final Map<Class<?>, List<EventListener<?>>> registry = new ConcurrentHashMap<>(); public <T> void subscribe(Class<T> type, EventListener<? super T> listener) { registry.computeIfAbsent(type, k -> new CopyOnWriteArrayList<>()).add(listener); } @SuppressWarnings("unchecked") public <T> void publish(T event) { Class<?> type = event.getClass(); List<EventListener<?>> listeners = registry.get(type); if (listeners == null || listeners.isEmpty()) { return; } for (EventListener<?> raw : listeners) { ((EventListener<T>) raw).onEvent(event); } } }

注意这里用的是event.getClass()精确匹配,而不是instanceof遍历所有类型。为什么?精确匹配的性能是 O(1) 哈希查找,而遍历判断是 O(n)。代价是不支持父类型订阅——如果你 publish 一个OrderCreatedEvent,只订阅了DomainEvent的监听器收不到。这个取舍我认为可以接受,因为领域事件本来就该按具体类型订阅,父类型订阅容易让人写出"什么都收"的监听器,反而模糊了职责。

如果确实需要支持继承关系,我建议在注册阶段做索引展开:注册DomainEvent时,把当前已知的所有子类型都映射到它,同时提供手动注册子类型的入口。但这会引入"新类型注册前已有监听器收不到"的一致性问题,复杂度陡增,我一般不推荐。

3.2 异常策略必须是显式的

同步分发里最容易被忽略的就是异常处理。默认行为是"一个监听器抛异常,后面的全不执行,异常传给发布方",这个行为合理吗?取决于业务。我的做法是在 EventBus 里统一捕获并转成运行时异常,同时保证所有监听器都被尝试执行一遍,然后把异常聚合起来抛出去。

public <T> void publish(T event) { List<EventListener<?>> listeners = registry.get(event.getClass()); if (listeners == null || listeners.isEmpty()) return; List<Throwable> errors = new ArrayList<>(); for (EventListener<?> raw : listeners) { try { ((EventListener<T>) raw).onEvent(event); } catch (Throwable t) { errors.add(t); log.error("listener failed, event={}, listener={}", event, raw.getClass().getName(), t); } } if (!errors.isEmpty()) { throw new EventDispatchException("分发失败 " + errors.size() + " 个监听器", errors); } }

为什么选择"全部执行完再抛"而不是"快速失败"?因为同步订阅的场景里,监听器之间通常互相独立(写审计、刷缓存),一个失败不应该导致另一个也不执行。如果确实有强依赖顺序的监听器,那不是事件该解决的问题,应该拆成一个编排服务。

自定义异常EventDispatchException里带上List<Throwable>,方便上层统一处理或者只记录不中断。这里有个细节:捕获的是Throwable而不是Exception,因为要防止某个监听器抛Error导致整个分发链条静默中断。当然捕获Throwable后不要吞掉OutOfMemoryError这类致命错误,我的做法是对Error单独判断,记录后重新抛出。

3.3 事务边界:什么时候发事件决定了数据一致性

这是本地事件分发最容易出事的地方,我单独拎出来讲。假设用户注册流程是:插用户表(事务内)、发注册事件(触发积分初始化)。如果事件在事务提交前发布,监听器里也操作数据库,用的是同一个事务,那没问题,要么全成功要么全回滚。但如果监听器是异步的,它在事务提交前就开始跑了,可能读到还没提交的数据(取决于隔离级别),也可能主事务回滚了但异步任务已经把积分加了——数据不一致。

所以我的原则非常明确:同步监听器可以在事务内发布,异步监听器必须在事务提交后发布。实现上依赖 Spring 的TransactionSynchronizationManager,在afterCommit回调里触发异步分发:

public void publishAfterCommit(Object event) { if (TransactionSynchronizationManager.isSynchronizationActive()) { TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { @Override public void afterCommit() { eventBus.publishAsync(event); } }); } else { eventBus.publishAsync(event); } }

这段代码看着简单,但它解决的是一个非常实际的问题。我见过有项目在事务里发异步事件,结果主事务因为唯一键冲突回滚了,但下游的站内信已经发出去了,用户收到"注册成功"的站内信却发现自己登录不上,客服电话直接打爆。加上afterCommit之后这类问题彻底消失。

提示:afterCommit里抛异常不会影响主事务(因为已经提交了),所以异步任务提交线程池失败时要在那里做好日志和补偿,别指望异常能回滚什么。

3.4 监听器顺序:需要时排序,不需要别排序

有些场景确实需要顺序,比如"先校验再落库"。我提供两种机制:一是注册时传一个 order 值,分发前按 order 排序;二是用@Order注解配合反射读取。前者更直接,后者更符合 Spring 风格。

排序有个性能代价:每次分发都排序太浪费。我的做法是在注册时维护一个有序结构,用一个volatile的不可变列表缓存排序结果,注册动作发生时重新构建这个列表。这样分发路径上一次排序都不用做,纯遍历。

private volatile List<EventListener<?>> orderedCache; private void rebuildCache(Class<?> type) { List<EventListener<?>> list = new ArrayList<>(registry.get(type)); list.sort(Comparator.comparingInt(this::orderOf)); this.orderedCache = List.copyOf(list); }

不过要提醒:顺序只在同步分发里有意义。异步分发时多个任务在线程池里并行跑,顺序完全不可控,这时候纠结 order 值是自欺欺人。如果业务真的需要异步且有序,那只有两个选择:单线程执行器(牺牲吞吐),或者干脆改成同步。这一点在设计阶段就要跟业务方对齐,别等到上线才发现顺序错了。

4. 异步分发:线程池是唯一的难点

4.1 线程池参数怎么定,别拍脑袋

异步分发本质就是把任务丢给线程池,难点全在参数上。核心线程数、最大线程数、队列容量、拒绝策略,这四个参数配错,轻则延迟飙升,重则整个应用被拖垮。

我的取值逻辑是这样的:这类任务是 IO 密集型(写库、发通知、调外部接口),按经验公式核心线程数 = CPU 核数 × 2起步。但这个公式只是起点,真正定值要看压测。我一般的做法是先给一个保守值(比如 8),然后观察队列的堆积情况和任务执行耗时,用监控数据反推。

队列容量我踩过的坑最深。曾经图省事用了LinkedBlockingQueue无界队列,结果下游某个接口变慢,任务堆积了几十万个,内存直接飙到 OOM。后来改成有界队列,容量按"峰值 QPS × 可接受延迟秒数"估算。比如峰值每秒 500 个事件,能容忍延迟 10 秒,那队列容量就是 5000 左右,留点余量给 8000。

拒绝策略是最后一道防线。AbortPolicy会抛异常(同步感知到,但异步场景里调用方往往是业务主流程,抛异常会打断它);CallerRunsPolicy让提交任务的线程自己执行,起到天然限流的作用,但会拖慢调用方;DiscardPolicy静默丢弃,最危险。我的选择是自定义策略:记录日志 + 打点计数 + 丢弃,并且把丢弃计数暴露到监控面板上,超过阈值就告警。因为对大多数非关键通知类事件来说,丢一条比拖垮主流程好。

4.2 上下文传递:ThreadLocal 的坑等着你

异步分发最容易被忽略的问题就是上下文丢失。主线程里的ThreadLocal(用户身份、链路追踪 ID、租户 ID)到了线程池的工作线程里全是空的。刚开始我没注意,结果审计日志里的操作人全是 null,排查了好久。

解决方案是在提交任务时抓取上下文快照,执行时恢复,执行完清理。Spring 有TaskDecorator可以挂在线程池上做这件事:

public class ContextCopyingDecorator implements TaskDecorator { @Override public Runnable decorate(Runnable task) { Map<String, String> ctx = MdcContext.snapshot(); String traceId = TraceContext.current(); return () -> { try { MdcContext.restore(ctx); TraceContext.set(traceId); task.run(); } finally { MdcContext.clear(); TraceContext.clear(); } }; } }

finally里的清理绝对不能省。线程池的线程是复用的,如果上一个任务留下的上下文没清掉,下一个任务就会读到一个"别人的"用户身份,这种 bug 极其隐蔽,可能几个月都没人发现,最后表现为"某用户的审计日志记到了别人头上"。

另外提醒一点:不要把整个HttpServletRequest或者大对象塞进上下文快照里,只抓那几个字符串字段。我见过有人把整个请求上下文都存进去,结果线程池里的任务持有请求对象引用,导致请求对象无法回收,内存泄漏。

4.3 背压:当生产者比消费者快得多

异步分发的隐患是生产速度超过消费速度时,队列无限增长。有界队列 + 拒绝策略能兜住内存,但兜住的方式是丢任务,业务上未必能接受。这时候需要考虑背压,也就是让生产方感知到压力并主动降速或降级。

我的做法分三个层次。第一层是监控,把队列长度、活跃线程数、拒绝次数暴露出去,先做到"看得见"。第二层是阈值告警,队列使用率超过 70% 就报警,给运维留出反应时间。第三层才是业务降级,比如在队列接近满的时候,把非核心事件(如运营统计)直接丢弃,只保留核心事件(如账户变动)。

如果业务对丢失零容忍,那本地事件分发就不合适了,应该老老实实上持久化队列。这个判断必须诚实,不要因为"已经写了一版本地实现"就硬撑着往里加持久化,最后写出来的东西是半个残缺的中间件,还不如直接用成熟的。

5. 那些只有上线后才会暴露的问题

5.1 递归分发导致栈溢出

监听器里再发事件是很自然的写法,比如"订单创建"事件的监听器发一个"积分变动"事件。正常情况没问题,但如果形成环——A 事件触发 B 事件,B 事件又触发 A 事件——就会无限递归,最后StackOverflowError。

我在测试环境就遇到过这种事,日志里刷了几千行然后服务挂掉。修复方案是加一个分发深度限制,用一个ThreadLocal<Integer>记录当前调用深度,超过阈值(比如 5 层)就记录警告并拒绝分发:

private static final ThreadLocal<Integer> DEPTH = ThreadLocal.withInitial(() -> 0); private static final int MAX_DEPTH = 5; private void checkDepth() { int d = DEPTH.get(); if (d >= MAX_DEPTH) { throw new IllegalStateException("事件分发深度超限,疑似循环依赖: " + d); } DEPTH.set(d + 1); try { doPublish(event); } finally { DEPTH.set(d); if (d == 0) DEPTH.remove(); } }

这个机制的价值不只是防栈溢出,更重要的是它能在测试阶段把循环依赖暴露成一个明确的异常,而不是让它在线上随机爆发。我在项目里还加了一个开发环境的开关,开启后把每次分发的链路(事件类型序列)打出来,配合深度限制,环在哪一环一目了然。

5.2 慢监听器拖垮整个线程池

异步线程池里的任务耗时不一致是很常见的。发站内信可能 10ms,但调外部短信接口可能 3 秒还超时。几个慢任务就能把线程池占满,导致后面的快任务排队。

解决办法是按耗时隔离线程池,把慢任务和快任务分开,各自用独立的有界线程池。这样慢任务再慢也影响不到快任务。隔离的粒度按事件类别划分,比如"通知类"一个池、"统计类"一个池,"强一致类"走同步不走池。

另外必须给监听器设超时。我用Future.get(timeout)的方式包一层太笨重,实际做法是在线程池层面配合业务层面的超时控制——外部调用本身必须配超时(HTTP 客户端连接超时、读取超时,数据库查询超时),这是根本。如果外部调用不设超时,你在线程池层面怎么隔离都会被堵死。

5.3 监听器的生命周期管理:注册容易注销难

大部分项目只关心注册,不关心注销。但如果你的监听器是动态的——比如按插件加载、按租户创建、按配置开关——就必须有注销机制。没有注销的后果是:监听器持有对象引用,对象无法回收,内存泄漏;或者功能下线圈后监听器还在跑,产生意料之外的副作用。

我给EventBus加了一个unsubscribe方法,注册时返回一个Subscription句柄,调用handle.close()就注销:

public interface Subscription extends AutoCloseable { @Override void close(); }

实现上就是记住注册时的类型和监听器实例,close()时从列表中移除。配合 try-with-resources 用起来很顺手,尤其适合测试代码——每个测试方法里注册的监听器在结束时自动注销,不会污染其他测试。

6. 怎么验证这套东西真的能用

6.1 单元测试要覆盖的五个场景

自己写的组件,测试得自己兜住。我列了一张必测清单,每次改动都跑一遍:

场景验证点
无监听器时发布不抛异常,正常返回
单监听器正常处理收到事件,字段值正确
多监听器部分抛异常全部被调用,异常被聚合抛出
通配监听器收到所有类型的事件
并发发布 + 并发注册无异常,无漏发

并发那个场景要用多线程跑,重点看有没有ConcurrentModificationException和事件漏发。我一般用CountDownLatch起 8 个线程各发 1000 次,注册线程同时增删监听器,跑完检查计数是否对得上。这类测试偶尔过、偶尔失败,恰恰说明并发问题真实存在,必须跑到稳定为止。

6.2 一个容易被忽略的验证点:事件对象确实不可变

不可变性这种东西,靠 code review 靠不住,得靠工具。要么在事件类上加@Immutable之类的注解配合静态检查,要么写一个测试,用反射把事件对象的所有字段拿出来,尝试构造一个"修改后"的对象去比较哈希值。更直接的办法是让事件类只有 record 或只有 final 字段,从语言层面杜绝。

还有一个实践:在事件分发前后各算一次hashCode(),如果变了就说明有监听器动了手脚,直接抛异常。生产环境我不建议开这个检查(有性能成本),但在测试环境打开非常有价值,能抓到那些"顺手改一下"的代码。

6.3 压测关注什么指标

本地事件分发的压测不看吞吐量绝对值,重点看三件事:分发链路的 P99 延迟、线程池队列的堆积速度、拒绝次数和异常次数。

同步分发关注的是监听器总耗时占主流程的百分比——如果某个监听器占了主流程 40% 的时间,那它就该改成异步。异步分发关注的是队列消费速度和堆积趋势,如果堆积持续上升,说明消费能力不足。我通常在压测报告里明确列出每个监听器的耗时,这样谁慢谁快一目了然,优化方向也清楚。

7. 一点选型之外的体会

自己写事件总线这件事,最容易被低估的不是代码量,而是语义定义的清晰度。同步还是异步、顺序有无保证、异常怎么传播、事务边界在哪、丢不丢事件——这几个问题在写代码之前回答得越清楚,后面的坑越少。我第一版就是因为没想清楚事务边界,上线后遇到了一次数据不一致,回头补afterCommit花的时间比写整个组件还多。

还有一点:不要因为有了事件总线就滥用。我见过把每个方法调用都改成事件的写法,结果调用链完全不可见,想知道"注册成功之后到底发生了什么"得在代码里搜所有订阅者,调试成本极高。事件适合用在"一对多、且订阅方会持续增加"的场景,一对一的直接调用老老实实写直接调用,别为了解耦而解耦。

最后分享一个小技巧:在EventBus里加一个开发环境的"分发日志"开关,打开后把每次分发的事件类型、监听器列表、耗时都打到日志里。这个开关我几乎在每次排查问题时都会打开,它的价值在于把一条"隐式"的调用链重新变成"显式"的,尤其是排查"为什么这个操作没触发那个副作用"这类问题时,一眼就能看出是没发事件、还是没人订阅、还是订阅了但抛异常被吞了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询