- Live软件开发面面谈
- 潘俊编著
- 4819字
- 2021-03-31 00:00:57
2.3 Java中的事件编程
上面观察者模式风格的代码是用Java语言编写的,本节将以它为起点探讨用Java语言进行事件编程的各种可能性,比较它们的优劣,并以此为背景揭示Java 8引入Lambda表达式和方法引用的意义。
2.3.1 通用的事件发布者和收听者
2.2节的Java代码有一个问题,就是事件发布者只能触发一种事件。在实际编程中,发布者往往需要区分多种不同意义的事件,例如鼠标的单击、移动、悬停。为此可以重构EventPublisher类,在添加、删除和调用事件收听者的方法中区分不同的类型。
2.3.2 通用事件收听者的问题
利用上面的通用EventPublisher类,可以给代码添加任意的事件,每个事件传递给收听者的信息,也可以利用EventInfo的put()方法自定义。然而在实际图形用户界面开发中使用它们,还会遇到一个问题。在向事件发布者添加收听者时,需要一个特定的实现IEventListener接口的类。最简洁的方式是就地创建一个匿名类,就像上面的代码那样。但在实际开发中,经常将构建用户界面的代码和收听者的代码分置于不同的类,这样就必须在调用用户界面对象的addEventListener()方法时传入收听者的实例。除了让对象的职责更加清晰,将两者分开还有一个可能的理由,就是有时用户界面上会有多个作用相同或类似的控件,例如分页列表上下方相同的翻页按钮和列表中每一行都有的功能链接,这些控件的事件处理程序是通用的,应该共用一个收听者,而不是为每个控件创建一个一模一样的(在第3章中我们还会分析用户界面及其事件收听者代码分开的方式和是否必要)。当我们将事件处理程序放在一个单独的类中时就会发现,虽然一个事件发布者可以发布多个事件,一个收听者却只能处理一种事件。这就意味着为用户界面类的每一种事件都必须创建一个收听者类,而这显然是极为麻烦的。这个问题自然的解决思路就是让事件收听者和发布者一样,能够处理多种事件。这正是AWT和Swing图形用户界面类库采用的方案。
2.3.3 Swing用户界面里的事件编程
下面介绍Oracle网站上Java教程里“How to Write a Mouse Listener”一文里对编写鼠标事件响应程序的范例(http://docs.oracle.com/javase/tutorial/uiswing/events/mouselistener.html)。
程序给一个扩展自JLabel的BlankArea类和一个JPanel控件添加了同一个事件收听者,该收听者会处理鼠标进入、退出、鼠标键按下、释放和单击五种事件。用户界面、事件处理程序和入口方法都混杂在一起,为了更清晰地展示事件发布者和收听者的关系,可以调整一下代码的结构。
2.3.4 专用事件收听者的问题
不妨把MouseListener这样的专门针对某种事件的收听者称为专用的事件收听者,以和之前通用的事件收听者对比。在2.3.3节的代码中,一个和用户界面分开的收听者Controller可以处理用户界面上多个控件的多种事件,通用事件收听者的问题似乎完满解决了。然而如果观察一下作为事件发布者的控件的内部代码,就会发现与之前的通用EventPublisher类比较,与事件有关的代码复杂了很多。
无须注意各个方法的细节,我们能看出的规律是:为了一组鼠标事件单击、按键、释放、进入和离开,创建了一个MouseEvent类,用于封装这些事件共同的信息;创建了一个MouseListener接口,为每一种事件指定了一个处理方法,这些事件的任何收听者都要实现这个接口;在Component类中为这一组事件编写了addMouseListener、removeMouseListener和processMouseEvent方法,前两者分别为控件添加和移除这些鼠标事件的收听者,任何一个用户界面事件发生时,processEvent方法先根据事件的类型调用相应的处理函数,如果属于这里讨论的鼠标事件,就调用processMouseEvent方法,它再次根据事件的类型调用收听者中对应的方法。
以上是事件发布者为了一组事件做的所有准备工作。控件要发布的事件很多,例如MouseMotionEvent、MouseWheelEvent、KeyEvent,除了事件信息类有可能共用,对每一组事件都要重复类似的套路。一组事件有可能有很多个,也可能只有一个,分组的依据仅仅是它们在性质上可视为同属一个类别以及事件信息可以共用一个类。上述事件在Java最初的AWT图形用户界面框架中就被支持,到了后来的Swing会不会简单一些呢?下面介绍JMenu类发布的菜单事件。
可以看出作为事件发布者的代码,仍然既不简便也没有重用,为了菜单的selected、deselected和canceled三个事件每个都编写一个内容基本重复的方法。实际上,刚刚所说的套路是Java 8发布之前Java世界里事件编程的标准写法,不仅是来自图形用户界面的事件,程序员为对象添加自定义事件也遵循这样的模式。为了一个事件这样大费周章,原因不止一个。了解这些原因可以更好地认识事件和面向对象编程都有好处。
2.3.5 彻底地面向对象
很多人在开始接触Java时,对Hello World在Java里的写法不习惯:
奇怪为什么要这样麻烦,在main方法里创建一个对象,再调用它的方法。而不是像在C等语言里那样简单:
后来渐渐明白了,作为彻底实践面向对象设计的编程语言,Java的“逻辑单元”是对象。这句断言有两层含义。第一层含义是Java代码的组织单元是类。所谓组织单元,就是指能够独立存在和运行的代码的最小单位。C这样的过程式语言的组织单元是过程(函数)。C++虽然引入了面向对象的设计,仍然允许以过程的方式组织代码。换句话说,C语言里一般的语句(除了声明变量和初始化等)都要写在某个函数里;而在Java中,一般的语句不仅要写在某个函数里,而且每个函数都要位于某个类中(即作为类的方法)。所以在Hello World程序中,Java要把“System.out.println("Hello World!")”;这条简单的语句置于一个类中,还要以一个对象的方法的形式来运行它。第二层含义是在Java中一切都是对象(此处让我们把int这样的原始类型也当成特殊的对象),变量指向的、方法的参数传递的都是对象。这一点上C与Java最大的差异是存在函数指针,也就是说,函数可以和其他数据一样作为参数传递。正是Java的这个特点使得在其中的事件编程呈现出上一节的样貌。
在上一节的讨论中已经看到,事件发布者会发布多种事件,收听者会包含感兴趣的多个事件的处理程序。问题是怎样将某个事件映射到收听者内对应的处理函数。因为函数在Java中无法独立存在,既不能从收听者直接传递给发布者,也不能被发布者保留,所以只好将它们的容器——收听者传递给发布者,发布者内保持收听者的列表。那么,当某个事件发生时,发布者如何知道应该调用收听者的哪个函数呢?没有其他办法,只能约定函数的名称。所以在上一节里,EventPublisher类每当事件发生时都调用收听者的handleEvent方法,AWT和Swing中控件每当鼠标事件发生时就分别调用收听者的mousePressed、mouseClicked等方法,当菜单事件发生时就分别调用menuSelected、menuCanceled等方法。Java又是静态强类型的语言,调用一个对象的方法在编译时要进行类型检查。为了确保事件收听者拥有那些约定的方法,必须创建一个接口(如MouseListener)来包含这些方法,然后收听者实现此接口,发布者在添加、删除收听者和调用其方法时也只使用该接口类型。类似地,为了对发布者传递给收听者的事件信息对象的属性进行编译时检查,需要给该信息对象创建一个特定于事件的类型(如MouseEvent)。事件收听者接口和信息对象相互匹配,通常为了一组相近的事件创建两者。再来看事件发布者,因为它在添加、删除收听者时使用的是特定于事件的接口,所以不能有EventPublisher中那样的通用方法addEventListener和removeEventListener,而只能为每一组事件都编写一对类似于addMouseListener和removeMouseListener的特定方法。发布者触发事件时,理论上可以在一个方法中完成,但为了代码清晰,通常会为每一组事件都编写一个方法(如processMouseEvent),有时更因为容纳收听者的容器的复杂性,为一组事件里的每一种都编写一个单独的方法(参看上一节的fireMenuSelected、fireMenuDeselected和fireMenuCanceled)。至此,在Java中进行事件编程的拼图就完整了,对一组具体的事件,总计需要一个事件信息类型、一个收听者接口、一个对该接口的实现、一个包含若干特定方法的发布者。
尽管工作量不小,这个方案仍然不能应对实际开发中稍微复杂一点的场合。用户在视窗的控件上做的动作触发它们的各种事件,选择恰当的事件编写处理程序是图形界面程序和用户交互的途径。从前面/上文可以看到,Java中的收听者能够处理控件发布的多种事件,对处理逻辑相同的事件,还能一对多地服务多个控件,可是对多个控件的处理逻辑不同的同一种事件却无能为力。最简单和常见的情形就是视窗上有多个按钮,每个的功能都不同,收听者照例要实现MouseListener接口的mouseClicked方法,但一个收听者的mouseClicked方法只能包含一个按钮的处理逻辑(将所有按钮的处理逻辑混合在一起或者再分配到子函数,虽然理论上可行,代码的结构却会变得不自然而难以理解),结果就是为每个按钮创建一个收听者,程序变得十分繁冗。究其原因,还是在Java中函数不能作为参数传递,不能保存在变量中。
2.3.6 Java 8带来的福音
前面分析的Java事件编程的局限和不便终于在Java 8发布之后见到了曙光。随着近年来函数式编程的流行,许多语言都引入了Lambda表达式的功能。千呼万唤之后,Java中的Lambda表达式也姗姗而来。Lambda表达式是函数式编程的基石,与命令式编程中的函数相比,其特点是与普通数据类型的值一样,能够被赋予变量,作为参数传给其他函数,作函数的返回值,也就是所谓的一级(first-class)函数。简言之,Lambda表达式是可以运行的数据。在Java中,Lambda表达式是以特殊语法的匿名函数的形式定义的。因而在给事件发布者添加就地定义的收听者时,比原来的匿名类更加简洁。
如果想把事件处理程序放在和用户界面分开的类中,上面的Lambda表达式可以简单引用该类的方法。为了类似这样的场合,Java引入了方法引用(Method reference),于是发布者在添加事件处理程序时可以直接引用另一个收听者对象的方法。
利用这些Java 8带来的新功能,事件编程现在能够以一种优雅的方式进行:收听者接口是唯一的、通用的;发布者内添加、删除和调用收听者的方法也是通用的;发布者和收听者可以分开定义,并且一个收听者可以包含任意多个发布者的任意多种事件的处理方法。Java新的图形用户界面框架JavaFX正是这样:EventHandler <T extends Event>是通用的收听者接口,Event和EventType <T extends Event>类层次分别代表事件信息和类型,图形界面控件的基类Node有一组addEventHandler(EventType <T> eventType,EventHandler <? super T> eventHandler)、removeEventHandler(EventType <T> eventType,EventHandler <? super T> eventHandler)这样的接收通用收听者接口的方法,和一批为了方便使用特定事件收听者接口的方法,setOnMouseClicked(EventHandler <? super MouseEvent> value)、EventHandler <? super MouseEvent> getOnMouseClicked()……
下面的代码片段演示的就是在与用户界面分离的收听者类内为一个按钮添加事件处理方法。
myButton.setOnAction(this::handleButtonAction); private void handleButtonAction(ActionEvent event){ //... }
Oracle公司的JavaFX只面向桌面环境,移动环境如Android下的Java开发,虽然也有非官方组织做的移植,但普遍还是使用Android的原生GUI框架。不过该框架中事件编程的View.OnClickListener、View.OnLongClickListener等收听者接口与JavaFX的EventHandler <T extends Event>一样,也是函数式接口,只要启用名为Jack的新编译器和相应的开发工具,Android下的图形用户界面程序事件编程也能使用上述Java 8的新功能。
2.3.7 这一切背后仍然是对象
Lambda表达式和方法引用似乎表明在Java中方法可以像对象一样传递了,然而实际上Java仍然固执地坚持着包含方法的对象才能作为数据使用的原则。Lambda表达式和方法引用背后不是一般函数式编程语言中的函数,而是某个函数式接口(Functional interface)的对象。
在Java中,一个接口如果只定义了一个抽象方法,就称为函数式接口。例如上文中的通用事件收听者接口、用于比较的Comparator <T>接口等。因为只包含一个方法,这种接口的实现类往往实质上就是充当该方法的包装。将一个函数式接口的实例赋予某个变量,作为参数传递给某个方法,作为某个方法的返回值,就以对象的形式实现了前文所说的一级函数的特点。函数式接口中的方法定义则保证了静态强类型语言对此一级函数签名的编译时类型检查。Java中Lambda表达式和方法引用的背后都是函数式接口的对象,这在下面的代码里体现得很清楚。
所以在上一节的样例代码中,添加Lambda表达式和方法引用形式的事件收听者,本质和传统的添加接口形式的收听者是一样的,只是创建同一类型的接口实例的语法上更便捷的方式。在IDE中将鼠标指针悬浮于该Lambda表达式和方法引用上方,也能看到它们的类型是IEventListener。所不同的是,对于普通接口,一个对象只能从整体上实现一次。也就是我们在2.3.5节中所说的,一个实现了MouseListener接口的收听者只有一个mouseClicked方法,所以只能处理一个控件的单击事件。用方法引用形式创建的函数式接口实例则不然,只要签名符合要求,一个对象中的每个方法都能创建一个包装它的接口实例。正是这种能力,给事件编程带来了上节所述的变化。
另外值得指出的是,函数式接口虽然是方法引用所依托的类型,但它本身由来已久,Runnable、Comparator这些接口在Java引入@FunctionalInterface标记以前就符合函数式接口的定义,在Java 8新增Lambda表达式和方法引用功能之前,函数式接口和普通接口的用法毫无二致。比如Java的另一图形用户界面框架SWT,供外界使用的事件收听者接口org.eclipse.swt.events.KeyListener与Swing类似都是专用的包含多个方法的,SWT内部使用的则是通用的只包含一个方法的org.eclipse.swt.widgets.Listener接口,然而这个函数式接口在Java 8之前仍然面临前面分析的通用事件收听者的问题。