Java 程序设计教程(四)
十、观察者
随着新对象的创建和现有对象的修改,程序的状态会随着时间而改变。该程序可以响应这些变化事件中的一些。例如,向一个外资银行账户存入一大笔存款可能会启动一个检查非法活动的流程。该程序还可以响应某些输入事件,例如鼠标动作和键盘输入。例如,鼠标点击按钮通常会得到响应,但鼠标点击标签通常会被忽略。
使用观察器是一种通用技术,用于管理程序对事件的响应。一个对象可以维护一个观察者列表,并在一个值得注意的事件发生时通知他们。本章介绍了观察者模式,这是将观察者合并到代码中的首选方式。本章给出了使用它的实际例子,并研究了各种设计问题和权衡。
观察者和可观察物
以银行业的演示为例。假设银行希望在创建新账户时执行一些操作。例如,营销部门希望向账户所有人发送“欢迎来到银行”信息包,审计部门希望对新的外资账户进行背景调查。
为了实现这个功能,Bank类将需要一个对每个想要被通知新帐户的对象的引用,这样它的newAccount方法就可以通知这些对象。清单 10-1 中的代码是这一思想的直接实现,其中Bank类保存了对MarketingRep和Auditor对象的引用。
public class Bank implements Iterable<BankAccount> {
private Map<Integer,BankAccount> accounts;
private int nextacct;
private MarketingRep rep;
private Auditor aud;
public Bank(Map<Integer,BankAccount> accounts, int n,
MarketingRep r, Auditor a) {
this.accounts = accounts;
nextacct = n;
rep = r; aud = a;
}
public int newAccount(int type, boolean isforeign) {
int acctnum = nextacct++;
BankAccount ba =
AccountFactory.createAccount(type, acctnum);
ba.setForeign(isforeign);
accounts.put(acctnum, ba);
rep.update(acctnum, isforeign);
aud.update(acctnum, isforeign);
return acctnum;
}
...
}
Listing 10-1Adding Observers to the Bank Class
MarketingRep和Auditor类被称为观察者类,它们的对象被称为观察者。Bank类被称为可观测类。当创建新帐户时,它会通知其观察者。按照惯例,通知方法被命名为“update ”,以表示可观察对象正在告诉它的观察者更新已经发生。
可观察对象-观察者的关系类似于发布者和他们的订阅者之间的关系。当出版商有新材料要分发时,它会通知其订户。因此,在程序中使用观察者也被称为发布-订阅技术。
Twitter 应用程序是一个众所周知的发布-订阅示例。一个 Twitter 用户有一个关注者列表。当有人在推特上发布消息时,该消息将被发送给列表中的每个关注者(订阅者)。发布-订阅技术也被留言板和 listservs 使用。如果有人向 listserv 发送消息,那么 listserv 的所有订户都会收到该消息。
清单 10-1 中的Bank代码的问题在于,银行确切地知道哪些对象正在观察它。换句话说,可观察类与其观察者类紧密耦合。这种紧密耦合使得每次观察器改变时都必须修改Bank。
例如,假设银行决定使用多个营销代理,比如一个用于国外账户,另一个用于国内账户。然后,银行将有两个MarketingRep对象观察它。或者假设银行决定添加一个观察者,将每个新帐户的信息记录到一个文件中。在这种情况下,Bank需要持有一个额外的观察者对象,这次是类型AccountLogger。
解决这个问题的正确方法是注意,银行并不真正关心它有多少个 observer 对象,也不关心它们的类是什么。银行只需持有一份观察员名单就足够了。当一个新帐户被创建时,它可以通知列表中的每个对象。
为了实现这个想法,observer 类必须实现一个公共接口。调用这个接口BankObserver。它将有一个名为update的方法,如清单 10-2 所示。
public interface BankObserver {
void update(int acctnum, boolean isforeign);
}
Listing 10-2The BankObserver Interface
然后,Bank代码将看起来像清单 10-3 。请注意这种设计如何极大地减少了可观察对象和观察者之间的耦合。
public class Bank implements Iterable<BankAccount> {
private Map<Integer,BankAccount> accounts;
private int nextacct;
private List<BankObserver> observers;
public Bank(Map<Integer,BankAccount> accounts,
int n, List<BankObserver> L) {
this.accounts = accounts;
nextacct = n;
observers = L;
}
public int newAccount(int type, boolean isforeign) {
int acctnum = nextacct++;
BankAccount ba =
AccountFactory.createAccount(type, acctnum);
ba.setForeign(isforeign);
accounts.put(acctnum, ba);
observers.forEach(obs->obs.update(acctnum, isforeign));
return acctnum;
}
...
}
Listing 10-3An Improved Bank Class
提供给Bank构造器的列表可以包含任意数量的观察者,这些观察者可以属于任何实现BankObserver的类。对于一个具体的例子,考虑一个简单版本的Auditor类,它将每个新的外国拥有的账户的账号写入控制台。它的代码可能看起来像清单 10-4 。
public class Auditor implements BankObserver {
public void update(int acctnum, boolean isforeign) {
if (isforeign)
System.out.println("New foreign acct" + acctnum);
}
}
Listing 10-4The Auditor Class
图 10-1 的类图描述了Bank类和它的观察者之间的关系。
图 10-1
银行阶级及其观察者
观察者模式
Bank和它的观察者之间的这种关系是观察者模式的一个例子。基本思想是一个可观察的物体拥有一系列的观察者。当被观察对象决定公布其状态的变化时,它会通知它的观察者。这个想法在图 10-2 的类图中有所表达。
图 10-2
观察者模式
这个类图很像图 10-1 的图。Bank类是可观察对象,BankObserver是观察者接口,Auditor、MarketingRep、AccountLogger是观察者类。
尽管图 10-2 描述了观察者模式的整体架构,但它在实际细节上有些欠缺。update方法的参数应该是什么?可观察对象是如何得到它的观察者列表的?事实证明,有几种方法可以回答这些问题,这导致了观察者模式的多种变化。以下小节研究了一些设计可能性。
推与拉
第一个问题是考虑对update方法的争论。在清单 10-2 的BankObserver接口中,update有两个参数,它们是新创建的银行账户的值,观察者对这些值感兴趣。在更现实的程序中,该方法可能需要更多的参数。例如,一个现实的Auditor类想要知道所有者的账号、外国身份和税收 id 号;而MarketingRep类想要所有者的账号、姓名和地址。
这种设计技术被称为推,因为可观察对象将值“推”给它的观察者。推送技术的困难在于,update方法必须发送任何观察者可能需要的所有值。如果观察者需要许多不同的值,那么update方法就变得不实用了。此外,可观察对象必须猜测任何未来的观察者可能需要什么值,这可能导致可观察对象“以防万一”地推出许多不必要的值。
另一种叫做拉的设计技术缓解了这些问题。在拉技术中,update方法包含对可观察对象的引用。然后,每个观察者可以使用该参考从可观察对象中“提取”它想要的值。
清单 10-5 显示了BankObserver的代码,修改后使用了拉技术。它的update方法传递一个对Bank对象的引用。它还传递新帐户的帐号,以便观察者可以从正确的帐户中提取信息。
public interface BankObserver {
void update(Bank b, int acctnum);
}
Listing 10-5Revising the BankObserver Interface to Use Pull
清单 10-6 显示了Auditor观察者的修改代码。注意它的update方法是如何从提供的Bank引用中提取外来状态标志的。
public class Auditor implements BankObserver {
public void update(Bank b, int acctnum) {
boolean isforeign = b.isForeign(acctnum);
if (isforeign)
System.out.println("New foreign acct" + acctnum);
}
}
Listing 10-6The Revised Auditor Class
拉技术有某种优雅之处,因为可观察对象为每个观察者提供了工具,使其能够提取所需的信息。拉技术的一个问题是,观测者必须返回到可观测值来检索所需的值,这种时间滞后可能会影响正确性。
例如,假设一个用户创建了一个新的国内帐户,但是不久之后调用setForeign方法将其更改为国外所有者。如果观察者在执行setForeign之后从银行提取账户信息,那么它将错误地认为该账户是作为外国账户创建的。
另一个问题是,拉技术只能在被观测者保留观测者想要的信息时使用。例如,假设一个银行观察者希望每次执行deposit方法时都得到通知,这样它就可以调查异常大的存款。如果银行不保存每笔存款的金额,那么拉是不可行的。相反,银行将需要通过其update方法推送存款金额。
混合推挽式设计可用于平衡推挽式设计。例如,update方法可以推送一些值以及对可观察对象的引用。或者,update方法可以推送一个相关对象的引用,观察者可以从中提取。清单 10-7 给出了后一种接口的例子。在这种情况下,可观察对象推送一个对新的BankAccount对象的引用,观察者可以从中获取他们需要的信息。
public interface BankObserver {
void update(BankAccount ba);
}
Listing 10-7A Hybrid Push-Pull BankObserver Interface
管理观察者列表
需要研究的第二个问题是,一个可观察对象如何获得它的观察列表。在清单 10-3 中,列表通过其构造器传递给可观察对象,并在整个程序生命周期中保持不变。然而,这样的设计不能处理观察者动态地来来去去的情况。
例如,假设您希望观察者记录正常银行营业时间之外发生的所有银行交易。一种选择是让观察者持续活跃。收到每个事件通知后,观察器会检查当前时间。如果银行关门了,它就会记录事件。
问题在于,银行活动通常在营业时间最繁忙,这意味着观察者将花费大量时间忽略它收到的大多数通知。更好的办法是在银行晚上关门时将观察者添加到观察者列表中,并在银行早上重新开门时将其删除。
为了适应这种需求,observables 必须提供方法来显式地在观察者列表中添加和删除观察者。这些方法通常被称为addObserver和removeObserver。有了这些变化,Bank代码看起来将如清单 10-8 所示。
public class Bank implements Iterable<BankAccount> {
private Map<Integer,BankAccount> accounts;
private int nextacct;
private List<BankObserver> observers = new ArrayList<>();
public Bank(Map<Integer,BankAccount> accounts, int n) {
this.accounts = accounts;
nextacct = n;
}
public void addObserver(BankObserver obs) {
observers.add(obs);
}
public void removeObserver(BankObserver obs) {
observers.remove(obs);
}
...
}
Listing 10-8Another Revision to the Bank Class
这种将观察者动态添加到可观察列表的技术是依赖注入的一种形式。可观察对象对每个观察者都有依赖关系,这种依赖关系通过它的addObserver方法注入到可观察对象中。这种形式的依赖注入被称为方法注入(相对于清单 10-3 中的构造器注入)。
有两种方法来执行方法注入。第一种方法是让另一个类(如BankProgram)将观察者添加到列表中;另一种方法是每个观察者添加自己。清单 10-9 的BankProgram代码说明了方法注入的第一种形式。
public class BankProgram {
public static void main(String[] args) {
...
Bank bank = new Bank(accounts, nextacct);
BankObserver auditor = new Auditor();
bank.addObserver(auditor);
...
}
}
Listing 10-9One Way to perform Method Injection
这种形式的方法注入的一个优点是观察者对象可以用 lambda 表达式来表示,因此不需要显式的观察者类。这个思路如清单 10-10 所示,假设清单 10-7 的BankObserver接口。
public class BankProgram {
public static void main(String[] args) {
...
Bank bank = new Bank(accounts, nextacct);
bank.addObserver(ba -> {
if (ba.isForeign())
System.out.println("New foreign acct: "
+ ba.getAcctNum());
});
...
}
}
Listing 10-10Revising BankProgram to Use a Lambda Expression
清单 10-11 展示了方法注入的第二种形式。Auditor观察者通过其构造器接收对可观察的Bank对象的引用,并将自己添加到银行的观察者列表中。
public class BankProgram {
public static void main(String[] args) {
...
Bank bank = new Bank(accounts, nextacct);
BankObserver auditor = new Auditor(bank);
...
}
}
public class Auditor implements BankObserver {
public Auditor(Bank b) {
b.addObserver(this);
}
...
}
Listing 10-11A Second Way to Perform Method Injection
这种技术导致了可观察对象和它的观察者之间非常有趣的关系。可观察对象调用其观察者的update方法,但对它们一无所知。另一方面,观察者知道哪个对象在调用他们。这种情况与典型的方法调用完全相反,在典型的方法调用中,方法的调用方知道它在调用谁,而被调用方不知道谁在调用它。
Java 中的通用观察者模式
Java 库包含接口Observer和类Observable,旨在简化观察者模式的实现。Observer是一个通用的观察者接口,其代码出现在清单 10-12 中。它的update方法有两个参数,支持混合推拉设计。
interface Observer {
public void update(Observable obs, Object obj);
}
Listing 10-12The Observer Interface
update的第一个参数是对发出调用的可观察对象的引用,供拉技术使用。第二个参数是一个包含 push 技术发送的值的对象。如果可观察对象想要推送多个值,那么它会将它们嵌入到单个对象中。如果可观察对象不想推送任何值,那么它会将 null 作为第二个参数发送。
Observable是一个抽象类,实现了观察者列表及其相关方法。observable 扩展了这个抽象类,以便继承这个功能。它的代码出现在清单 10-13 中。
public abstract class Observable {
private List<Observer> observers = new ArrayList<>();
private boolean changed = false;
public void addObserver(Observer obs) {
observers.add(obs);
}
public void removeObserver(Observer obs) {
observers.remove(obs);
}
public void notifyObservers(Object obj) {
if (changed)
for (Observer obs : observers)
obs.update(this, obj);
changed = false;
}
public void notifyObservers() {
notifyObservers(null);
}
public void setChanged() {
changed = true;
}
...
}
Listing 10-13The Observable Class
注意两种不同的notifyObservers方法。单参数版本将参数作为第二个参数传递给观察者update。零参数版本将 null 作为第二个参数发送给update。
还要注意,在客户端第一次调用setChanged之前,notifyObservers方法什么也不做。setChanged的目的是支持定期执行通知的程序,而不是在每次更改后立即执行通知。在这样的程序中,做定期通知的代码可以在任何时候调用notifyObservers,确信除非setChanged在上次通知后被调用,否则它不会有任何效果。
清单 10-14 展示了如何使用Observer和Observable类以及推送技术重写银行演示。这个列表包含了Bank(可观察对象)和Auditor(观察者)的相关代码。注意Bank不再需要代码来管理它的观察者列表和相关方法,因为它的超类Observable处理它们。
public class Bank extends Observable
implements Iterable<BankAccount> {
...
public int newAccount(int type, boolean isforeign) {
int acctnum = nextacct++;
BankAccount ba =
AccountFactory.createAccount(type, acctnum);
ba.setForeign(isforeign);
accounts.put(acctnum, ba);
setChanged();
ObserverInfo info =
new ObserverInfo(acctnum, isforeign);
notifyObservers(info);
return acctnum;
}
...
}
public class Auditor implements Observer {
public Auditor(Bank bank) {
bank.addObserver(this);
}
public void update(Observable obs, Object obj) {
ObserverInfo info = (ObserverInfo) obj;
if (info.isForeign())
System.out.println("New foreign account: "
+ info.getAcctNum());
}
}
Listing 10-14Rewriting Bank and Auditor Using Observable and Observer
update方法的第二个参数是一个类型为ObserverInfo的对象。这个类在一个对象中嵌入了帐号和外国身份标志。它的代码出现在清单 10-15 中。
public class ObserverInfo {
private int acctnum;
private boolean isforeign;
public ObserverInfo(int a, boolean f) {
acctnum = a;
isforeign = f;
}
public int getAcctNum() {
return acctnum;
}
public boolean isForeign() {
return isforeign;
}
}
Listing 10-15The ObserverInfo Class
虽然Observable和Observer实现了观察者模式的基础,但是它们的通用性质有一些缺点。Observable是一个抽象类,不是一个接口,这意味着 observable 不能扩展任何其他类。update方法是“一刀切”,因为应用程序必须将其推送的数据挤入和挤出一个对象,如ObserverInfo。由于这些缺点,以及编写它们提供的代码相当简单的事实,通常跳过使用Observable和Observer会更好。
事件
前面几节集中讨论了当一个新的银行账户被创建时,Bank类如何通知它的观察者。新账户的创建是事件的一个例子。一般来说,可观察对象可能希望向其观察者通知多种类型的事件。例如,版本 18 Bank类有四种事件类型。这些类型对应影响其银行账户的四种方式,即newAccount、deposit、setForeign、addInterest。版本 18 bank demo 将这四种事件类型定义为 enum BankEvent的常量。参见清单 10-16 。
public enum BankEvent {
NEW, DEPOSIT, SETFOREIGN, INTEREST;
}
Listing 10-16The Version 18 BankEvent Enum
问题是像Bank这样的可观察对象如何管理四个不同事件的通知。有两个问题:观察对象应该保留多少个观察列表,以及观察对象接口应该有多少个更新方法。也可以为每个事件创建一个单独的观察者接口。
考虑一下观察者列表。保持一个单一的列表更简单,但是这将意味着每个观察者将被通知每个事件。如果被观察对象能够为每个事件保留一个观察列表,那么通常会更好,这样它的观察对象就可以只注册他们关心的事件。
现在考虑更新方法。一种选择是观察者接口为每个事件提供一个更新方法。这样做的好处是,您可以设计每个方法,以便为其事件定制参数。缺点是观察者必须为每个方法提供一个实现,即使它只对其中一个感兴趣。
另一种方法是让接口有一个更新方法。该方法的第一个参数可以标识事件,其余的参数将传递足够的信息来满足所有观察者。缺点是可能很难将所有这些信息打包到一组参数值中。
对于 18 版本的银行演示,我选择使用单一的update方法。清单 10-17 给出了版本 18 BankObserver的接口。update方法有三个参数:事件、受影响的银行账户和一个表示存款金额的整数。并非所有的论点都适用于每个事件。例如,DEPOSIT观察者将使用所有的自变量;NEW和SETFOREIGN观察员将只使用赛事和银行账户;而INTEREST观察者将只使用事件。
public interface BankObserver {
void update(BankEvent e, BankAccount ba, int depositamt);
}
Listing 10-17The Version 18 BankObserver Interface
版本 18 Bank类为四种事件类型中的每一种都有一个观察者列表。为了方便起见,它将这些列表捆绑到一个基于事件类型的映射中。它的addObserver方法向指定的列表中添加一个观察者。removeObserver方法类似,但是为了方便起见,省略了它的代码。Bank还有一个notifyObservers方法,通知指定列表上的观察者。
Bank有四种生成事件的方法:newAccount、deposit、setForeign和addInterest。版本 18 修改了这些方法来调用notifyObservers方法。清单 10-18 给出了代码的相关部分。请注意,notifyObservers的第三个参数对于除了deposit之外的所有方法都是 0,因为DEPOSIT是唯一与该值相关的事件。其他事件忽略该值。
public class Bank implements Iterable<BankAccount> {
private Map<Integer,BankAccount> accounts;
private int nextacct;
private Map<BankEvent,List<BankObserver>> observers
= new HashMap<>();
public Bank(Map<Integer,BankAccount> accounts, int n) {
this.accounts = accounts;
nextacct = n;
for (BankEvent e : BankEvent.values())
observers.put(e, new ArrayList<BankObserver>());
}
public void addObserver(BankEvent e, BankObserver obs) {
observers.get(e).add(obs);
}
public void notifyObservers(BankEvent e, BankAccount ba,
int depositamt) {
for (BankObserver obs : observers.get(e))
obs.update(e, ba, depositamt);
}
public int newAccount(int type, boolean isforeign) {
int acctnum = nextacct++;
BankAccount ba =
AccountFactory.createAccount(type, acctnum);
ba.setForeign(isforeign);
accounts.put(acctnum, ba);
notifyObservers(BankEvent.NEW, ba, 0);
return acctnum;
}
public void setForeign(int acctnum, boolean isforeign) {
BankAccount ba = accounts.get(acctnum);
ba.setForeign(isforeign);
notifyObservers(BankEvent.SETFOREIGN, ba, 0);
}
public void deposit(int acctnum, int amt) {
BankAccount ba = accounts.get(acctnum);
ba.deposit(amt);
notifyObservers(BankEvent.DEPOSIT, ba, amt);
}
public void addInterest() {
forEach(ba->ba.addInterest());
notifyObservers(BankEvent.INTEREST, null, 0);
}
...
}
Listing 10-18The Version 18 Bank Class
类别Auditor的版本 18 代码出现在清单 10-19 中。该类是两个事件的观察者:NEW 和 SETFOREIGN。因为它观察两个事件,所以它检查其update方法的第一个参数来确定哪个事件发生了。
public class Auditor implements BankObserver {
public Auditor(Bank bank) {
bank.addObserver(BankEvent.NEW, this);
bank.addObserver(BankEvent.SETFOREIGN, this);
}
public void update(BankEvent e, BankAccount ba,
depositamt amt) {
if (ba.isForeign()) {
if (e == BankEvent.NEW)
System.out.println("New foreign account: "
+ ba.getAcctNum());
else
System.out.println("Modified foreign account: "
+ ba.getAcctNum());
}
}
}
Listing 10-19The Version 18 Auditor Class
版本 18 BankProgram代码出现在清单 10-20 中。该类创建了两个观察器:一个Auditor实例和一个观察DEPOSIT事件的 lambda 表达式。如果检测到超过 100,000 美元的存款,这个观察者调用银行的makeSuspicious方法。
public class BankProgram {
public static void main(String[] args) {
SavedBankInfo info = new SavedBankInfo("bank18.info");
Map<Integer,BankAccount> accounts = info.getAccounts();
int nextacct = info.nextAcctNum();
Bank bank = new Bank(accounts, nextacct);
Auditor aud = new Auditor(bank);
bank.addObserver(BankEvent.DEPOSIT,
(event,ba,amt) -> {
if (amt > 10000000)
bank.makeSuspicious(ba.getAcctNum());
});
...
}
}
Listing 10-20The Version 18 BankProgram Class
JavaFX 中的观察员
事件和事件观察器在 GUI 应用程序中起着重要的作用。在 JavaFX 中,用户与屏幕的交互导致一系列的输入事件发生。JavaFX 库指定了几种类型的输入事件。每个事件类型都是扩展类Event的类中的一个对象。三个这样的等级是MouseEvent、KeyEvent和ActionEvent。清单 10-21 显示了这些类的一些常见事件类型。
MouseEvent.MOUSE_CLICKED
MouseEvent.MOUSE_ENTERED
KeyEvent.KEY_TYPED
ActionEvent.ACTION
Listing 10-21Four Common JavaFX Event Types
事件类型表示生成的事件的种类。事件的目标是负责处理它的节点。例如,如果用户鼠标点击屏幕上的某个特定位置,那么该位置最顶端的节点将成为一个MOUSE_CLICKED事件的目标。
每个 JavaFX Node对象都是可观察的。节点为每种事件类型保留一个单独的观察器列表。也就是说,一个节点将有一个鼠标点击观察器、鼠标输入观察器、键盘输入观察器等的列表。
在 JavaFX 中,事件观察者被称为事件处理者。每个节点都有方法addEventHandler,该方法为给定的事件类型向节点的观察者列表添加一个观察者。这个方法有两个参数:感兴趣的事件类型和对事件处理程序的引用。
事件处理程序属于实现接口EventHandler的类。该接口只有一个方法,名为handle。它的代码出现在清单 10-22 中。
public interface EventHandler {
void handle(Event e);
}
Listing 10-22The EventHandler Interface
清单 10-23 给出了事件处理程序类ColorLabelHandler的代码,其handle方法将指定标签的文本更改为指定的颜色。
public class ColorLabelHandler
implements EventHandler<Event> {
private Label lbl;
private Color color;
public ColorLabelHandler(Label lbl, Color color) {
this.lbl = lbl;
this.color = color;
}
public void handle(Event e) {
lbl.setTextFill(color);
}
}
Listing 10-23The ColorLabelHandler Class
作为事件处理程序的使用示例,再次考虑清单 9-8 和 9-9 中的AccountCreationWindow程序。图 10-3 显示其初始屏幕。
图 10-3
初始帐户创建窗口屏幕
清单 10-24 修改了程序,增加了四个事件处理程序:
-
标题标签上的一个
MOUSE_ENTERED处理程序,当鼠标进入标签区域时,它的文本变成红色。 -
标题标签上的一个
MOUSE_EXITED处理程序,当鼠标退出标签区域时,它将文本变回绿色。这两个处理程序的组合产生了一种“翻转”效果,当鼠标滑过标签时,标签会暂时变成红色。 -
最外层窗格上的一个
MOUSE_CLICKED处理程序,通过取消选中复选框,将选择框的值设置为 null,并将标题标签的文本改回“Create a new bank account”来重置屏幕 -
按钮上的一个
MOUSE_CLICKED处理程序,它使用复选框和选择框的值来改变标题标签的文本。
public class AccountCreationWindow extends Application {
public void start(Stage stage) {
...
Label title = ... // the label across the top
title.addEventHandler(MouseEvent.MOUSE_ENTERED,
new ColorLabelHandler(title, Color.RED));
title.addEventHandler(MouseEvent.MOUSE_EXITED,
e -> title.setTextFill(Color.GREEN));
Pane p1 = ... // the outermost pane
p1.addEventHandler(MouseEvent.MOUSE_CLICKED,
e -> {
ckbx.setSelected(false);
chbx.setValue(null);
title.setText("Create a New Bank Account");
});
Button btn = ... // the CREATE ACCT button
btn.addEventHandler(MouseEvent.MOUSE_CLICKED,
e -> {
String foreign = ckbx.isSelected() ?
"Foreign " : "";
String acct = chbx.getValue();
title.setText(foreign + pref + acct
+ " Account Created");
stage.sizeToScreen();
});
...
}
}
Listing 10-24A Revised AccountCreationWindow Class
第一个处理程序使用清单 10-23 中的ColorLabelHandler类。它的handle方法将在鼠标进入标题标签的区域时执行。第二个处理程序使用 lambda 表达式来定义handle方法。lambda 表达式(或内部类)的一个特性是它可以从其周围的上下文中引用变量(如title)。这避免了像在ColorLabelHandler中那样将这些值传递给构造器的需要。
第三个处理程序观察窗格p1上的鼠标点击,第四个处理程序观察按钮上的鼠标点击。这两个处理程序都通过 lambda 表达式定义了它们的handle方法。
指定按钮处理程序的一种常见方式是用ActionEvent.ACTION替换事件类型MouseEvent.MOUSE_CLICKED。一个ACTION事件表示来自用户的“提交”请求。按钮支持几种提交请求,比如鼠标点击按钮,通过触摸屏触摸按钮,当按钮获得焦点时按空格键。为按钮处理程序使用ACTION事件通常比使用MOUSE_CLICKED事件更好,因为单个ACTION事件处理程序将支持所有这些请求。
Button类也有一个方法setOnAction,它进一步简化了按钮处理程序的规范。例如,清单 9-9 中的按钮处理程序使用了setOnAction而不是addEventHandler。以下两种说法效果相同。
btn.addEventHandler(ActionEvent.ACTION, h);
btn.setOnAction(h);
JavaFX 属性
JavaFX 节点的状态由各种属性表示。例如,ChoiceBox类的两个属性是items,它表示选择框应该显示的项目列表,以及value,它表示当前选中的项目。对于节点的每个属性,该节点都有一个方法返回对该属性的引用。方法的名称是属性名,后跟“property”例如,ChoiceBox有方法itemsProperty和valueProperty。
形式上,属性是实现接口Property的对象。它的三种方法如清单 10-25 所示。基于这些方法,您可以正确地推断出一个Property对象既是包装器又是可观察对象。方法getValue和setValue获取并设置包装的值,方法addListener将一个监听器添加到它的观察列表中。在下面的小节中,我们将研究属性的这两个方面。
public interface Property<T> {
T getValue();
void setValue(T t);
void addListener(ChangeListener<T> listener);
...
}
Listing 10-25Methods of the Property Interface
作为包装的属性
属性的getValue和setValue方法很少使用,因为每个节点都有替代的便利方法。特别是,如果一个节点有一个名为p的属性,那么它有便利的方法getP和setP。例如,清单 10-26 显示了清单 9-9 中createNodeHierarchy方法的开始。对getP和setP方法的调用以粗体显示。
private Pane createNodeHierarchy() {
VBox p3 = new VBox(8);
p3.setAlignment(Pos.CENTER);
p3.setPadding(new Insets(10));
p3.setBackground(...);
Label type = new Label("Select Account Type:");
ChoiceBox<String> chbx = new ChoiceBox<>();
chbx.getItems().addAll("Savings", "Checking",
"Interest Checking");
...
}
Listing 10-26The Beginning of the AccountCreationWindow Class
这些方法都是方便的方法,因为类VBox有属性alignment、padding和background,而ChoiceBox有属性items。为了证明这一点,清单 10-27 给出了不使用这些便利方法的代码的替代版本。
private Pane createNodeHierarchy() {
VBox p3 = new VBox(8);
Property<Pos> alignprop = p3.alignmentProperty();
alignprop.setValue(Pos.CENTER);
Property<Insets> padprop = p3.paddingProperty();
padprop.setValue(new Insets(10));
Property<Background> bgprop = p3.backgroundProperty();
bgprop.setValue(...);
Label type = new Label("Select Account Type:");
ChoiceBox<String> chbx = new ChoiceBox<>();
Property<String> itemsprop = chbx.itemsProperty();
itemsprop.getValue().addAll("Savings", "Checking",
"Interest Checking");
...
}
Listing 10-27Revising Listing 10-26 to Use Explicit Property Objects
可观察的属性
一个属性是一个可观察的对象,它维护着一个观察者列表。当其包装的对象改变状态时,该属性通知其观察者。一个属性观察者被称为变更监听器,并实现清单 10-28 中所示的接口ChangeListener。
public interface ChangeListener<T> {
void changed(Property<T> obs, T oldval, T newval);
}
Listing 10-28The ChangeListener Interface
该接口由一个名为changed的方法组成。注意changed是一种混合推挽观测器方法。第二个和第三个参数将新旧值推送给观察者。第一个论据是可观测性本身,观测者可以从中获得额外的信息。(从技术上讲,第一个参数属于类型ObservableValue,这是一个比Property更通用的接口。但是为了简单起见,我忽略了这个问题。)
创建变更监听器最简单的方法是使用 lambda 表达式。例如,清单 10-29 给出了可以添加到AccountCreationWindow类中的监听器代码。这个监听器观察复选框ckbx。如果框被选中,执行它的代码会使标签的文本变成绿色,如果框被取消选中,则变成红色。
ChangeListener<Boolean> checkboxcolor =
(obs, oldval, newval) -> {
Color c = newval ? Color.GREEN : Color.RED;
ckbx.setTextFill(c);
};
Listing 10-29A Check Box Change Listener
要让变更监听器执行,您必须通过调用属性的addListener方法将其添加到属性的观察者列表中,如清单 10-30 所示。结果是,当复选框被选中和取消选中时,它的颜色会从红色变为绿色,然后再变回绿色。
ChangeListener<Boolean> checkboxcolor = ... // Listing 10-29
Property<Boolean> p = ckbx.selectedProperty();
p.addListener(checkboxcolor);
Listing 10-30Attaching a Change Listener to a Property
清单 10-29 和 10-30 需要三条语句来创建一个监听器并将其添加到所需属性的观察者列表中。我这样写是为了一步一步地向你展示需要发生什么。实际上,大多数 JavaFX 程序员会将整个代码写成一条语句,如清单 10-31 所示。
ckbx.selectedProperty().addListener(
(obs, oldval, newval) -> {
Color c = newval ? Color.GREEN : Color.RED;
ckbx.setTextFill(c);
});
Listing 10-31Revising the Check Box Change Listener
更改侦听器也可用于同步 JavaFX 控件的行为。再次考虑图 10-3 中显示的AccountCreationWindow的初始屏幕。请注意,选择框是未选中的。如果用户此时点击了CREATE ACCT按钮,如果代码试图创建一个帐户,就会出现运行时错误。
为了消除出错的可能性,您可以设计屏幕,使按钮最初被禁用,只有在选择了帐户类型时才被启用。这种设计要求在选择框中添加一个更改监听器。其代码如清单 10-32 所示。
public class AccountCreationWindow extends Application {
public void start(Stage stage) {
...
chbx.valueProperty().addListener(
(obj, oldval, newval) ->
btn.setDisable(newval==null));
...
}
}
Listing 10-32Adding Change Listener for the Choice Box
变量chbx引用选择框。如果选择框的新值为空,更改监听器禁用按钮,否则启用按钮。结果是按钮的启用/禁用状态与选择框的选中/未选中状态同步。
事件侦听器和更改侦听器可以交互。回想一下清单 10-24 中,AccountCreationWindow的最外层窗格p1有一个事件监听器,当窗格被点击时,它将选择框的值设置为空。此更改将导致选择框的更改侦听器触发,然后禁用按钮。也就是说,从选择框中选择一个项目会启用该按钮,单击外部窗格会禁用该按钮。用户可以通过从选择框中选择一个帐户类型,然后单击外部窗格来反复启用和禁用该按钮。试试看。
JavaFX 绑定
JavaFX 支持计算属性的概念,它被称为绑定。绑定实现了接口Binding,清单 10-33 中显示了其中的两个方法。注意,绑定和属性之间的主要区别在于绑定没有setValue方法。绑定没有setValue,因为它们的值是计算出来的,不能手动设置。
public interface Binding<T> {
public T getValue();
public void addListener(ChangeListener<T> listener);
...
}
Listing 10-33The Binding Interface
可以用几种方法创建绑定,但最简单的方法是使用与您拥有的属性类型相关联的方法。例如,包装对象的属性扩展了类ObjectProperty并继承了方法isNull。清单 10-34 展示了如何为选择框的value属性创建一个绑定。
ChoiceBox chbx = ...
ObjectProperty<String> valprop = chbx.valueProperty();
Binding<Boolean> nullvalbinding = valprop.isNull();
Listing 10-34An example Binding
变量nullvalbinding引用了一个包装了布尔值的Binding对象。这个布尔值是从选择框的value属性计算出来的——特别是,如果value包含一个空值,那么这个布尔值将为真,否则为假。
当一个Binding对象被创建时,它将自己添加到其属性的观察者列表中。因此,对属性值的更改将通知绑定,然后绑定可以相应地更改其值。为了帮助你形象化这种情况,请看图 10-4 的图表,它描绘了清单 10-34 的三个变量的记忆图。
图 10-4
绑定与其属性之间的关系
chbk对象代表选择框。它有对其每个属性的引用。该图仅显示了对value的引用,并暗示了对items的引用。valprop对象代表value属性。它有一个对其包装对象(字符串“savings”)和观察者列表的引用。图表显示列表至少有一个观察者,这就是绑定nullvalbinding。请注意,绑定的结构类似于属性。它的包装对象是一个布尔值false。
当chbx节点改变其被包装的对象时,比如说通过执行代码valueProperty().setValue(null),value属性将发送一个改变通知给它的观察者。当绑定收到通知时,它会注意到属性的新值为 null,并将其包装对象的值设置为true。
清单 10-32 的代码为选择框创建了一个变更监听器。清单 10-35 重写代码以使用绑定。注意变更监听器如何将按钮的disable属性的值设置为绑定的值。不需要像清单 10-32 中那样显式地检查 null,因为检查是由绑定执行的。
public class AccountCreationWindow extends Application {
public void start(Stage stage) {
...
ObjectProperty<String> valprop = chbx.valueProperty();
Binding<Boolean> nullvalbinding = valprop.isNull();
nullvalbinding.addListener(
(obj, oldval, newval) -> btn.setDisable(
nullvalbinding.getValue()));
...
}
}
Listing 10-35Rewriting the Choice Box Change Listener
清单 10-35 的代码有些难读(也有些难写!).为了简化起见,Property对象有方法bind,它为您执行绑定。清单 10-36 相当于清单 10-35 的代码。
public class AccountCreationWindow extends Application {
public void start(Stage stage) {
...
btn.disableProperty()
.bind(chbx.valueProperty().isNull());
...
}
}
Listing 10-36Using the Bind Method to Create an Implicit Change Listener
bind方法有一个参数,它是一个绑定(或属性)。这里,方法的参数是由isNull方法创建的绑定。bind方法向该绑定添加了一个更改侦听器,这样当它的包装值更改时,按钮的disable属性的值也会随之更改。该行为与清单 10-35 中的行为完全相同。
清单 10-36 的代码格外漂亮。bind方法和isNull方法都创建变更监听器,这些监听器通过观察者模式(两次!)使这两个控件能够同步它们的值。而这一切都发生在幕后,客户毫不知情。这是观察者模式的有用性和适用性的极好例子。
摘要
一个观察者是一个对象,它的工作是响应一个或多个事件。一个可观察的是一个物体,它能识别特定事件何时发生,并保持一个对这些事件感兴趣的观察者列表。当一个事件发生时,它通知它的观察者。
观察者模式规定了观察者和可观察物之间的一般关系。但是这种模式没有解决多个设计问题。一个问题涉及到更新方法:一个可观察对象应该向它的观察者推送什么值,以及观察者应该从可观察对象获取什么值?第二个问题是关于可观察对象如何处理多种类型的事件:它应该用单独的更新方法和观察列表独立地处理每个事件,还是可以以某种方式组合事件处理?这些问题没有最佳解决方案。设计师必须考虑给定情况下的各种可能性,并权衡利弊。
观察者模式对于 GUI 应用程序的设计尤其有用。事实上,JavaFX 中充满了 observer 模式,如果不大量使用 observer 和 observables,设计 JavaFX 应用程序几乎是不可能的。即使应用程序没有显式地使用观察器,应用程序使用的类库几乎肯定会使用。
JavaFX 节点支持两种观察器:事件处理程序和变化监听器。事件处理程序响应输入事件,如鼠标点击和按键。每个事件处理程序都属于某个节点的观察者列表。变化监听器响应节点状态的变化。每个变更监听器属于一个节点的某个属性的观察者列表。通过适当地设计事件处理程序和更改侦听器,JavaFX 屏幕可以被赋予非常复杂的行为。
十一、模型、视图和控制器
这本书的最后一章讨论了如何将一个程序的计算相关的职责和它的表示相关的职责分开的问题。您可能还记得第一章在创建银行演示的第二版时首次解决了这个问题。版本 2 包含了新的类BankClient,它包含了表示责任,以及Bank,它包含了计算责任。
原来第一章走的还不够远。本章认为程序也应该将计算类和表现类隔离开来,为此你需要类在它们之间进行调解。计算、表示和中介类被称为模型、视图和控制器。本章介绍了 MVC 模式,这是在程序中组织这些类的首选方式。本章还讨论了使用 MVC 模式的优点,并给出了几个例子。
MVC 设计规则
一个程序通常有两个感兴趣的领域。首先是它如何与用户交互,请求输入和呈现输出。第二个是它如何从输入计算输出。经验表明,设计良好的程序会将这两个领域的代码分开。输入/输出部分被称为视图。计算部分被称为模型。
这一思想通过设计规则“将模型与视图分离”来表达在面向对象的环境中,这条规则意味着应该有专门用于计算结果的类,以及专门用于呈现结果和请求输入的类。此外,不应该有一个两者都做的类。
视图和模型有不同的关注点。该视图需要是一个视觉上有吸引力的界面,易于学习和使用。该模型应该是实用和高效的。由于这些问题没有共同点,因此视图和模型应该相互独立地设计。因此,模型不应该知道视图如何显示结果,视图也不应该知道它所显示的值的含义。
为了保持这种隔离,程序必须有连接模型和视图的代码。这个代码叫做控制器。控制器理解程序的整体功能,并在视图和模型之间进行协调。它知道模型的哪些方法对应于每个视图请求,以及在视图中显示模型的哪些值。
这些想法被编入下面的模型-视图-控制器设计规则,或者被称为 MVC 规则。这个规则是单一责任规则的一个特例,它断言一个类不应该组合模型、视图或控制器责任。
模型-视图-控制器规则
一个程序应该被设计成它的模型、视图和控制器代码属于不同的类。
例如,考虑版本 18 银行演示。Bank类是模型的一部分,它依赖的类和接口也是模型的一部分。因为这些类不包含视图或控制器代码,所以它们满足 MVC 规则。另一方面,BankClient和InputCommands类不满足 MVC 规则,因为它们都结合了视图和控制器代码。清单 11-1 和 11-2 中说明了这种情况。
清单 11-1 显示了InputCommands中定义常量DEPOSIT的部分。lambda 表达式包含视图代码(对扫描器和System.out.print的调用)以及控制器代码(对bank.deposit的调用)。
public enum InputCommands implements InputCommand {
...
DEPOSIT("deposit", (sc, bank, current)->{
System.out.print("Enter deposit amt: ");
int amt = sc.nextInt();
bank.deposit(current, amt);
return current;
}),
...
}
Listing 11-1A Fragment of the Version 18 InputCommands Enum
清单 11-2 显示了BankClient类的开头和它的两个方法。值得称赞的是,该类主要包含视图代码。唯一的问题是它的两个变量,bank和current,引用了这个模型。尽管该类没有以任何有意义的方式使用这些变量,但它们不属于视图。
public class BankClient {
private Scanner scanner;
private boolean done = false;
private Bank bank;
private int current = 0;
...
private void processCommand(int cnum) {
InputCommand cmd = commands[cnum];
current = cmd.execute(scanner, bank, current);
if (current < 0)
done = true;
}
}
Listing 11-2A Fragment of the Version 18 BankClient Class
版本 19 的银行演示通过将这些类的控制器代码移动到新的类InputController中来纠正这些类的问题。清单 11-3 给出了它的一些代码。
public class InputController {
private Bank bank;
private int current = 0;
public InputController(Bank bank) {
this.bank = bank;
}
public String newCmd(int type, boolean isforeign) {
int acctnum = bank.newAccount(type, isforeign);
current = acctnum;
return "Your new account number is " + acctnum;
}
public String selectCmd(int acctnum) {
current = acctnum;
int balance = bank.getBalance(current);
return "Your balance is " + balance;
}
public String depositCmd(int amt) {
bank.deposit(current, amt);
return "Amount deposited";
}
...
}
Listing 11-3The Version 19 InputController Class
控制器对每个输入命令都有一个方法。视图将调用这些方法,提供适当的参数值。控制器负责对模型执行必要的操作。它还负责构造一个描述结果的字符串,并将其返回给视图。控制器还管理保存当前帐户的变量current。
清单 11-4 和 11-5 给出了BankClient和InputCommands的版本 19 代码。这些类构成了视图。他们用扫描仪输入,用System.out输出。BankClient将控制器传递给InputCommands,而InputCommands将所有与模型相关的活动委托给控制器。
public enum InputCommands implements InputCommand {
QUIT("quit", (sc, controller)->{
sc.close();
return "Goodbye!";
}),
NEW("new", (sc, controller)->{
printMessage();
int type = sc.nextInt();
boolean isforeign = requestForeign(sc);
return controller.newCmd(type, isforeign);
}),
SELECT("select", (sc, controller)->{
System.out.print("Enter acct#: ");
int num = sc.nextInt();
return controller.selectCmd(num);
}),
DEPOSIT("deposit", (sc, controller)->{
System.out.print("Enter deposit amt: ");
int amt = sc.nextInt();
return controller.depositCmd(amt);
}),
...
}
Listing 11-5The Version 19 InputCommands Enum
public class BankClient {
private Scanner scanner;
private InputController controller;
private InputCommand[] commands = InputCommands.values();
public BankClient(Scanner scanner, InputController cont) {
this.scanner = scanner;
this.controller = cont;
}
public void run() {
String usermessage = constructMessage();
String response = "";
while (!response.equals("Goodbye!")) {
System.out.print(usermessage);
int cnum = scanner.nextInt();
InputCommand cmd = commands[cnum];
response = cmd.execute(scanner, controller);
System.out.println(response);
}
}
...
}
Listing 11-4The Version 19 BankClient Class
主类BankProgram必须修改以适应视图和控制器类。它的代码出现在清单 11-6 中。这个类最好理解为既不属于模型、控制器,也不属于视图。相反,它的工作是创建和配置模型、控制器和视图类。清单 11-6 中的粗体代码突出显示了这些类的创建顺序。BankProgram首先创建模型对象(类型为Bank)。然后,它创建控制器,向其传递对模型的引用。最后,它创建视图,将一个引用传递给控制器。
public class BankProgram {
public static void main(String[] args) {
SavedBankInfo info = new SavedBankInfo("bank19.info");
Map<Integer,BankAccount> accounts = info.getAccounts();
int nextacct = info.nextAcctNum();
Bank bank = new Bank(accounts, nextacct);
...
InputController controller = new InputController(bank);
Scanner scanner = new Scanner(System.in);
BankClient client = new BankClient(scanner, controller);
client.run();
info.saveMap(accounts, bank.nextAcctNum());
}
}
Listing 11-6The Version 19 BankProgram Class
图 11-1 显示了描述这些模型、视图和控制器类之间关系的类图。注意,尽管视图和模型由多个类组成,但是有一个类作为配置的“主要”类。这种情况通常适用于所有的 MVC 设计。
图 11-1
基于 MVC 的银行演示的类图
从理论上讲,模型和视图之间的区别是很明显的:如果某样东西的功能与它的呈现方式无关,那么它就属于模型,如果它与模型无关,那么它就属于视图。然而,在实践中,做出这些区分可能需要仔细分析。银行演示提供了一些例子。
经常账户的概念就是一个例子。我之前说过它不应该是视图的一部分。但是它应该是控制器的一部分还是模型的一部分呢?答案取决于当前帐户是仅与特定视图相关,还是模型固有的。对我来说,关键是要意识到银行客户机的每个会话可能有不同的当前帐户,我不希望模型负责管理特定于会话的数据。这向我表明,当前帐户属于控制器,而不是模型。
再举一个例子,考虑一下BankClient分配给输入选项的数字。输入命令被分配一个从 0 到 7 的数字,帐户类型被分配一个从 1 到 3 的数字,所有权规范是 1 代表“国内”,2 代表“国外”视图负责为命令和国内/国外选择分配号码,但是模型决定帐户类型号码。为什么呢?
标准是输入值的含义是否与模型相关。如果模型不关心,那么视图应该负责确定值的含义。这是命令和所有权号的情况,因为模型永远看不到它们。另一方面,帐户类型由模型操纵,因此必须由模型决定。
一个模型的多个视图
将模型与视图分离的一个优点是,您可以创建使用相同模型的不同程序。例如,银行模型可以由一个面向客户的程序(例如,在线银行)、另一个面向银行员工的程序以及另一个面向银行高管的程序使用。要编写每个程序,只需编写视图和一个将视图与现有模型挂钩的控制器。
模型和视图之间的分离也使得修改视图变得更加容易,这样它就有了不同的用户界面。例如,BankClient可以修改为使用命令名而不是数字,或者支持语音命令,或者拥有基于 GUI 的界面。这最后一种选择将在本章后面讨论。
18 版银行演示有四个使用银行模型的程序:BankProgram、FBIClient、IteratorStatProgram和StreamStatProgram。后三个程序不满足 MVC 规则。它们都非常简单——它们没有输入,它们的输出只是打印一些测试查询的结果。用 MVC 重写他们的代码有意义吗?最简单的程序是StreamStatProgram,它有相关的类StreamAccountStats。这些类最初在第六章中讨论过。让我们重写它们,看看会发生什么。
清单 11-7 给出了StreamAccountStats的前两种方法。它主要是模型代码;唯一的问题是这些方法调用了System.out.println。
public class StreamAccountStats {
private Bank bank;
public StreamAccountStats(Bank b) {
bank = b;
}
public void printAccounts6(Predicate<BankAccount> pred) {
Stream<BankAccount> s = bank.stream();
s = s.filter(pred);
s.forEach(ba->System.out.println(ba));
}
public void printAccounts7(Predicate<BankAccount> pred) {
bank.stream()
.filter(pred)
.forEach(ba->System.out.println(ba));
}
...
}
Listing 11-7The Original StreamAccountStats Class
清单 11-8 显示了版本 19 的修订版,称为StreamStatModel。两个printAccounts方法变了。他们名字中的前缀“print”已经被重命名为“get”,以反映他们的返回类型现在是String而不是void。此外,他们对forEach方法的使用必须修改为使用reduce,这样它可以从对ba.toString的单独调用中创建一个单独的字符串。
public class StreamStatModel {
private Bank bank;
public StreamStatModel(Bank b) {
bank = b;
}
public String getAccounts6(Predicate<BankAccount> pred) {
Stream<BankAccount> s = bank.stream();
s = s.filter(pred);
Stream<String> t = s.map(ba->ba.toString());
return t.reduce("", (s1,s2)->s1 + s2 + "\n");
}
public String getAccounts7(Predicate<BankAccount> pred) {
return bank.stream()
.filter(pred)
.map(ba->ba.toString())
.reduce("", (s1,s2)->s1 + s2 + "\n");
}
...
}
Listing 11-8The Revised StreamStatModel Class
StreamStatProgram的原始代码出现在清单 11-9 中。它包含视图和控制器代码。控制器代码由调用模型方法组成。视图代码包括打印它们的结果。
public class StreamStatProgram {
public static void main(String[] args) {
...
StreamAccountStats stats = ...
Predicate<BankAccount> pred = ba -> ba.fee() == 0;
...
System.out.println("Here are the domestic accounts.");
stats.printAccounts6(pred);
System.out.println("Here are the domestic accounts
again.");
stats.printAccounts7(pred);
}
}
Listing 11-9The Original StreamStatProgram Class
银行演示的版本 19 包含视图类StreamStatView。它的代码,如清单 11-10 所示,调用控制器方法而不是模型方法。注意,视图不知道谓词,因为谓词引用了模型。
public class StreamStatView {
StreamStatController c;
public StreamStatView(StreamStatController c) {
this.c = c;
}
public void run() {
...
System.out.println("Here are the domestic accounts.");
System.out.println(c.getAccounts6());
System.out.println("Here are the domestic accounts
again.");
System.out.println(c.getAccounts7());
}
}
Listing 11-10The Version 19 StreamStatView Class
StreamStatController类出现在清单 11-11 中。它根据模型实现了三种视图方法中的每一种。它还创建了谓词。
public class StreamStatController {
private StreamStatModel model;
Predicate<BankAccount> pred = ba -> ba.fee() == 0;
public StreamStatController (StreamStatModel model) {
this.model = model;
}
public String getAccounts6() {
return model.getAccounts6(pred);
}
public String getAccounts7() {
return model.getAccounts7(pred);
}
...
}
Listing 11-11The Version 19 StreamStatController Class
最后,清单 11-12 给出了版本 19 StreamStatProgram类的代码。该类配置模型、视图和控制器,然后调用视图的run方法。
public class StreamStatProgram {
public static void main(String[] args) {
SavedBankInfo info = new SavedBankInfo("bank19.info");
Map<Integer,BankAccount> accounts = info.getAccounts();
int nextacct = info.nextAcctNum();
Bank bank = new Bank(accounts, nextacct);
StreamStatModel m = new StreamStatModel(bank);
StreamStatController c = new StreamStatController(m);
StreamStatView v = new StreamStatView(c);
v.run();
}
}
Listing 11-12The Version 19 StreamStatProgram Class
比较 MVC 版本的StreamStatProgram类和它们的原始代码。你可能会惊讶于 MVC 版本是多么的干净和有组织。虽然它比原始版本包含更多的代码,但每个单独的类都很短,很容易修改。寓意是,即使对于小程序,MVC 设计也是值得考虑的。
Excel 中的 MVC
Excel 是遵循 MVC 设计规则的商业程序的一个例子。该模型由电子表格的单元格组成。电子表格的每个图表都是模型的视图。图 11-2 显示了一个描述图表及其底层单元格的屏幕截图。
图 11-2
电子表格的模型和视图
Excel 在模型和视图之间保持严格的分离。细胞不知道图表的事。每个图表都是位于单元格顶部的“对象”。
创建 Excel 图表有两个方面。第一个方面是图表是什么样子的。Excel 具有指定不同图表类型、颜色、标签等的工具。这些工具对应于视图方法;它们让你让图表看起来更有吸引力,不管它代表什么数据。
第二个方面是图表显示什么数据。Excel 有一个名为“选择数据”的工具,用于指定图表的基本单元格。该工具对应于控制器。图 11-3 给出了图 11-2 的控制器窗口截图。“名称”文本字段指定包含图表标题的单元格;“Y 值”指定包含人口值的单元格;而“水平(类别)轴标签”指定包含年份的单元格。
图 11-3
图表的控制器
分离模型和视图提供了很大的灵活性。一个单元格区域可以是许多不同图表的模型,一个图表可以是许多不同单元格区域的视图。控制器将它们连接在一起。
JavaFX 视图和控制器
再次考虑出现在图 10-3 中的AccountCreationWindow类。这个类显示 JavaFX 控件,允许用户选择要创建的银行帐户的类型。但是,该类没有连接到银行模型。点击CREATE ACCT按钮除了改变标题标签的文字外没有任何作用。换句话说,这个程序纯粹是视图代码,这是完全合适的,因为 JavaFX 是用来创建视图的。
如何创建将视图连接到银行模型的控制器?在回答这个问题之前,让我们从一个简单的 JavaFX 例子开始,来说明这个问题。程序Count1显示一个包含两个按钮和一个标签的窗口,如图 11-4 所示。标签显示变量count的值。这两个按钮增加和减少计数。
图 11-4
Count1 程序的初始屏幕
清单 11-13 给出了Count1的代码。这段代码不符合 MVC 设计规则。该模型由变量count组成。updateBy方法更新计数(对模型的操作),但也改变标签的文本(对视图的操作)。
public class Count1 extends Application {
private static int count = 0;
private static Label lbl = new Label("Count is 0");
public void start(Stage stage) {
Button inc = new Button("Increment");
Button dec = new Button("Decrement");
VBox p = new VBox(8);
p.setAlignment(Pos.CENTER);
p.setPadding(new Insets(10));
p.getChildren().addAll(lbl, inc, dec);
inc.setOnAction(e -> updateBy(1));
dec.setOnAction(e -> updateBy(-1));
stage.setScene(new Scene(p));
stage.show();
}
private static void updateBy(int n) {
count += n; // model code
lbl.setText("Count is " + count); // view code
}
public static void main(String[] args) {
Application.launch(args);
}
}
Listing 11-13The Count1 Class
计数演示的版本 2 将代码分为模型、视图和控制器类。主类Count2负责创建这些类并将它们相互连接。它的代码出现在清单 11-14 中。
public class Count2 extends Application {
public void start(Stage stage) {
CountModel model = new CountModel();
CountController controller = new CountController(model);
CountView view = new CountView(controller);
Scene scene = new Scene(view.getRoot());
stage.setScene(scene);
stage.show();
}
public static void main(String[] args) {
Application.launch(args);
}
}
Listing 11-14The Count2 Class
这个类的结构类似于版本 19 的BankProgram和StreamStatProgram类的结构。首先,创建模型。然后创建控制器,并传入模型。然后创建视图,并传入控制器。
该类调用视图方法getRoot,该方法返回其节点层次结构的根。这个根被传递到Scene构造器中,然后传递到 stage(通过它的setScene方法)。
该模型由一个名为CountModel的类组成,其代码出现在清单 11-15 中。该类有一个保存当前计数的变量count,以及获取和更新计数的方法getCount和updateBy。
public class CountModel {
private int count = 0;
public void updateBy(int n) {
count += n;
}
public int getCount() {
return count;
}
}
Listing 11-15The CountModel Class
视图由一个名为CountView的类组成。它的代码出现在清单 11-16 中。
class CountView {
private Pane root;
public CountView(CountController cont) {
root = createNodeHierarchy(cont);
}
public Pane getRoot() {
return root;
}
private Pane createNodeHierarchy(CountController cont) {
Button inc = new Button("Increment");
Button dec = new Button("Decrement");
Label lbl = new Label("Count is 0");
VBox p = new VBox(8);
p.setAlignment(Pos.CENTER);
p.setPadding(new Insets(10));
p.getChildren().addAll(lbl, inc, dec);
inc.setOnAction(e -> {
String s = cont.incrementButtonPressed();
lbl.setText(s);
});
dec.setOnAction(e ->
lbl.setText(cont.decrementButtonPressed()));
return p;
}
}
Listing 11-16The CountView Class
大多数视图代码都致力于创建节点层次结构的普通任务。更有趣的是视图如何使用它的两个按钮处理程序与控制器交互。increment按钮处理程序调用控制器的incrementButtonPressed方法。这个方法做它需要做的事情(在本例中是告诉模型增加计数),然后返回一个字符串,供视图在其标签中显示。类似地,decrement按钮处理程序调用控制器的decrementButtonPressed方法,并显示其返回值。
请注意,这两个处理程序具有相同的结构。我编写了彼此不同的代码,只是为了说明不同的编码风格。
控制器类被命名为 CountController 。它的代码出现在清单 11-17 中。控制器负责将视图上的事件转换成模型上的动作,并将模型的返回值转换成视图上可显示的字符串。
class CountController {
private CountModel model;
public CountController(CountModel model) {
this.model = model;
}
public String incrementButtonPressed() {
model.updateBy(1);
return "Count is " + model.getCount();
}
public String decrementButtonPressed() {
model.updateBy(-1);
return "Count is " + model.getCount();
}
}
Listing 11-17The CountController Class
注意控制器如何在模型和视图之间进行协调,这使得视图不知道模型。视图知道“嘿,我的按钮被按了”,但是它不知道该怎么办。因此视图将这项工作委托给控制器。此外,视图同意显示控制器返回的任何值。
2003 年,一位苹果工程师在苹果开发者大会上唱了一首关于 MVC 的精彩歌曲,他的表演被录制下来留给后人。你可以在 YouTube 上搜索“MVC 歌曲”来找到这个视频当你观看表演时,你可能会被朗朗上口的旋律吸引。但要特别注意歌词,它简洁地传达了 MVC 的真正之美。
扩展 MVC 架构
本章到目前为止已经展示了三个 MVC 程序的例子:BankProgram(列表 11-6 )、StreamStatProgram(列表 11-12 )和Count2(列表 11-14 )。这些程序都有相似的架构,视图与控制器对话,控制器与模型对话。这种架构简单明了。
尽管这种体系结构可以很好地处理单视图程序,但对于具有多视图的程序却很糟糕。例如,考虑计数演示的版本 3,它向版本 2 的演示添加了第二个视图。图 11-5 显示了点击几下按钮后的程序截图。
图 11-5
Count3 程序的屏幕截图
这两个视图在窗口中都有一个窗格。第二个视图是“观察者”视图。它跟踪计数改变的次数,并显示计数是偶数还是奇数。但是观察者视图如何知道模型何时发生了变化呢?答案是使用观察者模式!模型需要广播计数视图所做的更改,以便观察者视图可以观察到它们。因此,模型需要修改为可观测的。
定义一个观察者接口,名为CountObserver。这个接口将有一个观察器方法update,它将新的计数推送给它的观察器。它的代码出现在清单 11-18 中。
public interface CountObserver {
public void update(int count);
}
Listing 11-18The CountObserver Interface
类CountModel需要管理一个观察者列表。它的updateBy方法将向列表上的观察者广播新的计数。清单 11-19 显示了这个类的最终变化。
public class CountModel {
private int count = 0;
private Collection<CountObserver> observers
= new ArrayList<>();
public void addObserver(CountObserver obs) {
observers.add(obs);
}
private void notifyObservers(int count) {
for (CountObserver obs : observers)
obs.update(count);
}
public void updateBy(int n) {
count += n;
notifyObservers(count);
}
public int getCount() {
return count;
}
}
Listing 11-19The CountModel Class
观察者的控制器将是模型观察者。当控制器收到来自模型的通知时,它将确定需要对其视图进行的更改,并将这些更改传递给视图。不幸的是,这种行为目前是不可能的,因为观察者控制器不知道它的视图是谁!为了解决这个问题,需要修改观察器视图及其控制器:控制器需要一个对视图的引用,视图需要有一个控制器可以调用的方法。
通过给控制器一个对它的视图的引用,观察器视图和它的控制器将相互引用。视图通过构造器注入获得它的引用。但是控制器不能,因为在创建控制器时视图还没有被创建。解决方案是让控制器通过方法注入获得对视图的引用。它定义了一个方法setView。当创建视图时,它可以调用控制器的setView方法,向控制器传递对自身的引用。
观察者视图定义了控制器要调用的方法updateDisplay。该方法有三个参数,对应于控制器想要传递给视图的三个值:标签的新消息,以及两个复选框的所需值。
清单 11-20 给出了控制器的代码。注意,控制器负责跟踪模型变化的次数,因为我认为这个值与模型无关。如果您不这么认为,您应该更改模型,以便它保留这些信息。
public class WatcherController
implements CountObserver {
private WatcherView view;
private int howmany = 0;
public WatcherController(CountModel model) {
model.addObserver(this);
}
// called by the view
public void setView(WatcherView view) {
this.view = view;
}
// called by the model
public void update(int count) {
howmany++;
boolean isEven = (count%2 == 0);
boolean isOdd = !isEven;
String msg = "The count has changed "
+ howmany + " times";
view.updateDisplay(msg, isEven, isOdd);
}
}
Listing 11-20The WatcherController Class
清单 11-21 给出了观察者视图的代码。它的构造器调用控制器的setView方法,从而建立视图和控制器之间的双向连接。updateDisplay方法设置视图的三个控件的值。请注意,视图不知道这些值的含义。
class WatcherView {
private Label lbl
= new Label("The count has not yet changed");
private CheckBox iseven
= new CheckBox("Value is now even");
private CheckBox isodd = new CheckBox("Value is now odd");
private Pane root;
public WatcherView(WatcherController controller) {
root = createNodeHierarchy();
controller.setView(this);
}
public Pane root() {
return root;
}
public void updateDisplay(String s, boolean even,
boolean odd) {
lbl.setText(s);
iseven.setSelected(even);
isodd.setSelected(odd);
}
private Pane createNodeHierarchy() {
iseven.setSelected(true);
isodd.setSelected(false);
VBox p = new VBox(8);
p.setAlignment(Pos.CENTER);
p.setPadding(new Insets(10));
p.getChildren().addAll(lbl, iseven, isodd);
return p;
}
}
Listing 11-21The WatcherView Class
主程序Count3将两个视图配置到一个窗口中。为了处理多个视图,代码将两个视图的节点层次结构放在一个HBox窗格中。清单 11-22 给出了代码。合并这些观点的陈述用粗体表示。
public class Count3 extends Application {
public void start(Stage stage) {
CountModel model = new CountModel();
// the first view
CountController ccontroller
= new CountController(model);
CountView cview = new CountView(ccontroller);
// the second view
WatcherController wcontroller
= new WatcherController(model);
WatcherView wview = new WatcherView(wcontroller);
// Display the views in a single two-pane window.
HBox p = new HBox();
BorderStroke bs = new BorderStroke(Color.BLACK,
BorderStrokeStyle.SOLID,
null, null, new Insets(10));
Border b = new Border(bs);
Pane root1 = cview.root(); Pane root2 = wview.root();
root1.setBorder(b); root2.setBorder(b);
p.getChildren().addAll(root1, root2);
stage.setScene(new Scene(p));
stage.show();
}
public static void main(String[] args) {
Application.launch(args);
}
}
Listing 11-22The Count3 Class
MVC 模式
虽然WatcherController需要做模型观察者,但是CountController不需要。因为它是唯一可以改变模型的视图,所以它确切地知道模型何时以及如何改变。至少目前是这样。但是如果程序碰巧添加了另一个可以改变模型的视图呢?那么CountView显示的值可能会不正确。这种错误很难被发现。如果我们希望CountView总是显示当前的计数,而不管其他视图存在什么,那么CountController也需要成为一个模型观察者。
要成为模型观察者,CountController必须实现update方法。它的代码update将构造一个描述新计数的消息,并将其发送给视图。因此,按钮处理程序方法incrementButtonPressed和decrementButtonPressed现在应该是无效的,因为它们不再负责构造消息。此外,控制器需要一个对视图的引用。因此,它使用与WatcherController相同的技术实现方法setView。清单 11-23 给出了修改后的代码。
class CountController implements CountObserver {
private CountModel model;
private CountView view;
public CountController(CountModel model) {
this.model = model;
model.addObserver(this);
}
// Methods called by the view
public void setView(CountView view) {
this.view = view;
}
public void incrementButtonPressed() {
model.updateBy(1);
}
public void decrementButtonPressed() {
model.updateBy(-1);
}
// Method called by the model
public void update(int count) {
view.setLabel("Count is " + count);
}
}
Listing 11-23The Revised CountController Class
类CountView需要被修改以适应其控制器的变化。视图构造器调用控制器的方法setView,视图实现方法setLabel供控制器调用。代码出现在清单 11-24 中。
class CountView {
private Label lbl = new Label("Count is 0");
private Pane root;
public CountView(CountController controller) {
root = createNodeHierarchy(controller);
controller.setView(this);
}
public Pane root() {
return root;
}
public void setLabel(String s) {
lbl.setText(s);
}
private Pane createNodeHierarchy(CountController cont) {
Button inc = new Button("Increment");
Button dec = new Button("Decrement");
... // create the node hierarchy, having root p
inc.setOnAction(e -> cont.incrementButtonPressed());
dec.setOnAction(e -> cont.decrementButtonPressed());
return p;
}
}
Listing 11-24The revised CountView Class
为了理解这些变化的影响,考虑当点击Increment按钮时计数视图和计数控制器现在会发生什么。
-
视图调用控制器的
incrementButtonPressed方法。 -
该方法调用模型的
updateBy方法。 -
该方法更新计数并调用
notifyObservers,后者调用控制器的update方法。 -
该方法格式化视图显示的字符串,并调用视图的
setLabel方法。 -
该方法将其标签的文本修改为当前计数。
这个方法调用序列与在Count2控制器中的效果相同。不同之处在于,控制器调用一对 void 方法,而不是返回值的单个方法。这种增加的复杂性对于保证在所有视图中更新计数是必要的,无论哪个视图进行更新。
控制器应该是观察者的观点是 MVC 设计模式的基础。这种模式断言应用程序的结构应该类似于Count3。特别是:模型应该是可观测的,所有控制器都应该是模型观测器;控制器直接与模型对话;每个视图/控制器对可以直接相互对话。该模式由图 11-6 的类图表示。
图 11-6
MVC 模式
使用 MVC 模式的通信工作如下:
-
视图上的一个动作(比如一个按钮点击)被传递给它的控制器。
-
控制器将该动作转换为模型上的方法调用。
-
如果该方法调用是对数据的请求,那么模型将请求的数据直接返回给控制器,控制器将数据转发给它的视图。
-
如果这个方法调用导致了模型的改变,模型会通知它的观察者。
-
作为模型观察者,每个控制器决定更新是否与其视图相关。如果是这样,它调用适当的视图方法。
许多 GUI 应用程序依赖 MVC 模式来同步它们的视图。为了说明这一点,我将使用我电脑上 MacOS 界面中的两个例子,但是在 Windows 或 Linux 中也可以找到类似的例子。
对于第一个例子,考虑文件管理器。打开两个文件管理器窗口,让它们显示同一文件夹的内容。转到其中一个窗口,重命名一个文件。您将在另一个窗口中看到该文件被自动重命名。现在打开一个应用程序,创建一个文件,并将其保存到该文件夹中。您将看到该文件自动出现在两个文件管理器窗口中。
对于第二个例子,考虑文本文档和它的 pdf 版本之间的对应关系。在文本编辑器中打开文档。将文档保存为 pdf 文件,并在 pdf 查看器中打开 pdf 版本。更改文本文档,并将其重新保存为 pdf。pdf 查看器中显示的版本将自动更改。
在这两个例子中,计算机的文件系统充当模型。文件管理器窗口和 pdf 查看器是文件系统的视图。每个视图都有一个观察文件系统的控制器。当文件系统发生变化时,它会通知它的观察者。当文件管理器控制器收到通知时,它会确定这些更改是否会影响正在显示的文件,如果会,则更新其视图。当 pdf controller 收到通知时,它会确定这些更改是否会影响它正在显示的文件,如果会,它会告诉视图重新加载文件的新内容。
MVC 和银行演示
为银行演示开发基于 JavaFX 的接口的时机终于到来了。该界面将有一个包含三个视图的窗口:用于创建新帐户的视图;用于管理所选帐户的视图;以及显示所有帐户信息的视图。图 11-7 显示了三视图的截图。
图 11-7
银行演示的 JavaFX 接口
您已经遇到了标题为“创建新银行帐户”的视图用户选择所需的帐户类型,指定帐户是国内所有还是国外所有,然后单击按钮。创建了一个新帐户。
在标题为“访问现有帐户”的视图中,用户通过在顶部窗格的文本字段中输入帐号并单击“选择帐户”按钮来指定当前帐户。然后,帐户余额会出现在其下方的文本字段中,底部窗格中的国内/国外选择框会被设置为帐户的相应值。选择帐户后,用户可以向其存款、申请贷款授权或更改其所有权状态。自始至终,帐户余额始终保持最新。当发生存款或应计利息时,余额会更新。
标题为“管理所有帐户”的视图使用其toString方法的输出在文本区域中显示所有帐户。该视图还有一个用于执行银行的addInterest方法的按钮。帐户显示会自动保持最新。每当银行帐户的状态发生变化时,列表就会刷新。
该程序是使用 MVC 模式构建的。它的主类叫做FxBankProgram,有三个视图类和三个控制器类。Bank类就是模型。回想一下Bank在第十章中被修改为一个可观测值(见清单 10-18 ),不需要进一步修改。以下小节将研究FxBankProgram和每个视图/控制器对。
fxbank 程序类
这个类将视图配置到一个 JavaFX 窗口中。清单 11-25 给出了代码。JavaFX Application类除了start之外还有两个方法init和stop。launch方法首先调用init,然后是start,然后是stop。init的目的是初始化应用所需的值。这里,init为三个视图中的每一个创建节点层次结构,并将它们的根保存在变量root1、root2和root3中。它还创建了模型和三个控制器。stop方法保存银行的状态。
public class FxBankProgram extends Application {
private SavedBankInfo info =
new SavedBankInfo("bank19.info");
private Map<Integer,BankAccount> accounts =
info.getAccounts();
Bank bank = new Bank(accounts, info.nextAcctNum());
private Pane root1, root2, root3;
public void start(Stage stage) {
VBox left = new VBox();
left.getChildren().addAll(root1, root2);
HBox all = new HBox(left, root3);
stage.setScene(new Scene(all));
stage.show();
}
public void init() {
Auditor aud = new Auditor(bank);
bank.addObserver(BankEvent.DEPOSIT,
(event,ba,amt) -> {
if (amt > 10000000)
bank.makeSuspicious(ba.getAcctNum());
});
CreationController c1 = new CreationController(bank);
AllController c2 = new AllController(bank);
InfoController c3 = new InfoController(bank);
CreationView v1 = new CreationView(c1);
AllView v2 = new AllView(c2);
InfoView v3 = new InfoView(c3);
BorderStroke bs = new BorderStroke(Color.BLACK,
BorderStrokeStyle.SOLID,
null, null, new Insets(10));
Border b = new Border(bs);
root1 = v1.root(); root2 = v2.root(); root3 = v3.root();
root1.setBorder(b); root2.setBorder(b);
root3.setBorder(b);
}
public void stop() {
info.saveMap(accounts, bank.nextAcctNum());
}
public static void main(String[] args) {
Application.launch(args);
}
}
Listing 11-25The FxBankProgram Class
创建帐户视图
“创建帐户”视图类称为CreationView。它的代码出现在清单 11-26 中。代码类似于第九章和第十章中的AccountCreationWindow类,除了它现在有一个控制器与之对话。
public class CreationView {
private Pane root;
private Label title = new Label("Create a New Bank Acct");
public CreationView(CreationController controller) {
controller.setView(this);
root = createNodeHierarchy(controller);
}
public Pane root() {
return root;
}
public void setTitle(String msg) {
title.setText(msg);
}
private Pane createNodeHierarchy(CreationController cont) {
... // Create the hierarchy as in Listing 9-9\. Root is p1.
btn.addEventHandler(ActionEvent.ACTION, e -> {
cont.buttonPressed(chbx.getSelectionModel()
.getSelectedIndex(),
ckbx.isSelected());
String foreign = ckbx.isSelected() ? "Foreign " : "";
String acct = chbx.getValue();
title.setText(foreign + acct + " Account Created");
});
return p1;
}
}
Listing 11-26The CreationView Class
视图通过按钮处理程序与控制器对话。处理程序调用控制器的buttonPressed方法,将选择框和复选框的值传递给它。创建帐户后,控制器将调用视图的setTitle方法,向其传递要显示的消息。
控制器叫做CreationController。它的代码出现在清单 11-27 中。它的buttonPressed方法调用银行的newAccount方法来创建账户。
public class CreationController implements BankObserver {
private Bank bank;
private CreationView view;
public CreationController(Bank bank) {
this.bank = bank;
bank.addObserver(BankEvent.NEW, this);
}
// methods called by the view
void setView(CreationView view) {
this.view = view;
}
public void buttonPressed(int type, boolean isforeign) {
bank.newAccount(type+1, isforeign);
}
// method called by the model
public void update(BankEvent e, BankAccount ba, int amt) {
view.setTitle("Account " + ba.getAcctNum()
+ " created");
}
}
Listing 11-27The CreationController Class
像所有遵循 MVC 模式的控制器一样,控制器是一个模型观察者。回想一下第十章Bank支持四个事件。控制器向银行注册自己为新事件的观察者。当控制器接收到一个更新通知时,它为视图构建一个要显示的消息,并调用视图的setTitle方法。
帐户信息视图
“账户信息”视图类称为InfoView。它的代码出现在清单 11-28 中。
public class InfoView {
private Pane root;
private TextField balfld = createTextField(true);
private ChoiceBox<String> forbx = new ChoiceBox<>();
public InfoView(InfoController controller) {
controller.setView(this);
root = createNodeHierarchy(controller);
}
public Pane root() {
return root;
}
public void setBalance(String s) {
balfld.setText(s);
}
public void setForeign(boolean b) {
String s = b ? "Foreign" : "Domestic";
forbx.setValue(s);
}
private Pane createNodeHierarchy(InfoController cont) {
... // Create the hierarchy, with p1 as the root.
depbtn.setOnAction(e ->
controller.depositButton(depfld.getText()));
loanbtn.setOnAction(e ->
respfld.setText(controller.loanButton(
loanfld.getText())));
forbtn.setOnAction(e ->
controller.foreignButton(forbx.getValue()));
selectbtn.setOnAction(e ->
controller.selectButton(selectfld.getText()));
return p1;
}
}
Listing 11-28The InfoView Class
该视图有四个按钮,每个按钮的处理程序调用不同的控制器方法。请注意,发送到控制器方法的值是字符串,即使这些值表示数字。控制器负责将一个值转换成适当的类型,因为它知道如何使用该值。
loan authorization 按钮与其他按钮不同,它从模型中请求一个值。因此它的控制器方法不是无效的。该视图在其“贷款响应”文本字段中显示返回值。
视图的控制器叫做InfoController。它的代码出现在清单 11-29 中。它对视图的每个按钮都有一个方法;每个方法都为其按钮在模型上执行必要的操作。例如,depositButton方法调用银行的deposit方法。selectButton方法检索当前帐户的BankAccount对象,并告诉视图设置余额文本字段和所有权选择框的显示值。
public class InfoController implements BankObserver {
private Bank bank;
private int current = 0;
private InfoView view;
public InfoController(Bank bank) {
this.bank = bank;
bank.addObserver(BankEvent.DEPOSIT, this);
bank.addObserver(BankEvent.INTEREST, this);
bank.addObserver(BankEvent.SETFOREIGN, this);
}
// methods called by the view
public void setView(InfoView view) {
this.view = view;
}
public void depositButton(String s) {
int amt = Integer.parseInt(s);
bank.deposit(current, amt);
}
public String loanButton(String s) {
int loanamt = Integer.parseInt(s);
boolean result = bank.authorizeLoan(current, loanamt);
return result ? "APPROVED" : "DENIED";
}
public void foreignButton(String s) {
boolean b = s.equals("Foreign") ? true : false;
bank.setForeign(current, b);
}
public void selectButton(String s) {
current = Integer.parseInt(s);
view.setBalance(
Integer.toString(bank.getBalance(current)));
String owner = bank.getAccount(current).isForeign() ?
"Foreign" : "Domestic";
view.setForeign(bank.isForeign(current));
}
// method called by the model
public void update(BankEvent e, BankAccount ba, int amt) {
if (e == BankEvent.SETFOREIGN &&
ba.getAcctNum() == current)
view.setForeign(ba.isForeign());
else if (e == BankEvent.INTEREST ||
ba.getAcctNum() == current)
view.setBalance(
Integer.toString(bank.getBalance(current)));
}
}
Listing 11-29The InfoController Class
控制器将自己注册为三个银行事件的观察者:DEPOSIT、INTEREST和SETFOREIGN。它的update方法检查它的第一个参数,以确定哪个事件导致了更新。对于利息事件,控制器获取当前账户的余额,并将其发送给视图的setBalance方法。对于DEPOSIT或SETFOREIGN事件,控制器检查受影响的账户是否是当前账户。如果是,它将获取当前帐户的余额(或所有权)并将其发送给视图。
所有帐户视图
“所有帐户”视图类称为AllView。清单 11-30 给出了它的代码。Add Interest按钮的处理程序简单地调用控制器的interestButton方法。当控制器决定刷新账户显示时,它调用视图的setAccounts方法。
public class AllView {
private Pane root;
TextArea accts = new TextArea();
public AllView(AllController controller) {
controller.setView(this);
root = createNodeHierarchy(controller);
}
public Pane root() {
return root;
}
public void setAccounts(String s) {
accts.setText(s);
}
private Pane createNodeHierarchy(AllController cont) {
accts.setPrefColumnCount(22);
accts.setPrefRowCount(9);
Button intbtn = new Button("Add Interest");
intbtn.setOnAction(e -> cont.interestButton());
VBox p1 = new VBox(8);
p1.setAlignment(Pos.TOP_CENTER);
p1.setPadding(new Insets(10));
Label title = new Label("Manage All Accounts");
double size = title.getFont().getSize();
title.setFont(new Font(size*2));
title.setTextFill(Color.GREEN);
p1.getChildren().addAll(title, accts, intbtn);
return p1;
}
}
Listing 11-30The AllView Class
该视图在文本框中显示帐户列表。这种设计决策的问题是不能单独更新单个帐户值。因此,setAccounts方法必须用一个新的列表替换整个列表。接下来的两个部分将研究能够产生更好的实现的其他控件。
控制器叫做AllController。它的代码出现在清单 11-31 中。控制器是所有四个Bank事件的观察者。每当任何类型的事件发生时,控制器通过调用方法refreshAccounts来刷新显示的账户。这个方法遍历银行账户,并创建一个字符串来追加它们的toString值。然后,它将这个字符串发送到视图。
public class AllController implements BankObserver {
private Bank bank;
private AllView view;
public AllController(Bank bank) {
this.bank = bank;
bank.addObserver(BankEvent.NEW, this);
bank.addObserver(BankEvent.DEPOSIT, this);
bank.addObserver(BankEvent.SETFOREIGN, this);
bank.addObserver(BankEvent.INTEREST, this);
}
// methods called by the view
public void setView(AllView view) {
this.view = view;
refreshAccounts(); // initially populate the text area
}
public void interestButton() {
bank.addInterest();
}
// method called by the model
public void update(BankEvent e, BankAccount ba, int amt) {
refreshAccounts();
}
private void refreshAccounts() {
StringBuffer result = new StringBuffer();
for (BankAccount ba : bank)
result.append(ba + "\n");
view.setAccounts(result.toString());
}
}
Listing 11-31The AllController Class
可观察列表视图
使用文本区域来实现所有帐户的列表是不令人满意的:它看起来很糟糕,即使单个帐户发生变化,也需要完全刷新。JavaFX 有一个控件ListView,比较令人满意。图 11-8 显示了它在“所有账户”视图中的截图。
图 11-8
管理所有帐户屏幕
列表视图和文本区域的区别在于列表视图显示 Java List对象的内容。列表视图的每一行都对应列表中的一个元素,并显示调用该元素的toString方法的结果。
类AllView2和AllController2重写了AllView和AllController以使用ListView控件。清单 11-32 给出了 AllView2 的代码,新代码以粗体显示。
public class AllView2 {
private Pane root;
ListView<BankAccount> accts = new ListView<>();
public AllView2(AllController2 controller) {
root = createNodeHierarchy(controller);
accts.setItems(controller.getAccountList());
}
...
}
Listing 11-32The AllView2 Class
只有两行新代码。第一行创建一个新的ListView对象。第二行指定它应该显示的列表,在本例中是由控制器的getAccountList方法返回的列表。
AllView2不再需要方法来更新它的ListView控件。相反,控件及其列表通过观察者模式连接。这份名单是可观察的。ListView控件是其列表的观察者。当控制器更改列表时,列表会通知控件,控件会自动更新自身。
这个特性简化了视图与控制器的交互。控制器不再需要明确地管理视图更新。当模型通知控制器账户已经改变时,控制器只需要修改它的列表。视图计算出其余部分。
控制器的代码命名为AllController2,出现在清单 11-33 中。变量accounts保存着BankAccount对象的可观察列表。JavaFX 类FXCollections包含几个用于创建可观察对象的静态工厂方法;方法observableArrayList创建了一个包装了ArrayList对象的可观察列表。
public class AllController2 implements BankObserver {
private Bank bank;
private ObservableList<BankAccount> accounts
= FXCollections.observableArrayList();
public AllController2(Bank bank) {
this.bank = bank;
bank.addObserver(BankEvent.NEW, this);
bank.addObserver(BankEvent.DEPOSIT, this);
bank.addObserver(BankEvent.SETFOREIGN, this);
bank.addObserver(BankEvent.INTEREST, this);
for (BankAccount ba : bank)
accounts.add(ba); // initially populate the list
}
public ObservableList<BankAccount> getAccountList() {
return accounts;
}
public void interestButton() {
bank.addInterest();
}
public void update(BankEvent e, BankAccount ba, int amt) {
if (e == BankEvent.INTEREST)
refreshAllAccounts();
else if (e == BankEvent.NEW)
accounts.add(ba);
else {
int i = accounts.indexOf(ba);
refreshAccount(i);
}
}
private void refreshAccount(int i) {
// a no-op, to force the list to notify its observer
accounts.set(i, accounts.get(i));
}
private void refreshAllAccounts() {
for (int i=0; i<accounts.size(); i++)
refreshAccount(i);
}
}
Listing 11-33The AllController2 Class
控制器观察四种事件,它的update方法根据事件执行不同的动作。对于一个INTEREST事件,控制器调用refreshAllAccounts,这样视图将重新显示列表中的每个元素。对于NEW事件,控制器将新的银行账户添加到列表中。对于DEPOSIT和SETFOREIGN事件,控制器刷新具有指定账号的列表元素。
注意,DEPOSIT或SETFOREIGN事件改变了列表元素的状态,但实际上并没有改变列表。这是一个问题,因为列表不会通知视图,除非它改变了。refreshAccount方法通过设置列表元素的新值与旧值相同来解决这个问题。尽管该操作对列表元素没有影响,但列表会将其识别为对列表的更改,并通知视图重新显示该元素。
可观察的表格视图
ListView控件在单个单元格中显示每个BankAccount对象的信息。如果帐户信息可以显示为一个表格,每个值都在自己的单元格中,这在视觉上会更令人愉快。这就是TableView控制的目的。图 11-9 显示了修改后使用TableView控件的视图截图。
图 11-9
以表格视图的形式管理所有帐户
这个视图被命名为 AllView3 ,它的代码出现在清单 11-34 中。变量accts现在的类型为TableView。一个TableView控件观察一个列表,与ListView相同。它的方法setItems将控件与列表连接起来。因为机制与ListView控制器完全相同,AllView3可以像AllView2一样使用控制器AllController2。
public class AllView3 {
private Pane root;
TableView<BankAccount> accts = new TableView<>();
public AllView3(AllController2 controller) {
root = createNodeHierarchy(controller);
TableColumn<BankAccount,Integer> acctnumCol
= new TableColumn<>("Account Number");
acctnumCol.setCellValueFactory(p -> {
BankAccount ba = p.getValue();
int acctnum = ba.getAcctNum();
Property<Integer> result
= new SimpleObjectProperty<>(acctnum);
return result;
});
TableColumn<BankAccount,Integer> balanceCol
= new TableColumn<>("Balance");
balanceCol.setCellValueFactory(p ->
new SimpleObjectProperty<>
(p.getValue().getBalance()));
TableColumn<BankAccount,String> foreignCol
= new TableColumn<>("Owner Status");
foreignCol.setCellValueFactory(p -> {
boolean isforeign = p.getValue().isForeign();
String owner = isforeign ? "Foreign" : "Domestic";
return new SimpleObjectProperty<>(owner);
});
accts.getColumns().addAll(acctnumCol, balanceCol,
foreignCol);
accts.setItems(controller.getAccountList());
accts.setPrefSize(300, 200);
}
...
}
Listing 11-34The AllView3 Class
TableView和ListView的区别在于一个TableView控件有一个TableColumn对象的集合。方法getColumns返回这个集合。
一个TableColumn对象有一个头字符串,它被传递给它的构造器。一个TableColumn对象也有一个“单元格值工厂”该对象的参数是一个方法,它计算给定列表元素的单元格的显示值。该方法的参数p表示可观察列表的一个元素,这里是一个包装了BankAccount的对象。它的方法getValue返回被包装的BankAccount对象。SimpleObjectProperty类从它的参数对象中创建一个属性。
例如,考虑清单 11-34 中的第一个 lambda 表达式,它计算列acctnumCol的值。
p -> {
BankAccount ba = p.getValue();
int acctnum = ba.getAcctNum();
Property<Integer> result =
new SimpleObjectProperty<>(acctnum);
return result;
}
这个 lambda 表达式解开 p,从解开的银行帐户中提取帐号,将值包装为属性,并返回它。λ表达式可以更简洁地表达如下:
p -> new SimpleObjectProperty<>(p.getValue().getAcctNum())
摘要
MVC 设计规则声明程序中的每个类都应该有模型、视图或者控制器的职责。以这种方式设计程序可能需要纪律。您可能需要编写三个类来执行任务,而不是编写一个单独的类,以便将任务的模型、视图和控制器方面分开。尽管创建这些类无疑需要更多的努力,但它们带来了显著的好处。分离的关注点使程序更加模块化,更容易修改,因此更符合基本的设计原则。
MVC 模式描述了一种组织模型、视图和控制器类的有效方法。根据该模式,一个程序将有一个模型,可能还有几个视图。每个视图都有自己的控制器,并使用控制器作为中介来帮助它与模型通信。每个控制器都有一个对其视图和模型的引用,因此它可以向模型发送视图请求,并向视图发送模型更新。然而,这个模型对控制器和视图一无所知。相反,它通过观察者模式与他们交流。
MVC 模式在模型、控制器和视图之间编排了一段复杂的舞蹈。这个舞蹈的目的是支持程序的灵活性和可修改性。特别地,视图是相互独立的;您可以在 MVC 程序中添加和删除一个视图,而不会影响其他视图。
这种灵活性具有巨大的价值。本章给出了几个基于 MVC 的商业软件的例子——比如 Excel、pdf viewers 和文件管理器——并描述了由于它们的 MVC 架构而成为可能的特性。
尽管本章对 MVC 模式给予了热情的支持,但现实是该模式并没有一个统一的定义。本章给出的定义只是用来组织模型、视图和控制器的几种方法之一。然而,不管它们有什么不同,所有 MVC 定义的中心特征都是它们对观察者模式的使用:*控制器向模型发出更新请求,模型通知它的观察者由此产生的状态变化。*我更喜欢使用控制器作为模型观察者,但是也可以使用视图,甚至是视图和控制器的组合。对于如何将视图连接到它的控制器,也有不同的方法。
正如本书中所有的设计模式一样,总有一些折衷要做。一个好的设计师会调整 MVC 组件之间的连接,以适应给定程序的需要。一般来说,你对 MVC 模式的工作方式和原因理解得越深,你就有越多的自由来做出调整,这将使你得到最好的设计。