第11章 View功能之加强与重绘效率之提升

前面数章中,我们已经看到了View 如何扮演Document与使用者之间的媒介:View 显示Document 的数据内容,并且接受鼠标在窗口上的行为(左键按下、放开、鼠标移动),视为对 Document 的操作。

Scribble可以对同一份Document产生一个以上的Views,这是MDI程序的「天赋」 MDI 程序标准的【Window/New Window】窗体项目就是为达此目标而设计的。但有一个缺点还待克服,那就是你在窗口A的绘图动作不能实时影响窗口B,也就是说它们之间并没有所谓的同步更新——即使它们是同一份数据的一体两面!

Scribble Step4 解决上述问题。主要关键在于想办法通知所有相同血源(同一份Document)的各兄弟(各个Views),让它们一起行动。但却因此必须考虑这个问题:

如果使用者的一个鼠标动作引发许多许多的程序绘图动作,那么「同步更新」的绘图效率就变得非常重要。因此在考虑如何加强显示能力时,我们就得设计所谓的「必要绘图区」,也就是所谓的Invalidate Region,或称「不再适用的局部」或「重绘区」。每当使用者增加新的线条,Scribble Step4 便把「包围该线条之最小四方形」设定为「必要绘图区」。为了记录这项数据,从 Step1 延用至今的 Document 数据结构必须有所改变。

Step4 的同步更新,是以一笔画为单位,而非以一个点为单位。换句话说在一笔画未完成之前,不打算让同源的多个 View 窗口同步更新 -- 那毕竟太伤效率了。

Scribble Step4 的另一项改善是为 Document Frame 窗口增加垂直和水平滚动条,并且示范一种所谓的分裂窗口(Splitter window),如图 11-1。这种窗口的主要功能是当使用者欲对文件做一体两面(或多面)观察时,各个观察子窗口可以集中在一个大的母窗口中。在这里,子窗口被称为「窗口」(pane)。

图11-1 Scribble Step4,同源(同一份Document)之各个View 窗口具备同步更新的能力。Document Frame窗口具备分裂窗口与卷轴。

同时修改多个 Views:UpdateAllViews 和 OnUpdate

在Scribble View上绘图,然后选按【Window/New Window】,会蹦出另一个新的View,其内的图形与前一个View 相同。这两个Views 就是同一份文件的两个「观景窗」。新窗口的产生导至 WM_PAINT 产生,于是 OnDraw 发生效用,把文件内容画出来:

图11-2 一份Document连结两个Views,没有同步修正画面

但是,此后如果你在Scrib1:1窗口上绘图而未缩放其尺寸的话(也就是不产生WM_PAINT),Scrib1:2 窗口内看不到后续绘图内容。我们并不希望如此,不幸的是上一章的Scribble Step3 正是如此。

不能同步更新的关键在于,没有人通知所有的兄弟们(Views)一起动手——动手调用OnDraw。你是知道的,只有当WM_PAINT产生,OnDraw才会被调用。因此,解决方式是对每一个兄弟都发出WM_PAINT,或任何其它方法——只要能通知到就好。也就是说,让附属于同一Document的所有Views都能够立即反应 Document内容变化的方法就是,始作俑者(被使用者用来修改Document内容的那个View)必须想办法通知其他兄弟。

经由CDocument::UpdateAllViews,MFC提供了这样的一个标准机制。让所有的Views 同步更新数据的关键在于两个函数:

1.CDocument::UpdateAllViews——这个函数会巡访所有隶属同一份 Document 的各个Views,找到一个就通知一个,而所谓「通知」就是调用其 OnUpdate 函数。

2.CView::OnUpdate ——我们可以在这个函数中设计绘图动作。或许是全部重绘(这比较笨一点),或许想办法只绘必要的一小部分(这比较聪明一些)。

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

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

  • 3 View:1调用Document 的UpdateAllViews。

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

如果想让绘图程序聪明一些,不要每次都全部重绘,而是只择「必须重绘」的局部重绘,那么OnUpdate 需要被提示什么是「必须重绘的局部」,这就必须借助于 UpdateAllViews的参数:

virtual void UpdateAllViews(CView* pSender,
LPARAM lHint,
CObject* pHint);
  • 第一个参数代表发出此一通牒的始作俑者。这个参数的设计无非是希望避免重复而无谓的通牒,因为始作俑者自己已经把画面更新过了(在鼠标消息处理常式中),不需要再被通知。

  • 后面两个参数lHint和pHint是所谓的提示参数(Hint),它们会被传送到同一Document所对应的每一个Views的OnUpdate函数去。lHint可以是一些特殊的提示值,pHint则是一个派生自CObject的对象指针。靠着设计良好的「提示」,OnUpdate 才有机会提高绘图效率。要不然直接通知OnDraw就好了,也不需要再搞出一个OnUpdate。

另一方面,OnUpdate 收到三个参数(由 CDocument:: UpdateAllViews 发出):

virtual void OnUpdate(CView* pSender,
LPARAM lHint,
CObject* pHint);

因此,一旦Document 数据改变,我们应该调用 CDocument::UpdateAllVi

ews以通知所有相关的Views。而在CMyView::OnUpdate函数中我们应该以效率为第一考虑,利用参数中的hint设定重绘区,使后续被唤起的OnDraw有最快的工作速度。注意,通常你不应该在OnUpdate中执行绘图动作,所有的绘图动作最好都应该集中在OnDraw;你在OnUpdate 函数中的行为应该是计算哪一块区 域需要重绘,然后调用CWnd::InvalidateRect,发出WM_PAINT让OnDraw去画图。结论是,改善同步更新以及绘图效率的前置工作如下:

1. 定义hint 的数据类型,用以描述已遭修改的数据局部。

2. 当使用者透过View改变了Document 内容,程序应该产生一个hint,描述此一修改,并以它做为参数,调用UpdateAllViews。

3. 改写CMyView::OnUpdate,利用hint设计高效率绘图动作,使hint 描述区之外的局部不要重画。

在 View 中定义一个 hint

以Scribble 为例,当使用者加上一段线条,如果我们计算出包围此一线条之最小四方形,那么只有与此四方形有交集的其它线条才需要重画,如图 11-3。因此在Step4中把hint设计为RECT类型,差堪为用。

效率考虑上,当然我们还可以精益求精取得各线条与此四方形的交点,然后只重绘四方形内部的那一部分即可,但这么做是否动用太多计算,是否使工程太过复杂以至于不划算,你可得谨慎评估。

图11-3 在rect.SCB:1窗口中新增一线条#5,那么,只有与虚线四方形(此四方形将#5 包起来)有交集之其它线条,也就是#1和#4,才有必要在rectSCB:2 窗口中重画。

前面曾说UpdateAllViews 函数的第三个参数必须是CObject派生对象之指针。由于本例十分单纯,与其为了Hint特别再设计一个类,勿宁在CStroke中增加一个变量(事实上是一个 CRect 对象),用以表示前述之hint四方形,那么每一条线条就外罩了一个小小的四方壳。但是我们不能把CRect对象指针直接当做参数来传,因为CRect并不派生自 CObject。稍后我会说明该怎么做。

可以预期的是,日后一定需要一一从每一线条中取出这个「外围四方形」,所以现在先声明并定义一个名为GetBoundingRect 的函数。另外再声明一个 FinishStroke 函数,其作用主要是计算这四方形尺寸。稍后我会详细解释这些函数的行为。

// in SCRIBBLEDOC.H
class CStroke : public CObject
{
    ...
public:
    UINT         m_nPenWidth;
    CDWordArray  m_pointArray;
    CRect        m_rectBounding; //smallest rect that surrounds all
    //of the points in the stroke
public:
    CRect& GetBoundingRect() { return m_rectBounding; }
    void FinishStroke();
    ...
};

我想你早已熟悉了Scribble Document 的数据结构。为了应付Step4的需要,现在每一线条必须多一个成员变量,那是一个CRect对象,如图11-4所示。

CScribble Step4 Document:

图11-4 CScribbleDoc对象中的各项数据

设计观念分析完毕,现在动手吧。我们必须在SCRIBDOC.CPP中的Document 初始化动作以及文件读写动作都加入m_rectBounding 这个新成员:

// in SCRIBDOC.CPP
IMPLEMENT_SERIAL(CStroke, CObject, 2)  // 注意schema no.改变为2
CStroke::CStroke(UINT nPenWidth)
{
    m_nPenWidth = nPenWidth;
    m_rectBounding.SetRectEmpty();
}
void CStroke::Serialize(CArchive& ar)
{
    if (ar.IsStoring())
    {
        ar << m_rectBounding;
        ar << (WORD)m_nPenWidth;
        m_pointArray.Serialize(ar);
    }
    else
    {
        ar >> m_rectBounding;
        WORD w;
        ar >> w;
        m_nPenWidth = w;
        m_pointArray.Serialize(ar);
    }
}

如果我们改变了文件读写的格式,我们就应该改变schema number(可视为版本号代码)。由于Scribble 数据文件(.SCB)格式改变了,多了一个 m_rectBounding,所以我们在IMPLEMENT_SERIAL 宏中改变Document的 Schema no. ,以便让不同版本的Scribble 程序识得不同版本的文件文件。如果你以Scribble Step3读取Step4所产生的文件,会因为Schema号代码的不同而得到这样的消息:

我们还需要一个函数,用以计算「线条之最小外包四方形」,这件事情当然是在线条完成后进行之,所以此一函数命名为FinishStroke。每当一笔画结束(鼠标左键放开,产生WM_LBUTTONUP),OnLButtonUp 就调用FinishStroke让它计算边界。计算方法很直接,取出线条中的坐标点,比大小而已:

// in SCRIBDOC.CPP
void CStroke::FinishStroke()
{
    // 计算外围四方形。为了灵巧而高效率的重绘动作,这是必要的。
    if (m_pointArray.GetSize()==0)
    {
        m_rectBounding.SetRectEmpty();
        return;
    }
    CPoint pt = m_pointArray[0];
    m_rectBounding = CRect(pt.x, pt.y, pt.x, pt.y);
    for (int i=1; i < m_pointArray.GetSize(); i++)
    {
        // 如果点在四方形之外,那么就将四方形膨胀,以含入该点。
        pt = m_pointArray[i];
        m_rectBounding.left = min(m_rectBounding.left, pt.x);
        m_rectBounding.right = max(m_rectBounding.right, pt.x);
        m_rectBounding.top = min(m_rectBounding.top, pt.y);
        m_rectBounding.bottom = max(m_rectBounding.bottom, pt.y);
    }
    // 在四方形之外再加上笔的宽度。
    m_rectBounding.InflateRect(CSize(m_nPenWidth, m_nPenWidth));
    return;
}

把 hint传给OnUpdate

下一步骤是想办法把hint交给UpdateAllViews。让我们想想什么时候 Scribble的数据开始产生改变?答案是鼠标左键按下时!或许你会以为要在 OnLButtonDown中调用CDocument::UpdateAllViews。这个猜测的论点可以成立但是结果错误,因为左键按下后还有一连串的鼠标轨迹移动,每次移动都导至数据改变,新的点不断被加上去。如果我们等左键放开,线条完成,再来调用 UpdateAllViews,事情会比较单纯。因此Scribble Step4是在OnButtonUp 中调用UpdateAllViews。当然我们现在就可以预想得到,一笔画完成之前,同一 Document 的其它Views 没有办法实时显示最新数据。

下面是 OnButtonUp 的修改内容:

void CScribbleView::OnLButtonUp(UINT, CPoint point)
{
    ...
    m_pStrokeCur->m_pointArray.Add(point);
    // 已完成加点的动作,现在可以计算外围四方形了
    m_pStrokeCur->FinishStroke();
    // 通知其它的 views,使它们得以修改它们的图形。
    pDoc->UpdateAllViews(this, 0L, m_pStrokeCur);
    ...
    return;
}

程序逻辑至为简单,唯一需要说明的是UpdateAllViews的第三个参数(hint),原本我们只需设此参数为m_rectBounding,即可满足需求,但MFC规定,第三参数必须是一个CObject 指针,而CRect并不派生自CObject,所以我们干脆就把目前正作用中的整个线条(也就是m_pStrokeCur)传过去算了。CStroke 的确是派生自CObject!

// in SCRIBBLEVIEW.H
class CScribbleView : public CScrollView
{
protected:
    CStroke*   m_pStrokeCur; // the stroke in progress
    ...
};
// in SCRIBBLEVIEW.CPP
void CScribbleView::OnLButtonDown(UINT, CPoint point)
{
    ...
    m_pStrokeCur = GetDocument()->NewStroke();
    m_pStrokeCur->m_pointArray.Add(point);
    ...
}
void CScribbleView::OnMouseMove(UINT, CPoint point)
{
    ...
    m_pStrokeCur->m_pointArray.Add(point);
    ...
}
void CScribbleView::OnLButtonUp(UINT, CPoint point)
{
    ...
    m_pStrokeCur->m_pointArray.Add(point);
    m_pStrokeCur->FinishStroke();
    pDoc->UpdateAllViews(this, 0L, m_pStrokeCur);
    ...
}

UpdateAllViews会巡访CScribbleDoc 所连接的每一个Views(始作俑者那个View 除外),调用它们的OnUpdate函数,并把hint做为参数之一传递过去。

利用 hint 增加重绘效率

预设情况下,OnUpdate 所收到的无效区(也就是重绘区),是 Document Frame 窗口的整个内部。但谁都知道,原已存在而且没有变化的图形再重绘也只是浪费时间而已。上一节你已看到Scribble每加上一整个线条,就在 OnLButtonUp函数中调用UpdateAllViews 函数,并且把整个线条(内含其四方边界)传过去,因此我们可以想办法在 OnUpdate 中重绘这个四方形小局部就好。

话说回来,如何能够只重绘一个小局部就好呢?我们可以一一取出 Document中每一线条的四方边界,与新线条的四方边界比较,若有交点就重绘该线条。CRect 有一个IntersectRect 函数正适合用来计算四方形交集。

但是有一点必须注意,绘图动作不是集中在OnDraw 吗?因此OnUpdate和 OnDraw之间的分工有必要厘清。前面数个版本中这两个函数的动作是:

  • OnUpdate——啥也没做。事实上CScribbleView 原本根本没有改写此一函数。

  • OnDraw——迭代取得Document中的每一线条,并调用 CStroke::DrawStroke 将线条绘出。

Scribble Step4 之中,这两个函数的动作如下:

  • OnUpdate——判断Framework传来的hint是否为CStroke 对象。如果是,设定无效局部(重绘局部)为该线条的外围四方形;如果不是,设定无效局部为整个窗口局部。「设定无效局部」(也就是调用 CWnd::InvalidateRect)会引发WM_PAINT,于是引发 OnDraw。

  • OnDraw——迭代取得Document中的每一线条,并调用CStroke::GetBound

ingRect取线条之外围四方形,如果与「无效局部」有交集,就调用 CStroke::DrawStroke绘出整个线条。如果没有交集,就跳过不画。

以下是新增的 OnUpdate 函数:

// in SCRIBVW.CPP
void CScribbleView::OnUpdate(CView* /* pSender */, LPARAM /* lHint */,
CObject* pHint)
{
    // Document 通知 View 说,某些数据已经改变了
    if (pHint != NULL)
    {
        if (pHint->IsKindOf(RUNTIME_CLASS(CStroke)))
        {
            //hint提示我们哪一线条被加入(或被修改),所以我们要把该线条的
            // 外围四方形设为无效区。
            CStroke* pStroke = (CStroke*)pHint;
            CClientDC dc(this);
            OnPrepareDC(&dc);
            CRect rectInvalid = pStroke->GetBoundingRect();
            dc.LPtoDP(&rectInvalid);
            InvalidateRect(&rectInvalid);
            return;
        }
    }
    // 如果我们不能解释 hint 内容(也就是说它不是我们所预期的
    // CStroke 对象),那就让整个窗口重绘吧(把整个窗口设为无效区)。
    Invalidate(TRUE);
    return;
}

为什么OnUpdate 之中要调用OnPrepareDC?这关系到滚动条,我将在介绍分裂窗口时再说明。另,GetBoundingRect 动作如下:

CRect& GetBoundingRect() { return m_rectBounding; }

OnDraw函数也为了高效能重绘动作之故,做了以下修改。阴影部分是与 Scribble Step3不同之处:

// SCRIBVW.CPP
void CScribbleView::OnDraw(CDC* pDC)
{
    CScribbleDoc* pDoc = GetDocument();
    ASSERT_VALID(pDoc);
    // 取得窗口的无效区。如果是在打印状态情况下,则取
    // printer DC 的截割区(clipping region)。
    CRect rectClip;
    CRect rectStroke;
    pDC->GetClipBox(&rectClip);
    // 注意:CScrollView::OnPrepare 已经在 OnDraw 被调用之前先一步
    // 调整了 DC 原点,用以反应出目前的卷动位置。关于 CScrollView,
    // 下一节就会提到。
    // 调用 CStroke::DrawStroke 完成无效区中各线条的绘图动作
    CTypedPtrList<CObList,CStroke*>& strokeList = pDoc->m_strokeList;
    POSITION pos = strokeList.GetHeadPosition();
    while (pos != NULL)
    {
        CStroke* pStroke = strokeList.GetNext(pos);
        rectStroke = pStroke->GetBoundingRect();
        if (!rectStroke.IntersectRect(&rectStroke, &rectClip))
        continue;
        pStroke->DrawStroke(pDC);
    }
}

可卷动的窗口:CScrollView

到目前为止我们还没有办法观察一张比窗口还大的图,因为我们没有滚动条。一个View窗口没有滚动条,是很糟糕的事,因为通常Document 范围大而观景窗范围小。我们不能老让Document与View 窗口一样大。一个具备滚动条的View 窗口更具有「观景窗」的意义。

图11-5a 一个具备滚动条的View窗口更具「观景窗」的意义

如果你有SDK程序设计经验,你就会知道设计一个可卷动的窗口是多么烦琐的事(文字的卷动还算好,图形的卷动更惨)。MFC 当然不可能对此一般性功能坐视不管,事实上它已设计好一个CScrollView,其中的滚动条有实时卷动(边拉卷动杆边跑)的效果。

基本上要使View窗口具备滚动条,你必须做到下列事情:

定义Document大小。如果没有大小,Framework 就没有办法计算滚动条尺寸,以及卷动比例。这个大小可以是常数,也可以是个储存在每一Document 中的变量,随着执行时期变动。

  • 以CScrollView 取代CView。

  • 只要Document的大小改变,就将尺寸传给CScrollView 的 SetScrollSizes 函 式。如果程序设定Document为固定大小(本例就是如此),那么当然只要一开始做一次滚动条设定动作即可。

  • 注意装置坐标(窗口坐标)与逻辑坐标(Document 坐标)的转换。关于此点稍后另有说明。

Application Framework 对滚动条的贡献是:

  • 处理WM_HSCROLL和WM_VSCROLL消息,并相对地卷动Document(其实是移动View 落在Document上的位置 )以及移动「卷轴拉杆」(所谓的thumb)。拉杆位置可以表示出目前窗口中显示的局部在整个Document的位置。如果你按下滚动条两端箭头,卷动的幅度是一行(line),至于一行代表多少,由程序员自行决定。如果你按下的是拉杆两旁的杆子,卷动的幅度是一页(page),一页到底代表多少,也由程序员自行决定。

图11-5b 滚动条View 窗口与Document之间的关系

窗口一旦被放大缩小,立刻计算窗口的宽度高度与滚动条长度的比例,以重新设定卷动比例,也就是一行或一页的大小。

以下分四个步骤修改Scribble原始代码:

1 定义Document 的大小。我们的作法是设定一个变量,代表大小,并在 Document初始化时设定其值,此后全程不再改变(以简化问题)。MFC中有一个 CSize 很适合当作此一变量类型。这个成员变量在文件进行文件读写(Serialization)时也应该并入文件内容中。回忆一下,上一章讲到笔宽时,由于每一线条有自己的一个宽度,所以笔宽资料应该在CStroke::Serialize 中读写,现在我们所讨论的文件大小却是属于整份文件的资料,所以应该在 CScribbleDoc::Serialize 中读写:

// in SCRIBBLEDOC.H
class CScribbleDoc : public CDocument
{
protected:
    Csize    m_sizeDoc;
    public:
    CSize GetDocSize() { return m_sizeDoc; }
    ...
};
// in SCRIBBLEDOC.CPP
void CScribbleDoc::InitDocument()
{
    m_bThickPen = FALSE;
    m_nThinWidth = 2;   // default thin pen is 2 pixels wide
    m_nThickWidth = 5;  // default thick pen is 5 pixels wide
    ReplacePen();       // initialize pen according to current width
    // 预设 Document 大小为 800 x 900 个屏幕图素
    m_sizeDoc = CSize(800,900);
}
void CScribbleDoc::Serialize(CArchive& ar)
{
    if (ar.IsStoring())
    {
        ar << m_sizeDoc;
    }
    else
    {
        ar >> m_sizeDoc;
    }
    m_strokeList.Serialize(ar);
}

2 将CScribbleView 的父类由CView改变为CScrollView。同时准备改写其虚函数OnInitialUpdate,为的是稍后我们要在其中,根据Document的大小,设定卷动范围。

// in SCRIBBLEVIEW.H
class CScribbleView : public CScrollView
{
public:
    virtual void OnInitialUpdate();
    ...
};
// in SCRIBBLEVIEW.CPP
IMPLEMENT_DYNCREATE(CScribbleView, CScrollView)
BEGIN_MESSAGE_MAP(CScribbleView, CScrollView)
...
END_MESSAGE_MAP()

3 改写OnInitialUpdate,在其中设定滚动条范围。这个函数的被调用时机是在View 第一次附着到Document 但尚未显现时,由Framework 调用之。它会调用OnUpdate,不带任何Hint参数(于是lHint是0而pHint是NULL)。如果你需要做任何「只做一次」的初始化动作,而且初始化时需要 Document 的数据,那么在这里做就最合适了:

// in SCRIBVW.CPP
void CScribbleView::OnInitialUpdate()
{
    SetScrollSizes(MM_TEXT, GetDocument()->GetDocSize());
    // 这是 CScrollView 的成员函数。
}

SetScrollSizes 总共有四个参数:

  • int nMapMode:代表映射模式(Mapping Mode)

  • SIZE sizeTotal:代表文件大小

  • const SIZE& sizePage:代表一页大小(预设是文件大小的 1/10)

  • const SIZE& sizeLine:代表一行大小(预设是文件大小的 1/100)

本例的文件大小是固定的。另一种比较复杂的情况是可变大小,那么你就必须在文件大小改变之后立刻调用SetScrollSizes。

窗口上增加滚动条并不会使View的OnDraw 负担加重。我们并不因为滚动条把观察镜头移动到Document的中段或尾段,而就必须在OnDraw中重新计算绘图原点与平移向量,原因是绘图坐标与我们所使用的DC有关。当滚动条移动了DC原点,CScrollView自动会做调整,让数据的某一部份显示而某一部份隐藏。

让我做更详细的说明。「GDI 原点」是DC(注)的重要特征,许许多多CDC 成员函数的绘图结果都会受它的影响。如果我们想在绘图之前(也就是进入 OnDraw 之前)调整DC,我们可以改写虚函数OnPrepareDC,因为Framework是先调用OnPrepareDC,然后才调用OnDraw并把DC传进去。好,窗口由无滚动条到增设滚动条的过程中,之所以不必修改OnDraw 函数内容,就是因为CScrollView已经改写了CView的OnPrepareDC虚函数。Framework先调用CScrollView::OnPrepareDC再调用CScribbleView::OnDraw,所有因为滚动条而必须做的特别处理都已经在进入OnDraw之前完成了。

注意上面的叙述,别把CScrollView和CSribbleView混淆了。下图是个整理。

此类的OnPrepareDC 虚函数会因滚动条的位置而调整DC原点。

此类原针对「无滚动条窗口」而设计,所以 Step4之前的View类是直接派生自CView。彼时所写的OnDraw函数内容在如今改变了继承关系后(改继承自CScrollView),依然完全适用,原因是所有的差异性早都在进入OnDraw之前便由更早被Framework 调用的 CScrollView::OnPrepare 处理掉了。

DC就是Device Context,在Windows中凡绘图动作之前一定要先获得一个 DC,它可能代表屏幕,也可能代表一个窗口,或一块内存,或打印机...。DC中有许多绘图所需的元素,包括坐标系统(映射模式)、原点、绘图工具(笔、刷、颜色...)等等。它还连接到低阶的输出装置驱动程序。由于DC,我们在程序中对屏幕作画和对打印机作画的动作才有可能完全相同。

4 修正鼠标坐标。虽说OnDraw不必因为坐标原点的变化而有任何改变,但是幕后出力的CScrollView::OnPrepareDC 却不知道什么是Windows 消息!这话看似牛头和马嘴,但我一点你就明白了。CScrollView::OnPrepareDC 虽然能够因着滚动条行为而改变GDI原点,但「改变GDI 原点」这个动作却不会影响你所接收的WM_LBUTTONDOWN 或WM_LBUTTONUP或WM_MOUSEMOVE的坐标值,原因是Windows 消息并非DC 的一个成份。因此,我们作画的基础,也就是鼠标移动产生的轨迹点坐标,必须由「以窗口绘图区左上角为原点」的窗口坐标系统,改变为「以文件左上角为原点」的逻辑坐标系统。文件中储存的,也应该是逻辑坐标。

下面是修改坐标的程序动作。其中调用的OnPrepareDC 是哪一个类的成员函数?想想看,CScribbleView派生自CScrollView,而我们并未在CscriBbleView中改写此一函数,所以程序中调用的是CScrollView::OnPrepareDC。

// in SCRIBVW.CPP
void CScribbleView::OnLButtonDown(UINT, CPoint point)
{
    // 由于CScrollView改变了DC原点和映射模式,所以我们必须先把
    // 装置坐标转换为逻辑坐标,再储存到Document中。
    CClientDC dc(this);
    OnPrepareDC(&dc);
    dc.DPtoLP(&point);
    m_pStrokeCur = GetDocument()->NewStroke();
    m_pStrokeCur->m_pointArray.Add(point);
    SetCapture();// 抓住鼠标
    m_ptPrev = point; // 做为直线绘图的第一个点
    return;
}
void CScribbleView::OnLButtonUp(UINT, CPoint point)
{
    ...
    if (GetCapture() != this)
    return;
    CScribbleDoc* pDoc = GetDocument();
    CClientDC dc(this);
    OnPrepareDC(&dc);  // 设定映射模式和 DC 原点
    dc.DPtoLP(&point);
    ...
}
void CScribbleView::OnMouseMove(UINT, CPoint point)
{
    ...
    if (GetCapture() != this)
    return;
    CClientDC dc(this);
    OnPrepareDC(&dc);
    dc.DPtoLP(&point);
    m_pStrokeCur->m_pointArray.Add(point);
    ...
}

除了上面三个函数,还有什么函数牵扯到坐标?是的,线条四周有一个外围四方形,那将在OnUpdate 中出现,也必须做坐标系统转换:

void CScribbleView::OnUpdate(CView* /* pSender */, LPARAM /* lHint */,
CObject* pHint)
{
    if (pHint != NULL)
    {
        if (pHint->IsKindOf(RUNTIME_CLASS(CStroke)))
        {
            //hint的确是一个CStroke对象。现在将其外围四方形设为重绘区
            CStroke* pStroke = (CStroke*)pHint;
            CClientDC dc(this);
            OnPrepareDC(&dc);
            CRect rectInvalid = pStroke->GetBoundingRect();
            dc.LPtoDP(&rectInvalid);
            InvalidateRect(&rectInvalid);
            return;
        }
    }
    // 无法识别 hint,只好假设整个画面都需重绘。
    Invalidate(TRUE);
    return;
}

注意,上面的LPtoDP所受参数竟然不是CPoint,而是CRect,那是因为 LPtoDP有重载函数(overloaded function),既可接受点,也可接受四方形。DPtoLP 也有类似的重载能力。

线条的外围四方形还可能出现在CStroke::FinishStroke 中,不过那里只是把线条数组中的点拿出来比大小,决定外围四方形罢了;而你知道,线条数组的点已经在加入时做过坐标转换了(分别在OnLButtonDown、OnMouseMove、OnLButtonUp 函数中的 AddPoint动作之前)。

至此,Document 的数据格式比起 Step1,有了大幅的变动。让我们再次分析文件文件的格式,以期获得更深入的认识与印证。我以图 11-6a 为例,共四条线段,宽度分别是 2, 5, 10, 20(十进制)。分析内容显示在图11-6b。

图11-6a 四条线段的图形与文件文件倾印代码

数值(hex)                      说明(共173bytes)

图11-6b 文件(图11-6a)的分析

大窗口中的小窗口:Splitter

MDI程序的标准功能是允许你为同一份Document开启一个以上的Views。这种情况类似我们以多个观景窗观看同一份数据。我们可以开启任意多个 Views,各有滚动条,那么我们就可以在屏幕上同时观察一份数据的不同局部。这许多个 View 窗口各自独立运作,因此它们的观看区可能互相重迭。

如果这些隶属同一Document的Views能够结合在一个大窗口之内,又各自有独立的行为(譬如说有自己的滚动条),似乎可以带给使用者更好的感觉和更方便的使用,不是吗?

分裂窗口的功能

把View做成所谓的「分裂窗口(splitter)」是一种不错的想法。这种窗口可以分裂出数个窗口,如图11-7,每一个窗口可以映射到Document的任何位置,窗口与窗口之间彼此独立运作。

在Splitter Box 上以鼠标左键快按两下,就可以将窗口分裂开来。Splitter Box 有水平和垂直两种。分裂窗口的窗口个数,由程序而定,本例是 2x2。不同的窗口可以观察同一份 Document 的不同局部。本例虽然很巧妙地安排出一张完整的图出来,其实四个窗口各自看到原图的某一部份。

图11-7 分裂窗口(splitter window)

在Splitter Box上以鼠标左键快按两下,就可以将窗口分裂开来。Splitter Box 有水平和垂直两种。分裂窗口的窗口个数,由程序而定,本例是 2x2。不同的窗口可以观察同一份 Document 的不同局部。本例虽然很巧妙地安排出一张完整的图出来,其实四个窗口各自看到原图的某一部份。

分裂窗口的程序概念

回忆第8章所说的Document/View 架构,每次打开一个Document,需有两个窗口通力合作才能完成显示任务,一是CMDIChildWnd 窗口,负责窗口的外框架与一般行为,一是CView 窗口,负责数据的显示。但是当分裂窗口引入,这种结构被打破。现在必须有三个窗口通力合作完成显示任务(图 11-8):

1. Document Frame 窗口:负责一般性窗口行为。其类派生自 CMDIChildWnd。

2. Splitter 窗口:负责管理各窗口。通常直接使用CSplitterWnd 类。

3. View 窗口:负责数据的显示。其类派生自CView。

图11-8 欲使用分裂窗口,必须三个对象合作才能完成显示任务,

一是Document Frame 窗口,负责一般性窗口行为;二是CSplitterWnd窗口,管理窗口内部空间(各个窗口);三是CView窗口,负责显示数据

给SDK程序员

你有以SDK撰写MDI程序的经验吗?MDI程序有三层窗口架构:

程序员想要控制MDI Child 窗口的大小、位置、排列状态,必须藉助另一个已经由Windows 系统定义好的窗口,此窗口称为MDI Client 窗口,其类 名称为"MDICLIENT"。

Frame窗口、Client窗口和Child窗口构成MDI的三层架构。Frame窗口产生之后,通常在WM_CREATE时机就以CreateWindow("MDICLIENT",...);的方式建立Client窗口,此后几乎所有对Child窗口的管理工作,诸如产生新的Child 窗口、重新排列窗口、重新排列图示、在选单上列出已开启窗口...等等,都由 Client代劳,只要Frame窗口向Client 窗口下命令(送 MDI 消息如 WM_MDICREATE或WM_MDITILE 就去)即可。

你可以把CSplitterWnd对象视为MDI Client,观念上比较容易打通。

在 SDK 程序中,MDI Child 窗口的消息预设处理函数是DefMDIChildProc(),而不是 DefWindowProc()。

MDI Client 是 Windows 预设好的窗口类,名为"MDIClient"。你也可以把它视为一种控制组件。

MDI Frame窗口发出 MDI 消息(如 WM_MDICASCADE、WM_MDITILE),命令 MDI Client 窗口管理其子窗口(管理动作包括窗口产生、位置排列等等)。

在 SDK 程序中,MDI Frame 窗口的消息预设处理函数是DefFrameProc(),而不是 DefWindowProc()。

分裂窗口之实现

让我先把Scribble目前使用的类之中凡与本节主题有关的,做个整理。

Visual C++ 4.0 以前的版本,AppWizard为Scribble产生的类是这样子的:

用途            类名称            基类(MFC 类)

main frame      CMainFrame          CMDIFrameWnd

document frame  直接使用 MFC 类   CMDIChildWnd CMDIChildWnd

view            CScribbleView       CView

document        CscribbleDoc        CDocument

而其CMultiDocTemplate对象是这样子的:

pDocTemplate = new CMultiDocTemplate(
IDR_SCRIBTYPE,
RUNTIME_CLASS(CScribbleDoc),
RUNTIME_CLASS(CMDIChildWnd),
RUNTIME_CLASS(CScribbleView));

为了加上分裂窗口,我们必须利用ClassWizard 新增一个类(在 Scribble 程序中名为CScribbleFrame),派生自CMDIChildWnd,并让它拥有一个CSplitterWnd 对象,名为m_wndSplitter。然后为CSrcibbleFrame 改写 OnCreateClient 虚函数,在其中调用m_wndSplitter.Create 以产生分裂窗口、设定窗口个数、设定窗口的最初尺寸等初始状态。最后,当然,我们不能够再直接以CMDIChildWnd负责document frame窗口,而必须以CScribbleFrame取代之。也就是说,得改变CMultiDocTemplate 构造函数的第三个参数:

pDocTemplate = new CMultiDocTemplate(
IDR_SCRIBTYPE,
RUNTIME_CLASS(CScribbleDoc),
RUNTIME_CLASS(CScribbleFrame),
RUNTIME_CLASS(CScribbleView));

俱往矣!Visual C++ 4.0之后的AppWizard为Scribble产生的类是这个样子:

用途                     类名称                 基类

而其CMultiDocTemplate对象是这样子的:

pDocTemplate = new CMultiDocTemplate(
IDR_SCRIBTYPE,
RUNTIME_CLASS(CScribbleDoc),
RUNTIME_CLASS(CChildFrame),
RUNTIME_CLASS(CScribbleView));

这就方便多了,CChildFrame相当于以前(MFC 4.0之前)你得自力完成的CScribbleFrame。现在,我们可以从「为此类新添成员变量」开始作为。

以下是加上分裂窗口的步骤:

  • 在ClassView(注意,不是ClassWizard)中选择CChildFrame。按下右键,选择突冒式选单中的【Add Variable】

  • 出现【Add Member Variable】对话框。填充如下,然后选按【OK】。

现在你可以从ClassView 画面中实时看到CChildFrame 的新变量。

  • 打开ChildFrm.cpp,在izardBar的【Messages】清单中选择 OnCreateClient。

  • 以Yes回答WizardBar 的询问,产生处理例程。

  • 在函数空壳中键入以下内容:

    return m_wndSplitter.Create(this, 2, 2, CSize(10, 10), pContext);
    
  • 回到ClassView 之中,你可以看到新的函数。

CSplitterWnd::Create 正是产生分裂窗口的关键,它有七个参数:

1. 表示父窗口。这里的this代表的是CChildFrame窗口。

2. 分裂窗口的水平窗口数(row)

3. 分裂窗口的垂直窗口数(column)

4. 窗口的最小尺寸(应该是一个CSize 对象)

5. 在窗口上使用哪一个View类 。此参数直接取用Framework交给OnCreateClient的第二个参数即可。

6.指定分裂窗口的风格。预设值是:WS_CHILD|WS_VISIBLE|WS_HSCROLL| WS_VSCROLL|SPLS_DYNAMIC_SPLIT,意思就是一个可见的子窗口,有着水平卷轴和垂直滚动条,并支持动态分裂。关于动态分裂(以及所谓的静态分裂),第13 章将另有说明。

7.分裂窗口的ID。默认值是AFX_IDW_PANE_FIRST,这将成为第一个窗口的 ID。

我们的原始代码有了下列变化:

// in CHILDFRM.H
class CChildFrame : public CMDIChildWnd
{
    protected:
    CSplitterWnd m_wndSplitter;
    protected:
    virtual BOOL OnCreateClient(LPCREATESTRUCT lpcs, CCreateContext* pContext);
    ...
};
// in CHILDFRM.CPP
BOOL CChildFrame::OnCreateClient(LPCREATESTRUCT /* lpcs */,
CCreateContext* pContext
{
    return m_wndSplitter.Create(this,
    2, 2,           //TODO: adjust the number of rows, columns
    CSize(10, 10),  //TODO: adjust the minimum pane size
    pContext);
}

本章回顾

这一章里我们追求的是精致化。

Scribble Step3已经有绘图、文件读写、变化笔宽的基本功能,但是「连接到同一份Document 的不同的Views」之间却不能够做到同步更新的视觉效果,此外View窗口中没有滚动条也是遗憾的事情。

Scribble Step4弥补了上述遗憾。它让「连接到同一份Document 的不同的 Views」之间做到同步更新——关键在于CDocument::UpdateAllViews和 CView::Update 两个虚函数。而由于同步更新引发的绘图效率问题,所以我们又学会了如何设计所谓的hint,让绘图动作更聪敏些。也因为hint缘故,我们改变了Document的格式,为每一线条加上一个外围四方形记录。

在滚动条方面,MFC 提供了一个名为 CScrollView 的类,内有滚动条功能,因此直接拿来用就好了。我们唯一要担心的是,从 CView 改为 CScrollView,原先的 OnDraw 绘图动作要不要修改?毕竟,卷来卷去把原点都不知卷到哪里去了,何况还有映射模式(坐标系统)的问题。这一点是甭担心了,因为application framework 在调用OnDraw 之前,已经先调用了 OnPrepareDC,把问题解决掉了。唯一要注意的是,送进 OnDraw 的滑鼠坐标点应该改为逻辑坐标,以文件左上角为原点。DP2LP 函数可以帮我们这个忙。

此外,我们接触了另一种新而且更精致的UI接口:分裂窗口,让一个窗口分裂为数个窗口,每一个窗口容纳一个View。MFC提供CSplitterWnd 做此服务。