MVC模式,是天底下编写GUI程序最为经典、实效的一种软件架构模式。当一个人学完菜单栏、开始学习工具栏时,就是他的一生中,最适合开始认识 MVC 模式的好时机之一。这节将安排您学习:
- Model-View-Controller 模式
- 如何创建工具栏以及如何于其上创建普通、多选、单选工具按钮
- 控件之间如何通过“空闲”事件实现状态统一
0. 课堂视频
建议先看视频,再看文本教程。最后到 d2school 课堂做本课的学习强化练习。
GUI07-学工具栏,懂 MVC
1. 才不是题外话:MVC
MVC模式,是天底下编写GUI程序最为经典、实效的一种软件架构模式。当一个人学完菜单栏、开始学习工具栏时,就是他的一生中,最适合开始认识 MVC 模式的好时机之一。
MVC 是 “Model-View-Controller” 的简写:
- Model / 模型:可认为就是我们在代码中定义的各种业务数据;
- View / 视图:业务数据的展现形式,一种模型(数据)往往有多种展现形式;
- Controller / 控制器: 用户通过控制器,以可定制的方式,获取数据模式,及指定形式展现(视图)。
程序员开发软件时,将代码按照 Model-View-Controller 加以分离,能让软件代码:
- 逻辑,意图更清晰
- 耦合更低
- 更可维护
MVC 比较适用于中大型软件代码的组织,我们前几节课所写的程序,看似简单,但其实, GUI软件没有小项目,是底层的库(比如 wxWidgets)默默地做出大量支持,包括 MVC 架构。
以前面课堂写的小例程为例:我们在窗体上鼠标位置下显示鼠标所在的坐标值,并且允许用户做如下控制:
- 是否显示坐标值,或仅显示一行提示文字;
- 文字以蓝或红哪种颜色显示。
则,至少有两个数据模型:
编号 | Model |
---|---|
M1 | 表达坐标的两个整数:x, y |
M2 | 表达用户选中颜色的菜单项ID |
以及,这两个数据,至少四种组合态的展现形式:
编号 | View (数据视图) |
---|---|
V1 | 蓝色 + 显示坐标 |
V2 | 红色 + 显示坐标 |
V3 | 蓝色 + 不显示坐标(仅显示提示) |
V4 | 红色 + 不显示坐标(仅显示提示) |
以下是实际运行时,显示的 V2 状态界面截图:
正如本课视频所讲到,View 并不仅仅包含业务数据的展现,用于实现和用户交互的 GUI 界面控制,比如我们已学的菜单、工具栏、状态栏等界面控件,也是 View 的组成。它们也会有和业务数据紧密关联的状态,比如菜单或工具图标的多选或单选状态。
以上归纳了 Model 和 View,那 Controller是什么呢?是控制流程。在我们所写的程序中,程序接收用户的输入(鼠标点击等),然后调用相应的事件函数,在事件函数中修改 Model 数据,最后引发视图刷新,包括业务视图刷新和人机交互界面中控件状态的改变,这整个过程即为 Controller。
初接触时,对 MVC 的三个最常见的误解为:
误解一:以为 View的展现内容只能给人看。正解:一个程序的输出可以是另一个程序的输入,此时可视为后者是前者的“用户”,同理,同一个程序的控制,也可能是来自另一个程序的输入。正因为 Views 不一定真实的人,所以在服务端程序中,特别是多层网络架构的软件系统中,MVC 的应用也广泛可见;
误解二:以为人机交互过程中界面控件,就是 Controller 。正解:Controller 是接受用户(不一定是人,见上)的输入,然后引发(即动作的发起者)Model 被修改,并最终体现到 View 变化的每一个完整过程。
误解三:以为只有业务数据的展现是 Views。正解:凡是需要展现给用户看的内容,基本都是 View,包括控件的状态。
2. 创建图标
在 Windows 系统下,应用程序工具栏按钮所需要的图标,需要使用 ICON 类型的图标,在 wxWidgets中,对应到 wxIcon 类。
2.1 从文件直接创建
可在程序运行时,通过读取指定路径(相对或绝对)读取指定的图标文件,以构造出一个wxIcon对象,示例代码:
wxIcon myIcon = wxIcon(wxT("路径/图标文件.ico"), wxBITMAP_TYPE_ICO, 16, 16);
注意,创建所得是普通的栈对象,非堆对象。
- 入参1:图标的磁盘位置(包括路径和文件名,可使用绝对或相对路径);
- 入参2:图标文件的类型(注意是 wxBITMAP_TYPE_ICO,而非 wxBITMAP_TYPE_ICON);
- 入参3,4:两个整数,图标的长、宽(像素)。
在程序运行时读取外部文件以创建图标(或其它资源)——
- 好处:可随时更换图标——不少应用程序支持 “换肤”,对于工具栏按钮来说,这是一种简便可行的方法;
- 坏处:程序无法单独运行。比如你想把程序发给你的朋友,就得同时附上这些图标文件,并需确保路径正确。
2.2 从程序嵌入资源中创建
Windows 支持将一些资源,嵌入到可执行文件(.exe)“体内”,从而实现可执行文件可独立运行。
Code::Blocks 向导生成的 wxWidgets 项目中,已经自带 resource.rc 的文件,打开它,默认内容为:
aaaa ICON "wx/msw/std.ico"
#include "wx/msw/wx.rc"
默认包含的 wxWidgets 程序图标。其中,aaaa 为资源的名字,ICON 为资源类型,此处为图标,最后一列 “wx/msw/std.ico” 为图标在磁盘上的路径和名字(仅编译时需用到,运行时不再需要此路径)。
假设我们已经在当前项目下创建名为 icons 的子目录,并于其下准备好 about.ico、show_info.ico、blue_txt.ico、red_txt.ico、quit.ico 等图标,则可将 resouce.rc 内容修改成:
aaaa ICON "wx/msw/std.ico"
about ICON "icons/about.ico"
blue_txt ICON "icons/blue_txt.ico"
red_txt ICON "icons/red_txt.ico"
quit ICON "icons/quit.ico"
show_info ICON "icons/show_info.ico"
#include "wx/msw/wx.rc"
这样,在编译后,相关图标资源就会内嵌在编译生成的可执行文件中,并可通过 wxIcon 的另一个构造函数读出并且成图标对象:
wxIcon icon = wxIcon(wxT("资源名"), wxBITMAP_TYPE_ICO_RESOURCE, 16, 16);
比如,创建 quit 图标:
wxIcon iconQuit = wxIcon(wxT("quit"), wxBITMAP_TYPE_ICO_RESOURCE, 16, 16);
本文后续代码均从内嵌资源创建图标。
3. 工具栏与工具栏按钮
3.1 创建工具栏
wxFrame::CreateToolBar() 用于创建一个空白的工具栏,该方法返回一个 wxToolBar * (指针)。
示例代码:
wxToolBar* tb = this->CreateToolBar(); // 创建工具栏
以上代码中的 this 工具栏所属的框架窗口,它也将负责该工具栏的生命周期(在框架窗口关闭前,将自释放上面创建工具栏堆对象)。
以下各种工具栏按钮,都需要由工具栏(wxToolBar)对象来创建。
3.2 普通工具按钮
使用工具栏对象,创建一个普通工具栏按钮,方法名为 “AddTool()”示例代码:
// 准备图标 (从内嵌资源中读取)
wxIcon iconQuit = wxIcon(wxT("quit"), wxBITMAP_TYPE_ICO_RESOURCE, 16, 16);
// 创建带图标的普通工具按钮
tb->AddTool(idMenuQuit, _("Quit"), iconQuit, _("Quit the Application"));
- 入参1:工具按钮要执行的命令对应的ID,通常来自已经创建的菜单项所绑定的事件ID。比如本例的 idMenuQuit 为 “Quit” 菜单项的ID,该命令用于退出整个应用程序;
- 入参2:工具按钮的标签,默认状态下并不显示,复杂情况下,可设置为在图标底部或右侧显示按钮的名字;
- 入参3:前面创建的图标对象(并非指针);
- 入参4:鼠标在该工具按钮上浮动时出现的提示。
也可将构造图标对象和创建工具按钮合成一步:
tb->AddTool(idMenuQuit, _("Quit"),
wxIcon(wxT("quit"), wxBITMAP_TYPE_ICO_RESOURCE, 16, 16),
_("Quit the Application"));
实际还需要第三步,但当连续为同一个工具栏创建多个工具按钮时,仅需在最后调用一次:
tb->Realize(); // 真实展现,最后调用,只需一次
仅在调用该方法后,工具栏才真正地将新创建的各工具按钮展现出来。
3.3 复选工具按钮
使用方法为:AddCheckTool(),示例代码:
//复选工具按钮(是否显示鼠标坐标)
tb->AddCheckTool(idMenuShowMotionInfo, _("Show Info"),
wxIcon(wxT("show_info"), wxBITMAP_TYPE_ICO_RESOURCE, 16, 16),
wxNullBitmap, // 按钮变灰时显示的图标,由程序自动生成
_("Show motion info or no")); // 提示
第四个入参用于指定,该复选按钮在不可用(disabled)状态下如何展现,除非你确实想为之提供一种特别的样子,否则,可如代码所示,指定使用代表空图片的对象 wxNullBitmap,系统将自动以 “灰度” 方式生成该状态的展现样式。
因为方法名已经是 “AddCheckTool ()”,故无需像普通的 “AddTool ()” 那样,再由入参指定按钮的类型,下面用于创建单选工具按钮的方法,也是如此。
3.4 单选工具按钮
使用方法为:AddRadioTool(),示例代码:
//单选工具按钮(两个:选择使用蓝色文本或使用红色文本)
tb->AddRadioTool(idMenuBlueText, _("Blue Text"),
wxIcon(wxT("blue_txt"), wxBITMAP_TYPE_ICO_RESOURCE, 16, 16),
wxNullBitmap, // 按钮变灰时显示的图标,由程序自动生成
_("Set text blue")); // 提示
tb->AddRadioTool(idMenuRedText, _("Red Text"),
wxIcon(wxT("red_txt"), wxBITMAP_TYPE_ICO_RESOURCE, 16, 16),
wxNullBitmap,
_("Set text red"));
和复选按钮不同,单独一个单选按钮没有意义,通常需要接连创建一组,让用户从多个中选择一个(即为单选之意)。
同一工具栏上如有多组单选按钮,除了可考虑使用分隔(见下面小节)在视觉上加以分组之外,并不需要再额外在逻辑上分组,因为工具按钮通常对应到菜单项,而对菜单项,我们已经作了逻辑分组,详见 《第5节 玩转主菜单》。
3.5 分隔线
tb->AddSeparator(); // 分隔线
3.6 连续代码
以下是在示例项目中, wxToolBarFrame 构造函数尾部,创建工具栏及相应按钮、分隔线的代码片段:
// 工具栏
wxToolBar* tb = this->CreateToolBar(); // 创建工具栏
wxIcon iconQuit = wxIcon(wxT("quit"), wxBITMAP_TYPE_ICO_RESOURCE, 16, 16);
tb->AddTool(idMenuQuit, _("Quit"), iconQuit, _("Quit the Application"));
tb->AddSeparator(); // 分隔线
//复选工具按钮(是否显示鼠标坐标)
tb->AddCheckTool(idMenuShowMotionInfo, _("Show Info"),
wxIcon(wxT("show_info"), wxBITMAP_TYPE_ICO_RESOURCE, 16, 16),
wxNullBitmap, // 按钮变灰时显示的图标,由程序自动生成
_("Show motion info or no")); // 提示
tb->AddSeparator(); // 分隔线
// 单选工具按钮(两个)
tb->AddRadioTool(idMenuBlueText, _("Blue Text"),
wxIcon(wxT("blue_txt"), wxBITMAP_TYPE_ICO_RESOURCE, 16, 16),
wxNullBitmap,
_("Set text blue"));
tb->AddRadioTool(idMenuRedText, _("Red Text"),
wxIcon(wxT("red_txt"), wxBITMAP_TYPE_ICO_RESOURCE, 16, 16),
wxNullBitmap,
_("Set text red"));
tb->Realize(); // 真实展现,最后调用,只需一次
4. 控件状态同步
为了使用方便,GUI 程序往往允许用户从不同控件入口发起操作,在本例,用户通过菜单栏或工具栏,都可以修改文字的颜色。窗体上输出的文字归属 View,但是菜单项或工具按钮的显示状态,也归于 View 范畴,它们的选中状态,必须保持同步。
举例:用户选中 “变帅” 菜单项,则 工具栏上 “变帅” 按钮也需要在操作后,变成“被按下”的状态;反过来也一样:用户按下 “变帅” 按钮,则同名的菜单项之前,必须打上勾。这还是复选控件,如果是单选控件,则需要的维护一组控件的状态,会更加啰嗦,容易出错。
因此,无论是大家比较熟悉的 Windows 或 苹果的 GUI 系统,还是 Linux 下的各种 GUI 桌面系统,或者是新兴的智能手机 Android 系统,或者是为浏览器前端开发提供支持各种前端库,基本都会提供一套机制,来辅助程序员实现界面组件的状态同步。
GUI 程序有一个共同点:它不可能很忙。有人说,我打开 Word,然后以平均每分钟 120 个汉字,接近 500 次按键的速度打字,这 Word 肯定忙坏了呀? 不是的,这点输入对现在的计算机和程序来说真是一点压力没有,程序仍然能在你的指法间隙间,“挤出”一大把无所事事的时间(不信你可以打开 Windows 任务管理器,看看你要能以怎样的打字速度,让 CPU 占用率提高 0.1%?)。
程序这么闲,自然就可以利用起来,闲着也是闲着,不如打打孩子——不是,不如去检查一下,有哪个控件状态不对,我们好把它纠正过来。
这种空闲事件,在很多 GUI 系统(比如 Windows 或 Android)中称为 “Idle-Event” (对应的事件处理器称为 “Idle-Handler”)。我们正在学习的 wxWidgets 又将它细分出一个 “wxUpdateUIEvent”,即专门用来处理用户界面(UI)的控件状态更新(Update)的事件。对应的,绑定事件函数的宏为:“EVT_UPDATE_UI”。
以用户可以切换坐标是否显示这一功能为例,按照 MVC 的划分原则,我们首先需要有的 Model 数据是:
- a) 坐标数据 x,y (之前课堂已经添加了);
- b) 是否显示,之前课堂没有添加,因此本课需为框架窗口类加上该成员数据,取名 showMotionInfo,一个 bool 值。
接着,当用户依赖某个操作路径(菜单或工具按钮),发起切换操作时,作为一个GUI开发框架,wxWidget将配合作为一个 GUI 操作系统的 Windows,共同帮我们完成复杂的 Controller 过程(视频里说的那些“线”),最终调用到我们写的事件响应函数,该函数修改作为 Model 数据之一的 showMotionInfo。如何知道将它修改成 true 或 false 呢?也就是说,如何知道用户当前操作是选中或是取消选中呢?仍然得感谢 wxWidgets 和其下的操作系统,它已经将该信息,放在事件数据中的 “IsCheck()” 方法里了。
接受用户输入,修改 Model 数据(showMotionInfo)的示例代码:
// 1 绑定到菜单上:
EVT_MENU(idMenuShowMotionInfo, HelloToolBarFrame::OnShowMotionInfo)
...
// 2 在事件响应函数中,修改 Model,状态信息在事件中:
void HelloToolBarFrame::OnShowMotionInfo(wxCommandEvent& event)
{
this->showMotionInfo = event.IsChecked(); // 修改 model
}
接下来,程序在某个时刻发现自己很闲,于是它去开始检查并纠正哪个“孩子”(控件)状态不对——但是,它自己并不知道,也不记录具体哪个控件所应该处于正确状态是什么——谁知道,当然只有作为程序员的我们知道,所以程序会自动触发 UpdateUIEvent 事件,让我们在这个事件里去告诉它,某个“孩子”现在的正确状态应该是:在睡觉……如果有十个不同ID的控件需要更新状态,程序会触发十次这样的事件。
以 “idMenuShowMotionInfo” 为例,空闲的程序想知道绑定该ID的菜单项和工具图标当前应该处于什么状态时,它会触发以下的事件函数被调用:
// 3 绑定控件ID
EVT_UPDATE_UI(idMenuShowMotionInfo, HelloToolBarFrame::OnUpdateShowMotionInfo)
...
// 4 在事件中,由程序员告诉程序,当前控件的正确状态(是否选中)
void HelloToolBarFrame::OnUpdateShowMotionInfo(wxUpdateUIEvent& event)
{
event.Check(this->showMotionInfo); // 有没有选中
}
请对比代码中的 3 和 4,从 “SET/写” 和 “GET/读” 的角度,加以理解。
以上讲的是控件的复选状态,它的状态就是 “选中” 或 “没选中”;对比之下,单选状态稍显复杂。因为单选状态的控件通常成组出现(至少两个),对应的详细状态应该是:“选中哪一个了?”。再次感谢所有 GUI 库,当它们在触发控件的 “UpdateEvent” 时,会带上这个控件的 ID,因此,我们只需检查它是不是我们记录的,用户选中的那个控件ID即可。
比如颜色选择有两项:蓝或红,当用户做出选择,我们就用 Model 数据(selectedColorId) 记录下来:
// 5 绑定控件ID,同一组单选控件需要全部绑定到同一个事件函数:
EVT_UPDATE_UI(idMenuBlueText, HelloToolBarFrame::OnUpdateTextColor)
EVT_UPDATE_UI(idMenuRedText, HelloToolBarFrame::OnUpdateTextColor)
...
// 6 记录用户选的是哪个ID
void HelloToolBarFrame::OnTextColorSelected(wxCommandEvent& event)
{
selectedColorId = event.GetId();
}
对应的,当程序因为闲着也是闲着,又要 “打孩子” 时,我们是这样告诉它哪个 “孩子” 需要被选中,哪个 “孩子” 不需要:
// 7 同样需要绑定一组单选控件(的ID)
EVT_UPDATE_UI(idMenuBlueText, HelloToolBarFrame::OnUpdateTextColor)
EVT_UPDATE_UI(idMenuRedText, HelloToolBarFrame::OnUpdateTextColor)
...
// 8 在事件中,由程序员告诉程序,它来“问”的控件,是不是被选中的那个
void HelloToolBarFrame::OnUpdateTextColor(wxUpdateUIEvent& event)
{
event.Check(event.GetId() == this->selectedColorId);
}
5 Model 数据
最后,汇总一下本例程用到的 Model 数据:
int xPos, yPos;
int selectedColorId = idMenuBlueText;
bool showMotionInfo = false;
其中,前两项是在之前的课堂定义的。