欢迎大家前往,获取更多腾讯海量技术实践干货哦~
作者:吴涛
导语: 已经火了很长一段时间了。最近我们项目决定引入EventBus,替换我们播放器现在的事件总线框架,以解决我们存在的一些问题。
自研事件机制介绍
腾讯视频的播放器架构是基于总线设计的,不同的功能模块被抽象成一个个插件管理器,挂载在总线上,收听、发布事件,完成业务逻辑处理。
图 1
上图是播放器的总线示意图,每个节点表示一个逻辑插件,红色的线条代表总线。插件可以有子插件,父插件要负责将事件派发给它的子插件。
图 2
上面三个类图中,Event是描述事件的类,不同的事件通过不同的id值来区分。IEventProxy即是播放器的总线,publish(Event event)方法负责将事件抛到总线上。Plugin即是插件的抽象类,当总线上有新事件到达时,插件的onEvent(Event event)方法会被调用,onEvent方法内部根具事件的id值辨识不同的事件,做相应的业务逻辑处理。拥有子插件的插件,还需要循环调用mChildPlugins的onEvent(Event event)方法,将事件传递给子插件处理。
下面是典型的插件onEvent方法代码片段:
@Override public void onEvent(Event event) { switch (event.getId()) { case Event.PageEvent.UPDATE_VIDEO: mVideoInfo = (VideoInfo) event.getMessage(); break; case Event.PlayerEvent.DEFINITION_FETCHED: updateIcon(); break; case Event.PluginEvent.BULLET_CLOSE: updateIcon(); break; default: break; } for (Plugins plugin : mChildPlugins){ plugin.onEvent(event); } }
一个插件将事件发布到总线上的代码示例:
@Override public void onClick(View v) { mEventProxy.publishEvent(Event.makeEvent(Event.UIEvent.ON_AUDIO_PLAY_ICON_CLICKED));}
自研总线的缺陷
通过之前对播放器架构的介绍,我们可以发现,我们的事件机制还是比较简陋。主要存在以下几点缺陷:
1、 插件代码结构不够松散,所有事件响应处理都在onEvent方法中处理。2、 事件过度广播。当一个事件发生时,所有插件的onEvent方法都会被调用执行,浪费了cpu时间片,程序执行效率不高。3、 事件类型不安全。每个事件只能携带一个Object的对象message,事件收听者如果要解析message,收听者只能靠“猜”,是否猜中取决于发布该事件的人是否按照收听者的意愿携带指定类型的message。如果没有通过instanceof校验而直接强转,极有可能发生强转失败。4、 事件参数不可拓展。事件只能携带一个Object的message。一旦某事件携带某种类型的message,该事件携带的message类型不能再变更,一旦变更,所有收听该事件的插件也必须要修改代码。基于此,我们决定引入EventBus开源库来重构我们的事件机制。
EventBus介绍
了解过EventBus的同学都知道,EventBus的核心是使用反射。不同的事件用不同的类型来表示,插件类要收听某一事件,就要声明一个相应的方法来接收事件。例如,已知有AEvent,BEvent,CEvent三种事件,有X、Y、Z三个插件,假设X插件收听AEvent,Y插件收听BEvent,Z插件收听CEvent,则X、Y、Z三个插件类中需如下声明:
X.java:public class X{@Subscribepublic void onAEvent(AEvent event){ doSomeThing();}}Y.java:public class Y{ @Subscribe public void onBEvent(BEvent event){ doSomeThing(); }}Z.java:public class Z{@Subscribepublic void onCEvent(CEvent event){ doSomeThing();}}
当我们需要发布某AEvent时,需要调用EventBus的post方法:
mEventBus.post(new AEvent());
更多如何使用EventBus及EventBus原理的知识,这篇文章不作讲解,您可以搜索其它文章或者在GitHub上了解。
工作量评估
通过以上分析,我们这次重构的主要工作内容就明确了:
1、 将Event类中所有预定义的事件全部映射成具体的类,即有多少Event id就有多少Event类的原则。比如,我们需要将Event.PageEvent.UPDATE_VIDEO转换成UpdateVideoEvent.java。
2、 将插件的onEvent方法中switch语句中的每一条case语句映射为一个方法声明,即有多少case就有多少方法原则。例如在上述代码示例中的case Event.PageEvent.UPDATE_VIDEO:
@Subscribepublic void onUpdateVideoEvent(UpdateVideoEvent event){ mVideoInfo = event.getVIdeoInfo();}
3、 将所有使用IEventProxy发布事件的地方,全部修改为使用EventBus的post方法。比如有:
mEventProxy.publish(Event.makeEvent(Event.PageEvent.UPDATE_VIDEO, videoInfo));要替换为:mEventBus.post(new UpdateVideoEvent(videoInfo));
如果耐心把这篇文章看到这里的话,大家可能会觉得,你要做的工作很简单嘛,无压力,so easy。
开始工作之前,老大都要求我们先把工作量评估出来。由于代码中有多少事件,有多少个插件,每个插件具体收听处理了多少种事件,这是很难统计出来的,特别是最后一点。不过,工作量肯定和插件的个数,以及插件的代码规模肯定是成正比的,我只需要把这两点统计出来,估计一个大概的工作量还是可以的。于是,有下面的统计表:
图 3
横坐标是代码行数,纵坐标是在插件个数。插件总个数有151个,总代码行数47000多行。按照每200行代码1个小时的工作速度,每天8小时不停写代码,一个人也要整整30个工作日,还不包括自测,代码审核等等其它工作量。我拿着这个表就去找老大说,两个人需要三周的工作量。结果老大直接跟我说,帮手没有,你一个人先搞,看看进度咋样(好吧,其实老大是对这个评估不满意)。
就这样,两眼一抹黑,踏上了EventBus重构之路。
第一天,我先入手了几个插件类。遇到需要映射的XXX事件,就手动创建其对应于的XXXEvent.java文件,此操作大概需要近一分钟。将switch中的语句写成对应的方法,然后把case中的语句复制到方法体中,此操作视语句长度及case分支的多少,耗时不等。最后将onEvent方法删除。就这样一天工作下来,不断重复着这样的工作,一个八百多行的插件竟耗费了我半天工作时间,极其烦躁,而且人工修改还特别容易出错,比如拼写错误,漏掉case分支等等,带来的后果直接表现在代码运行不正确,而后续却难以排查。
于是,我有一个大胆的想法。程序员是脑力劳动者,任何时候,都不应该成为搬运工。是否能够编写脚本或者自动化工具,自动化的完成重构工作。
实施方案
使用注解解析自动生成文件
我们都知道,EventBus是通过注解来实现的。通过注解解析,在编译阶段生成了一个java文件,这个文件被称作SubscribeInfoIndex,其硬编码了每个使用了Subscribe注解的类的信息。
受到EventBus的启发,我们的事件类是否也能通过注解解析的方式生成呢?答案是肯定的。关于注解解析相关的知识可参看我的另一篇KM《apt与JavaPoet 自动生成代码》,由于篇幅限制,这里不做讲解。
首先,自定义一个注解:
@Retention(RetentionPolicy.SOURCE)@Target(ElementType.TYPE)public @interface OldEvent { String packageName();}
packageName 属性指明该Event 类对应生成的新Event文件的包路径。
然后在Event.java中使用该注解:
图 4
图 5
(注:PlayerEvent 和UIEvent是Event中定义的内部类,事件Id定义在内部类中。除此之外,还有AudioEvent、PageEvent等)。
编写注解解析器,注解解析器的逻辑也比较简单:
图 6
例如,PlayerEvent.INIT对应生成的文件如下:
图 7
语法解析修改代码
现在,我们剩下的工作是如何完成代码自动替换,将publish替换为post,将case替换为方法。
我首先想到的是使用正则表达式,通过对源文件进行扫描,将匹配的代码行替换为指定代码。比如,我们使用正则表达式^\s\w+\.publish\s\(\s(.+)\s(,\s(\w+)\s)?\)来匹配代码中的mEventProxy.publish()方法调用,然后将其替换为相应的post。但是,我们仅仅通过正则匹配,没有办法确定匹配到的就是IEventProxy类中com.tencent.qqlive.ona.player.event.IEventProxy.publishEvent(com.tencent.qqlive.ona.player.event.Event)的方法调用。例如,完全有可能有一个类A,它内部也声明了一个public void publish(SomeKind params)方法,我们的正则也会匹配,导致错误替换。另外,case语句的替换也是更加的困难。首先,哪些类中的onEvent方法的switch case需要被替换?只有那些继承自Plugin的类才需要替换,如何判断一个类是否继承自Plugin也是很难判断的,不但有直接继承,还有间接的继承。
因此,正则匹配这条路是走不通了,有太多语法、语义上的信息我们需要知道后才能处理。
那么,如何去做语法解析呢?写一个java语法解析器吧。但是我最多只有一个月的时间,好像不太现实。
不能自己写就只能搜索下是否有现成的语法解析库,还真有!
介绍
JavaSymbolResolver是一个用于Java语法语义解析的库,其实现基础是库。比如,有下面代码:
int a = 0;void foo() { while (true) { String a = "hello!"; Object foo = a + 1; }}
对于表达式a + 1中的a,JavaParser只能告诉我们a是一个变量,而JavaSymbolResolver则能识别出这里的a是一个变量,其类型是String。
又例如,有如下A、B两个类:
import static B.b;public class A{private int a;void foo(){ a = b + 1;}}public class B { public static int b = 2;}
JavaSymbolResolver能够识别出,b + 1表达式中的b即是B类中的b, 而且其初始值为2。
JavaSymbolResolver的这些强大的符号解析能力要基于JavaParser的语法解析。JavaParser接受一个java文件(或者代码片段),然后输出一个叫CompliationUnit的对象,叫编译单元,其内部结构是一个树形结构,被称作抽象语法树Abstract Syntax Tree(AST)。JavaParser 将源代码中的一个类定义、一个方法声明、一句方法调用语句,甚至一个break语句,都抽象为AST上的一个节点(Node),而ComplationUnit则是树的根节点,AST完整的描述了一个java文件。
图 8
例如,有如下代码:
package com.github.javaparser;import java.time.LocalDateTime;public class TimePrinter {public static void main(String args[]){System.out.print(LocalDateTime.now());} }
通过JavaParser处理后,输出如下语法树:
图 9
上图中展示了输出的ComplationUnit中包含了三个子节点,一个package申明,一个import申明,一个类定义。上图并没有完整的描述整个语法数,绿色三角形的部分被省略了,下图展示了省略的MethodDeclatation部分:
图 10
通过其四个节点,我们可看出其返回类型是void,方法名是main,方法参数是String args,以及其方法体:
图 11
可以看到,即使是System.out.print(LocalDateTime.now());这么一句代码,也可以完整的描述成一颗树。
有了AST后,我们如何遍历这棵树呢?JavaPaser已经为我们把遍历树的代码封装好了,并且提供了Visitor类,基于访问者模式,你只需要实现不同的Visitor类来处理具体的节点,而不是将精力放在编写如何遍历树的代码上。
前面我们已经说过,JavaSymbolResolver是建立在JavaParser上的,JavaSymbolResolver借助JavaParser的AST树,便可实现其符号解析。比如,当判断一个MethodCallExpr是否是对com.tencent.qqlive.ona.player.event.IEventProxy.publishEvent(com.tencent.qqlive.ona.player
.event.Event)的调用时,JavaSymbolResolver提供的solve方法,不断回溯当前节点的父节点,以找到这个MethodCallExpr方法调用声明的原型MethodDeclaration,MethodDeclaration记录了方法声明的全限定名,通过将全限定名与com.tencent.qqlive.ona.player.event.IEventProxy.publishEvent(com.tencent.qqlive.ona.player.event.Event)比较是否相等,我们便可得出结果。使用JavaSymbolResolver进行重构
一开始,我是通过新建工程,然后在工程build.gradle文件中,引入JavaSymbolResolver库的:
dependencies {compile group: 'com.github.javaparser', name: 'java-symbol-solver-core', version: '0.6.1'}
在开发过程中,我发现这个库现在还很不稳定,有许多bug。例如,使用Lexical-Preserving Printing模式解析的AST,JavaSymbolResolver根本没有办法解析,会直接crash,所以导致我只能使用Pretty Printing模式解析java文件。有一些内部接口,JavaSymbolResolver也不能正确解析,比如,有如下代码:
public class BaseClass{ public interface AnInterface{ void doSomething();}}public class ClassA extends BaseClass{}public class ClassB implements ClassA.AnInterface{ public void doSomething(){ }}
遗憾的JavaSolverResolver 无法解析出ClassB的类型,因为ClassA.AnInterface无法解析出来,因为AnInterface没有定义在ClassA中,但是,我们都知道,从java语法的角度,ClassB这么写是完全正确的!
由于JavaSymbolResolver目前存在一些气人bug,所以我不得不下载他的源码,以修复这些阻碍我的bug,希望JavaSymbolResolver尽快修复这些bug。
下面两张图是我用beyong compare将处理后的文件和处理之前的文件进行的对比,左边是处理后的文件,右边是原始文件。第一张图可以看出onEvent整个被删除了,第二张图可以看到处理后的文件末尾添加了很多@Subscrbe注解的方法,第三张图看到原始文件中的mEventProxy.publish()方法已经被替换成了对应的mEventBus.post()。
图 12
图 13
图 14
总结
本文主要记述了我如何通过编写工具自动生成代码的方式,提高代码重构的效率。原本计划需要共计60人日的工作量,实际一个人只用了不到三周的时间便完成了任务。另外,本文还对注解解析,JavaSymbolResolver及JavaParser的基础知识进行了讲解。
由于文章已经比较长了,篇幅限制,本文并未对实现自动化工具的代码实现细节进行过多的讲解,这部分内容待到以后来分享了。
阅读推荐
此文已由作者授权腾讯云技术社区发布,转载请注明
原文链接: