富客户端应用以丰富的界面展现及良好的交互性,重新回到了人们的视野中。但客户端的事件驱动性,在带来快速交互与反馈的同时,也带来了设计上的复杂性。当 Web 应用开发常用的 MVC 模式应用于富客户端应用时,总会觉得无法得心应手。
设计一个包含 UI 的客户端,通常需要考虑三个方面的问题:
- UI 的状态
- 逻辑
- 数据
这里我们说的“逻辑”是指 UI 控制逻辑,而不是业务逻辑。在富客户端应用中,这三个基本元素往往是紧密耦合的,如图 1 所示。
图 1. 富客户端应用结构
另外,UI 组件的创建和初始化需要环境的支持,控制逻辑与状态紧密耦合,事件的触发需要人为的操作,因此测试 UI 组件一般使用自动化工具来模拟键盘和鼠标操作,无法进行单元测试,如单独测试控制逻辑。这样会加重功能测试负担,并且会与 UI 的结构 / 层次紧密耦合,轻微的 UI 修改也会使测试用例失效。更重要的是,这样的测试无法做到单元测试的细粒度,代码分支和条件很难覆盖全。这也正是富客户端单元测试的难点。为了解决这个问题,我们需要一个更好的富客户端应用设计。
2004 年 Martin Fowler 提出了 Presentation Model ( 以下简称 PM 模式 ) ,用于改善用户界面设计中状态、逻辑及数据对 UI 组件本身的耦合。使用 PM 模式的富客户端应用设计如图 2 所示。
图 2. 使用 PM 模式的富客户端应用设计
从图 2 可以看出,PM 模式中增加了一种新的对象—— View Model,将 UI 组件中保存的状态、逻辑和数据抽出,在 View Model 中实现;同时将 UI 组件中的状态和数据与 View Model 中保存的状态和数据进行同步复制。PM 模式中一个很重要的理念是 View Model 对 UI 组件没有任何的依赖,甚至根本不需要知道 UI 组件的存在。UI 组件对 View Model 是感知的,可以紧密耦合。
View Model 是一个 Pure Object,在 Java 语言中可以是一个 Java Bean。与其他 UI 层的设计模式不同,PM 模式将彼此相关的 UI 状态及状态的控制逻辑封装在一起,并从与环境或平台相关的 UI 控件中抽出。这部分代码脱离 UI 控件后,可以利用依赖注入将其与外部环境隔离,这时 View Model 就成为了一个低耦合度,并且非常容易测试的对象。
对 View Model 的测试不仅是控制逻辑上的测试,同时可以对状态进行测试验证,而这往往是一般 UI 测试所做不到的。可以想象一下,当用户在界面输入了一些非法的数据,保存按钮这时应当为 Disable 状态。这样的测试对于一个富客户端应用来说非常必要,但很难实现自动化,利用 PM 模式,这样的测试将变成一个简单的对 View Model 状态的 Assert。
更多 Presentation Model 模式的介绍请参考 这里。
我们已经了解了什么是 PM 模式,但是从上面的介绍可以感觉到,PM 仅仅是一种设计上的理念,在实现时还有诸多的困难,比如如何复制状态和数据等问题。
在以往的实践中,这往往是一个需要大量重复代码,从一个对象向另外一个对象拷贝数据的过程。这样的代码需要监听 UI 控件及模型对象的事件,并触发数据复制的过程。实现这个过程非常枯燥、容易出错,而且与 UI 控件紧密耦合 (UI 事件的监听 ),难以测试。因此,PM 模式的实现不是仅仅在设计上需要改变,而且需要基础的服务来简化实现的难度,这个服务就是 Data Binding ---- 数据绑定。
数据绑定可以在数据发生变化时,将其复制到目标数据对象中。这个复制过程可以是单向的,也可以是双向的。SWT 提供了一组针对 Java Bean 与 SWT/JFace 控件的数据绑定服务。其实 SWT 中的数据绑定服务并不是专门针对 SWT/JFace 实现的,这个实现可以用于任何两个 PO 对象的绑定 ( 后面的“更近一步”的例子中,我们将会看到应用 ) 。SWT/JFace 数据绑定的教程请参见 JFace Data Binding - Tutorial。
下面我们参考一个简单的例子来了解如何用 SWT 的数据绑定来实现 PM 模式。
我们的例子很简单,需要实现一个购物支付的界面,需求如下。
1. 可以选择用现金方式支付或者会员方式支付。
2. 当选择现金时,需要显示出支付的金额,实际用户支付的金额,及找零的金额。
- 其中用户实际支付的现金金额为输入项,其他为只读项。并且当输入实际支付金额时,需要及时显示计算出的找零数。
3. 当选择会员支付,并且会员预存余额足够支付时,显示会员预存剩余的金额及需要支付的金额。
4. 当选择会员支付,并且会员预存余额不足以支付时,显示会员预存余额,需要支付的总金额,同时显示现金需要支付的金额,用户实际支付的现金金额,及需要找零的金额。
- 其中用户实际支付的现金金额为输入项,其他为只读项。并且当输入实际支付金额时,需要及时显示计算出的找零数。
从需求中可以发现,无论选择何种支付方式,现金支付的部分 UI 及逻辑是可以重用的。另外,其中有部分数据需要实时的计算及回显。
用 SWT 实现这个应用很简单,这里我们不再详细讨论。我们来看一下用 PM 模式的实现。下图是业务模型的定义。
图 3. 业务模型
SWT 的 Data Binding 的实现,需要绑定的双方在数据发生变化时能够提供相应的事件。SWT 及 JFace 中的 UI 控件实现了数据及状态变化的事件定义,已经符合 Data Binding 的要求。为了能够实现双向的数据绑定,即 UI 中的数据与业务模型中的数据双向复制。我们需要业务模型类实现基类 NotifyPropertyChanged。NotifyPropertyChanged 是一个自定义的工具类,是为了更简单的实现数据变化通知,利用了 SWT Data Binding 提供的 PropertyChangeSupport 类来完成事件管理。其定义如下:
清单 1. 自定义数据变化通知类 NotifyPropertyChanged
package lucifer.swt.databinding.util;
import java.beans.PropertyChangeListener;
import java.beans.PropertyChangeSupport;
public class NotifyPropertyChanged {
protected PropertyChangeSupport propertyChangeSupport = new PropertyChangeSupport(
this);
public void addPropertyChangeListener(PropertyChangeListener listener) {
propertyChangeSupport.addPropertyChangeListener(listener);
}
public void addPropertyChangeListener(String propertyName,
PropertyChangeListener listener) {
propertyChangeSupport.addPropertyChangeListener(propertyName, listener);
}
public void removePropertyChangeListener(PropertyChangeListener listener) {
propertyChangeSupport.removePropertyChangeListener(listener);
}
public void removePropertyChangeListener(String propertyName,
PropertyChangeListener listener) {
propertyChangeSupport.removePropertyChangeListener(propertyName,listener);
}
}
|
并且在业务模型属性 setXXX 方法中增加 firePropertyChange 方法调用。比如在 CashPayment 类的 setChange 方法定义如下:
public void setChange(double change) {
propertyChangeSupport.firePropertyChange(“change”, this.change,
this.change = change);
}
|
根据需求,CashPayment 在 realPayAmount 发生变化时,需要实时的计算找零的数量。由于业务对象中定义有 change,因此这个计算属于业务逻辑而不是显示逻辑,应当由业务模型实现。
public void setRealPayAmount(double realPayAmount) {
if (this.realPayAmount != realPayAmount) {
propertyChangeSupport.firePropertyChange(“realPayAmount”,
this.realPayAmount, this.realPayAmount = realPayAmount);
recalculate();
}
}
private void recalculate() {
setChange(getRealPayAmount() - getAmount());
}
|
完成的 CashPayment 的定义如下:
清单 2. 自定义 CashPayment 类
package lucifer.swt.databinding.model;
public class CashPayment extends Payment {
private double realPayAmount;
private double change;
public double getRealPayAmount() {
return realPayAmount;
}
public double getChange() {
return change;
}
public void setRealPayAmount(double realPayAmount) {
if (this.realPayAmount != realPayAmount) {
propertyChangeSupport.firePropertyChange(“realPayAmount”,
this.realPayAmount, this.realPayAmount = realPayAmount);
recalculate();
}
}
public void setChange(double change) {
propertyChangeSupport.firePropertyChange(“change”, this.change,
this.change = change);
}
@Override
public void setAmount(double amount) {
if (getAmount() != amount) {
super.setAmount(amount);
recalculate();
}
}
private void recalculate() {
setChange(getRealPayAmount() - getAmount());
}
}
|
其他的业务模型定义类似,请参考本文附带的源码。
基于需求及业务模型对界面的要求,我们来定义 View Model。View Model 中的数据需要与 UI 中的数据及状态同步,因此,View Model 也需要继承 NotifyPropertyChanged 基类。
从上面的讨论可知,所有的业务模型已经具备数据绑定的条件,也就是说,我们可以把业务模型直接绑定到 UI 控件上显示数据,那么为什么还需要定义独立的 View Model ? Presentation Model 模式需要将 UI 的状态及控制逻辑提取到 View Model 中,而我们的业务模型并没有包含任何的 UI 控制逻辑及状态。因此,我们需要独立的 View Model 来完成 UI 状态的绑定及控制逻辑的定义。换句话说,如果不需要控制 UI 状态,也就没有必要独立定义 View Model,业务模型也可以作为 View Model 来使用。从需求可以看到,我们需要保存支付方式的数据,及不同支付方式中,是否需要显示现金支付部分 UI 的状态。
清单 3. 保存支付方式数据及显示部分 UI 状态
// 篇幅限制,部分代码省略,完整代码请参考本文所附完整源代码
public class CheckoutViewModel extends NotifyPropertyChanged {
public final static PayType DEFAULT_PAY_TYPE = PayType.Cash;
private final List<PayType> payTypes; // 可以选择的支付类型
private PayType payType; // 当前选择的支付类型
private CashPayment cashPayment; // 现金支付的数据
private MemberAccountPayment memberPayment; // 会员支付的数据
public CheckoutViewModel(Order order, Member member) {
this.order = order;
this.member = member;
payTypes = new ArrayList<PayType>();
payTypes.add(PayType.Cash);
payTypes.add(PayType.Member);
payType = DEFAULT_PAY_TYPE;
// 初始化默认支付方式的数据
OnPayTypeChanged();
}
// 修改支付方式
public void setPayType(PayType payType) {
if (!Util.equals(this.payType, payType)) {
propertyChangeSupport.firePropertyChange(“payType”, this.payType,
this.payType = payType);
OnPayTypeChanged(); // 修改支付类型后更改相应的支付数据
}
}
public boolean getHasCashPay() {
return getCashPayment() != null; // 当前支付方式是否包含现金支付
}
public boolean getHasMemberPay() {
return getMemberPayment() != null; // 当前支付方式是否包含会员支付
}
void OnPayTypeChanged() {
if (Util.equals(getPayType(), PayType.Cash)) {
convertToCashPayment();
} else if (Util.equals(getPayType(), PayType.Member)) {
convertToMemberPayment();
} else {
throw new IllegalArgumentException(“Unknow PayType:” + getPayType());
}
}
// 将当前的支付方式转换成现金支付
void convertToCashPayment() {
setMemberPayment(null);
CashPayment cashPayment = new CashPayment();
cashPayment.setAmount(order.getAmount());
setCashPayment(cashPayment);
}
// 将当前的支付方式转换成会员支付
void convertToMemberPayment() {
MemberAccountPayment memberPayment = new MemberAccountPayment();
if (member.getAccountBalance() >= order.getAmount()) {
memberPayment.setAmount(order.getAmount()); // 预存余额足够支付
setCashPayment(null);
} else { // 会员预存不够支付,需要现金补充
memberPayment.setAmount(member.getAccountBalance());
double leftAmount = order.getAmount() - member.getAccountBalance();
CashPayment cashPayment = new CashPayment();
cashPayment.setAmount(leftAmount);
setCashPayment(cashPayment);
}
setMemberPayment(memberPayment);
}
void setCashPayment(CashPayment cashPayment) {
if (!Util.equals(this.cashPayment, cashPayment)) {
CashPayment oldCashPayment = this.cashPayment;
boolean oldHasCashPayment = getHasCashPay();
this.cashPayment = cashPayment;
propertyChangeSupport.firePropertyChange(“cashPayment”,
oldCashPayment, cashPayment);
if ((oldHasCashPayment) ^ (getHasCashPay())) {
propertyChangeSupport.firePropertyChange(“hasCashPay”,
oldHasCashPayment, getHasCashPay());
}
}
}
void setMemberPayment(MemberAccountPayment memberPayment) {
if (!Util.equals(this.memberPayment, memberPayment)) {
MemberAccountPayment oldMemberPayment = this.memberPayment;
boolean oldHasMemberPayment = getHasMemberPay();
this.memberPayment = memberPayment;
propertyChangeSupport.firePropertyChange(“memberPayment”,
oldMemberPayment, memberPayment);
if (oldHasMemberPayment ^ getHasMemberPay()) {
propertyChangeSupport.firePropertyChange(“hasMemberPay”,
oldHasMemberPayment, getHasMemberPay());
}
}
}
}
|
根据理解的不同,以上的 View Model 实现中的部分代码逻辑可能需要放入到业务模型中,我们在这里主要讨论 PM 的实现,因此不深入讨论这个话题。
到这里,我们还没有实现任何和真正 UI 显示相关的代码,但是,View Model 已经完整的定义了需求中的重点 UI 功能,这就是 PM 中 View Model 对 UI View 没有依赖的含义。从设计的角度来看,取消依赖性,可以提高 View Model 的可重用性。比如一个 View Model 可用不同的方式来展现,同时可以保证逻辑一致性。
下面我们来简单的看看 UI View 的实现。View 的实现与普通的 SWT 实现区别不大,为了提高可重用性,我们没有直接在 ViewPart 中定义控件,而是使用一个 Composite 的子类来封装所有的控件,再将 Composite 聚合到 ViewPart 中。以下是部分代码片段:
清单 4. 实现 UI View
// 创建及布局 UI 控件 Label payTypeLabel = new Label(parent, SWT.NONE); payTypeLabel.setText(“支付方式”); payTypes = new Combo(parent, SWT.DROP_DOWN | SWT.READ_ONLY); // 数据绑定 PayTypeConverter payTypeConverter = new PayTypeConverter(); ISWTObservableList payTypesWidgetItems = WidgetProperties.items().observe(payTypes); IObservableList payTypesBeanValue = BeanProperties.list(“payTypes”) .observe(viewModel); UpdateListStrategy payTypesModelToTargetStrategy = new UpdateListStrategy( UpdateListStrategy.POLICY_UPDATE); payTypesModelToTargetStrategy.setConverter(payTypeConverter); payTypesBinding = getBindingContext().bindList(payTypesWidgetItems, payTypesBeanValue, new UpdateListStrategy(UpdateListStrategy.POLICY_NEVER), payTypesModelToTargetStrategy); ISWTObservableValue payTypesWidgetSelection = WidgetProperties .selection().observe(payTypes); IObservableValue payTypeBeanValue = BeanProperties.value(“payType”) .observe(viewModel); UpdateValueStrategy payTypeUpdateStrategy = new UpdateValueStrategy( UpdateValueStrategy.POLICY_UPDATE); payTypeUpdateStrategy.setConverter(payTypeConverter); payTypeBinding = getBindingContext().bindValue(payTypesWidgetSelection, payTypeBeanValue, payTypeUpdateStrategy, payTypeUpdateStrategy); |
在数据绑定的代码中,我们使用到了一个 Converter,将 View Model 中使用的数据类转换成更易于理解的方式来显示。Converter 需要继承 IConverter 接口。在绑定中,我们有两种 PayType 的数据需要转换,一种是普通的 PayType 枚举数据,用来定义当前用户选择的支付类型,另外一种是 PayType 的 List,用来保存可以选择的支付类型。这两中类型本质上都是在转换同一种数据,示例中使用了一个 Converter 类,在运行态来判断正在转换的是什么类型。在真实的应用中,可以使用更好的实现方式(完整的 View 实现请参考源代码)。
上一节的描述中,我们定义了一个简单的 PM 的实现。我们可以看到由于对 View Model 的抽象, View 的实现中只有 UI 控件的创建,布局及绑定的代码,View 中并没有包含任何的逻辑和状态控制。在进行单元测试时,集中测试 View Model 即可以覆盖完整的逻辑及状态验证。
清单 5. 对 View Model 集中测试
public class TestCheckoutViewModel {
private static final double TEST_MEMBER_BALANCE = 50.0d;
private static final double TEST_ORDER_AMOUNT = 100.0d;
private CheckoutViewModel testViewModel;
private Order testOrder;
@Before
public void setup() {
testOrder = new Order();
testOrder.setAmount(TEST_ORDER_AMOUNT);
Member testMember = new Member();
testMember.setAccountBalance(TEST_MEMBER_BALANCE);
testViewModel = new CheckoutViewModel(testOrder, testMember);
}
@After
public void teardown() {
testViewModel = null;
testOrder = null;
}
@Test
public void testDefaultPayTypeShouldBeCash() {
assertEquals(PayType.Cash, testViewModel.getPayType());
}
@Test
public void testChangePayTypeShouldTriggerEvent() {
final List<Object> triggered = new ArrayList<Object>();
testViewModel.addPropertyChangeListener(“payType”,
new PropertyChangeListener() {
@Override
public void propertyChange(PropertyChangeEvent event) {
triggered.add(new Object());
}
});
testViewModel.setPayType(PayType.Member);
assertEquals(1, triggered.size());
}
@Test
public void testDefaultPayTypesShouldIncludeCashAndMember() {
assertEquals(2, testViewModel.getPayTypes().size());
assertTrue(testViewModel.getPayTypes().contains(PayType.Cash));
assertTrue(testViewModel.getPayTypes().contains(PayType.Member));
}
@Test
public void testDefaultPaymentShouldBeCashPayment() {
assertNotNull(testViewModel.getCashPayment());
assertNull(testViewModel.getMemberPayment());
assertTrue(testViewModel.getHasCashPay());
assertFalse(testViewModel.getHasMemberPay());
assertEquals(PayType.Cash, testViewModel.getPayType());
}
@Test
public void testChangeToCashPaymentShouldOnlyHaveCashPayment(){
testViewModel.setPayType(PayType.Cash);
assertNotNull(testViewModel.getCashPayment());
assertNull(testViewModel.getMemberPayment());
assertTrue(testViewModel.getHasCashPay());
assertFalse(testViewModel.getHasMemberPay());
assertEquals(PayType.Cash, testViewModel.getPayType());
}
@Test
public void testChangeToMemberPaymentShouldOnlyHaveMemberPaymentIfBalanceIsEnough(){
Member member = new Member();
member.setAccountBalance(testOrder.getAmount() + 10);
CheckoutViewModel viewModel = new CheckoutViewModel(testOrder, member);
viewModel.setPayType(PayType.Member);
assertNotNull(viewModel.getMemberPayment());
assertNull(viewModel.getCashPayment());
assertFalse(viewModel.getHasCashPay());
assertTrue(viewModel.getHasMemberPay());
assertEquals(PayType.Member, viewModel.getPayType());
}
@Test
public void
testChangeToMemberPaymentShouldHaveMemberAndCashPaymentIfBalanceIsnotEnough(){
testViewModel.setPayType(PayType.Member);
assertNotNull(testViewModel.getMemberPayment());
assertNotNull(testViewModel.getCashPayment());
assertTrue(testViewModel.getHasCashPay());
assertTrue(testViewModel.getHasMemberPay());
assertEquals(PayType.Member, testViewModel.getPayType());
}
}
|
( 以上的测试用例完成简单的 View Model 的测试,并非完整的 Unit-Test)
UI 中的控件往往都有很好的封装,封装的 UI 控件更易于重用。我们可以将 UI 封装的思想继续沿用于 PM 模式。以下的静态类图展现了一种简易的解决方案。
图 4. 实现 PM 模式的 UI 组件
DataContext 接口提供了保持数据上下文的抽象描述,其抽象实现 BindingComposite 是应用 PM 模式的 UI 组件的父类,实现了对数据上下文的读写方法。抽象方法 attachTo 和 detach 由具体的 UI 控件实现,用于实现对当前数据上下文的绑定或取消绑定。接口与抽象类均为泛型类型,其参数类型为其实现控件所依赖的 View Model 决定。
接口与抽象类的引入使得支持 PM 模式的控件有了统一访问接口。容器控件不再需要了解子控件如何绑定其 View Model,只需调用子控件的 attachTo 方法将与其对应的 View Model 绑定即可。CheckoutComposite 实现片段如下所示:
清单 6. 利用统一访问接口测试
public class CheckoutComposite extends BindingComposite<CheckoutViewModel> {
...
private Composite cashPaymentSection;
private Binding hasCashPayBinding;
private Binding cashPaymentBinding;
private MemberPaymentComposite memberPaymentSection;
...
private void createControls(Composite parent) {
...
memberPaymentSection = new
MemberPaymentComposite(getBindingContext(),
paymentContainer, SWT.BORDER);
cashPaymentSection = new CashPaymentComposite(getBindingContext(),
parent, SWT.BORDER);
cashPaymentSection.setLayoutData(containerData);
}
@Override
protected void attachTo(CheckoutViewModel viewModel) {
...
ISWTObservableValue cashPaymentSectionWidgetVisible = WidgetProperties
.visible().observe(cashPaymentSection);
IObservableValue hasCashPayBeanValue = BeanProperties.value(
“hasCashPay”).observe(viewModel);
hasCashPayBinding = getBindingContext().bindValue(
cashPaymentSectionWidgetVisible, hasCashPayBeanValue,
new UpdateValueStrategy(UpdateValueStrategy.POLICY_NEVER),
new UpdateValueStrategy(UpdateValueStrategy.POLICY_UPDATE));
IObservableValue cashPaymentSectionWidgetDataContext = BeanProperties
.value(“dataContext”).observe(cashPaymentSection);
IObservableValue cashPaymentBeanValue = BeanProperties.value(
“cashPayment”).observe(viewModel);
cashPaymentBinding = getBindingContext().bindValue(
cashPaymentSectionWidgetDataContext, cashPaymentBeanValue,
new UpdateValueStrategy(UpdateValueStrategy.POLICY_NEVER),
new UpdateValueStrategy(UpdateValueStrategy.POLICY_UPDATE));
IObservableValue memberPaymentSectionWidgetDataContext = BeanProperties
.value(“dataContext”).observe(memberPaymentSection);
IObservableValue memberPaymentBeanValue = BeanProperties.value(
“memberPayment”).observe(viewModel);
getBindingContext().bindValue(
memberPaymentSectionWidgetDataContext, memberPaymentBeanValue,
new UpdateValueStrategy(UpdateValueStrategy.POLICY_NEVER),
new UpdateValueStrategy(UpdateValueStrategy.POLICY_UPDATE));
ISWTObservableValue memberPaymentSectionWidgetVisible = WidgetProperties
.visible().observe(memberPaymentSection);
IObservableValue hasMemberPaymentBeanValue = BeanProperties.value(
“hasMemberPay”).observe(viewModel);
getBindingContext().bindValue(
memberPaymentSectionWidgetVisible, hasMemberPaymentBeanValue,
new UpdateValueStrategy(UpdateValueStrategy.POLICY_NEVER),
new UpdateValueStrategy(UpdateValueStrategy.POLICY_UPDATE));
}
@Override
protected void detach() {
unbind(hasCashPayBinding);
unbind(cashPaymentBinding);
}
}
|
组件化的 PM UI 控件可以从其容器控件中脱离,独立的完成单元测试。完整测试过的 PM UI 控件可以在多个容器中重用。另一方面,从一种物理展现抽象出来的 View Model,也可以被用于其他的物理展现,并能保证不同展现中的逻辑一致性。
虽然 PM 模式在一定程度上提高了 UI 的可测试性,但是还存在大量的代码无法覆盖,比如,无法测试绑定部分的代码逻辑。而绑定的定义使用了大量字符串来描述 Bean 属性名称,这很容易引入错误,而且在对 Bean 代码重构时,IDE 无法识别出这些字符串中的属性名,不会自动修改,也不会产生编译错误,从而成为了隐藏的、只有运行态才能检查出来的错误,这些错误往往是更大隐患。因此,需要更多的辅助类来使这部分代码更简洁,就像自动化的 Binding 可以避免重复拷贝代码,从而减少错误出现的几率一样。
另外,PM 模式在 SWT/JFace 中的应用还有大量的工作要做。比如, SWT 的数据绑定涉及数据校验,但实际项目中对校验可能有很多特殊要求,比如需要将多个数据校验结果的聚合与另外一个 UI 控件的属性绑定等。这些在当前的实现中是没有涉及的。有兴趣的读者可以进一步研究这方面的设计与实现,使得 PM 模式在 SWT/JFace 中能应用的更好。
学习
- 查看文章“JFace Data Binding - Tutorial”,JFace Data Binding 的基础教程。
- 查看文章“Presentation Model”,Martin Fowler, Presentation Model 模式介绍。
- 随时关注 developerWorks 技术活动和网络广播。
- 访问 developerWorks Open source 专区获得丰富的 how-to 信息、工具和项目更新以及最受欢迎的文章和教程,帮助您用开放源码技术进行开发,并将它们与 IBM 产品结合使用。
讨论
- 加入 developerWorks 中文社区,developerWorks 社区是一个面向全球 IT 专业人员,可以提供博客、书签、wiki、群组、联系、共享和协作等社区功能的专业社交网络社区。
- 加入 IBM 软件下载与技术交流群组,参与在线交流。