原文 wxPython wiki :: ModelViewPresenter

即将翻译,标记一下

1. 前言

ModelViewPresenter 是ModelViewController模式的一个派生产物。其目的是提供一个Observer连接(模型与视图)的整洁实现方法。此模式更多信息可以参考 http://c2.com/cgi/wiki?ModelViewPresenterhttp://www.martinfowler.com/eaaDev/ModelViewPresenter.html。原始文章请看 http://www.oodesign.com.br/forum/index.php?act=Attach&type=post&id=74。而 http://atomicobject.com/media/files/PresenterFirst.pdf 这篇文章展示了使用MVP来构建GUI程序测试驱动开发(TDD)的模式和过程。(Caveat: I've only briefly scanned the paper, and don't know whether it will warrant the link on closer inspection. -- Don Dwiggins)

接下来的大部分信息来源于Martin Fowler's MVP页面,但经调整可用于wxPython。很多文字仅仅是复制和粘贴而来。

2. 简介

Model View Presenter 将表现行为分离出来,形成单独的presenter类。所有用户事件都被转向presenter,由presenter处理视图(view)的状态。在分离任务的同时,它也允许在没有UI的清况下测试这些行为,允许对不同的UI使用相同的基本行为。

3. 如何运作

Model View Presenter 的核心是将所有的presentation行为从view中分离出来,并放置到单独的presenter类中去。最终得到的view是几乎没有能动性的 - 除了保持GUI控件。这种方式的分离和经典的Model View Controller分离是非常相似的。

MVC与MVP的差别在于MVP中presenter不像MVC的controller那样直接处理GUI事件,而是利用interactor,通过delegation来实现。在我看来,这使得presenter的测试更加容易。

4. 示例: Album Window

模型代码仅是简单的数据对象。

   1 class Album(object):
   2     def __init__(self, artist, title, isClassical=False, composer=None):
   3         self.artist = artist
   4         self.title = title
   5         self.isClassical = isClassical
   6         self.composer = composer

通过创建presenter来启动程序。

   1 class AlbumPresenter(object):
   2     def __init__(self, albums, view, interactor):
   3         self.albums = albums
   4         self.view = view
   5         interactor.Install(self, view)
   6         self.isListening = True
   7         self.initView()
   8         view.start()

View在wx.Frame内构建

   1 class AlbumWindow(wx.Frame):
   2     def __init__(self):
   3         self.app = wx.App(0)
   4         wx.Frame.__init__(self, None)
   5         self.SetBackgroundColour("lightgray")
   6         ...

将Frame实例化之前,我们创建应用程序的wx.App对象,当view启动时,我们开始Main Loop。在实际工作中,你可以使用wxGlade之类的工具创建布局,并从生成的类中进行继承。

presenter负责把所有的数据放入窗口。它从主类中获取数据,并通过view的接口将数据置入窗口。

   1 class AlbumPresenter(object):
   2     ...
   3     def loadViewFromModel(self):
   4         if self.isListening:
   5             self.isListening = False
   6             self.refreshAlbumList()
   7             self.view.setTitle(self.selectedAlbum.title)
   8             self.updateWindowTitle()
   9             self.view.setArtist(self.selectedAlbum.artist)
  10             self.view.setClassical(self.selectedAlbum.isClassical)
  11             if self.selectedAlbum.isClassical:
  12                 self.view.setComposer(self.selectedAlbum.composer)
  13             else:
  14                 self.view.setComposer("")
  15             self.view.setComposerEnabled(self.selectedAlbum.isClassical)
  16             self.enableApplyAndCancel(False)
  17             self.isListening = True
  18 
  19     def refreshAlbumList(self):
  20         currentAlbum = self.view.getSelectedAlbum()
  21         self.view.setAlbums(self.albums)
  22         self.view.setSelectedAlbum(currentAlbum)
  23         self.selectedAlbum = self.albums[currentAlbum]
  24 
  25     def updateWindowTitle(self):
  26         self.view.setWindowTitle("Album: " + self.view.getTitle())
  27 
  28     def enableApplyAndCancel(self, enabled):
  29         self.view.setApplyEnabled(enabled)
  30         self.view.setCancelEnabled(enabled)
  31     ...

loadViewFromModel方法对view中所有项目进行更新。有一些更新事件会导致递归式的触发更新方法,所以在这里我使用对更新方法加入一个防护,防止递归的发生。

view中的方法允许通过fields访问下层的控件。

   1 class AlbumWindow(wx.Frame):
   2     ...
   3     def setClassical(self, isClassical):
   4         self.classical.SetValue(isClassical)
   5     def isClassical(self):
   6         return self.classical.GetValue()

除了set/get方法之外,view还可以使用对象属性以及通过简单的assignment/access方法来实现。

   1 class AlbumWindow(wx.Frame):
   2     ...
   3     def _setTitle(self, title):
   4         self._title.SetValue(title)
   5     def _getTitle(self):
   6         return self._title.GetValue()
   7     title = property(_getTitle, _setTitle)

接下来

   1 class AlbumPresenter(object):
   2     ...
   3     def loadViewFromModel(self):
   4         if self.isListening:
   5             ...
   6             self.view.title = self.selectedAlbum.title
   7     ...
   8     def updateWindowTitle(self):
   9         self.view.setWindowTitle("Album: " + self.view.title)

interactor对象从presenter被安装,它安装所有必需的event handler, 这些handler将event委托给presenter。

   1 class AlbumInteractor(object):
   2     def Install(self, presenter, view):
   3         self.presenter = presenter
   4         self.view = view
   5         view.albums.Bind(wx.EVT_LISTBOX, self.OnReloadNeeded)
   6         view.title.Bind(wx.EVT_TEXT, self.OnDataFieldUpdated)
   7         view.artist.Bind(wx.EVT_TEXT, self.OnDataFieldUpdated)
   8         view.composer.Bind(wx.EVT_TEXT, self.OnDataFieldUpdated)
   9         view.classical.Bind(wx.EVT_CHECKBOX, self.OnDataFieldUpdated)
  10         view.apply.Bind(wx.EVT_BUTTON, self.OnApply)
  11         view.cancel.Bind(wx.EVT_BUTTON, self.OnReloadNeeded)
  12 
  13     def OnApply(self, evt):
  14         self.presenter.updateModel()
  15 
  16     def OnReloadNeeded(self, evt):
  17         self.presenter.loadViewFromModel()
  18 
  19     def OnDataFieldUpdated(self, evt):
  20         self.presenter.dataFieldUpdated()

当用户点击apply按钮时,将数据保存到model。

   1     def updateModel(self):
   2         self.selectedAlbum.title = self.view.getTitle()
   3         self.selectedAlbum.artist = self.view.getArtist()
   4         self.selectedAlbum.isClassical = self.view.isClassical()
   5         if self.view.isClassical:
   6             self.selectedAlbum.composer = self.view.getComposer()
   7         else:
   8             self.selectedAlbum.composer = None
   9         self.enableApplyAndCancel(False)
  10         self.loadViewFromModel()

要检查apply是否正常工作,你可以使用unittest模块,模仿view和Interactor对象。

   1 class TestAlbumPresenter(unittest.TestCase):
   2     ...
   3     def testApplySavesDataToModel(self):
   4         view = mock_objects.MockAlbumWindow();
   5         model = [models.Album(*data) for data in self.someAlbums]
   6         interactor = mock_objects.MockAlbumInteractor()
   7         presenter = presenters.AlbumPresenter(model, view, interactor);
   8         newTitle = "Some Other Title"
   9         view.title = newTitle
  10         presenter.updateModel()
  11         assert view.title == newTitle

完整的实现(包括模仿对象和测试): mvp.zip 使用 albums.pyw 运行程序。

5. 使用MVP构架扩展应用程序

假设下一步要增加功能:增加新的albums,提供将albums按照升/降方式来排序的方法。此程序应该看起来是这个样子:

扩展这个例子的最好顺序是:从测试模块开始,首先升级测试,然后是presenter,模仿的views,然后,当这些功能完整移植到真正的view中之后,增加必须的部分并替换interactor来连接view和presenter。方便起见,从View开始,然后Presenter,最后Interactor。

首先增加可视部分:

AlbumWindow中增加两个按钮,放置于albums列表的下方,并提供访问排序按钮标签的方法。

   1 class AlbumWindow(wx.Frame):
   2     def __init__(self):
   3         ...
   4         self.add = wx.Button(self, label="New Album")
   5         self.order = wx.Button(self, label="A->Z")
   6 
   7         leftSizer = wx.GridBagSizer(5,5)
   8         leftSizer.Add(self.albums, (0,0), (1,2),flag=wx.EXPAND)
   9         leftSizer.Add(self.add, (1,0), (1,1),flag=wx.EXPAND)
  10         leftSizer.Add(self.order, (1,1), (1,1),flag=wx.EXPAND)
  11         ...
  12         mainSizer.Add(leftSizer, 0, wx.EXPAND|wx.ALL, 5)
  13     ...
  14     def setOrderLabel(self, label):
  15         self.order.SetLabel(label)

接下来,在presenter中增加相应功能。首先,增加标记来保存顺序(order):

   1 class AlbumPresenter(object):
   2     def __init__(self, albums, view, interactor):
   3         ...
   4         self.order = 1
   5         self.albums.sort(lambda a, b: cmp(a.title, b.title))

接下来,考虑到应该根据我们选择的顺序对列表排序,对refreshAlbumList方法进行修改:

   1 class AlbumPresenter(object):
   2     def refreshAlbumList(self):
   3         currentAlbum = self.view.getSelectedAlbum()
   4         self.selectedAlbum = self.albums[currentAlbum]
   5         self.albums.sort(lambda a, b:self.order*cmp(a.title, b.title))
   6         self.view.setAlbums(self.albums)
   7         self.view.setSelectedAlbum(self.albums.index(self.selectedAlbum))

Provide a way to toggle the order and a way to add a new album

   1 class AlbumPresenter(object):
   2     def toggleOrder(self):
   3         self.order = -1*self.order
   4         self.loadViewFromModel()
   5 
   6     def addNewAlbum(self):
   7         newAlbum = models.Album("Unknown Artist", "New Album Title")
   8         self.albums.append(newAlbum)
   9         self.view.setAlbums(self.albums)
  10         self.view.setSelectedAlbum(self.albums.index(newAlbum))
  11         self.loadViewFromModel()

剩下要做的就是更新AlbumInteractor,提供按钮点击和新功能之间的连接:

   1 class AlbumInteractor(object):
   2     def Install(self, presenter, view):
   3         ...
   4         view.add.Bind(wx.EVT_BUTTON, self.OnAddNewAlbum)
   5         view.order.Bind(wx.EVT_BUTTON, self.OnToggleOrder)
   6 
   7     def OnAddNewAlbum(self, evt):
   8         self.presenter.addNewAlbum()
   9 
  10     def OnToggleOrder(self, evt):
  11         self.presenter.toggleOrder()

全部完成!

新版的源代码: mvp2.zip

记住!编写产品代码时,从unittests开始,这样你就可以在相关的逻辑错误被view部分替换之前,尽早的发现它们。