Visual Component Framework 中的 MVC






3.88/5 (20投票s)
Visual Component Framework 中模型-视图-控制器模式的介绍。
- 从 SourceForge 下载 VCF 源代码 (tar.gz) - 10.5 MB
- 从 SourceForge 下载 VCF Windows 安装程序 - 37.2 MB
- 下载 ModelData 示例 - 745 KB

引言
这将是四部分系列文章的第一篇,文章将解释如何使用 Visual Component Framework 内置的模型-视图-控制器功能,以及其 DocumentManager 类,这些类提供了与 MFC 的 Doc/View 类类似的高级功能集。在文章的整个过程中,我们将讨论框架中 MVC 的基本设计,如何使用 Model 和非常简单的 Controller 来创建一个简单的 MVC 应用。我将讨论如何使用 VCF 的 Visual Form File 格式来构建您的 UI,以及如何将 C++ 代码中的事件处理程序(或回调)连接到布局文件中定义的 UI 组件。然后,我们将逐步构建一个完整的基于 MVC 的应用程序,该应用程序从绘制简单的形状开始,最终成为一个简单的绘图应用程序,可以绘制形状,允许您打开或保存不同文件格式,支持剪切/复制/粘贴、撤销和重做、对选定的文件和形状对象的拖放,并且可以拖放到外部应用程序、与 shell 集成,最后在 UI 中支持更新各种 UI 元素的状态,例如菜单项和工具栏按钮。
模型-视图-控制器入门
模型-视图-控制器(MVC)是一个被广泛讨论的模式。它起源于 Xerox PARC 的 Smalltalk 小组,由 Alan Kay 于 20 世纪 70 年代初领导。它基本上是一种设计模式,其目标是将处理应用程序数据(模型)的代码与其用户界面(视图)以及响应用户操作的代码清晰地分开。市面上有各种各样的实现,有的好一些,有的差一些,有的更容易使用,有的更难。Java 的 Swing 框架是 MVC 的一个例子(尽管有些人可能认为它有点过头了)。MFC 也有一种原始的 MVC 形式,尽管有人可能认为它做得不好。
通常,模型被设计为只包含数据。您通过模型来修改其数据。模型通常有一个或多个视图,每个视图负责显示模型的部分或全部数据。如果模型发生更改,则应有一些机制来通知视图,以便它们可以更新自身。控制器充当模型和视图之间的“裁判”。例如,如果用户单击视图,则控制器将决定如何解释该操作(鼠标单击)并进行任何更改。也许,如果程序是一个绘图程序,单击可能会导致添加一个新形状。在这种情况下,控制器将需要告诉模型添加一个新形状。这将导致模型被修改,进而通知其视图,UI 将被更新和重绘。
在获得整体概述后,让我们看看 VCF 如何实现其具体细节。VCF 有两个主要类定义了模型和视图。此外,还有一个 Control 类实现了视图接口,并允许您设置自定义视图。所有各种 UI 控件类,例如 TextControl、ListControl、TreeControl 等,都使用模型来存储其数据,因此 MVC 不仅仅是框架中的一个可有可无的选项,而是内置且广泛使用的。

模型基础
Model 类是一个抽象类,其他更具体的模型类从中派生。它提供了管理和连接视图与模型的基本实现。它还通过使用名为 ModelChanged 的特定委托变量,为任何对模型更改感兴趣的方提供了一个通用通知系统。模型的视图通过 updateAllViews() 函数进行更新,该函数遍历模型的所有注册视图并为每个视图调用 updateView() 函数。

数据访问
Model 类本身不存储或定义如何存储数据。它提供了一些基本的方法来设置和/或检索数据,以及确定模型是否包含任何数据,还有一个清除或清空所有数据的方法。
您可以使用 Model 的 getValue() 或 getValueAsString() 方法来通用地访问数据。这些方法接受一个可选的键,该键是一个变体,可用于帮助检索特定值。例如,如果模型代表一大堆文本,那么 getValue 的实现可能只是忽略键参数而直接返回所有文本。如果模型是某种列表,那么键可能被解释为索引,您将返回列表中指定索引处的值。这些方法不应成为提供模型数据访问的唯一方式,而是您在无法调用更具体的派生模型类方法的情况下提供的可选方式。
同样,可以通过 Model 的 setValue() 方法来通用地设置数据。与 getValue() 方法一样,此方法接受一个用于存储数据的变体和一个用于指定键的可选变体。同样,这不被视为设置模型数据的方法,而是可选方法。
数据存储和变体
如果您查看 Model 类的方法 getValue() 和 setValue(),并且查看 ListModel 或 TreeModel 等其他模型实现,您会发现 VariantData 类用于表示数据片段。这使得在各种数据类型之间轻松透明地传递数据变得容易,但这并不意味着您必须使用 VariantData 来实现模型存储。例如,如果您有一系列浮点值,您可以选择实现 ListModel 并使用 std::vector<double> 将数据存储在 double 数组中。
视图
虽然 Model 有许多与之相关的方法,但 View 类相对简单。它的主要功能是渲染或绘制模型。这在 View 的 paintView() 方法中完成,该方法由框架调用。会传入一个 GraphicsContext,您可以从中绘制任何您需要的东西。
View 还有一个指向其关联 Model 的指针,以及一个指向其关联 Control 实例的指针。除此之外,具体视图的其他任何内容都取决于应用程序的特定设计。
控件和视图
Control 从 View 类派生,并且包含一个指向 View 的指针。这意味着,默认情况下,任何控件都可以添加到模型中。默认情况下,Control 的 paintView() 方法将仅调用 Control 的 paint() 方法中的代码。通过允许控件拥有外部视图的可能性,您可以在不使用继承的情况下自定义控件的显示。控件的默认绘制行为是检查其 View 实例是否为 NULL;如果是,则调用 View 实例的 paintView() 方法,否则执行其自身的内部绘制。
控制器在哪里?
到目前为止,我还没有提到一个特定的控制器类。那是因为没有!控制器往往是极其应用程序特定的,没有足够共同的元素来证明一个类是合理的。一些可能的选择是使用现有的 Application 类并将您的控制器逻辑放在那里,或者创建一个全新的类来作为您的控制器。重要的是该类做什么以及它如何与模型和视图交互,这可以将其归类为控制器。
开始之前
在我们深入研究之前,请确保您已安装最新版本的 VCF(0-9-8 或更高版本)。然后,您可能想在此处的 CodeProject 上浏览一些文章,以了解事物的工作原理。一些有用的文章
一个简单示例
作为第一步,让我们创建一个简单的应用程序来显示通用 Person 类中的信息。

首先,让我们创建我们的模型。我们将其命名为 Person 并这样定义
class Person : public Model { public: virtual bool isEmpty() { return attributes.empty(); } protected: Dictionary attributes; };
这显然很简陋。我们没有为每个成员变量(如姓名、地址等)设置单独的字段,而是打算偷懒,使用框架的 Dictionary 类,它只是 std::map<string,variantdata> 的封装,并额外包含一些实用方法。这让我们能够写出类似这样的内容
attributes["Age"] = 38; attributes["Name"] = "Bob";
当您实现模型类时,需要实现 isEmpty() 方法;这允许框架判断您的模型是否包含任何数据。在我们的例子中,我们只返回 attributes 成员变量是否 empty()。
现在,让我们通过实现通用的 getValue() 和 setValue() 方法来添加通过键获取或设置数据的支持
class Person : public Model { public: virtual bool isEmpty() { return attributes.empty(); } virtual VariantData getValue( const VariantData& key=VariantData::null() ) { String strKey = key; return attributes[ strKey ]; } virtual void setValue( const VariantData& value, const VariantData& key=VariantData::null() ) { String strKey = key; attributes[ strKey ] = value; ModelEvent e( this, Model::MODEL_CHANGED ); changed( &e ); } protected: Dictionary attributes; };
注意我们的 setValue() 实现。我们声明一个 ModelEvent,传入事件的源(模型),将其事件类型设置为 Model::MODEL_CHANGED,然后调用 Model 的 changed() 方法。这为我们做了两件事:它确保调用 ModelChanged 委托并传入事件实例,同时它调用 Model 的 updateAllViews() 方法。这是一个便利方法,所以不要忘记自己也要这样做。
如果我们想做得更花哨,我们可以为我们的类添加一个特定的委托以获得更高的粒度;例如,我们可以有一个 NameChanged 委托,但现在,我们只是保持简单。
显然,我们不希望记住各种属性键是什么,所以让我们添加一些方法来方便地获取和设置 Person 类的各种属性。
class Person : public Model { public: virtual bool isEmpty() { return attributes.empty(); } virtual VariantData getValue( const VariantData& key=VariantData::null() ) { String strKey = key; return attributes[ strKey ]; } virtual void setValue( const VariantData& value, const VariantData& key=VariantData::null() ) { String strKey = key; attributes[ strKey ] = value; ModelEvent e( this, Model::MODEL_CHANGED ); changed( &e ); } uint32 getAge() const { return attributes["Age"]; } String getFirstName() const { return attributes["FirstName"]; } String getLastName() const { return attributes["LastName"]; } String getZIPCode() const { return attributes["ZIPCode"]; } String getAddress() const { return attributes["Address"]; } String getState() const { return attributes["State"]; } String getCountry() const { return attributes["Country"]; } String getPhoneNumber() const { return attributes["PhoneNumber"]; } void setAge( const uint32& val ) { attributes["Age"] = val; ModelEvent e( this, Model::MODEL_CHANGED ); changed( &e ); } void setFirstName( const String& val ) { attributes["FirstName"] = val; ModelEvent e( this, Model::MODEL_CHANGED ); changed( &e ); } void setLastName( const String& val ) { attributes["LastName"] = val; ModelEvent e( this, Model::MODEL_CHANGED ); changed( &e ); } void setZIPCode( const String& val ) { attributes["ZIPCode"] = val; ModelEvent e( this, Model::MODEL_CHANGED ); changed( &e ); } void setAddress( const String& val ) { attributes["Address"] = val; ModelEvent e( this, Model::MODEL_CHANGED ); changed( &e ); } void setState( const String& val ) { attributes["State"] = val; ModelEvent e( this, Model::MODEL_CHANGED ); changed( &e ); } void setCountry( const String& val ) { attributes["Country"] = val; ModelEvent e( this, Model::MODEL_CHANGED ); changed( &e ); } void setPhoneNumber( const String& val ) { attributes["PhoneNumber"] = val; ModelEvent e( this, Model::MODEL_CHANGED ); changed( &e ); } protected: Dictionary attributes; };
因此,我们现在可以轻松地获取和设置 Person 类上的值。请注意,使用 Dictionary 对象存储值并没有真正的理由,除非是为了偷懒。它使得在实现通用的 getValue() 和 setValue() 方法以及 isEmpty() 时代码更少,但它不如使用更传统的、为每个属性设置单独成员变量那样高效。
至此,我们已经拥有了一个完整但简单的模型实现。
既然我们已经定义了模型,让我们来设置用户界面。我们将使用 VCF 的 Visual File Format (VFF) 来构建所有这些示例的 UI,它基本上是 Borland 的 Delphi 多年来使用的格式的扩展。它易于解析,不是 XML,而且可能更重要的是,它非常容易手动阅读和编辑。有关其更完整的定义,请参阅这些链接
基本上,VCF 为它尝试创建的每个 UI 类加载一个 VFF 文件,使用其资源加载逻辑将数据加载到内存中。资源可以存储在更传统的资源脚本 (.RC) 中并编译到最终的可执行文件中,或者它们可以作为外部文件存在。为了在本系列文章中使事情变得简单,我们将使用外部文件形式的资源。实际文件名必须与类名相同,并以 ".vff" 结尾。我们的窗口类名为 "ModelDataWindow",所以我们的文件将存储为 "ModelDataWindow.vff",它将位于 Resources/ 目录中,与可执行文件处于同一级别。
我们将从以下内容开始定义我们的主窗口
object ModelDataWindow  : VCF::Window
    top = 200
    left = 200
    height = 110pt
    width = 240pt
    caption = 'ModelData Window'    
    minHeight = 110pt
    maxHeight = 110pt    
endd
快速说几点
- 正如您所见,您定义一个对象,指定其名称(这成为组件的名称)、其 C++ 类,然后写出您想要定义的属性。
- VFF 允许您以像素(默认)、点、英寸或厘米为单位指定坐标值。点、英寸和厘米都使用屏幕的当前 DPI 来确定实际像素值。
- 注意 minHeight/maxHeight的使用 - 通过设置这些值,我们可以限制主窗口的大小。由于我们已将它们设置为相同的值,因此我们将无法更改窗口的高度。我们将能够更改窗口的宽度。
我们将有一个简单的 UI 来显示一些值,以表格形式。为了使此过程简单,我们将使用一个特殊的容器类,该类将完成定位控件的所有工作。
object ModelDataWindow  : VCF::Window
    //rest commented out
    object hlContainer : VCF::HorizontalLayoutContainer
        numberOfColumns = 2
        maxRowHeight = 35
        rowSpacerHeight = 10
        widths[0] = 80
        widths[1] = 80
        tweenWidths[0] = 10
    end
    object hlContainer2 : VCF::HorizontalLayoutContainer
        numberOfColumns = 2
        maxRowHeight = 35
        rowSpacerHeight = 10
        widths[0] = 80
        widths[1] = 80
        tweenWidths[0] = 10
    end
end
这定义了两个我们可以稍后引用和使用的容器。大多数值仅指定容器的各种尺寸。
我们将把 UI 分成两部分:左侧是可编辑的,右侧将仅显示当前值,并在模型发生更改时进行更新。
object ModelDataWindow  : VCF::Window
    //rest commented out
    object hlContainer : VCF::HorizontalLayoutContainer
        //rest commented out
    end
    object hlContainer2 : VCF::HorizontalLayoutContainer
        //rest commented out
    end
    
    object pnl1 : VCF::Panel 
        border = null
        alignment = AlignLeft
        width = 120pt
        container = @hlContainer        
    end
    
    object pnl1 : VCF::Splitter 
        alignment = AlignLeft
    end
    object pnl2 : VCF::Panel 
        container = @hlContainer2
        border = null
        alignment = AlignClient
    end    
end
现在,我们已经设置好了 UI 的基本布局。让我们添加其余的控件,特别是我们将使用的标签、文本控件和按钮。标签将用于显示我们将在其中显示 Person 属性的名称,以及一些只读属性值。文本控件将包含可编辑的 LastName 属性。按钮将用于在每次单击时将人的年龄增加 1 岁。
object ModelDataWindow  : VCF::Window
    //commented out
    object pnl1 : VCF::Panel 
        border = null
        alignment = AlignLeft
        width = 120pt
        container = @hlContainer
        object lbl1 : VCF::Label
            caption = 'First Name'        
        end
        object edt1 : VCF::Label
            
        end
        object lbl2 : VCF::Label
            caption = 'Last Name'        
        end
        object edt2 : VCF::TextControl
            
        end
        object lbl3 : VCF::Label
            caption = 'Age'        
        end
        object edt3 : VCF::TextControl
            readonly = true
            enabled = false            
        end
        object lbl4 : VCF::Label
            caption = 'Modify Age'        
        end
        object btn1 : VCF::CommandButton
            caption = 'Click Me'
            delegates
                ButtonClicked = [ModelDataApp@ModelDataApp::clickMe]
            end
        end
    end
    
    object pnl1 : VCF::Splitter 
        alignment = AlignLeft
    end
    object pnl2 : VCF::Panel 
        container = @hlContainer2
        border = null
        alignment = AlignClient
        object lbl1a : VCF::Label
            caption = 'First Name'        
        end
        object valLbl1 : VCF::Label
            
        end
        object lbl2a : VCF::Label
            caption = 'Last Name'        
        end
        object valLbl2 : VCF::Label
            
        end
        object lbl3a : VCF::Label
            caption = 'Age'        
        end
        object valLbl3 : VCF::Label
            
        end
    end    
end
既然我们已经定义了基本 UI,让我们放一些代码来加载它
class ModelDataWindow : public Window { public: ModelDataWindow() {} virtual ~ModelDataWindow(){}; }; _class_rtti_(ModelDataWindow, "VCF::Window", "ModelDataWindow") _class_rtti_end_ class ModelDataApp : public Application { public: ModelDataApp( int argc, char** argv ) : Application(argc, argv) { addCallback( new ClassProcedure1<Event*,ModelDataApp>(this, &ModelDataApp::clickMe), "ModelDataApp::clickMe" ); } virtual bool initRunningApplication(){ bool result = Application::initRunningApplication(); REGISTER_CLASSINFO_EXTERNAL(ModelDataWindow); Window* mainWindow = Frame::createWindow( classid(ModelDataWindow) ); setMainWindow(mainWindow); mainWindow->show(); return result; } void clickMe( Event* ) { //omitted } };
首先需要注意的是,我们需要为 VCF 的高级反射/RTTI 功能添加支持,这就是 _class_rtti_ 和 REGISTER_CLASSINFO_EXTERNAL 宏发挥作用的地方。您需要定义这些宏,以便框架可以动态加载您的窗口类。这发生在调用 Frame::createWindow 静态函数时 - 它会根据其 RTTI 类实例加载一个窗口,并自动为您加载相应的 VFF 资源。
接下来,我们在应用程序的构造函数中添加一个回调。这样,它就可以使用了,并且当我们加载窗口的 VFF 时,我们可以将其引用为一个回调,用于添加到按钮的 ButtonClicked 委托。
当我们运行它时,我们会得到类似这样的结果

显然,还不是很有用,因为没有数据!我们的计划是在 ModelDataWindow VFF 文件中创建模型并设置其初始属性。您不必这样做,但对于第一步来说,这是一种处理它的简便方法。为了能够做到这一点,我们需要添加 RTTI 支持,就像我们对主窗口所做的那样,但我们还将添加对我们想要公开的各种属性的支持。为此,我们定义了类似这样的内容
_class_rtti_(Person, "VCF::Model", "Person")
_property_( uint32, "age", getAge, setAge, "" );
_property_( String, "firstName", getFirstName, setFirstName, "" );
_property_( String, "lastName", getLastName, setLastName, "" );
_property_( String, "zipCode", getZIPCode, setZIPCode, "" );
_property_( String, "address", getAddress, setAddress, "" );
_property_( String, "state", getState, setState, "" );
_property_( String, "country", getCountry, setCountry, "" );
_property_( String, "phoneNumber", getPhoneNumber, setPhoneNumber, "" );
_class_rtti_end_
这意味着我们可以通过属性名 "age" 在 VFF 中引用 person 的年龄(或我们在此处声明的任何其他属性)。我们通过添加代码在应用程序中注册此内容
virtual bool initRunningApplication(){ bool result = Application::initRunningApplication(); REGISTER_CLASSINFO_EXTERNAL(ModelDataWindow); REGISTER_CLASSINFO_EXTERNAL(Person); //rest omitted }
现在,我们可以对我们的 UI 进行一些更改并执行以下操作
- 我们将创建一个新的 Person对象。这将是我们的模型。
- 我们将设置我们 Person实例的名字、姓氏和年龄。
- 我们将设置我们某些控件的 model 属性,并设置控件的 model key,以便它知道如何使用 Model::getValue()函数从模型中检索数据。
首先,让我们创建 Person 对象
object ModelDataWindow  : VCF::Window
    //rest omitted
    object joeBobSnake :  Person
        firstName = 'Joe Bob'
        lastName = 'Snake'
        age = 24
    end
    //rest omitted
end
我们已经使用 firstName、lastName 和 age 属性设置了它的属性。
接下来,让我们修改我们的控件
object ModelDataWindow  : VCF::Window
    //rest omitted
    object joeBobSnake :  Person
        firstName = 'Joe Bob'
        lastName = 'Snake'
        age = 24
    end
    object pnl1 : VCF::Panel 
        //rest omitted
        object edt1 : VCF::Label
            model = @joeBobSnake
            modelKey = 'FirstName'
        end
        //rest omitted
        
        object edt2 : VCF::TextControl
            model = @joeBobSnake
            modelKey = 'LastName'
        end
        //rest omitted
        
        object edt3 : VCF::TextControl
            readonly = true
            enabled = false
            model = @joeBobSnake
            modelKey = 'Age'
        end
    end    
    //rest omitted
end
注意我们设置的两个属性 "model" 和 "modelKey"。model 属性非常直观,我们正在设置特定控件的模型。请记住,控件实例是一个视图,因此这会调用 setViewModel,进而导致视图被添加到模型中。使用 "@joeBobSnake" 意味着我们引用了名为 "joeBobSnake" 的组件实例。 "modelKey" 属性设置了当控件调用 Model::getValue() 时使用的键。一些控件在需要显示某些数据时会使用它,例如,标签控件在需要显示其标题时会使用它。请注意,正在分配的键与我们在 Person 类中访问属性时使用的名称相同。
第一个文本控件(edt2)将允许我们修改 Person 实例的姓氏。当控件确定其文本正在被更改时,它会尝试修改模型,使用分配给它的模型键。在我们的例子中,无论我们输入什么文本,都将使用 Model::setValue 和键 "LastName" 分配给 Person 模型。
现在,让我们添加一些代码来修改人的年龄,然后我们就完成了!
class ModelDataApp : public Application { public: //rest omitted void clickMe( Event* ) { Person* person = (Person*)findComponent( "joeBobSnake", true ); person->setAge( person->getAge() + 1 ); } };
请注意,我们之前已经为此定义了一个方法。这只是检索组件,并增加其年龄属性。我们最终得到的东西如下

关于构建示例的说明
您需要安装最新版本的 VCF(至少 0-9-8 或更高版本)。当前示例有一个使用 Visual C++ 6 构建的 exe。如果您想使用其他版本的 Visual C++ 进行构建,则需要手动构建它们,并确保构建静态框架库,而不是动态库。
如果您不确定如何构建框架本身,请参阅:构建 VCF,它解释了使用各种 IDE 和工具链构建框架的基础知识。
结论
至此,我们已经完成了创建模型类、创建用户界面并将两者连接在一起的基础知识。我们的“控制器”,如果您愿意的话,既是文本控件,也是应用程序类。文本控件(也是视图)负责转换键盘输入并更新模型。这反过来又会更新附加到模型的任何其他视图。另一个控制器是应用程序,它在 UI 元素被单击时收到通知,然后通过增加年龄来修改模型。显然,这是一个相当简化的实现,所以我们的下一步将是增强此功能。
欢迎提问关于框架的问题,您可以在此处或在论坛中提问。如果您对如何改进这一点有任何建议,我很乐意听到!


