给那些不甘于平庸,还在为梦想而奋斗的人,他们心里有火,眼底有光,浑身充满着乐观和热情
背景描述
在开发新版本功能时,遇到一个bug困扰了0.5天的开发时间。在分析的过程中,自己又对EventBus的设计思想理解深入了一些,写文章简单总结一番。
Bug的表现是,用户从A页面跳转到B页面,同时播放一段语音。在上一版本时,跳转和播放语音是同时发生的,但是当前大家共同开发的版本,发生一个奇怪的现象,语音播放晚了大概5s。
代码里是通过EventBus.postStickyEvent(AudioEvent)来触发语音播放的,通过日志可以看到从发射Event到接收Event,中间相差5s。
EventBus是一个解决模块间调用的快捷方式,由于接入成本低,在当前的项目中采用广泛。但在我看来,过于滥用的EventBus,并不是一种好的设计模式。
- 将模块之间的依赖关系变得更加松散和混乱
- 调用顺序难以保证,虽然可以通过threadMode进行控制,但控制程度很弱,难以满足复杂的业务场景
- 接口增删不会导致编译失败,容易掩藏问题,将本可以在编译阶段暴露的问题拖延至线上
- 由于定义Event成本很低,过多使用极易造成Event类膨胀
分析过程
一开始我是没有太多头绪的,按照Common Sense,EventBus发送的事件,理应立即处理才对。把日志翻来覆去看了好几遍,也并没有发现有用的信息。然后我尝试把触发语音播放的代码从EventBus改为同步调用,这次可以正常播放了,因此定位到问题的原因在于EventBus内部处理。
发送语音事件、接收处理事件的代码都是通过@Subscribe(threadMode = ThreadMode.BACKGROUND)指定给了后台线程。好在可以打断点,这部分代码位于BackgroundPoster.java。
public void enqueue(Subscription subscription, Object event) { // 1. 将订阅者和事件组装成PendingPost,并加入队列
PendingPost pendingPost = PendingPost.obtainPendingPost(subscription, event);
synchronized (this) {
queue.enqueue(pendingPost);
if (!executorRunning) {
executorRunning = true;
eventBus.getExecutorService().execute(this);
}
}
}
@Override
public void run() {
try {
try {
while (true) {
PendingPost pendingPost = queue.poll(1000);
if (pendingPost == null) {
synchronized (this) {
// Check again, this time in synchronized
pendingPost = queue.poll();
if (pendingPost == null) {
executorRunning = false;
return;
}
}
}
eventBus.invokeSubscriber(pendingPost); // 2. 无限循环从队列中取出任务串行执行
}
} catch (InterruptedException e) {
eventBus.getLogger().log(Level.WARNING, Thread.currentThread().getName() + " was interruppted", e);
}
} finally {
executorRunning = false;
}
}
其实分析到这里,就基本定位出,是由于前一个任务耗时过多(5s),导致同在BACKGROUND线程执行的语音播放任务被顺延执行。通过断点,找到了前一个任务是读取数据库,由于需要等待数据同步,该同学在代码里增加了3s的等待,因此造成了后续任务全部顺延。
确定原因后,改动也很简单,将上一个任务另起一个IO协程执行,不阻塞当前队列。问题解决。
EB原理:核心思想
问题本身不具有价值,通过问题学到了什么才真正有价值。虽然我不赞成代码里滥用EventBus,但作为最经典的一种事件总线实现,EventBus还是有其值得学习的地方的。
EventBus采用的是典型的发布-订阅模式,可以说理解了EventBus,就理解了发布者-订阅者(Publisher-Subscriber)模式,其控制流程可以概括如下:
注册过程:
- EventBus是一个单例对象,初次调用时进行创建(饱汉模式)
- 订阅者通过
EventBus.getDefault().register(Object),将自身注册给EventBus单例 - 注册过程中,EventBus扫描该类的方法列表,找出那些通过
@Subscribe进行注解的函数,将它们与Subscriber(订阅者)和Event(所订阅事件)相关联
接收过程:
- 发送者通过
post/postSticky,将事件发送给总线 - 发送者所处的线程会维护一个
ThreadLocal类型的名为PostingThreadState的对象,其内部维护了事件队列eventQueue,事件会被加入到这个队列中 - while循环遍历该队列,并取出事件分发执行
- 根据不同的分发策略,事件被立即执行,或者加入到待执行队列中
- 最终通过反射
method.invoke()来调用订阅者相应的函数
EB原理:5种线程模式
这次遇到的Bug,本质上是对EventBus的线程模式理解模糊,导致错误地在BACKGROUND模式下执行了耗时操作,导致后续任务被推迟。接下来就盘点下EventBus中5种不同的ThreadMode。
// EventBus.java
private void postToSubscription(Subscription subscription, Object event, boolean isMainThread) {
switch (subscription.subscriberMethod.threadMode) {
case POSTING:
invokeSubscriber(subscription, event);
break;
case MAIN:
if (isMainThread) {
invokeSubscriber(subscription, event);
} else {
mainThreadPoster.enqueue(subscription, event);
}
break;
case MAIN_ORDERED:
if (mainThreadPoster != null) {
mainThreadPoster.enqueue(subscription, event);
} else {
// temporary: technically not correct as poster not decoupled from subscriber
invokeSubscriber(subscription, event);
}
break;
case BACKGROUND:
if (isMainThread) {
backgroundPoster.enqueue(subscription, event);
} else {
invokeSubscriber(subscription, event);
}
break;
case ASYNC:
asyncPoster.enqueue(subscription, event);
break;
default:
throw new IllegalStateException("Unknown thread mode: " + subscription.subscriberMethod.threadMode);
}
}
分发任务的核心代码如上,根据订阅者声明的ThreadMode区分,它们有的直接调用订阅者响应函数,有的将任务放入Poster的执行队列中。Poster内部则封装了不同的实现(BACKGROUND、ASYNC、MAIN)
POSTING:在发送者的线程执行任务,直接调用invokeMAIN:在UI线程执行任务,如果发送者本身处于UI线程,则直接执行;否则将任务放入mainThreadPoster的队列中,内部通过mainLooper与UI线程进行绑定MAIN_ORDERED:将任务放入mainThreadPoster的队列顺序执行BACKGROUND:它是一个单线程的模型,所有任务在一个while循环里顺序得到执行ASYNC:任务被丢给CachedThreadPool,如果当前有空闲线程则立即执行,否则进行等待。CachedThreadPool用于执行大量短期异步任务,默认参数是- corePoolSize=0
- maximumPoolSize=Integer.MAX_VALUE
- keepAliveTime=60s
- SynchronousQueue,不存储元素,每个插入操作必须等待空闲线程出现,如若没有则阻塞
- DefaultThreadFactory
分析到这里,EventBus的核心思想基本都盘点清楚了,接下来思考,对于前文中发生的bug,应该如何降低解决它的成本,更进一步,怎样提前规避这一类滥用、误用BACKGROUND事件的问题发生?
AOP(Aspect Oriented Programming)提供了一个很好的思路,对于BACKGROUND类型的任务,记录其执行时间,如果超出阈值(例如100ms)则发出警告。
Work Around: AspectJ无缝打点
通过分析我们知道,EventBus中所有的回调,最终都会走到EventBus.invokeSubscriber(Subscription, Object)这个函数:
void invokeSubscriber(Subscription subscription, Object event) {
try {
subscription.subscriberMethod.method.invoke(subscription.subscriber, event);
} catch (InvocationTargetException e) {
handleSubscriberException(subscription, event, e.getCause());
} catch (IllegalAccessException e) {
throw new IllegalStateException("Unexpected exception", e);
}
}
AspectJ的功能非常强大,因此,想要完全掌握它需要认真花费一番功夫,好在这里我只需要关注指定函数的执行耗时。这里要用到AspectJ的功能是切点函数execution,其语法如下
execution(<修饰符模式>?<返回类型模式><方法名模式>(<参数模式>)<异常模式>?)
通过上述5个参数,就可以匹配一类或者某一个函数。
@Aspect
class TraceAspect {
private val TAG = "TraceAspect"
// 匹配指定函数
@Pointcut("execution(* org.greenrobot.eventbus.EventBus.invokeSubscriber(Subscription, Object))")
fun monitorMethod() {}
@Around("monitorMethod()")
fun profile(pjb: ProceedingJoinPoint) {
val beginTime = SystemClock.elapsedRealtime()
pjb.proceed()
val endTime = SystemClock.elapsedRealtime()
val costTime = endTime - beginTime
Log.i(TAG, "Execution of ${pjb.signature.toShortString()}, cost time: $costTime ms")
}
}
一切似乎都按照计划进行,预期这段代码运行起来后,可以计算出EventBus中每一次invokeSubscriber执行耗时。但实际运行起来,事情并不像想象那样发生,日志窗口纹丝不动,没有一点输出。
问题出在了自己对AOP的理解上,AspectJ仅仅能够处理本地存在Java代码的场景。而我们是通过aar包的方式接入EventBus的,此时代码是class字节码的形式,应该采取另一个更偏底层的工具——Javassit,对字节码进行修改。
确定了方向路线,剩下的就都是体力活了,Javassit的接入和使用方法不是本文的重点,不赘述。