【问题标题】:The MVC pattern and SwingMVC 模式和 Swing
【发布时间】:2011-07-10 05:03:44
【问题描述】:

我发现在“真实的 Swing 生活”中最难真正掌握的设计模式之一是 MVC 模式。我已经浏览了该站点上讨论该模式的很多帖子,但我仍然觉得我对如何在我的 Java Swing 应用程序中利用该模式没有清楚的了解。

假设我有一个 JFrame,其中包含一个表格、几个文本字段和几个按钮。我可能会使用 TableModel 将 JTable 与底层数据模型“桥接”起来。但是,所有负责清除字段、验证字段、锁定字段以及按钮操作的功能通常都直接在 JFrame 中。但是,这不是把模式的Controller和View混在一起了吗?

据我所知,在查看 JTable(和模型)时,我设法“正确”地实现了 MVC 模式,但是当我查看整个 JFrame 时,事情变得混乱了。

我真的很想听听其他人对此的看法。当您需要使用 MVC 模式向用户显示一个表格、几个字段和一些按钮时,您会怎么做?

【问题讨论】:

  • 这是一个相关的example
  • 对于参加这个聚会的其他人 - Swing is NOT a pure MVC - 它借鉴了这个概念,但将“视图和控制器”“折叠”在一起

标签: java swing model-view-controller


【解决方案1】:

我强烈推荐给你的一本关于 MVC in swing 的书是 Freeman 和 Freeman 的“Head First Design Patterns”。他们对 MVC 有非常全面的解释。

简要总结

  1. 您是用户——您与视图交互。视图是模型的窗口。当您对视图执行某些操作时(例如单击 播放按钮)然后视图告诉控制器你做了什么。这是 控制器的工作来处理它。

  2. 控制器要求模型更改其状态。控制器会执行您的操作并对其进行解释。如果你点击一个 按钮,控制器的工作就是弄清楚这意味着什么 如何根据该操作操作模型。

  3. 控制器也可能要求视图改变。当控制器从视图接收到一个动作时,它可能需要告诉 视图因此而改变。例如,控制器可以启用 或禁用界面中的某些按钮或菜单项。

  4. 模型在其状态发生变化时通知视图。 当模型中发生某些变化时,基于您采取的某些操作 (例如单击按钮)或其他一些内部更改(例如下一个 播放列表中的歌曲已开始),模型通知视图 它的状态已经改变。

  5. 视图向模型询问状态。视图直接从模型中获取其显示的状态。例如,当模型 通知视图一首新歌已经开始播放,视图 从模型中请求歌曲名称并显示它。该视图可能 还向模型询问作为控制器结果的状态 请求对视图进行一些更改。

Source(如果你想知道“奶油控制器”是什么,想想奥利奥饼干,控制器是奶油中心,视图是顶部饼干,模型是底部饼干。)

嗯,如果你有兴趣,你可以从here下载一首关于MVC模式的相当有趣的歌曲!

您在使用 Swing 编程时可能遇到的一个问题涉及将 SwingWorker 和 EventDispatch 线程与 MVC 模式合并。根据您的程序,您的视图或控制器可能必须扩展 SwingWorker 并覆盖放置资源密集型逻辑的 doInBackground() 方法。这可以很容易地与典型的 MVC 模式融合,并且是 Swing 应用程序的典型。

编辑#1

此外,将 MVC 视为各种模式的组合也很重要。例如,您的模型可以使用观察者模式(需要将视图注册为模型的观察者)来实现,而您的控制器可能使用策略模式。

编辑#2

我还想具体回答您的问题。您应该在视图中显示您的表格按钮等,这显然会实现一个 ActionListener。在您的actionPerformed() 方法中,您检测事件并将其发送到控制器中的相关方法(请记住 - 视图包含对控制器的引用)。所以当一个按钮被点击时,视图检测到事件,发送给控制器的方法,控制器可能会直接要求视图禁用按钮什么的。接下来,控制器将与模型进行交互和修改(主要有 getter 和 setter 方法,以及其他一些用于注册和通知观察者的方法等)。一旦模型被修改,它将调用注册观察者的更新(这将是您的情况下的视图)。因此,视图现在将自行更新。

【讨论】:

  • 我确实读过这本书,但我发现很难将这种模式应用到 SWING。我还阅读了一些地方,已经阅读到 JFrame 也可以被视为既代表视图又代表控制器。
  • ... JFrame 是一个组件,而不是叶子。通常,控制器所做的更新被发送到 JFrame,它负责其余的工作,因此,这可能会给人一种它是控制器的错觉,但实际上情况并非如此,因为它没有改变模型,只有视图。如果您的 JFrame 以某种方式直接更改了模型 - 您做错了。
  • ...再次,这里的关键字是“直接”。在您的情况下,您可能会听到鼠标点击表格,并将逻辑发送到控制器中修改表格模型的方法。
  • @DhruvGairola 第二点描述是针对第三点的,第三点和for点有相同的重复描述。你能纠正他们吗?
  • 那首歌太经典了! =D
【解决方案2】:

不喜欢视图应该是模型在其数据更改时通知的想法。我会将该功能委托给控制器。在这种情况下,如果您更改应用程序逻辑,则无需干预视图的代码。视图的任务仅针对应用程序组件+布局,仅此而已。 Swing 中的布局已经是一个冗长的任务,为什么还要让它干扰应用程序逻辑呢?

我对 MVC 的想法(我目前正在使用它,到目前为止还不错)是:

  1. 视图是三者中最愚蠢的。它对控制器和模型一无所知。它只关心摆动组件的假肢和布局。
  2. 模型也很笨,但没有视图那么笨。它执行以下功能。
  • 一个。当控制器调用其设置器之一时,它将向其侦听器/观察者发出通知(就像我说的,我会将这个角色委托给控制器)​​。我更喜欢 SwingPropertyChangeSupport 来实现这一点,因为它已经为此目的进行了优化。
  • 乙。数据库交互功能。
  1. 非常智能的控制器。非常了解视图和模型。控制器有两个功能:
  • 一个。它定义了用户与之交互时视图将执行的操作。
  • 乙。它听模型。就像我所说的,当模型的设置器被调用时,模型将向控制器发出通知。解释此通知是控制器的工作。它可能需要反映视图的变化。

代码示例

观点:

就像我说的那样,创建视图已经很冗长了,所以只需创建自己的实现 :)

interface View{
    JTextField getTxtFirstName();
    JTextField getTxtLastName();
    JTextField getTxtAddress();
}

出于可测试性目的,将这三个接口连接起来是理想的选择。我只提供了模型和控制器的实现。

模型:

public class MyImplementationOfModel implements Model{
    ...
    private SwingPropertyChangeSupport propChangeFirer;
    private String address;
    private String firstName;
    private String lastName;

    public MyImplementationOfModel() {
        propChangeFirer = new SwingPropertyChangeSupport(this);
    }
    public void addListener(PropertyChangeListener prop) {
        propChangeFirer.addPropertyChangeListener(prop);
    }
    public void setAddress(String address){
        String oldVal = this.address;
        this.address = address;
        
        //after executing this, the controller will be notified that the new address has been set. Its then the controller's
        //task to decide what to do when the address in the model has changed. Ideally, the controller will update the view about this
        propChangeFirer.firePropertyChange("address", oldVal, address);
    }
    ...
    //some other setters for other properties & code for database interaction
    ...
}

控制器:

public class MyImplementationOfController implements PropertyChangeListener, Controller{

    private View view;
    private Model model;

    public MyImplementationOfController(View view, Model model){
        this.view = view;
        this.model = model;
        
        //register the controller as the listener of the model
        this.model.addListener(this);
        
        setUpViewEvents();
    }

    //code for setting the actions to be performed when the user interacts to the view.
    private void setUpViewEvents(){
        view.getBtnClear().setAction(new AbstractAction("Clear") { 
            @Override
            public void actionPerformed(ActionEvent arg0) {
                model.setFirstName("");
                model.setLastName("");
                model.setAddress("");
            }
        });
        
        view.getBtnSave().setAction(new AbstractAction("Save") { 
            @Override
            public void actionPerformed(ActionEvent arg0) {
                ...
                //validate etc.
                ...
                model.setFirstName(view.getTxtFName().getText());
                model.setLastName(view.getTxtLName().getText());
                model.setAddress(view.getTxtAddress().getText());
                model.save();
            }
        });
    }
    
    public void propertyChange(PropertyChangeEvent evt){
        String propName = evt.getPropertyName();
        Object newVal = evt.getNewValue();
        
        if("address".equalsIgnoreCase(propName)){
            view.getTxtAddress().setText((String)newVal);
        }
        //else  if property (name) that fired the change event is first name property
        //else  if property (name) that fired the change event is last name property
    }
}

Main,设置 MVC 的地方:

public class Main{
    public static void main(String[] args){
        View view = new YourImplementationOfView();
        Model model = new MyImplementationOfModel();
        
        ...
        //create jframe
        //frame.add(view.getUI());
        ...
        
        //make sure the view and model is fully initialized before letting the controller control them.
        Controller controller = new MyImplementationOfController(view, model);
        
        ...
        //frame.setVisible(true);
        ...
    }
}

【讨论】:

  • 很有趣,但是当单个实体模型显示在多个视图中时效率会降低...然后您的设计可能会导致“大控制器”处理单个模型但管理所有相关视图。如果您尝试重用一组“小模型”,这将变得更加棘手,这要归功于“大模型”的聚合,因为视图显示在多个“小模型”实体中分派的信息。
  • @onepotato 我刚试过你的代码。当我按下一个按钮时,我可以让 setUpViewEvents() 中的代码触发。但是,当我执行 model.setSomething(123) 时,propertyChange 中的代码不会被触发。我什至在 Object newVal = evt.getNewValue(); 下直接放了一个 println它不打印。
  • 这不是MVC 架构模式,而是密切相关 MVP(模型-视图-演示者)模式。在典型的 MVC 中,当视图发生更改时通知视图正是模型的工作,这正是您“不喜欢”的内容。查看this diagram,了解典型 MVC 中的交互是如何工作的。
【解决方案3】:

MVC 模式是如何构建用户界面的模型。 因此它定义了 3 个元素 Model、View、Controller:

  • 模型 模型是呈现给用户的事物的抽象。在摇摆中,您可以区分 gui 模型和数据模型。 GUI 模型抽象了 ui 组件的状态,例如 ButtonModel。数据模型抽象了用户界面呈现给用户的结构化数据,例如TableModel
  • 视图 视图是一个 UI 组件,负责将数据呈现给用户。因此它负责所有与 ui 相关的问题,如布局、绘图等。 JTable
  • 控制器 控制器封装了为了用户交互(鼠标移动、鼠标点击、按键等)而执行的应用程序代码。控制器可能需要输入来执行它们并产生输出。他们从模型中读取输入并更新模型作为执行的结果。他们还可能重构 ui(例如,替换 ui 组件或显示全新的视图)。但是他们一定不知道 ui 组件,因为您可以将重组封装在控制器仅调用的单独接口中。在 Swing 中,控制器通常由 ActionListenerAction 实现。

示例

  • 红色 = 型号
  • 绿色 = 视图
  • 蓝色 = 控制器

当点击Button 时,它会调用ActionListenerActionListener 仅取决于其他型号。它使用一些模型作为输入,其他模型作为结果或输出。它就像方法参数和返回值。模型在更新时会通知 ui。所以控制器逻辑不需要知道 ui 组件。模型对象不知道 ui。通知由观察者模式完成。因此,模型对象只知道有人想要在模型发生变化时得到通知。

在 java swing 中有一些组件也实现了模型和控制器。例如。 javax.swing.Action。它实现了一个 ui 模型(属性:启用、小图标、名称等)并且是一个控制器,因为它扩展了 ActionListener

详细说明、示例应用程序和源代码https://www.link-intersystems.com/blog/2013/07/20/the-mvc-pattern-implemented-with-java-swing/

不到 260 行的 MVC 基础知识:

import java.awt.BorderLayout;
import java.awt.Container;
import java.awt.event.ActionEvent;
import java.util.ArrayList;
import java.util.List;

import javax.swing.AbstractAction;
import javax.swing.Action;
import javax.swing.DefaultListModel;
import javax.swing.DefaultListSelectionModel;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JList;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextField;
import javax.swing.ListSelectionModel;
import javax.swing.WindowConstants;
import javax.swing.event.ListSelectionEvent;
import javax.swing.event.ListSelectionListener;
import javax.swing.text.BadLocationException;
import javax.swing.text.Document;
import javax.swing.text.PlainDocument;

public class Main {

    public static void main(String[] args) {
        JFrame mainFrame = new JFrame("MVC example");
        mainFrame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
        mainFrame.setSize(640, 300);
        mainFrame.setLocationRelativeTo(null);

        PersonService personService = new PersonServiceMock();

        DefaultListModel searchResultListModel = new DefaultListModel();
        DefaultListSelectionModel searchResultSelectionModel = new DefaultListSelectionModel();
        searchResultSelectionModel
                .setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
        Document searchInput = new PlainDocument();

        PersonDetailsAction personDetailsAction = new PersonDetailsAction(
                searchResultSelectionModel, searchResultListModel);
        personDetailsAction.putValue(Action.NAME, "Person Details");

        Action searchPersonAction = new SearchPersonAction(searchInput,
                searchResultListModel, personService);
        searchPersonAction.putValue(Action.NAME, "Search");

        Container contentPane = mainFrame.getContentPane();

        JPanel searchInputPanel = new JPanel();
        searchInputPanel.setLayout(new BorderLayout());

        JTextField searchField = new JTextField(searchInput, null, 0);
        searchInputPanel.add(searchField, BorderLayout.CENTER);
        searchField.addActionListener(searchPersonAction);

        JButton searchButton = new JButton(searchPersonAction);
        searchInputPanel.add(searchButton, BorderLayout.EAST);

        JList searchResultList = new JList();
        searchResultList.setModel(searchResultListModel);
        searchResultList.setSelectionModel(searchResultSelectionModel);

        JPanel searchResultPanel = new JPanel();
        searchResultPanel.setLayout(new BorderLayout());
        JScrollPane scrollableSearchResult = new JScrollPane(searchResultList);
        searchResultPanel.add(scrollableSearchResult, BorderLayout.CENTER);

        JPanel selectionOptionsPanel = new JPanel();

        JButton showPersonDetailsButton = new JButton(personDetailsAction);
        selectionOptionsPanel.add(showPersonDetailsButton);

        contentPane.add(searchInputPanel, BorderLayout.NORTH);
        contentPane.add(searchResultPanel, BorderLayout.CENTER);
        contentPane.add(selectionOptionsPanel, BorderLayout.SOUTH);

        mainFrame.setVisible(true);
    }

}

class PersonDetailsAction extends AbstractAction {

    private static final long serialVersionUID = -8816163868526676625L;

    private ListSelectionModel personSelectionModel;
    private DefaultListModel personListModel;

    public PersonDetailsAction(ListSelectionModel personSelectionModel,
            DefaultListModel personListModel) {
        boolean unsupportedSelectionMode = personSelectionModel
                .getSelectionMode() != ListSelectionModel.SINGLE_SELECTION;
        if (unsupportedSelectionMode) {
            throw new IllegalArgumentException(
                    "PersonDetailAction can only handle single list selections. "
                            + "Please set the list selection mode to ListSelectionModel.SINGLE_SELECTION");
        }
        this.personSelectionModel = personSelectionModel;
        this.personListModel = personListModel;
        personSelectionModel
                .addListSelectionListener(new ListSelectionListener() {

                    public void valueChanged(ListSelectionEvent e) {
                        ListSelectionModel listSelectionModel = (ListSelectionModel) e
                                .getSource();
                        updateEnablement(listSelectionModel);
                    }
                });
        updateEnablement(personSelectionModel);
    }

    public void actionPerformed(ActionEvent e) {
        int selectionIndex = personSelectionModel.getMinSelectionIndex();
        PersonElementModel personElementModel = (PersonElementModel) personListModel
                .get(selectionIndex);

        Person person = personElementModel.getPerson();
        String personDetials = createPersonDetails(person);

        JOptionPane.showMessageDialog(null, personDetials);
    }

    private String createPersonDetails(Person person) {
        return person.getId() + ": " + person.getFirstName() + " "
                + person.getLastName();
    }

    private void updateEnablement(ListSelectionModel listSelectionModel) {
        boolean emptySelection = listSelectionModel.isSelectionEmpty();
        setEnabled(!emptySelection);
    }

}

class SearchPersonAction extends AbstractAction {

    private static final long serialVersionUID = 4083406832930707444L;

    private Document searchInput;
    private DefaultListModel searchResult;
    private PersonService personService;

    public SearchPersonAction(Document searchInput,
            DefaultListModel searchResult, PersonService personService) {
        this.searchInput = searchInput;
        this.searchResult = searchResult;
        this.personService = personService;
    }

    public void actionPerformed(ActionEvent e) {
        String searchString = getSearchString();

        List<Person> matchedPersons = personService.searchPersons(searchString);

        searchResult.clear();
        for (Person person : matchedPersons) {
            Object elementModel = new PersonElementModel(person);
            searchResult.addElement(elementModel);
        }
    }

    private String getSearchString() {
        try {
            return searchInput.getText(0, searchInput.getLength());
        } catch (BadLocationException e) {
            return null;
        }
    }

}

class PersonElementModel {

    private Person person;

    public PersonElementModel(Person person) {
        this.person = person;
    }

    public Person getPerson() {
        return person;
    }

    @Override
    public String toString() {
        return person.getFirstName() + ", " + person.getLastName();
    }
}

interface PersonService {

    List<Person> searchPersons(String searchString);
}

class Person {

    private int id;
    private String firstName;
    private String lastName;

    public Person(int id, String firstName, String lastName) {
        this.id = id;
        this.firstName = firstName;
        this.lastName = lastName;
    }

    public int getId() {
        return id;
    }

    public String getFirstName() {
        return firstName;
    }

    public String getLastName() {
        return lastName;
    }

}

class PersonServiceMock implements PersonService {

    private List<Person> personDB;

    public PersonServiceMock() {
        personDB = new ArrayList<Person>();
        personDB.add(new Person(1, "Graham", "Parrish"));
        personDB.add(new Person(2, "Daniel", "Hendrix"));
        personDB.add(new Person(3, "Rachel", "Holman"));
        personDB.add(new Person(4, "Sarah", "Todd"));
        personDB.add(new Person(5, "Talon", "Wolf"));
        personDB.add(new Person(6, "Josephine", "Dunn"));
        personDB.add(new Person(7, "Benjamin", "Hebert"));
        personDB.add(new Person(8, "Lacota", "Browning "));
        personDB.add(new Person(9, "Sydney", "Ayers"));
        personDB.add(new Person(10, "Dustin", "Stephens"));
        personDB.add(new Person(11, "Cara", "Moss"));
        personDB.add(new Person(12, "Teegan", "Dillard"));
        personDB.add(new Person(13, "Dai", "Yates"));
        personDB.add(new Person(14, "Nora", "Garza"));
    }

    public List<Person> searchPersons(String searchString) {
        List<Person> matches = new ArrayList<Person>();

        if (searchString == null) {
            return matches;
        }

        for (Person person : personDB) {
            if (person.getFirstName().contains(searchString)
                    || person.getLastName().contains(searchString)) {
                matches.add(person);
            }

        }
        return matches;
    }
}

【讨论】:

  • 我喜欢这个答案 +1,提到 ActionController 实际上我猜所有 EventListener 都是控制器..
  • @nachokk 是的,确实如此。正如我所说的A controller encapsulates the application code that is executed in order to an user interaction。移动鼠标、点击组件、按键等都是用户交互。为了更清楚,我更新了我的答案。
【解决方案4】:

您可以在单独的普通 Java 类中创建模型,并在另一个中创建控制器。

然后您可以在其上添加 Swing 组件。 JTable 将是视图之一(表模型事实上将成为视图的一部分 - 它只会从“共享模型”转换为 JTable)。

每当编辑表格时,其表格模型都会告诉“主控制器”更新某些内容。但是,控制器应该对表一无所知。所以调用应该更像:updateCustomer(customer, newValue),而不是updateCustomer(row, column, newValue)

为共享模型添加监听器(观察者)接口。一些组件(例如你的表)可以直接实现它。另一个观察者可能是协调按钮可用性等的控制器。


这是一种方法,但如果它对您的用例来说太过分了,您当然可以简化或扩展它。

您可以将控制器与模型合并,并具有相同的类流程更新并保持组件可用性。您甚至可以将“共享模型”设为TableModel(但如果它不仅被表使用,我建议至少提供一个不会泄漏表抽象的更友好的 API)

另一方面,您可以使用复杂的更新接口(@98​​7654326@、OrderItemListenerOrderCancellationListener)和仅用于协调不同视图的专用控制器(或中介)。

这取决于你的问题有多复杂。

【讨论】:

  • 大约 90% 的视图包含一个表格,用户可以在其中选择要编辑的元素。到目前为止,我所做的是我有一个数据模型,所有 CRUD 操作都通过它进行。我使用 TableModel 使数据模型适应 JTable。所以,要更新一个元素,我会调用 table.getModel().getModel().update(Element e)。换句话说,JTable 现在是控制器。所有按钮操作都放置在单独的类中(我在不同的上下文中重用它们)并通过底层模型的方法完成它们的工作。这是一个可行的设计吗?
【解决方案5】:

为了正确分离,您通常会有一个控制器类,框架类将委托给它。有多种方法可以设置类之间的关系 - 您可以实现一个控制器并使用主视图类对其进行扩展,或者使用 Frame 在事件发生时调用的独立控制器类。视图通常会通过实现侦听器接口从控制器接收事件。

有时,MVC 模式的一个或多个部分是微不足道的,或者太“单薄”以至于增加了不必要的复杂性来将它们分开。如果您的控制器充满了单行调用,则将其放在单独的类中可能最终会混淆底层行为。例如,如果您正在处理的所有事件都与 TableModel 相关并且是简单的添加和删除操作,您可能会选择在该模型中实现所有表操作函数(以及在表)。这不是真正的 MVC,但它避免了在不需要的地方增加复杂性。

无论您如何实现它,请记住使用 JavaDoc 编写您的类、方法和包,以便正确描述组件及其关系!

【讨论】:

  • @AndyT 虽然您的大部分解释都很好,但我对您将模型与控制器组合的建议有疑问。如果我想突然更换控制器怎么办?现在我发现您已将模型与控制器耦合,并且还需要修改模型。您的代码不再可扩展。无论您的控制器有多短,我都不会将它与模型结合使用。或视图。
  • 我不会不同意 - 这在很大程度上取决于您的应用程序。如果您的模型并不比 List 对象复杂,并且您的控制器只是添加和删除元素,那么创建三个单独的类(List 模型、控制器和适配器以使您的模型与 JTable 一起使用)是多余的。在不太可能需要不同控制器的情况下将其重构出来,而不是为了一些未知的未来需求而大量生产 shim 类。
  • @AndyT 同意,也许如果您的应用程序很小,这可能是最快的方法。但出于可扩展性的考虑(考虑是否由同一程序员添加),它可能会成为一个缺点。
  • @AndyT:我不知道你开发软件多久了,但你的帖子表明你已经接受了 KISS 原则。太多聪明但缺乏经验的 Java 开发人员接受设计模式,就像他们是圣经一样(设计模式只不过是高级剪切和粘贴编程)。在大多数情况下,通过构建单独的控制器和视图类来采取纯粹的方法只会使除了原始开发人员之外的任何人的维护对于超过几百行代码的程序来说都是一场噩梦。如有疑问,请保持简单,愚蠢!
  • @AndyT:启蒙之路充满了坑坑洼洼、蛇油推销员和自以为是的人。然而,没有什么比不得不长时间沉浸在自己的排便中来教一个人保持简单的事情了。设计模式没有错。然而,了解设计模式与了解软件设计并不相同。从未使用食谱方法构建过任何突破性的软件产品。设计满足要求且易于维护的高性能软件仍然是一门需要多年时间才能掌握的艺术形式。
【解决方案6】:

我发现了一些关于实现 MVC 模式的有趣文章,它们可能会解决您的问题。

【讨论】:

    【解决方案7】:

    如果您使用GUI 开发程序,mvc pattern 几乎就在那里,但很模糊。

    剖析模型、视图和控制器代码很困难,通常不仅仅是重构任务。

    当您的代码可重用时,您就知道您拥有它。如果你正确实现了 MVC,应该很容易实现具有相同功能的 TUICLIRWDmobile first design。做起来比实际做起来容易,而且在现有代码上。

    事实上,模型、视图和控制器之间的交互是使用其他隔离模式(如观察者或监听者)发生的

    我想这篇文章详细解释了它,从直接的非 MVC 模式(就像您将在 Q&D 上做的那样)到最终的可重用实现:

    http://www.austintek.com/mvc/

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2012-07-08
      • 1970-01-01
      • 1970-01-01
      • 2013-08-03
      • 1970-01-01
      • 2014-10-17
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多