第8章 Document-View 深入探讨

形而上者谓之道,形而下者谓之器。

对于 Document/View 而言,很少有人能够先道而后器。

完全由AppWziard代劳做出的Scribble step0,应用程序的整个架构(空壳)都已经构造起来了,但是Document和View还空着好几个最重要的函数(都是虚函数)等着你设计其实体。这就像一部汽车外面的车体以及内部的油路电路都装配好了,但还等着最重要的发动机(引擎)植入,才能够产生动力,开始「有所为」。

我已经在第7章概略介绍了Document/View以及Document Template,还有更多的秘密将在本章揭露。

为什么需要 Document-View(形而上)

MFC 之所以为 Application Framework,最重要的一个特征就是它能够将管理数据的程序代码和负责数据显示的程序代码分离开来,这种能力由MFC的 Document/View 提供。

Document/View是MFC的基石,了解它,对于有效运用MFC有极关键的影响。甚至OLE复合文件(compound document)都是建筑在Document/View的基础上呢!

几乎每一个软件都致力于数据的处理,毕竟信息以及数据的管理是计算机技术的主要用途。把数据管理和显示方法分离开来,需要考虑下列几个议题:

1. 程序的哪一部分拥有数据

2. 程序的哪一部分负责更新数据

3. 如何以多种方式显示数据

4. 如何让数据的更改有一致性

5. 如何储存数据(放到永久储存装置上)

6. 如何管理使用者接口。不同的数据类型可能需要不同的使用者接口,而一个程序可能管理多种类型的数据。

其实Document / View不是什么新主意,Xerox PARC实验室是这种观念的滥觞。它是Smalltalk 环境中的关键性部分,在那里它被称为 Model-View-Controller(MVC)。其中的Model就是MFC的Document,而 Controller 相当于 MFC 的 Document Template。

回想在没有Application Framework帮助的时代(并不太久以前),你如何管理数据?只要程序需要,你就必须想出各种表现数据的方法;你有责任把数据的各种表现方法和资料本体调解出一种关系出来。100 位程序员,有 100 种作法!如果你的程序只处理一种数据类型,情况还不至于太糟。举个例,字处理软件可以使用巨大的字符串数组,把文字统统含括进来,并以 ASCII 型式显示之,顶多嘛,变换一下字形!

但如果你必须维护一种以上的数据类型,情况又当如何?想象得到,每一种数据类型可能需要独特的处理方式,于是需要一套功能选单;每一种数据类型显现在窗口中,应该有独特的窗口标题以及缩小图标;当数据编辑完毕要存盘,应该有独特的扩展名;登录在 Registry 之中应该有独特的型号。再者,如果你以不同的窗口,不同的显现方式,秀出一份数据,当数据在某一窗口中被编辑,你应该让每一窗口的数据显像与实际数据之间常保一致。吧啦吧啦吧啦……繁杂事务不胜枚举。

很快地,问题就浮显出来了。程序不仅要做数据管理,更要做「与数据类型相对应的 UI」的管理。幸运的是,解决之道亦已浮现,那就是面向对象观念中的Model-View-Controller(MVC),也就是MFC的Document/View。

Document

名称有点令人惧怕 -- Document 令我们想起文字处理软件或电子表格软件中所谓的「文件」。但,这里的Document其实就是数据。的确是,不必想得过份复杂。有人用data set 或data source 来表示它的意义,都不错。

Document在MFC的CDocument里头被具体化。CDocument本身并无实务贡献,它只是提供一个空壳。当你开发自己的程序,应该从CDocument派生出一个属于自己的Document 类,并且在类中声明一些成员变量,用以承载(容纳)数据。然后再(至少)改写专门负责文件读写动作的Serialize函数。事实上,AppWizard为我们把空壳都准备好了,以下是Scribble step0 的部分内容:

由于CDocument派生自CObject,所以它就有了CObject 所支持的一切性质,包括执行时期型别信息(RTTI)、动态生成(Dynamic Creation)、文件读写(Serialization)。又由于它也派生自CCmdTarget,所以它可以接收来自选单或工具列的WM_COMMAND 消息。

View

View 负责描述(呈现)Document 中的数据。

View 在MFC的CView里头被具体化。CView本身亦无实务贡献,它只是提供一个空壳。当你开发自己的程序,应该从CView派生出一个属于自己的View类,并且在类中(至少)改写专门负责显示数据的OnDraw函数(针对屏幕)或OnPrint函数(针对打印机)。事实上,AppWizard 为我们把空壳都准备好了,以下是Scribble step0 的部分内容:

由于CView派生自CWnd,所以它可以接收一般Windows 消息(如WM_SIZE、 WM_PAINT 等等),又由于它也派生自CCmdTarget,所以它可以接收来自选单或工具列的 WM_COMMAND 消息。

在传统的C/SDK 程序中,当窗口函数收到WM_PAINT,我们(程序员)就调用BeginPaint,获得一个Device Context(DC),然后在这个 DC 上作画。这个 DC 代表萤幕装置。在 MFC 里头,一旦 WM_PAINT 发生,Framework 会自动调用 OnDraw 函数。

View 事实上是个没有边框的窗口。真正出现时,其外围还有一个有边框的窗口,我们称为 Frame 窗口。

Document Frame(View Frame)

如果你的程序管理两种不同类型的数据,譬如说一个是TEXT,一个是 BITMAP,作为一位体贴的程序设计者,我想你很愿意为你的使用者考虑多一些:你可能愿意在使用者操作TEXT数据时,换一套TEXT专属的使用者接口,在使用者操作 BITMAP 数据时,换一套 BITMAP 专属的使用者接口。这份工作正是由 Frame 窗口负责。

乍见这个观念,我想你会惊讶为什么UI的管理不由View直接负责,却要交给Frame窗口?你知道,有时候机能与机能之间要有点黏又不太黏才好,把UI管理机能隔离出来,可以降低彼此之间的依存性,也可以使机能重复使用于各种场合如 SDI、MDI、OLE in-place editing(即地编辑)之中。如此一来View的弹性也会大一些。

Document Template

MFC把Document/View/Frame 视为三位一体。可不是吗!每当使用者欲打开(或新增)一份文件,程序应该做出Document、View、Frame 各一份。这个「三口组」成为一个运作单元,由所谓的Document Template 掌管。MFC有一个 CDocTemplate 负责此事。它又有两个派生类,分别是 CMultiDocTemplate 和 CSingleDocTemplate。所以我在上一章说了,如果你的程序能够处理两种数据类型,你必须制造两个Document Template 出来,并使用AddDocTemplate 函数将它们一一加入系统之中。这和程序是不是MDI并没有关系。如果你的程序支持多种数据类型,但却是个 SDI,那只不过表示你每次只能开启一份文件罢了。

但是,逐渐地,MDI这个字眼与它原来的意义有了一些出入(要知道,这个字眼早在SDK时代即有了)。因此,你可能会看到有些书籍这么说:『MDI 程序使用 CMultiDocTemplate,SDI 程序使用CSingleDocTemplate』,那并不是很精准。

CDocTemplate 是个抽象类,定义了一些用来处理「Document/View/Frame 三口组」的基础函数。

CDocTemplate 管理 CDocument / CView / CFrameWnd

好,我们说Document Template 管理「三口组」,谁又来管理Document Template 呢?答案是CWinApp。下面就是InitInstance 中应有的相关作为:

想一想文件是怎么开启的:使用者选按【File/New】或【File/Open】(前者开启一份空档,后者读文件放到文件中),然后在View窗口内展现出来。我们很容易误以为是CWinApp直接产生Document:

BEGIN_MESSAGE_MAP(CScribbleApp, CWinApp)
ON_COMMAND(ID_APP_ABOUT, OnAppAbout)
ON_COMMAND(ID_FILE_NEW, CWinApp::OnFileNew)
ON_COMMAND(ID_FILE_OPEN, CWinApp::OnFileOpen)
ON_COMMAND(ID_FILE_PRINT_SETUP, CWinApp::OnFilePrintSetup)
END_MESSAGE_MAP()

其实才不,是Document Template的杰作:

图8-1 Document/View/Frame 的产生

图8-1的灰色部份,正是Document Template动态产生 「 三位一体之Document/View/Frame」的行动。下面的流程以及MFC原始代码足以澄清一切疑虑。在CMultiDocTemplate::OpenDocumentFile(注)出现之前的所有流程,我只做文字叙述,不显示其原始代码。本章稍后有一节「台面下的Serialize读文件奥秘」,则会将每一环节的原始代码呈现在你眼前,让你无所挂虑。

注:如果是 SDI 程序,那么就是 CSingleDocTemplate::OpenDocumentFile 被调用。但「多」比「单」有趣,而且本书范例Scribble 程序也使用 CMultiDocTemplate,所以我就以此为说明对象。

CSingleDocTemplate 只支持一种文件类型,所以它的成员变量是:

当使用者选按【File/New】命令项,根据AppWizard为我们所做的Message Map,此一命令由CWinApp::OnFileNew 接手处理。后者调用 CDocManager::OnFileNew,后者再调用CWinApp::OpenDocumentFile,后者再调用CDocManager::OpenDocumentFile,后者再调用CMultiDocTemplate::OpenDocumentFile(这是观察MFC原始代码所得结果):

顾名思义,我们很容易作出这样的联想:CreateNewDocument动态产生 Document,CreateNewFrame 动态产生Document Frame。的确是这样没错,它们利用CRuntimeClass的CreateObject 做「动态生成」动作:

// in DOCTEMPL.CPP

在CreateNewFrame函数中,不仅Frame被动态生成出来了,其对应窗口也以LoadFrame产生出来了。但有两件事情令人不解。第一,我们没有看到View的动态生成动作;第二,出现一个奇怪的家伙CCreateContext,而前一个不解似乎能够着落到这个奇怪家伙的身上,因为 CDocTemplate::m_pViewClass 被塞到它的一个字段中。

但是线索似乎已经中断,因为我们已经看不到任何可能的调用动作了。等一等!context 被用作LoadFrame的最后一个参数,这意味什么?还记得第六章「CFrameWnd::Create 产生主窗口(并先注册窗口类)」那一节提过 Create 的最后一个参数吗,正是这context。

那么,是不是Document Frame窗口产生之际由于WM_CREATE 的发生而刺激了什么动作?虽然其结果是正确的,但这样的联想也未免太天马行空了些。我只能说,经验累积出判断力!是的,WM_CREATE 引发CFrameWnd::OnCreate 被唤起,下面是相关的调用次序(经观察 MFC 原始代码而得知):

不仅 View 对象被动态生成出来了,其对应的实际 Windows 窗口也以 Create 函数产生出来。

正因为MFC 把View 对象的动态生成动作包装得如此诡谲奇险,所以我才在图8-1中把「构造View对象」和「产生View窗口」这两个动作特别另立一旁:

图8-2 解释CDocTemplate、CDocument、CView、CFrameWnd 之间的关系。下面则是一份文字整理:

  • CWinApp 拥有一个对象指针:CDocManager* m_pDocManager。

  • CDocManager拥有一个指针串列CPtrList m_templateList,用来维护一系列的Document Template。一个程序若支持两「种」文件类型,就应该有两份Document Templates,应用程序应该在CMyWinApp::InitInstance中以 AddDocTemplate将这些Document Templates加入由CDocManager所维护的串列之中。

  • CDocTemplate拥有三个成员变量 , 分别持有Document 、 View 、 Frame 的CRumtimeClass 指针,另有一个成员变量m_nIDResource,用来表示此 Document显现时应该采用的UI对象。这四份数据应该在 CMyWinApp::InitInstance 函数构造CDocTemplate(注1)时指定之,成为构造函数的参数。当使用者欲打开一份文件(通常是借着【File/Open】或【File/New】命令项),CDocTemplate 即可藉由Document/View/Frame之CRuntimeClass 指针(注2)进行动态生成。

注1:在此我们必须有所选择,要不就使用CSingleDocTemplate,要不就使用CMultiDocTemplate , 两者都是CDocTemplate的派生类 。 如果你选用CSingleDocTemplate,它有一个成员变量CDocument* m_pOnlyDoc,亦即它一次只能打开一份Document。如果你选用CMultiDocTemplate,它有一个成员变量 CPtrList m_docList,表示它能同时打开多个Documents。

注2:关于CRuntimeClass 与动态生成,我在第3章已经以DOS程序仿真之,本章稍后亦另有说明。

  • CDocument有一个成员变量CDocTemplate* m_pDocTemplate,回指其 Document Template;另有一个成员变量CPtrList m_viewList,表示它可以同时维护一系列的Views。

  • CFrameWnd有一个成员变量CView* m_pViewActive,指向目前正作用中的View。

  • CView有一个成员变量CDocument* m_pDocument,指向相关的Document。

图8-2 CDocTemplate、CDocument、CView、CFrameWnd之间的关系

我把Document/View/Frame 的观念以狂风骤雨之势对你做了一个交待。模糊?晦暗?没有关系,马上我们就开始实现 Scribble Step1,你会从实现过程中慢慢体会上述观念。

Scribble Step1的Document——数据结构设计

Scribble 允许使用者在窗口中画图,画图的方式是以鼠标做为画笔,按下左键拖曳拉出线条。每次按下鼠标左键后一直到放开为止的连续坐标点构成线条(stroke)。整张图(整份文件)由线条构成,线条可由点、笔宽、笔色等等数据构成(但本例并无笔色数据)。

MFC的Collections Classes 中有许多适用于各种数据类型(如Byte、Word、DWord、Ptr)以及各种数据结构(如数组、串列)的现成类。如果我们尽可能把这些现成的类应用到程序的数据结构上面,就可以节省许多开发时间:

我们的设计最高原则就是尽量使用MFC已有的类,提高软件IC的重复使用性。上图浅色部分是Scribble 范例程序在16位MFC中采用的两个类。深色部分是Scribble范例程序在32位MFC中采用的两个类。

MFC Collection Classes 的选用

第5章末尾我曾经大致提过 MFC Collection Classes。它们分为三种类型,用来管理一大群对象:

  • Array:数组,有次序性(需依序处理),可动态增减大小,索引值为整数。

  • List:双向串列,有次序性(需依序处理),无索引。串列有头尾,可从头尾或从串列的任何位置安插元素,速度极快。

  • Map:又称为Dictionary,其内对象成对存在,一为键值对象(key object), 一为实值对象(value object)。

下面是其特性整理:

MFC Collection classes 所收集的对象中,有两种特别需要说明,一是Ob 一是Ptr:

  • Ob 表示派生自CObject 的任何对象。MFC提供CObList、CObArray 两种类。

  • Ptr表示对象指针。MFC 提供CPtrList、CPtrArray 两种类。

当我们考虑使用MFC collection classes 时,除了考虑上述三种类型的特性,还要考虑以下几点:

  • 是否使用C++ template(对于type-safe 极有帮助)。

  • 储存于collection class 之中的元素是否要做文件读写动作(Serialize)。

  • 储存于collection class 之中的元素是否要有倾印(dump)和错误诊断能力。

下表是对所有collection classes 性质的一份摘要整理(参考自微软的官方手册:Programming With MFC and Win32):

➀ 若要文件读写,你必须明白调用collection object的Serialize 函数;若要内容倾印,你必须明白调用其 Dump 函数。不能够使用archive << objdmp << obj 这种型式。

➁究竟是否 Serializable,必须视其内含对象而定。举个例,如果一个typedpointerarray 是以 CObArray 为基础,那么它是 Serializable;如果它是以 CPtrArray 为基础,那么它就不是 Serializable。一般而言,Ptr 都不能够被 Serialized。

➂ 虽然它是 non-template,但如果照预定计划去使用它(例如以 CByteArray 储存 bytes,而不是用来储存 char),那么它还是 type-safe 的。

➃ 手册上说它并非 Serializable,但我存疑。各位不妨试验之。

Template-Based Classes

本书第2章末尾已经介绍过所谓的 C++ template。MFC 的 collection classes 里头有一些是 template-based,对于类型检验的功夫做得比较好。这些类区分为:

简单型——CArray、CList、CMap。它们都派生自CObject,所以它们都具备了文件读写、执行时期型别鉴识、动态生成等性质。

类型指针型——CTypedPtrArray、CTypedPtrList、CTypedPtrMap。这些类要求你在参数中指定基类,而基类必须是MFC之中的 non-template pointer collections,例如CObList或CPtrArray。你的新类将继承基类的所有性质。

Template-Based Classes 的使用方法(注意:需含入afxtempl.h,如 p.903 stdafx.h)

简单型 template-based classes 使用时需要指定参数:

  • CArray<TYPE, ARG_TYPE>

  • CList<TYPE, ARG_TYPE>

  • CMap<KEY, ARG_KEY, VALUE, ARG_VALUE>

其中TYPE用来指定你希望收集的对象的类型,它们可以是:

  • C++ 基础型别,如 int、char、long、float 等等。

  • C++ 结构或类。

ARG_TYPE 则用来指定函数的参数类型。举个例,下面程序代码表示我们需要一个int 阵列,数组成员函数(例如 Add)的参数是int:

CArray<int, int> m_intArray;
m_intArray.Add(15);

再举一例,下面程序代码表示我们需要一个由int组成的串列,串列成员函数(例如AddTail)的参数是int:

CList<int, int> m_intList;
m_intList.AddTail(36);
m_intList.RemoveAll();

再举一例,下面程序代码表示我们需要一个由CPoint组成的数组,数组成员函数(例如Add)的参数是CPoint:

CArray<CPoint, CPoint> m_pointArray;
CPoint point(18, 64);
m_pointArray.Add(point);

「类型指针」型的template-based classes使用时亦需指定参数:

CTypedPtrArray<BASE_CLASS, TYPE>
CTypedPtrList<BASE_CLASS, TYPE>
CTypedPtrMap<BASE_CLASS, KEY, VALUE>

其中TYPE用来指定你希望收集的对象的类型,它们可以是:

  • C++ 基础型别,如 int、char、long、float 等等。

  • C++ 结构或类。

BASE_CLASS 则用来指定基类,它可以是任何用来收集指针的 non-template collection classes,例如CObList或CObArray或CPtrList或CPtrArray等等。举个例子,下面程序代码表示我们需要一个派生自CObList 的类,用来管理一个串列,而串列组成份子为CStroke*:

CTypedPtrList<CObList,CStroke*> m_strokeList;
CStroke* pStrokeItem = new CStroke(20);
m_strokeList.AddTail(pStrokeItem);

CScribbleDoc 的修改

了解了Collection Classes中各类的特性以及所谓 template/nontemplate 版本之后,以本例之情况而言,很显然:

不定量的线条数可以利用串列(linked list)来表示,那么MFC的CObList 恰可用来表现这样的串列。CObList 规定其每个元素必须是一个「CObject 派生类」的对象实体,好啊,没问题,我们就设计一个名为CStroke 的类,派生自CObject,代表一条线条。为了type-safe,我们选择template 版本,所以设计出这样的Document:

线条由笔宽和坐标点构成,所以CStroke应该有m_nPenWidth 成员变量,但一长串的坐标点以什么来管理好呢?数组是个不错的选择,至于数组内要放什么类型的数据,我们不妨先着一鞭,想想这些坐标是怎么获得的。这些坐标显然是在鼠标左键按下时进入程序之中,也就是利用OnLButtonDown函数的参数 CPoint。CPoint符合前一节所说的数组元素类型条件,所以CStroke的成员变量可以这么设计:

至于CPoint实际内容是什么,就甭管了吧。

事实上CPoint是一个由两个long组成的结构,两个long各代表x和y坐标。

CScribble Step1 Document:(本图为了说明方便,以 CObList 代替实际使用之 CTypedPtrList)

图8-3a Scribble Step1的文件由线条构成,线条又由点数组构成

图8-3b Scribble Step1文件所使用的类

CScribbleDoc 内嵌一个CObList对象,CObList串列中的每个元素都是一个CStroke对象指针,而CStroke又内嵌一个CArray 对象。下面是Step1程序的 Document 设计。

SCRIBBLEDOC.H(阴影表示与 Step0 的差异)

如果你把本书第一版(使用VC++ 4.0)的Scribble step1原封不动地在 VC++ 4.2或VC++ 5.0 中编译,你会获得好几个编译错误。问题出在 SCRIBBLEDOC.H 文件:

并不是程序设计上有什么错误,你只要把CStroke的声明由CScribbleDoc 之后搬移到CScribbleDoc 之前即可。由此观之,VC++ 4.2和VC++ 5.0 的编译器似乎不支持forward declaration。真是没道理!

SCRIBBLEDOC.CPP(阴影表示与 Step0 的差异)

为了了解线条的产生经历了哪些成员函数,使用了哪些成员变量,我把图 8-3所显示的各类的成员整理于下。让我们以top-down的方式看看文件组成份子的运作。

文件:一连串的线条

Scribble 文件本身由许多线条组合而成。而你知道,以串列(linked list)表示不定个数的东西最是理想了。MFC有没有现成的「串列」类呢?有,CObList 就是。它的每一个元素都必须是CObject*。回想一下我在第二章介绍的「职员」例子:

我们有一个职员串列,串列的每一个元素的类型是「指向最基类之指针」。如果基类有一个「计薪」方法(虚函数),那么我们就可以一个「一般性」的循环把串列巡访一遍;巡到不同的职员型别,就调用该型别的计薪方法。

如今我们选用CObList,情况不就和上述职员例子如出一辙吗?CObject 的许多好性质,如 Serialization、RTTI、Dynamic Creation,可以非常简便地应用到我们极为「一般性」的操作上。这一点在稍后的 Serialization 动作上更表现得淋漓尽致。

CScribbleDoc 的成员变量

  • m_strokeList:这是一个CObList 对象,代表一个串列。串列中的元素是什么类型?答案是CObject*。但实际运作时,我们可以把基类之指针指向派生类之对象(还记得第2章我介绍虚函数时特别强调的吧)。现在我们想让这个串列成为「由CStroke 对象构成的串列」,因此显然CStroke 必须派生自CObject才行,而事实上它的确是。

  • m_nPenWidth:每一线条都有自己的笔宽,而目前使用的笔宽记录于此。

  • m_penCur:这是一个CPen 对象。程序依据上述的笔宽,配置一支笔,准备用来画线条。笔宽可以指定,但那是第10 章的事。注意,笔宽的设定对象是线条,不是单一的点,也不是一整张图。

CObList

这是MFC的内建类,提供我们串列服务。串列的每个元素都必须是 CObject*。本处将用到四个成员函数:

  • AddTail:在串列尾端加上一个元素。

  • IsEmpty:串列是否为空?

  • RemoveHead:把串列整个拿掉。

  • Serialize:文件读写。这是个空的虚函数,改写它正是我们稍后要做的努力。

CScribbleDoc 的成员函数

  • OnNewDocument、OnOpenDocument、InitDocument。产生Document 的时机有二,一是使用者选按【File/New】,一是使用者选按【File/Open】。当这两种情况发生,Application Framework 会分别调用 Document 类的 OnNewDocument 和OnOpenDocument。为了应用程序本身的特性考虑(例如本例画笔的产生以及笔宽的设定),我们应该改写这些虚函数。

    本例把文件初始化工作(画笔以及笔宽的设定)分割出来,独立于 InitDocument 函数中,因此上述的OnNew 和OnOpen 两函数都调用 InitDocument。

  • NewStroke。这个函数将产生一个新的 CStroke对象,并把它加到串列之中。 很显然这应该在鼠标左键按下时发生(我们将在 CScribbleView 之中处理鼠标消息)。本函数动作如下:

    这就产生了一个新线条,设定了线条宽度,并将新线条加入串列尾端。

  • DeleteContent。利用 CObList::RemoveHead 把串列的最前端元素拿掉。

  • Serialize。这个函数负责文件读写。由于文件掌管线条串列,线条串列又掌管各线条,我们可以善用这些阶层关系:

我们有充份的理由认为,CObList::Serialize 的内部动作,一定是以一个循环巡访所有的元素,一一调用各元素(是个指针)所指向的对象的 Serialize 函数。就好像第2章「职员」串列中的计薪方法一样。

马上我们就会看到,Serialize 如何层层下达。那是很深入的探讨,你要先有心理准备。

线条与坐标点

Scribble 的文件数据由线条构成,线条又由点数组构成,点又由(x,y)坐标构成。我们将设计CStroke用以描述线条,并直接采用MFC的CArray描述点数组。

CStroke 的成员变量

  • m_pointArray:这是一个 CArray 对象,用以记录一系列的CPoint对象,这些CPoint 对象由鼠标坐标转化而来。

  • m_nPenWidth:一个整数,代表线条宽度。虽然Scribble Step1的线条宽度是固定的,但第10章允许改变宽度。

CArray<CPoint, CPoint>

CArray 是MFC内建类,提供数组的各种服务。本例利用其template性质,指定数组内容为CPoint。本例将用到CArray 的两个成员函数和一个运算符:

  • GetSize:取得数组中的元素个数。

  • Add:在数组尾端增加一个元素。必要时扩大数组的大小。这个动作会在鼠标左键按下后被持续调用,请看ScribbleView::OnLButtonDown。

  • operator[ ]:以指定之索引值取得或设定数组元素内容。

它们的详细规格请参考 MFC Class Library Reference。

CStroke 的成员函数

  • DrawStroke :绘图原本是View 的责任,为什么却在CStroke中有一个DrawStroke?因为线条的内容只有CStroke自己知道,当然由CStroke 的成员函数把它画出来最是理想。这么一来,View 就可以一一调用线条自己的绘图函数,很轻松。

    此函数把点坐标从数组之中一个一个取出,画到窗口上,所以你会看到整个原始绘图过程的重现,而不是一整张图啪一下子出现。想当然耳,这个函数内会有CreatePen、SelectObject、MoveTo、LineTo等GDI动作,以及从数组中取坐标点的动作。取点动作直接利用CArray的operator[ ]运算符即可办到:

  • Serialize:让我们这么想象写文件动作:使用者下命令给程序,程序发命令给文件,文件发命令给线条,线条发命令给点数组,点数组于是把一个个的坐标点写入磁盘中。请注意,每一线条除了拥有点数组之外,还有一个笔划宽度,读写文件时可不要忘了这份数据。

肯定你会产生两个疑问:

1.为什么点数组的读文件写文件动作完全一样,都是Serialize(ar)呢?

2. 线条串列的Serialize 函数如何能够把命令交派到线条的Serialize 函数呢?

第一个问题的答案很简单,第二个问题的答案很复杂。稍后我对此有所解释。

图8-4 Scribble的Document/View成员鸟瞰

图8-4 把Scribble Step1的Document/View重要成员集中在一起显示,帮助你做大局观。请注意,虽然本图把「成员函数」和「成员变量」画在每一个对象之中,但你知道,事实上C++ 类的成员函数另放在对象内存以外,并不是每一个对象都有一份函数代码。只有non-static成员变量,才会每个对象各有一份。这个观念我曾在第2章强调过。

Scribble Step1 的 View:数据重绘与编辑

View 有两个最重要的任务,一是负责数据的显示,另一是负责数据的编辑(透过键盘或鼠标)。本例的 CScribbleView 包括以下特质:

  • 解读CScribbleDoc中的数据,包括笔宽以及一系列的CPoint对象,画在 View窗口上。

  • 允许使用者以鼠标左键充当画笔在View窗口内涂抹,换句话说 CSribbleView必须接受并处理WM_LBUTTONDOWN、WM_MOUSEMOVE、WM_LBUTTONUP三个消息。

当Framework收到WM_PAINT,表示画面需要重绘,它会调用OnDraw(注),由OnDraw 执行真正的绘图动作。什么时候会产生重绘消息WM_PAINT 呢?当使用者改变窗口大小,或是将窗口图标化之后再恢复原状,或是来自程序(自己或别人)刻意的制造。除了在必须重绘时重绘之外,做为一个绘图软件,Scribble 还必须「实时」反应鼠标左键在窗口上移动的轨迹,不能等到WM_PAINT 产生了才有所反应。所以,我们必须在OnMouseMove 中也做绘图动作,那是针对一个点一个点的绘图,而 OnDraw 是大规模的全部重绘。

注:其实Framework是先调用OnPaint,OnPaint再调用OnDraw。关于 OnPaint,第12 章谈到打印机时再说。

绘图前当然必须获得数据内容,调用GetDocument即可获得,它传回一个 CScribbleDoc对象指针。别忘了View 和Document 以及Frame窗口早在注册 Document Template 时就建立彼此间的关联了。所以,从CScribbleView 发出的GetDocument 函数当然能够获得CScribbleDoc 的对象指针。View可以藉此指针取得Document的数据,然后显示。

CScribbleView 的修改

以下是Step1程序的View 的设计。其中有鼠标接口,也有数据显示功能OnDraw。

SCRIBBLEVIEW.H(阴影表示与 Step0 的差异)

SCRIBBLEVIEW.CPP(阴影表示与Step0的差异)

View 的重绘动作:GetDocument 和 OnDraw

以下是CScribbleView中与重绘动作有关的成员变量和成员函数。

CScribbleView 的成员变量

  • m_pStrokeCur:一个指针,指向目前正在工作的线条。

  • m_ptPrev:线条中的前一个工作点。我们将在这个点与目前鼠标按下的点之间画一条直线。虽说理想情况下鼠标轨迹的每一个点都应该被记录下来,但如果鼠标移动太快来不及记录,只好在两点之间拉直线。

CScribbleView 的成员函数

  • OnDraw:这是一个虚函数,负责将Document的数据显示出来。改写它是程序员最大的责任之一。

  • GetDocument:AppWizard 为我们做出这样的代码,以inline方式定义于表头文件:

inline CScribbleDoc* CScribbleView::GetDocument()
{ return (CScribbleDoc*)m_pDocument; }

其中m_pDocument是CView的成员变量。我们可以推测,当程序设定好 Document Template之后,每次Framework 动态产生View 对象,其内的 m_pDocument 已经被Framework 设定指向对应之Document了。

View对象何时被动态产生?答案是当使用者选按【File/Open】或【File/New】。每当产生一个 Document,就会产生一组 Document/View/Frame「三口组」。

  • OnPreparePrinting, OnBeginPrinting, OnEndPrinting:这三个CView 虚函数将用来改善印表行为。AppWizard 只是先帮我们做出空函数。第12章才会用到它们。

我们来看看CView之中居最重要地位的OnDraw,面对Scribble Document的数据结构,

将如何进行绘图动作。为了获得数据,OnDraw一开始先以GetDocument取得 Document对象指针;然后以while循环一一取得各线条,再调用 CStroke::DrawStroke 绘图。想象中绘图函数应该放在 View 类之内(绘图不正是View 的责任吗),但是DrawStroke 却否!原因是把线条的数据和绘图动作一并放在CStroke中是最好的包装方式。

其中用到两个CObList 成员函数:

  • GetNext:取得下一个元素。

  • GetHeadPosition:传回串列之第一个元素的「位置」。传回来的「位置」是一个类型为 POSITION 的数值,这个数值可以被使用于CObList的其它成员函数中,例如GetAt或SetAt。你可以把「位置」想象是串列中用以标示某个节点(node)的指针。当然,它并不真正是指针。

View与使用者的交谈(鼠标消息处理实例)

为了实现「以鼠代笔」的功能,CScribbleView 必须接受并处理三个消息:

BEGIN_MESSAGE_MAP(CScribbleView, CView)
ON_WM_LBUTTONDOWN()
ON_WM_LBUTTONUP()
ON_WM_MOUSEMOVE()
...
END_MESSAGE_MAP()

三个消息处理例程的的内容总括来说就是追踪鼠标轨迹、在窗口上绘图、以及调用CStroke成员函数以修正线条内容——包括产生一个新的线条空间以及不断把坐标点加上去。三个函数的重要动作摘记于下。这些函数的骨干及其在 Messape Map中的映射项目,不劳我们动手,有ClassWizard代劳。下一个小节我会介绍其操作方法。

void CScribbleView::OnLButtonDown(UINT nFlags, CPoint point)
{
    // 当鼠标左键按下,
    // 利用 CScribbleDoc::NewStroke 产生一个新的线条空间;
    // 利用 CArray::Add 把这个点加到线条上去;
    // 调用 SetCapture 取得鼠标捕捉权(mouse capture);
    // 把这个点记录为 「上一点」(m_ptPrev);
}
void CScribbleView::OnMouseMove(UINT, CPoint point)
{
    // 当鼠标左键按住并开始移动,
    // 利用 CArray::Add 把新坐标点加到线条上;
    // 在上一点(m_ptPrev)和这一点之间画直线;
    // 把这个点记录为「上一点」(m_ptPrev);
}
void CScribbleView::OnLButtonUp(UINT, CPoint point)
{
    // 当鼠标左键放开,
    // 在上一点(m_ptPrev)和这一点之间画直线;
    // 利用 CArray::Add 把新的点加到线条上;
    // 调用 ReleaseCapture() 释放鼠标捕捉权(mouse capture)。
}

ClassWizard 的辅佐

前述三个CScribbleView成员函数(OnLButtonDown,OnLButtonUp,OnMouseMove)是Message Map的一部分,ClassWizard 可以很方便地帮助我们完成相关的Message Map设定工作。

首先,选按【View/ClassWizard】启动ClassWizard,选择其【Message Map】附页:

在图右上侧的【Class Name】清单中选择 CScribbleView,然后在图左侧的【Object IDs】清单中选择 CScribbleView,再在图右侧的【Messages】清单中选择 WM_LBUTTONDOWN,然后选按图右的【Add Function】钮,于是图下侧的【Member functions】清单中出现一笔新项目。

然后,选按【Edit Code】钮,文字编辑器会跳出来,你获得了一个 OnLButtonDown 函数空壳,请在这里键入你的程序代码:

另两个消息处理例程的实现作法雷同。

Message Map 因此有什么变化呢?ClassWizard为我们自动加上了三笔映射项目:

此外ScribbleView的类声明中也自动有了三个成员函数的声明:

WizardBar的辅佐

WizardBar是Visual C++ 4.0之后的新增工具,也就是文字编辑器上方那个有着【Object IDs】和【Messages】清单的横杆。关于修改Message Map这件事,WizardBar 可以取代ClassWizard 这个大家伙。

首先,进入ScribbleViwe.cpp(因为我们确定要在这里加入三个鼠标消息处理例程),选择WizardBar上的【Object IDs】 为CScribbleView , 再选择【 Messages 】为WM_LBUTTONDOWN,出现以下画面:

回答Yes,于是你获得一个OnLButtonDown 函数空壳,一如在ClassWizard 中所得。请在函数空壳中输入你的程序代码。

Serialize :对象的文件读写

你可能对Serialization 这个名词感觉陌生,事实上它就是面向对象世界里的 Persistence(永续生存),只是后者比较抽象一些。对象必须能够永续生存,也就是它们必须能够在程序结束时储存到文件中,并且在程序重新启动时再恢复回来。储存和恢复对象的过程在MFC 之中就称为serialization。负责这件重要任务的,是MFC CObject类中一个名为Serialize的虚函数,文件的「读」「写」动作均透过它。

如果文件内容是借着层层类向下管理(一如本例),那么只要每一层把自己份内的工作做好,层层交待下来就可以完成整份数据的文件动作。

Serialization 以外的文件读写动作

其实有时候我们希望在重重包装之中返璞归真一下,感受一些质朴的动作。在介绍Serialization 的重重包装之前,这里给你一览文件实际读写动作的机会。

文件I/O 服务是任何操作系统的主要服务。Win32 提供了许多文件相关 APIs:开文件、关文件、读文件、写文件、搜寻数据...。MFC 把这些操作都包装在 CFile 之中。可想而知,它必然有 Open、Close、Read、Write、Seek... 等等成员函数。下面这段程序代码示范 CFile 如何读文件:

char* pBuffer = new char[0x8000];
CFile file("mydoc.doc", CFile::modeRead); //打开mydoc.doc 文件,使用只读模式。
UINT nBytesRead = file.Read(pBuffer, 0x8000);//读取8000h个字节到 pBuffer中。

上述程序片段中,对象file的构造函数将打开mydoc.doc檔。并且由于此对象产生于函数的堆栈之中,当函数结束,file的析构函数将自动关闭mydoc.doc檔。

开文件模式有许多种,都定义在 CFile(AFX.H)之中:

再举一例,下面这段程序代码可将文件mydoc.doc 的所有文字转换为小写:

文件的操作常需配合对异常情况(exception)的处理,因为文件的异常情况特别多:檔案找不到啦、文件handles 不足啦、读写失败啦...。上一例加入异样情况处理后如下:

台面上的 Serialize 动作

让我以Scribble为例,向你解释台面上的(应用程序代码中可见的)serialization 动作。根据图 8-3 的数据结构,Scribble 程序的文件读写动作是这么分工的:

  • Framework调用CSribbleDoc::Serialize,用以对付文件。

  • CScribbleDoc 再往下调用CStroke::Serialize,用以对付线条。

  • CStroke 再往下调用CArray::Serialize,用以对付点数组。

读也由它,写也由它,究竟Serialize 是读还是写?这一点不必我们操心。Framework 调用Serialize 时会传来一个CArchive 对象(稍后我会解释 CArchive),你可以想象它代表一个文件,透过其IsStoring 成员函数,即可知道究竟要读还是写。图8-5 是各层级的Serialize 动作示意图,文字说明已在图片之中。

注意:Scribble 程序使用 CArray<CPoint, CPoint> 储存鼠标位置坐标,而CArray 是一个template class,解释起来比较复杂。所以稍后我挖给各位看的 Serialize 函数原始代码,采用CDWordArray 的成员函数而非CArray的成员函数。Visual C++ 1.5版的Scribble 范例程序就是使用 CDWordArray(彼时还未有 template class)。

然而,为求完备,我还是在此先把CArray的Serialize函数原始代码列出:

图8-5a Scribble Step1的文件读写(文件)动作

图8-5b CObList::Serialize 原始代码

图8-5c CDWordArray::Serialize 原始代码

图8-5d Scribble Document的Serialize动作细部分解

实际看看储存在磁盘中的 .SCB 文件内容,对Serialize 将会有深刻的体会。图8-6a 是使用者在Scribble Step1程序的绘图画面及存盘内容(以Turbo Dump观察获得),图 8-6b 是文件内容的解释。我们必须了解隐藏在 MFC 机制中的serialization细部动作,才能清楚这些二进位数据的产生原由。如果你认为看倾印代码(dump code)是件令人头晕的事情,那么你会错失许多美丽事物。真的,倾印代码使我们了解许多深层结构。

我在Scribble 中作画并存文件。为了突显笔宽的不同,我用了第10章的 Step3 版本,该版本的 Document 格式与 Step1 的相同,但允许使用者设定笔宽。图 8-6a 第一条线条的笔宽是 2,第二条是 5,第三条是 10,第四条是 20。文件储存于 PENWIDTH.SCB 文件中。

图8-6a 在Scribble中作画并存文件。PENWIDTH.SCB 文件全长109 个字节

图8-6b PENWIDTH.SCB 文件内容剖析。别忘了Intel采用 "little-endian" 字节排列方式,每一个字组的前后字节系颠倒放置

台面下的 Serialize 写文件奥秘

你属于打破砂锅问到底,不到黄河心不死那一型吗?我会满足你的好奇心。从应用程序代码的层面来看,关于文件的读写,我们有许多环节无法打通,类的层层调用动作似乎有几个缺口,而图8-6a文件文件倾印代码中神秘的FF FF 01 00 07 00 43 53 74 72 6F 6B 65也暧昧难明。现在让我来抽丝剥茧。

在挖宝过程之中,我们当然需要一些工具。我不选用昂贵的电钻、空压机或怪手(因为你可能没有),我只选用简单的鹤嘴锄和铲子:一个文字搜寻工具,一个文件倾印工具,一个Visual C++ 内含的除错器。

  • GREP.COM:UNIX上赫赫有名的文字搜寻工具,Borland C++ 编译器套件附了一个DOS版。此工具可以为我们搜寻文件中是否有特定字符串。PC Tools 也有这种功能,但PC Tools属于重量级装备,不符合我的选角要求。GREP的使用方式如下:

  • TDUMP.EXE:Turbo Dump,Borland C++ 所附工具,可将任何文件以16进位代码显示。使用方式如下:

    C:\> tdump penwidth.scb  (输出结果将送往屏幕)
    

    C:\> tdump penwidth.scb > filename  (输出结果将送往文件)
    
  • Visual C++ 除错器:我已在第4章介绍过这个除错器。我假设你已经懂得如何设定中断点、观察变量值,并以Go、Step Into、Step Over、Step Out、Step to Cursor 进行除错。这里我要补充的是如何观察"Call Stack"。

如果我把中断点设在 CScribbleDoc::OnOpenDocument 函数中的第一行,

然后以Go进入除错程序,当我在Scribble 中打开一份文件(首先面对一个对话框,然后指定文件名),程序停留在中断点上,然后我选按【View/Call Stack】,出现【Call Stack】窗口,把中断点之前所有未结束的函数列出来。这份数据可以帮助我们挖掘MFC。

好,图 8-5a的函数流程使图8-6a 的文件文件倾印代码曙光乍现,但是其中有些关节仍还模模糊糊,旋明旋暗。那完全是因为 CObList 在处理每一个元素(一个 CObject 派生类之对象实体)的文件动作时,有许多幕后的、不易观察到的机制。让我们从使用者按下【Save As】选单项目开始,追踪程序的进行。

你属于打破砂锅问到底,不到黄河心不死那一型吗?这段刨根究底的过程应能解你疑惑。根据我的经验,经过这么一次巡礼,我们就能够透析 MFC 的内部运作并确实掌握MFC的类运用了。换言之,我们现在到达知其所以然的境界了。

台面下的 Serialize 读文件奥秘

大大地喘口气吧,能够把MFC的Serialize 写文件动作完全摸透,是件值得慰劳自己的「功绩」。但是你只能轻松一下下,因为读文件动作还没有讨论过,而读文件绝不只是「写文件的逆向操作」而已。

把对象从文件中读进来,究竟技术关键在哪里?读取数据当然没问题,问题是「Document/View/Frame 三口组」怎么产生?从文件中读进一个类名称,又如何动态产生其对象?当我从文件读到"CStroke" 这个字符串,并且知道它代表一个类名称,然后我怎么办?我能够这么做吗:

CString aStr;
... // read a string from file to aStr
CStroke* pStroke = new aStr;

不行!这是语言版的动态生成;没有任何一个C++编译器支持这种能力。那么我能够这么做吗:

可以,但真是粗糙啊。万一再加上一种新类呢?万一又加上一种新类呢?不胜其扰也!

第3章已经提出动态生成的观念以及实现方式了。主要关键还在于一个「类型录网」。这个型录网就是 CRuntimeClass 组成的一个串列。每一个想要享有动态生成机能的类,都应该在「类型录网」上登记有案,登记数据包括对象的构造函数的指针。也就是说,上述那种极不优雅的比对动作,被 MFC 巧妙地埋起来了;应用程序可以风姿优雅地,单单使用DECLARE_SERIAL和 IMPLEMENT_SERIAL两个宏,就获得文件读写以及动态生成两种机制。

我将仿效前面对于写文件动作的探索,看看读文件的程序如何。

DYNAMIC / DYNCREATE / SERIAL 三宏

我猜你被三组看起来难分难解的宏困扰着,它们是:

  • DECLARE_DYNAMIC / IMPLEMENT_DYNAMIC

  • DECLARE_DYNCREATE / IMPLEMENT_DYNCREATE

  • DECLARE_SERIAL / IMPLEMENT_SERIAL

事实上我已经在第3章揭露其原始代码及其观念了。这里再以图 8-7 三张图片把宏原始代码、展开结果、以及带来的影响做个整理。SERIAL宏中比较令人费解的是它对>>运算符的重载动作。稍后我有一个CArchive小节,会交待其中细节。

你将在图8-7abc中看到几个令人困惑的大写常数,像是AFXAPI、AFXDATA等等。它们的意义可以在 VC++ 5.0 的\DEVSTUDIO\VC\MFC\INCLUDE\AFXVER_.H 中获得:

后二者就像afx_msg一样(我曾经在第6章的 Hello MFC 原始代码一出现之后解释过),是一个"intentional placeholder",可能在将来会用到,目前则为「无物」。

DYNAMIC / DYNCREATE / SERIAL三套宏分别在CRuntimeClass 所组成的「类型录网」中填写不同的记录,使MFC 类(以及你自己的类)分别具备三个等级的性能:

  • 基础机能以及对象诊断(可利用afxDump 输出诊断消息),以及Run Time Type Information(RTTI)。也有人把RTTI称为Run Time Class Information(RTCI)。

  • 动态生成(Dynamic Creation)

  • 文件读写(Serialization)

你的类究竟拥有什么等级的性能,得视其所使用的宏而定。三组宏分别实现不同等级的功能,如图 8-8。

Scribble Step1程序中与主结构相关的六个类,所使用的各式宏整理如下:

类名称     基类                  使用之宏

Serializable 的必要条件

欲让一个对象有Serialize 能力,它必须派生自一个Serializable 类。一个类意欲成为Serializable,必须有下列五大条件;至于其原因,前面的讨论已经全部交待过了。

1. 从CObject 派生下来。如此一来可保有RTTI、Dynamic Creation 等机能。

2. 类的声明部分必须有DECLARE_SERIAL 宏。此宏需要一个参数:类名称。

3. 类的实现部分必须有IMPLEMENT_SERIAL宏。此宏需要三个参数:一是类名称,二是父类名称,三是schema no.。

4. 改写Serialize 虚函数,使它能够适当地把类的成员变量写入文件中。

5. 为此类加上一个default 构造函数(也就是无参数之构造函数)。这个条件常为人所忽略,但它是必要的,因为若一个对象来自文件,MFC 必须先动态生成它,而且在没有任何参数的情况下调用其构造函数,然后才从文件中读出对象资料。

如此,让我们再复习一次本例之CStroke,看看是否符合上述五大条件:

CObject类

为什么绝大部分的 MFC 类,以及许多你自己的类,都要从 CObject 派生下来呢?因为当一个类派生自 CObject,它也就继承了许多重要的性质。CObject 这个「老祖宗」至少提供两个机能(两个虚函数):IsKindOf 和 IsSerializable。

IsKindOf

当Framework 掌握「类型录网」这张王牌,要设计出 IsKindOf 根本不是问题。所谓IsKindOf就是RTTI的化身,用白话说就是「xxx 对象是一种xxx 类吗?」例如「长臂猿是一种哺乳类吗?」「蓝鲸是一种鱼类吗?」凡支持 RTTI 的程序就必须接受这类询问,并对前者回答Yes,对后者回答No。

下面是 CObject::IsKindOf 虚函数的原始代码:

这项作为,也就是在图8-9中借着m_pBaseClass 寻根。只要在寻根过程中比对成功,就传回TRUE ,否则传回FALSE。 而你知道,图8-9 的「类型录网」是靠DECLARE_DYNAMIC和IMPLEMENT_DYNAMIC宏构造起来的。第3章的「RTTI」一节对此多有说明。

IsSerializable

一个类若要能够进行Serialization 动作,必须准备Serialize 函数,并且在「类型录网」中自己的那个CRuntimeClass元素里的schema字段里设立 0xFFFF 以外的号代码,代表数据格式的版本(这样才能提供机会让设计较佳的 Serialize 函数能够区分旧版数据或新版资料,避免牛头不对马嘴的困惑) 。这些都是DECLARE_SERIAL和IMPLEMENT_SERIAL宏的责任范围。

CObject提供了一个虚函数,让程序在执行时期判断某类的schema号代码是否为0xFFFF,藉此得知它是否可以Serialize:

BOOL CObject::IsSerializable() const
{
    return (GetRuntimeClass()->m_wSchema != 0xffff);
}

CObject::Serialize

这是一个虚函数。每一个希望具备Serialization 能力的类都应该改写它。事实上Wizard 为我们做出来的程序代码中也都会自动加上这个函数的调用动作。MFC手册上总是说,每一个你所改写的Serialize函数都应该在第一时间调用此一函数,那么是不是CObject::Serialize 之中有什么重要的动作?

// in AFX.INL
_AFX_INLINE void CObject::Serialize(CArchive&)
{ /* CObject does not serialize anything by default */ }

不,什么也没有。所以,现阶段(至少截至 MFC 4.0)你可以不必理会手册上的谆谆告诲。然而,Microsoft 很有可能改变CObject::Serialize 的内容,届时没有遵循告诲的人恐怕就后悔了。

图8-9 DECLARE 和IMPLEMENT宏合力构造起这张网

于是RTTI和Dynamic Creation和Serialization等机能便可轻易达成

CArchive 类

谈到Serialize 就不能不谈CArchive,因为serialize 的对象(无论读或写)是一个CArchive 对象,这一点相信你已经从上面数节讨论中熟悉了。基本上你可以想象archive相当于文件,不过它其实是文件之前的一个内存缓冲区。所以我们才会在前面的「台面下的 Serialize 奥秘」中看到这样的动作:

operator<<operator>>

CArchive 针对许多C++ 数据类型、Windows 数据类型以及CObject 派生类,定义operator<<operator>> 重载运算符:

这些重载运算符均定义于AFX.INL 文件中。另有些函数可能你会觉得眼熟,没错,它们在稍早的「台面下的Serialize奥秘」中已经出现过了,它们是 ReadObject、WriteObject、ReadClass、WriteClass。

各种类型的operator>>operator<< 重载运算符,正是为什么你可以将各种类型的资料(甚至包括CObject*)读出或写入archive 的原因。一个「C++ 类」(而非一般资料类型)如果希望有Serialization 机制,它的第一要件就是直接或间接派生自Object,为的是希望自 CObject 继承下列三个运算符:

其中CArchive::WriteObject先把类的CRuntimeClass资讯写出, 再调用类的Serialize 函数。CArchive::ReadObject的行为类似,先把类的 CRuntimeClass 信息读入,再调用类的Serialize 函数。Serialize是 CObject的虚函数,因此你必须确定你的类改写的Serialize 函数的回返值和参数类型都符合CObject 中的声明:传回值为void,唯一一个参数为 CArchive&。

注意:CString、CRect、CSize、CPoint 并不派生自CObject,但它们也可以直接使用针对CArchive 的<<>>运算符,因为它们自己设计了一套:

一个类如果希望有Serialization 机制,它的第二要件就是使用 SERIAL宏。这个宏包容DYNCREATE 宏,并且在类的声明之中加上:

friend CArchive& AFXAPI operator>>(CArchive& ar, class_name* &pOb);

在类的实现文件中加上:

CArchive& AFXAPI operator>>(CArchive& ar, class_name* &pOb) \
{ pOb = (class_name*) ar.ReadObject(RUNTIME_CLASS(class_name)); \
return ar; } \

如果我的类名为CStroke,那么经由

class CStroke : public CObject
{
    ...
    DECLARE_SERIAL(CStroke)
}

IMPLEMENT_SERIAL(CStroke, CObject, 1)

我就获得了两组和 CArchive 读写动作的关键性程序代码:

class CStroke : CObject
{
    ...
    friend CArchive& AFXAPI operator>>(CArchive& ar, CStroke* &pOb);
}
CArchive& AFXAPI operator>>(CArchive& ar, CStroke* &pOb)
{ pOb = (CStroke*) ar.ReadObject(RUNTIME_CLASS(CStroke));
return ar; }

好,你看到了,为什么只改写operator>>,而没有改写operator<<?原因是 WriteObject 并不需要CRuntimeClass 信息,但ReadObject 需要,因为在读完文件后还要做动态生成的动作。

效率考虑

我想你一定在前面解剖文件文件倾印代码时就注意到了,当文件文件内含有许多对象数据时,凡对象隶属同一类者,只有第一个对象才连同类的 CRuntimeClass 信息一并写入,此后同类之对象仅以一个代码表示,例如图 8-6c 中时而出现的 8001 代码。为了效率的考虑,这是有必要的。想想看,如果一张 Scribble 图形有成千上万个线条,难不成要写入成千上万个 CStroke 信息不成?在哈滴(Hard Disk)极为便宜的今天,考虑的重点并不是文件的大小,而是文件大小背后所影响的读写时间,以及网络传输时间。别忘了,一切桌上的东西都将跃于网上。

CArchive维护类信息的作法是,当它做输出动作,对象名称以及参考值被维护在一个map之中;当它做读入动作,它把对象维护在一个array 之中。CArchive 中的成员变量m_pSchemaMap 就是为此而来:

union
{
    CPtrArray* m_pLoadArray;
    CMapPtrToPtr* m_pStoreMap;
};
// map to keep track of mismatched schemas
CMapPtrToPtr* m_pSchemaMap;

自定 SERIAL 宏给抽象类使用

你是知道的,所谓抽象类就是包含纯虚函数的类,所谓纯虚函数就是只有声明没有定义的虚函数。所以,你不可能将抽象类具现化(instantiated)。那么,IMPLEMENT_SERIAL 展开所得的这段代码:

CObject* PASCAL class_name::CreateObject() \
{ return new class_name; } \

面对如果一个抽象类 class_name 就行不通了,编译时会产生错误消息。这时你得自行定义宏如下:

#define IMPLEMENT_SERIAL_MY(class_name, base_class_name, wSchema) \
_IMPLEMENT_RUNTIMECLASS(class_name, base_class_name, wSchema, NULL) \
CArchive& AFXAPI operator>>(CArchive& ar, class_name* &pOb) \
{ pOb = (class_name*) ar.ReadObject(RUNTIME_CLASS(class_name)); \
return ar; } \

也就是,令CreateObject函数为NULL,这才能够使用于抽象类之中。

在 CObList 中加入 CStroke 以外的类

Scribble Document倾印代码中的那个代表「旧类」的8001一直令我如坐针毡。不知道什么情况下会出现8002?或是8003?或是什么其它东东。因此,我打算做点测试。除了CStroke,我打算再加上 CRectangle 和 CCircle 两个类,并把其对象挂到CObList中。这个修改纯粹为了测试不同类写到文件文件中会造成什么后果,没有考虑使用者介面或任何外围因素,我并不是真打算为 Scribble 加上画四方形和画圆形的功能(不过如果你喜欢,这倒是能够给你作为一个导引),所以我把 Step1 拷贝一份,在拷贝版上做文章。

首先我必须声明 CCircle 和 CRectangle。在新文件中做这件事当然可以,但考虑到简化问题,以及它们与 CStroke 可能会有彼此前置参考的情况,我还是把它们放在原有的ScribbleDoc.h中好了。为了能够”Serialize”,它们都必须派生自CObject,使用DECLARE_SERIAL宏,并改写Serialize 虚函数,而且拥有default constructor。

CRectange有一个成员变量CRect m_rect,代表四方形的四个点;CCircle 有一个成员变量CPoint m_center 和一个成员变量UINT m_radius,代表圆心和半径:

接下来我必须在 ScribbleDoc.cpp 中使用 IMPLEMENT_SERIAL 宏,并定义成员函数。手册上要求每一个Serializable 类都应该准备一个空的构造函数(default constructor)。照着做吧,免得将来遗憾:

接下来我应该改变使用者接口,加上选单或工具列,以便在涂鸦过程中得随时加上一个四方形或一个圆圈。但我刚才说了,我只是打算做个小小的文件文件格式测试而已,所以简单化是我的最高指导原则。我打算搭现有之使用者接口的便车,也就是每次鼠标左键按下开始一条线条之后,再 new 一个四方形和一个圆形,并和线条一起加入 CObList 之中,然后才开始接受左键的坐标...。所以,我修改 CScribDoc::NewStroke 函数如下:

并将scribbledoc.h 中的m_strokeList 修改为:

CTypedPtrList<CObList, CObject*> m_strokeList;

重新编译链接,获得结果如图 8-10a。图8-10b 对此结果有详细的剖析。

图8-10a TEST.SCB 文件内容,文件全长146个字节

每次鼠标左键按下,开始一条线条,图8-10a中的程序立刻new一个四方形和一个圆形,并和线条一起加入CObList 之中,然后才开始接受左键的坐标。所以图8-10a 的执行画面造成本图的数据结构。

图8-10b TEST.SCB文件内容剖析。别忘了Intel采用"little-endian"

位组排列方式,每一字组的前后字节系颠倒放置。本图已将之摆正。

Document 与 View 交流——为 Step4 做准备

虽然 Scribble Step1 已经可以正常工作,有些地方仍值得改进。在一个子窗口上作画,然后选按【Window/New Window】,会蹦出一个新的子窗口,内有第一个子窗口的图形,同时,第一个子窗口的标题加上 :1 字样,第二个子窗口的标题则有 :2 字样。这是Document/View 架构带给我们的礼物,换句话说,想以多个窗口观察同一份数据,程序员不必负担什么任务。但是,如果此后使用者在其中一个子窗口上作画而不缩放窗口尺寸的话(也就是没有产生 WM_PAINT),另一个子窗口内看不到新的绘图内容:

这不是好现象!一体的两面怎么可以不一致呢?!

那么,让「作用中的 View 窗口」以消息通知隶属同一份 Document 的其它「兄弟窗口」,是不是就可以解决这个问题?是的,而且 Framework 已经把这样的机制埋伏下去了。

CView 之中的三个虚函数:

  • CView::OnInitialUpdate——负责View的初始化。

  • CView::OnUpdate——当Framework 调用此函数,表示Document的内容已有变化。

  • CView::OnDraw——Framework将在WM_PAINT 发生后,调用此函数。此函数应负责更新 View 窗口的内容。

    这些函数往往成为程序员改写的目标。Scribble第一版就是因为只改写了其中的OnDraw函数,所以才有「多个 View 窗口不能同步更新」的缺失。想要改善这项缺失,我们必须改写 OnUpdate。

    让所有的 View 窗口「同步」更新数据的关键在于两个函数:

  • CDocument::UpdateAllViews——如果这个函数执行起来,它会巡访所有隶属同一Document 的各个Views,找到一个就通知一个,而所谓「通知」就是调用View的OnUpdate 函数。

  • CView::OnUpdate——这是一个虚函数,我们可以改写它,在其中设计绘图动作,也许全部重绘(这比较笨一点),也许想办法只绘必要的一小部分(这样速度比较快,但设计上比较复杂些)。

因此,当一个Document 的数据改变时,我们应该设法调用其 UpdateAllViews,通知所有的Views。什么时候Scribble 的数据会改变?答案是鼠标左键按下时 ! 所以你可能猜测到,我打算在CView::OnLButtonDown 内调用CDocument::UpdateAllViews。这个猜测的立论点是对的而结果是错的,Scribble Step4 的作法是在 CView::OnButtonUp 内部调用它。

CView::OnUpdate 被调用,代表着 View 被告知:「嘿,Document 的内容已经改变了,请你准备修改你的显示画面」。如果你想节省力气,利用 Invalidate(TRUE) 把窗口整个设为重绘区(无效区)并产生 WM_PAINT,再让 CView::OnDraw 去伤脑筋算了。但是全部重绘的效率低落,程序看起来很笨拙,Step4 将有比较精致的作法。

  • 1 使用者在 View:1 做动作(View 扮演使用者接口的第一线)。

  • 2 View:1 调用 GetDocument,取得 Document 指针,更改数据内容。

  • 3 View:1 调用 Document 的 UpdateAllViews。

  • 4 View:2 和 View:3 的 OnUpdate 一一被调用起来,这是更新画面的时机。

图8-11 假设一份Document链接了三个Views

注意:在MFC手册或其它书籍中,你可能会看到像「View1 以消息通知 Document」或「Document 以消息通知 View2、View3」的说法。这里所谓的「消息」是面向对象学术界的术语,不要和 Windows 的消息混淆了。事实上整个过程中并没有任何一个Windows消息参与其中。