VC++深入详解(11):文档与串行化

来源:互联网 发布:mac系统压缩文件 编辑:程序博客网 时间:2024/05/21 09:11

其实这一部分的内容本该是放在网络编程之前的。但是由于网络编程比较重要就先看了。

所谓的文档串行化,解决的是这么一类问题:对于内存中的变量,当程序结束时就会消失,我们有没有办法把它们的结果记录在硬盘上,当程序结束,我们重新启动程序时,有没有办法重新加载这些保存在硬盘上的对象呢?我们首先考虑的是CFile对象。但是,因为CFile在读写时始终要求的是字符串buffer,相对比较麻烦,MFC为我们提供了一个更简单的类CArchive来实现文件的读写。
使用CArchive对象时需要注意的是:
1.这个对象必须与一个CFile对象相关联
2.CArchive对象重载了<<和>>操作符,不仅可以处理基本数据类型,也能够处理从CObject派生出来的类型。
先看一个简单的例子:对一个单文档应用程序,增加菜单项“写文件”、“读文件”,并添加消息响应函数如下:

void CCH_13_ArchiveView::OnFileWrite() {// TODO: Add your command handler code here//构造CFile对象CFile file("1.txt",CFile::modeCreate | CFile::modeWrite);//将CArchive与CFile对象相关联CArchive ar(&file,CArchive::store);int i = 9;char ch = 'a';float f = 1.3f;CString str("hello,world!");ar<<i<<ch<<f<<str;}void CCH_13_ArchiveView::OnFileRead() {// TODO: Add your command handler code here//构造CFile对象CFile file("1.txt",CFile::modeRead);//构造存档对象CArchive ar(&file,CArchive::load);int i;char ch;float f;CString str;//用来显示结果CString strResult;//顺序必须与写入顺序相同ar>>i>>ch>>f>>str;strResult.Format("%d,%c,%f,%s",i,ch,f,str);MessageBox(strResult);}
当我们点击写文件后,会在工程的文件夹下面增加一个名为“1.txt”的文件,打开以后会看到:
  aff? hello,world!


似乎与我们保存的内容有点不一样。这是因为我们保存的是2进制流。它们有的是int,有的是float,有的是char,有的是CString。而.txt文件却强行将它们当做char类型来显示。
我们可以利用UltraEdit显示文本的16进制形式:
09  00  00  00  61  66  66  A6  3F  0C  68  65  6C  6C  6C  6F  2C  77  6F  72  6C  64  21
我们保存的int类型的9,占用了4个字节09 00 00 00(注意是小端),而txt文档会把它们当成4个char显示,所以09对应的是制表符(\t)而其他几个全是NULL,所以会前面空出来;a本身是char类型,所以正常显示;1.3f换成2进制为01100110  01100110  10100110  00111111(不理解的可以参看《关于浮点数在计算机中的储存》这篇博客:http://blog.csdn.net/thefutureisour/article/details/8184717),恰好对应66  66  A6  3F,66对应的ASCII码为f,A6对应的是一个特殊字符,3F对应的ASCII码为?;而hello,world!与68  65  6C  6C  6C  6F  2C  77  6F  72  6C  64  21的ASCII码对应,也能正常显示,0C是CString对象的长度,这里为12.转成ASCII码就是乱码了。


在MFC中的文档类中,有一个OnNewDocument函数。在此处设置一个断点,程序开始运行时会经过它,继续执行后,如果点击文件->新建,也会执行它。在它里面调用:

SetTitle("我的文件");
可以设置标题。
其实在字符窜资源的IDR_MAINFRAME中,也可以修改程序的一些名字。IDR_MAINFRAME是由7个以\n分隔的字符串组成的,其中的每一个代表了不同的名字。我们要说明两个问题:
1.这个字符串资源是如何被加载到程序中去的?在我们的应用程序类的InitInstance中完成:

CSingleDocTemplate* pDocTemplate;pDocTemplate = new CSingleDocTemplate(IDR_MAINFRAME,RUNTIME_CLASS(CCH_13_ArchiveDoc),RUNTIME_CLASS(CMainFrame),       // main SDI frame windowRUNTIME_CLASS(CCH_13_ArchiveView));AddDocTemplate(pDocTemplate);
2.这7个字符串分别代表了什么?我们可以通过GetDocString 函数的参数来查到:
(1)CDocTemplate::windowTitle,主窗口标题栏上的字符串,MDI程序不需要指定,将以IDR_MAINFRAME字符串为默认值。
(2)CDocTemplate::docName,缺省文档的名称。如果没有指定,缺省文档的名称是无标题。
(3)CDocTemplate::fileNewName,文档类型的名称。如果应用程序支持多种类型的文档,此字符串将显示在"File/New"对话框中。如果没有指定,就不能够在"File/New"对话框处理这种文件。
(4)CDocTemplate::filterName,文档类型的描述和一个适用于此类型的通配符过滤器。这个字符串将出现在“File/Open”对话框中的文件类型列表框中。要和CDocTemplate::filterExt一起使用。
(5)CDocTemplate::filterExt,文档的扩展名。如果没有指定,就不能够在“File/Open”对话框中处理这种文档。要和CDocTemplate::filterName一起使用。
(6)CDocTemplate::regFileTypeId,如果你以::RegisterShellFileTypes向系统的注册表注册文件类型,此值会出现在HEY_CLASSES_ROOT之下成为其子项,并仅供Windows内部使用。如果没有指定,这种文件类型就无法注册。
(7)CDocTemplate::regFileTypeName,这也是存储在注册表中的文件类型名称。它会显示于程序中用以访问注册表的对话框内。
我们可以对这个字符串资源做以下修改:
将第一个字符串改为:单文档应用程序;第二个改为:hello,world!;第四个改为:Text File(*.txt);第五个改为.txt。

回到之前的OnNewDocument函数。我们知道一般情况下,“新建”的命令响应函数应该是OnFileNew。那么这里为什么是OnNewDocument呢?我们得仔细看看。首先,这个函数是CDocument的一个虚函数,并不是消息响应函数。我们应该猜的到,OnFileNew函数肯定是在MFC内部被调用了,而在OnFileNew中,调用了虚函数OnNewDocument,这才跳转到了我们自己的OnNewDocument中。
通过搜索MFC源代码,在APPDLG.CPP:
void CWinApp::OnFileNew(){if (m_pDocManager != NULL)m_pDocManager->OnFileNew();}
查看其中的OnFileNew函数的定义:

afx_msg void OnFileNew();
可见,他的确是新建的消息响应函数。
在这里,他先判断m_pDocManager是否为空,如果不为空则调用该函数。而m_pDocManager是一个CDocManager类型的指针,而CDocManager则有一个指针链表CPtrList m_templateList;用以维护文档模板的指针。当在InitInstance中调用AddDocTemplate时,实际上就是把指针加到了这里。
由于 OnFileNew是一个虚函数,所以,这里会调用CDocManager的OnFileNew,在DOCMGR.CPP中:

void CDocManager::OnFileNew(){if (m_templateList.IsEmpty()){TRACE0("Error: no document templates registered with CWinApp.\n");AfxMessageBox(AFX_IDP_FAILED_TO_CREATE_DOC);return;}CDocTemplate* pTemplate = (CDocTemplate*)m_templateList.GetHead();if (m_templateList.GetCount() > 1){// more than one document template to choose from// bring up dialog prompting userCNewTypeDlg dlg(&m_templateList);int nID = dlg.DoModal();if (nID == IDOK)pTemplate = dlg.m_pSelectedTemplate;elsereturn;     // none - cancel operation}ASSERT(pTemplate != NULL);ASSERT_KINDOF(CDocTemplate, pTemplate);pTemplate->OpenDocumentFile(NULL);// if returns NULL, the user has already been alerted}

通过调试,我们发现,单文档应用程序中走的是pTemplate->OpenDocumentFile(NULL);而OpenDocumentFile又是CSingleDocTemplate类的虚函数,其源码位于DOCSINGL.CPP中。这个代码比较长,这里就列出了,只说一下它的思路:首先定义了文档类和框架类的指针。然后调用CreateNewDocument创建文档类对象,CreateNewFrame创建框架类对象和视类对象。最后调用OnNewDocument。而这个函数是一个虚函数,最终引起了我们自己的文档类中的OnNewDocument的调用。

总结起来,先调用CWinApp::OnFileNew,它调用CDocManager::OnFileNew,它调用CSingleDocTemplate::OpenDocumentFile,在其中创建了文档类、视类、框架类,最终调用OnNewDocument,而这个函数是虚函数,调用的是我们自己的OnNewDocument。

在我们自己的文档类中,有个串行化函数Serialize:

void CCH_13_ArchiveDoc::Serialize(CArchive& ar){if (ar.IsStoring()){// TODO: add storing code here}else{// TODO: add loading code here}}
通过给这个函数增加断点,我们发现,当选择文件->保存时,或者文件->打开时,会进入这个函数。实际上,这个函数就是文档类提供的用于保存和加载我们自己的数据的函数。我们可以简单的举个例子:

void CCH_13_ArchiveDoc::Serialize(CArchive& ar){if (ar.IsStoring()){// TODO: add storing code hereint i = 9;char ch = 'b';float f = 1.2f;CString str("hello,world!");ar<<i<<ch<<f<<str;}else{// TODO: add loading code hereint i;char ch;float f;CString str;CString strResult;strResult.Format("%d,%c,%f,%s",i,ch,f,str);AfxMessageBox(strResult);}}



但是这个程序有一个奇怪的现象:当你保存一个文件时,的确会有一个文件在对应的目录下生成,但是如果你此时打开刚保存的文件,则不会弹出对话框。可是如果打开的是另外的文件,或者是关闭程序重新运行,则可以打开保存的对话框。这是为什么呢?通过单步调试,我们发现,当打开上一次保存的文件时,程序就没有进入Serialize函数,这是为什么呢?
先讲一个其他的例子:在word中,如果你打开一份刚刚保存的文档,那么文档的内容和之前的是一模一样的;同理,在MFC中,如果判断出这次打开的是刚保存的同一份数据,就没有必要去打开了,MFC认为数据已经在视类中了。看来这个设计也挺合理的,我们就将就着用吧。
但是,我们这里要深究一个细节:为什么当打开上一次保存的文件时,程序就没有进入Serialize函数?
这得从这个函数的调用讲起了。首先,对于文件->打开,直接的形影函数肯定是OnFileOpen,我们可以跟踪一下OnFileOpen函数是何时被调用的:

void CWinApp::OnFileOpen(){ASSERT(m_pDocManager != NULL);m_pDocManager->OnFileOpen();}
其中,OnFileOpen是一个虚函数,调用的是文档管理器的OnFileOpen(DOCMGR.CPP中):

void CDocManager::OnFileOpen(){// prompt the user (with all document templates)CString newName;if (!DoPromptFileName(newName, AFX_IDS_OPENFILE,  OFN_HIDEREADONLY | OFN_FILEMUSTEXIST, TRUE, NULL))return; // open cancelledAfxGetApp()->OpenDocumentFile(newName);// if returns NULL, the user has already been alerted}
其中的DoPromptFileName函数较长,我们这里不贴源码了,但是我们可以看出它的思路:定义一个CFileDialog类型的对象,最后调用DoModal显示它。
而OpenDocumentFile则是一个虚函数,实际调用的是CWinApp中的(在APPUI.CPP中):

BOOL CDocument::OnOpenDocument(LPCTSTR lpszPathName){if (IsModified())TRACE0("Warning: OnOpenDocument replaces an unsaved document.\n");CFileException fe;CFile* pFile = GetFile(lpszPathName,CFile::modeRead|CFile::shareDenyWrite, &fe);if (pFile == NULL){ReportSaveLoadException(lpszPathName, &fe,FALSE, AFX_IDP_FAILED_TO_OPEN_DOC);return FALSE;}DeleteContents();SetModifiedFlag();  // dirty during de-serializeCArchive loadArchive(pFile, CArchive::load | CArchive::bNoFlushOnDelete);loadArchive.m_pDocument = this;loadArchive.m_bForceFlat = FALSE;TRY{CWaitCursor wait;if (pFile->GetLength() != 0)Serialize(loadArchive);     // load meloadArchive.Close();ReleaseFile(pFile, FALSE);}CATCH_ALL(e){ReleaseFile(pFile, TRUE);DeleteContents();   // remove failed contentsTRY{ReportSaveLoadException(lpszPathName, e,FALSE, AFX_IDP_FAILED_TO_OPEN_DOC);}END_TRYDELETE_EXCEPTION(e);return FALSE;}END_CATCH_ALLSetModifiedFlag(FALSE);     // start off with unmodifiedreturn TRUE;}

我们看到,在其中构造了一个CFile对象,利用其构造了CArchive指针,然后调用了Serialize函数,这个函数是虚函数,会引起我们的文档类的Serialize调用。

说了这么长,我们稍微总结一下,打开引起CWinApp函数OnFileOpen被调用,而OnFileOpen中调用了文档管理器中的OnFileOpen,而这个函数调用了CWinApp的OpenDocumentFile,而它则调用了文档管理器中的OpenDocumentFile,而它则调用了CDocumen的OnOpenDocument,其中调用了虚函数Serialize,引起我们的文档类的Serialize。


到了这里,我们可以调试一下,发现打开一个刚刚保存的文件,和打开一个其他的文件的不同:
在CDocManager::OpenDocumentFile中,

match = pTemplate->MatchDocType(szPath, pOpenDocument);
如果是重新打开一个文件,则pOpenDocument为空,返回

return pBestTemplate->OpenDocumentFile(szPath);
引起CDocumen的OnOpenDocument被调用,其中调用了虚函数Serialize。
而如果是打开一个刚保存的文件,那么pOpenDocument不为空,返回pOpenDocument指针,没有执行CDocumen的OnOpenDocument,也就没有调用Serialize虚函数了。


下面我们考虑如何写一个支持串行化的类。
首先我们还以之前讲过的画图为例,给程序添加了画图菜单,在我们的视类中添加了成员变量保存图形类型、起点等内容。又增加了CGraph类。
从MSDN得知,要写一个可以串行化的类,需要5步:
1.让这个类从CObject派生出来。
2.重新覆盖Serialize 函数。
3.使用DECLARE_SERIAL 宏
4.定义一个不带参数的构造函数
5.在实现文件中使用IMPLEMENT_SERIAL宏
这个宏的参数分别为:类名、基类名、版本号。其中保存时的版本号要与打开时的相一致,否则会提示“非预期的文件格式”。


1.从从CObject派生

class CGraph  :public CObject{public:CGraph();CGraph(UINT m_DrawType, CPoint m_ptOrigin, CPoint m_ptEnd);virtual ~CGraph();UINT m_DrawType;CPoint m_ptOrigin;CPoint m_ptEnd;};
2.增加虚函数Serialize:

//头文件声明:virtual void Serialize(CArchive& ar);//源文件定义void CGraph::Serialize(CArchive& ar){if(ar.IsStoring()){ar<<m_nDrawType<<m_ptOrigin<<m_ptEnd;}else{ar>>m_nDrawType>>m_ptOrigin>>m_ptEnd;}}

3.在头文件中类的内部声明:

class CGraph  :public CObject{DECLARE_SERIAL(CGraph)public:CGraph();
4.因为这个类本身已经有一个不带参数的构造函数,所以不用再重新写一个了。
5.在类的实现文件中增加:

IMPLEMENT_SERIAL(CGraph,CObject,1)
接下来,为CGraph类增加一个Draw函数:

void CGraph::Draw(CDC *pDC){CBrush *pBrush = CBrush::FromHandle((HBRUSH)GetStockObject(NULL_BRUSH));CBrush *pOldBrush = pDC->SelectObject(pBrush);switch(m_nDrawType){case 1:pDC->SetPixel(m_ptEnd,RGB(255,0,0));break;case 2:pDC->MoveTo(m_ptOrigin);pDC->LineTo(m_ptEnd);break;case 3:pDC->Rectangle(CRect(m_ptOrigin,m_ptEnd));break;case 4:pDC->Ellipse(CRect(m_ptOrigin,m_ptEnd));break;}pDC->SelectObject(pOldBrush);}
将view类中的OnLButtonUp改动如下:

void CCH_13_ArchiveView::OnLButtonUp(UINT nFlags, CPoint point) {// TODO: Add your message handler code here and/or call defaultCClientDC dc(this);CBrush *pBrush = CBrush::FromHandle((HBRUSH)GetStockObject(NULL_BRUSH));CBrush *pOldBrush = dc.SelectObject(pBrush);//画图switch(m_nDrawType){case 1:dc.SetPixel(point,RGB(0,0,0));break;case 2:dc.MoveTo(m_ptOrigin);dc.LineTo(point);break;case 3:dc.Rectangle(CRect(m_ptOrigin,point));break;case 4:dc.Ellipse(CRect(m_ptOrigin,point));break;}//将当前图形的参数保留CGraph *pGraph = new CGraph(m_nDrawType,m_ptOrigin,point);//使用CObArray对象来存储CGraph对象的指针m_obArray.Add(pGraph);dc.SelectObject(pOldBrush);CView::OnLButtonUp(nFlags, point);}
这里并没有实现复杂的字体、颜色的保存。我们的重点是如何将这些信息保存到硬盘上。
首先我们使用CObArray类型的m_obArray来保存每次画图的类型、起点、终点。


然后,在我们的文档类的Serialize中,将m_obArray保存的图形写入到文件中或者是读取文件中的图形参数。

void CCH_13_ArchiveDoc::Serialize(CArchive& ar){//获取与文档类相关的第一个视类的的位置POSITION pos = GetFirstViewPosition();//获取指定的视类指针CCH_13_ArchiveView *pView = (CCH_13_ArchiveView*)GetNextView(pos);if (ar.IsStoring()){//先获取m_obArray存储的图形的数目int nCount = pView->m_obArray.GetSize();//将数目先存入ar<<nCount;for(int i = 0; i < nCount;++i){//存入每个Cgraph指针ar<<pView->m_obArray.GetAt(i);}}else{// TODO: add loading code hereint nCount;ar>>nCount;CGraph *pGraph;for(int i = 0; i < nCount;++i){//获取每个CGraph指针ar>>pGraph;//存入m_obArray中pView->m_obArray.Add(pGraph);}}}

如果是读取图像则需要读取完之后把它们显示出来,在OnDraw函数调用我们之前写的Draw即可:

void CCH_13_ArchiveView::OnDraw(CDC* pDC){CCH_13_ArchiveDoc* pDoc = GetDocument();ASSERT_VALID(pDoc);// TODO: add draw code for native data hereint nCount;nCount = m_obArray.GetSize();for(int i = 0; i < nCount; ++i){((CGraph*)m_obArray.GetAt(i))->Draw(pDC);}}
这样的话当我们画一些图形之后,将它们保存为txt文件,关闭程序,再重新打开这个txt文件,就能够获得之前的图形了。回顾一下我们的思路:
1.将CGraph变得可串行化。尤其是在串行化函数中确定都要保存什么。
2.在绘制完图形后,立即将图形的类型、起点、终点保存为一个CGraph类的对象,并将指向这个对象的指针保存在m_obArray中。
4.在文档的串行化函数中写入或者读取一个个CGraph对象。
5.在<<或者>>时,会调用CGraph的Serialize函数实现具体的操作。
5.如果是读取,则会引起OnDraw函数的调用,在这个函数中调用CGraph的Draw函数将加载的画图类型,起点、终点变为实际图形。


其实,CObjectArray类本身也支持串行化。我们可以在自己的文档类中调用这个类的串行化操作来保存图形:

void CCH_13_ArchiveDoc::Serialize(CArchive& ar){//获取与文档类相关的第一个视类的的位置POSITION pos = GetFirstViewPosition();//获取指定的视类指针CCH_13_ArchiveView *pView = (CCH_13_ArchiveView*)GetNextView(pos);if (ar.IsStoring()){}else{}pView->m_obArray.Serialize(ar);}

可以看看CObArray类中Serialize的定义:

void CObArray::Serialize(CArchive& ar){ASSERT_VALID(this);CObject::Serialize(ar);if (ar.IsStoring()){ar.WriteCount(m_nSize);for (int i = 0; i < m_nSize; i++)ar << m_pData[i];}else{DWORD nOldSize = ar.ReadCount();SetSize(nOldSize);for (int i = 0; i < m_nSize; i++)ar >> m_pData[i];}}

在程序中,先调用基类的Serialize函数,然后判断是在保存还是在读取。如果是保存则先记录保存的个数,然后一个个保存;反之如果是读取,则先读取个数,然后依次读取,感觉跟我们之前写的差不多啊!

如果我们理解了CObArray可以直接用来串行化数据,那么我们就可以把CObArray对象m_obArray直接放到文档类中使用,然后把之前在文档类中使用的视类中的m_obArray变为直接调用自己的m_obArray成员。而在视类中调用m_obArray的地方改为调用文档类的m_obArray。这可以通过函数GetDocument 来实现。
比如在OnLButtonUp函数中:

CGraph *pGraph = new CGraph(m_nDrawType,m_ptOrigin,point);CCH_13_ArchiveDoc *pDoc = GetDocument();pDoc->m_obArray.Add(pGraph);
在OnDraw中:

void CCH_13_ArchiveView::OnDraw(CDC* pDC){CCH_13_ArchiveDoc* pDoc = GetDocument();ASSERT_VALID(pDoc);// TODO: add draw code for native data hereint nCount;nCount = pDoc->m_obArray.GetSize();for(int i = 0; i < nCount; ++i){((CGraph*)(pDoc->m_obArray).GetAt(i))->Draw(pDC);}}
当然程序写到这里,稍微有点经验的程序员就会发现一个问题,我们newCGraph对象,却没有delete它。当我们新建一个文档时,源文档上的所有的数据都应该被销毁。我们可以看看DOCCORE.CPP中的源代码:

BOOL CDocument::OnNewDocument(){if (IsModified())TRACE0("Warning: OnNewDocument replaces an unsaved document.\n");DeleteContents();m_strPathName.Empty();      // no path name yetSetModifiedFlag(FALSE);     // make cleanreturn TRUE;}
其中DeleteContents是一个由框架调用的虚函数,来删除文档的数据。我们应该在自己的文档类中重写这个函数:

void CCH_13_ArchiveDoc::DeleteContents() {// TODO: Add your specialized code here and/or call the base classint iCount;nCount = m_obArray.GetSize();for(int i = 0; i < m_obArray.GetSize();++i){delete m_obArray.GetAt(i);//下面的写法是错误的//m_obArray.RemoveAt(i);}m_obArray.RemoveAll();CDocument::DeleteContents();}

这里有两处细节需要强调:
1.delete m_obArray.GetAt(i);只是删除了CGraph*指针所指向的内存,并没有把这个指针从m_obArray中删除。
2.为什么没有在for循环中依次RemoveAt?因为RemoveAt后,会导致后面的指针依次向前移动一位。比如把第0个指针删除后,原来的第1个指针会自动变成第0个指针,所以会导致删除错误。用RemoveAll相对更加方便。


下面我们小结一下,MFC提供的Document/View结构:
(1)文档类管理数据,提供保存和加载数据的功能,视类负责数据的显示,以及给用户提供修改数据的编辑和修改的功能。
(2)Document/View结构意味着数据处理与显示的大框架已经搭建好了,这些函数都是虚函数,我们只要在派生类中重写这些函数即可:读写函数在文档的Serialize中进行,数据显示在视类的OnDraw中进行。
(3)当打开新的文档时,原来记录文档的数据会被销毁,我们可以在这个销毁函数中释放之前分配的内存。