• MFC文档序列化


         引入“文档序列化”的概念会让许多人感到迷惑,什么是“文档序列化”?其实说白了就是将数据(广义上的概念)从硬盘中读出或将数据写入硬盘中。其稍正规的定义可以看看MFC文档如下:
    The CArchive class allows you to save a complex network of objects in a permanent binary form (usually disk storage) that persists after those objects are deleted. Later you can load the objects from persistent storage, reconstituting them in memory. This process of making data persistent is called "serialization."
         在MFC中,CArchive类和CRuntimeClass结构以及一些神秘地宏共同完成“文档序列化”这一工作,当然了我们也不能忘记CFile类以及它的那些派生类的功劳。

    正文:
        “文档序列化”显然可以分成两个部分“写文件”和“读文件”。我在本文中也将从这两个方面来为你挖掘文档序列化的奥秘。
                           ///////////////////////////////////////////////
                           /*  1.“写读文件”的共同基础  */
                           ////////////////////////////////////////////// 
        无论是写还是读都等借助CRuntimeClass结构以及一些神秘的宏的帮助。在前几篇文章中我们没少和CRuntimeClass结构打交道,什么MFC执行期类型识别,什么动态创建技术等等。提到的这两种技术是文档序列化的基础,下面我们就看看为什么可以这么说:
        除了与动态创建有关的成员外,在CRuntimeClass结构中还与序列化有关的重要成员有:
        //in afx.h
        struct CRuntimeClass
       {
          // Attributes
            ...//
     LPCSTR m_lpszClassName;
     int m_nObjectSize;
     UINT m_wSchema; // schema number of the loaded class
            ...//
            void Store(CArchive& ar) const;
     static CRuntimeClass* PASCAL Load(CArchive& ar, UINT* pwSchemaNum);
            const AFX_CLASSINIT* m_pClassInit;
            ...//
       }
       其中两个重要函数Store和Load的源代码如下:这两个函数主要写和读a runtime class description,其中包括m_lpszClassName和m_wSchema(版本号);
       //in arccore.cpp
       void CRuntimeClass::Store(CArchive& ar) const
     // stores a runtime class description
       {
     WORD nLen = (WORD)lstrlenA(m_lpszClassName);
     ar << (WORD)m_wSchema << nLen;
     ar.Write(m_lpszClassName, nLen*sizeof(char));
       }
       // loads a runtime class description
      CRuntimeClass* PASCAL CRuntimeClass::Load(CArchive& ar, UINT* pwSchemaNum)
     
      {
     WORD nLen;
     char szClassName[64];

     WORD wTemp;
     ar >> wTemp; *pwSchemaNum = wTemp;
     ar >> nLen;

     // load the class name
     if (nLen >= _countof(szClassName) ||
      ar.Read(szClassName, nLen*sizeof(char)) != nLen*sizeof(char))
     {
      return NULL;
     }
     szClassName[nLen] = '\0';

     // match the string against an actual CRuntimeClass
     CRuntimeClass* pClass = FromName(szClassName);
     if (pClass == NULL)
     {
      // not found, trace a warning for diagnostic purposes
      TRACE(traceAppMsg, 0, "Warning: Cannot load %hs from archive.  Class not                         defined.\n",szClassName);
     }

     return pClass;
       }
       下面看看那两个神秘的宏DECLARE_SERIAL和IMPLEMENT_SERIAL吧!
       //in afx.h
       #define DECLARE_SERIAL(class_name) \
     _DECLARE_DYNCREATE(class_name) \
     AFX_API friend CArchive& AFXAPI operator>>(CArchive& ar, class_name* &pOb);
       #define IMPLEMENT_SERIAL(class_name, base_class_name, wSchema) \
     CObject* PASCAL class_name::CreateObject() \
      { return new class_name; } \
     AFX_COMDAT AFX_CLASSINIT _init_##class_name(RUNTIME_CLASS(class_name)); \
     _IMPLEMENT_RUNTIMECLASS(class_name, base_class_name, wSchema, \
      class_name::CreateObject, &_init_##class_name) \
     CArchive& AFXAPI operator>>(CArchive& ar, class_name* &pOb) \
      { pOb = (class_name*) ar.ReadObject(RUNTIME_CLASS(class_name)); \
       return ar; } \
       宏的定义验证了上面的“基础”一说;
       下面是IMPLEMENT_SERIAL进行初始化时的辅助结构AFX_CLASSINIT及函数AfxClassInit的定义:
       // generate static object constructor for class registration
      void AFXAPI AfxClassInit(CRuntimeClass* pNewClass);
      struct AFX_CLASSINIT
     { AFX_CLASSINIT(CRuntimeClass* pNewClass) { AfxClassInit(pNewClass); } };
      //in objcore.cpp
      void AFXAPI AfxClassInit(CRuntimeClass* pNewClass)
      {
     AFX_MODULE_STATE* pModuleState = AfxGetModuleState();
     AfxLockGlobals(CRIT_RUNTIMECLASSLIST);
     pModuleState->m_classList.AddHead(pNewClass);
     AfxUnlockGlobals(CRIT_RUNTIMECLASSLIST);
      }
      有了前面文章的基础,在这里我就不详细将宏展开详解了,有了上面基础,我们就可以"真刀真枪"的读写文件了,比较起来“写文件”较容易,所以就让我们拿它先开刀吧!^_^

                           //////////////////////////////////////
                           /*        2.“写文件”        */
                           //////////////////////////////////////   
       大家都应该了解“写文件”的“导火索”是什么吧?你说对了"save"or"save as",下面我们就沿着这个导火索一路下行看看到底发生了什么吧?
        这里大家会发现一个小问题:那就是你在MFC应用程序向导为你做的SDI or MDI代码中找不到
    "save"or"save as"功能项的处理函数,但它的功能却还能淋漓尽致的展现在你面前。这是为什么呢?原来这些函数是由CDocument类提供的。你的Doc类继承CDocument类的同时也将这些处理函数完全集成了下来。下面我们就来看看他们的卢山真面目吧!
        当你按下"save"or"save as"功能键(包括菜单中的和工具栏中的)后,应用程序将调用
    CMyDoc::OnFileSave() or CMyDoc::OnFileSaveAs()(以后将只提及一个),但由于CMyDoc类继承了其基类
    CDocument的处理函数,所以实际调用的是CDocument::OnFileSave();
     //in doccore.cpp
     void CDocument::OnFileSave()
     {
     DoFileSave();
     }
     BOOL CDocument::DoFileSave()
     {
     DWORD dwAttrib = GetFileAttributes(m_strPathName);
     if (dwAttrib & FILE_ATTRIBUTE_READONLY)
     {
      // we do not have read-write access or the file does not (now) exist
      if (!DoSave(NULL))
      {
       TRACE(traceAppMsg, 0, "Warning: File save with new name failed.\n");
       return FALSE;
      }
     }
     else
     {
      if (!DoSave(m_strPathName))
      {
       TRACE(traceAppMsg, 0, "Warning: File save failed.\n");
       return FALSE;
      }
     }
     return TRUE;
     }
     BOOL CDocument::DoSave(LPCTSTR lpszPathName, BOOL bReplace)
     // Save the document data to a file
     // lpszPathName = path name where to save document file
     // if lpszPathName is NULL then the user will be prompted (SaveAs)
     // note: lpszPathName can be different than 'm_strPathName'
     // if 'bReplace' is TRUE will change file name if successful (SaveAs)
     // if 'bReplace' is FALSE will not change path name (SaveCopyAs)
     {
     CString newName = lpszPathName;
     if (newName.IsEmpty())
     {
      ...//
      if (!AfxGetApp()->DoPromptFileName(newName,
        bReplace ? AFX_IDS_SAVEFILE : AFX_IDS_SAVEFILECOPY,
        OFN_HIDEREADONLY | OFN_PATHMUSTEXIST, FALSE, pTemplate))//***
       return FALSE;       // don't even attempt to save
     }

     CWaitCursor wait;
            if (!OnSaveDocument(newName))
     {
      if (lpszPathName == NULL)
      {
       // be sure to delete the file
       TRY
       {
        CFile::Remove(newName);
       }
       CATCH_ALL(e)
       {
        TRACE(traceAppMsg, 0, "Warning: failed to delete file after                                                            failed SaveAs.\n");
        DELETE_EXCEPTION(e);
       }
       END_CATCH_ALL
      }
      return FALSE;
     }
            // reset the title and change the document name
     if (bReplace)
      SetPathName(newName);
            return TRUE;        // success
     }
      CDocument::DoSave函数继续调用OnSaveDocument(...)函数来完成写入任务。
      BOOL CDocument::OnSaveDocument(LPCTSTR lpszPathName)
     {
     CFileException fe;
     CFile* pFile = NULL;
     pFile = GetFile(lpszPathName, CFile::modeCreate |
      CFile::modeReadWrite | CFile::shareExclusive, &fe);
            ...//
     CArchive saveArchive(pFile, CArchive::store | CArchive::bNoFlushOnDelete);
     saveArchive.m_pDocument = this;
     saveArchive.m_bForceFlat = FALSE;
     TRY
     {
      CWaitCursor wait;
      Serialize(saveArchive);     // save me
      saveArchive.Close();
      ReleaseFile(pFile, FALSE);
     }
     ...//

     SetModifiedFlag(FALSE);     // back to unmodified

     return TRUE;        // success
      }
       该函数创建了一个与要保存的文件相关联的CArchive实例saveArchive,并调用了函数 CMyDoc::Serialize(CArchive&ar);
       void CMyDoc::Serialize(CArchive& ar)
      {
     if (ar.IsStoring())
     {
      // TODO:在此添加存储代码/写入
     }
     else
     {
      // TODO:在此添加加载代码/读出
     }
      }
      看看该函数,你有些傻眼了,空函数几乎什么也没有,这又对了,因为MFC不知道你的数据是什么样式的,所以它没有这个能力越俎代庖。如果你没有为该函数添加代码,则该函数也就到此为止了,但我们要把其奥秘挖掘出来就不能到此结束,我们也用类似侯捷老师的Scribble例子给CMyDoc类加一点代码,改为:
      class CStroke:public CObject//线条类
      { ...//
        protected: UINT m_nPenWidth;
        public:    CArray<CPoint,CPoint>m_pointArray;
      }
      class CMyDoc:public CDocument
      { 
        ...//
        CTypedPtrList<CObList,CStroke*>m_strokList;
      }
      void CMyDoc::Serialize(CArchive& ar)
      {
     if (ar.IsStoring())
     {
      // TODO:在此添加存储代码/写入
     }
     else
     {
      // TODO:在此添加加载代码/读出
     }
           m_strokList.Serialize(ar);
      }
     void CStroke::Serialize(CArchive& ar)
      {
         if (ar.IsStoring())
     {
      // TODO:在此添加存储代码/写入
           ar<<(WORD)m_nPenWidth;
                  m_pointArray.Serialize(ar);
            }
     else
     {
      // TODO:在此添加加载代码/读出
                     WORD w;
                     ar>>w;
                  m_nPenWidth=w;
                  m_pointArray.Serialize(ar);
     }
        
      下面我们可以继续我们的挖掘了:
       CTypedPtrList::Serialize函数被调用,由于CTypedPtrList并为改写Serialize函数,所以实际调用的是其基类CObList的Serialize函数;
       //in list_o.cpp
      void CObList::Serialize(CArchive& ar)
      {
     ASSERT_VALID(this);
            CObject::Serialize(ar);
            if (ar.IsStoring())
     {
      ar.WriteCount(m_nCount);//***
      for (CNode* pNode = m_pNodeHead; pNode != NULL; pNode = pNode->pNext)
      {
       ASSERT(AfxIsValidAddress(pNode, sizeof(CNode)));
       ar << pNode->data;//***
      }
     }
     else
     {
              ...//
     }
       }
       其中void CArchive::WriteCount(DWORD_PTR dwCount)函数用于将CObList中的表元素个书写入。
       CArchive重载了<<运算符,代码如下:
     //in afx.inl
     _AFX_INLINE CArchive& AFXAPI operator<<(CArchive& ar, const CObject* pOb)
     { ar.WriteObject(pOb); return ar; }
      //in arcobj.cpp
      void CArchive::WriteObject(const CObject* pOb)
     {
     ...//
     // make sure m_pStoreMap is initialized
     MapObject(NULL);
            if (pOb == NULL)
     {
      // save out null tag to represent NULL pointer
      *this << wNullTag;
     }
     else if ((nObIndex = (DWORD)(DWORD_PTR)(*m_pStoreMap)[(void*)pOb]) != 0)
      // assumes initialized to 0 map
     {
      // save out index of already stored object
      if (nObIndex < wBigObjectTag)
       *this << (WORD)nObIndex;
      else
      {
       *this << wBigObjectTag;
       *this << nObIndex;
      }
     }
     else
     {
      // write class of object first
      CRuntimeClass* pClassRef = pOb->GetRuntimeClass();
      WriteClass(pClassRef);//***

      // enter in stored object table, checking for overflow
      CheckCount();
      (*m_pStoreMap)[(void*)pOb] = (void*)(DWORD_PTR)m_nMapCount++;

      // cause the object to serialize itself
      ((CObject*)pOb)->Serialize(*this);//***
     }
     }
      ((CObject*)pOb)->Serialize(*this);
      循线而上,你可以发现该函数最终调用的是CStroke::Serialize(CArchive&ar);
     
      也许你可能不晓得wNullTag,dwBigClassTag等之类是何东东?看看下面它们是如何定义的:
    // Pointer mapping constants,in arcobj.cpp
    #define wNullTag        ((WORD)0)           // special tag indicating NULL ptrs
    #define wNewClassTag    ((WORD)0xFFFF)      // special tag indicating new CRuntimeClass
    #define wClassTag       ((WORD)0x8000)      // 0x8000 indicates class tag (OR'd)
    #define dwBigClassTag   ((DWORD)0x80000000) // 0x8000000 indicates big class tag (OR'd)
    #define wBigObjectTag   ((WORD)0x7FFF)      // 0x7FFF indicates DWORD object tag
    #define nMaxMapCount    ((DWORD)0x3FFFFFFE) // 0x3FFFFFFE last valid mapCount
       他们不是别的,只是一些记号,比如当你写入一个CStroke类时,它首先判断CStroke以前是否出现过,若没有,则写入 wNewClassTag  (0xFFFF),否则写入wClassTag+一定的offsets,表示这是与前面相同的旧类。
      //MFC文档:to store the version and class information of a base class during serialization of the derived class.
       void CArchive::WriteClass(const CRuntimeClass* pClassRef)
      {
            ...//
     // make sure m_pStoreMap is initialized
     MapObject(NULL);
            // write out class id of pOb, with high bit set to indicate
     // new object follows
            // ASSUME: initialized to 0 map
     DWORD nClassIndex;
     if ((nClassIndex = (DWORD)(DWORD_PTR)(*m_pStoreMap)[(void*)pClassRef]) != 0)
     {
      // previously seen class, write out the index tagged by high bit
      if (nClassIndex < wBigObjectTag)
       *this << (WORD)(wClassTag | nClassIndex);
      else
      {
       *this << wBigObjectTag;
       *this << (dwBigClassTag | nClassIndex);
      }
     }
     else
     {
      // store new class
      *this << wNewClassTag;
      pClassRef->Store(*this);//***

      // store new class reference in map, checking for overflow
      CheckCount();
      (*m_pStoreMap)[(void*)pClassRef] = (void*)(DWORD_PTR)m_nMapCount++;
     }
      }
      最终调用void CRuntimeClass::Store(CArchive& ar) const;来存储class information;
      到这就"写"完了。下面总结一下:
      我们看看本程序到底向硬盘中写了什么?
      按顺序应该是:CTypedPtrList中的元素个数---〉新旧类标志---〉版本号(m_wSchema)---〉
                    类名称字符串中的字符个数----〉类名称(ANSI码)---〉调用其它成员的                              Serialize 函数。---〉以此类推。
                           //////////////////////////////////////
                           /*        3.“读文件”        */
                           //////////////////////////////////////  
      看完了“写文件”,让我们看看“读文件”的内幕吧!“读文件”顾名思义,即当你打开一个文件时,应用程序从硬盘中将文件的数据读出的过程。
       当你选中“文件”菜单中的“打开”或在工具栏中单击“打开”项时,应用程序将连续调用以下序列的函数:
       CWinApp::OnOpenFile()——>CDocManager::OnFileOpen()-->CWinApp::OpenDocumentFile(LPCTSTR lpszFileName)-->CDocManager::OpenDocumentFile(LPCTSTR lpszFileName)--->
    CSingleDocTemplate::OpenDocumentFile(LPCTSTR lpszPathName,BOOL bMakeVisible)-->

     //in Doccore.cpp
     BOOL CDocument::OnOpenDocument(LPCTSTR lpszPathName)
     {      ...//
            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-serialize

     CArchive loadArchive(pFile, CArchive::load | CArchive::bNoFlushOnDelete);//***
     loadArchive.m_pDocument = this;
     loadArchive.m_bForceFlat = FALSE;
     TRY
     {
      CWaitCursor wait;
      if (pFile->GetLength() != 0)
       Serialize(loadArchive);     // load me***
      loadArchive.Close();
      ReleaseFile(pFile, FALSE);
     }
     CATCH_ALL(e)
     {
      ReleaseFile(pFile, TRUE);
      DeleteContents();   // remove failed contents

      TRY
      {
       ReportSaveLoadException(lpszPathName, e,
        FALSE, AFX_IDP_FAILED_TO_OPEN_DOC);
      }
      END_TRY
      DELETE_EXCEPTION(e);
      return FALSE;
     }
     END_CATCH_ALL

     SetModifiedFlag(FALSE);     // start off with unmodified

     return TRUE;
    }

    --->void CMyDoc::Serialize(CArchive& ar)
       {
     if (ar.IsStoring())
     {
      // TODO:在此添加存储代码/写入
     }
     else
     {
      // TODO:在此添加加载代码/读出
     }
           m_strokList.Serialize(ar);
       }
      //in list_o.cpp
    -->void CObList::Serialize(CArchive& ar)
     {
     ASSERT_VALID(this);
            CObject::Serialize(ar);
            if (ar.IsStoring())
     {
      ...//
     }
     else
     {
      DWORD_PTR nNewCount = ar.ReadCount();//读入CTypedPtrList中的元素个数
      CObject* newData;
      while (nNewCount--)
      {
       ar >> newData;//***
       AddTail(newData);
      }
     }
     }
    ---〉
    _AFX_INLINE CArchive& AFXAPI operator>>(CArchive& ar, CObject*& pOb)
     { pOb = ar.ReadObject(NULL); return ar; }

    --->//in arcobj.cpp
     CObject* CArchive::ReadObject(const CRuntimeClass* pClassRefRequested)
     {
     ...//
     // attempt to load next stream as CRuntimeClass
     UINT nSchema;
     DWORD obTag;
     CRuntimeClass* pClassRef = ReadClass(pClassRefRequested, &nSchema, &obTag);//***

     // check to see if tag to already loaded object
     CObject* pOb;
     if (pClassRef == NULL)
     {
      if (obTag > (DWORD)m_pLoadArray->GetUpperBound())
      {
       // tag is too large for the number of objects read so far
       AfxThrowArchiveException(CArchiveException::badIndex,
        m_strFileName);
      }

      pOb = (CObject*)m_pLoadArray->GetAt(obTag);
      if (pOb != NULL && pClassRefRequested != NULL &&
        !pOb->IsKindOf(pClassRefRequested))
      {
       // loaded an object but of the wrong class
       AfxThrowArchiveException(CArchiveException::badClass,
        m_strFileName);
      }
     }
     else
     {
      // allocate a new object based on the class just acquired
      pOb = pClassRef->CreateObject();//***
      if (pOb == NULL)
       AfxThrowMemoryException();

      // Add to mapping array BEFORE de-serializing
      CheckCount();
      m_pLoadArray->InsertAt(m_nMapCount++, pOb);

      // Serialize the object with the schema number set in the archive
      UINT nSchemaSave = m_nObjectSchema;
      m_nObjectSchema = nSchema;
      pOb->Serialize(*this);//***
      m_nObjectSchema = nSchemaSave;
      ASSERT_VALID(pOb);
     }

     return pOb;
     }

    --->//读取CRuntimeClass信息
      CRuntimeClass* CArchive::ReadClass(const CRuntimeClass* pClassRefRequested,
     UINT* pSchema, DWORD* pObTag)
     {
     ...//
     // make sure m_pLoadArray is initialized
     MapObject(NULL);

     // read object tag - if prefixed by wBigObjectTag then DWORD tag follows
     DWORD obTag;
     WORD wTag;
     *this >> wTag;//***读取标志位
     if (wTag == wBigObjectTag)
      *this >> obTag;
     else
      obTag = ((wTag & wClassTag) << 16) | (wTag & ~wClassTag);

     // check for object tag (throw exception if expecting class tag)
     if (!(obTag & dwBigClassTag))
     {
      if (pObTag == NULL)
       AfxThrowArchiveException(CArchiveException::badIndex, m_strFileName);

      *pObTag = obTag;
      return NULL;
     }

     CRuntimeClass* pClassRef;
     UINT nSchema;
     if (wTag == wNewClassTag)
     {
      // new object follows a new class id
      if ((pClassRef = CRuntimeClass::Load(*this, &nSchema)) == NULL)//***
       AfxThrowArchiveException(CArchiveException::badClass, m_strFileName);

      // check nSchema against the expected schema
      if ((pClassRef->m_wSchema & ~VERSIONABLE_SCHEMA) != nSchema)
      {
       if (!(pClassRef->m_wSchema & VERSIONABLE_SCHEMA))
       {
        // schema doesn't match and not marked as VERSIONABLE_SCHEMA
        AfxThrowArchiveException(CArchiveException::badSchema,
         m_strFileName);
       }
       else
       {
        // they differ -- store the schema for later retrieval
        if (m_pSchemaMap == NULL)
         m_pSchemaMap = new CMapPtrToPtr;
        ASSERT_VALID(m_pSchemaMap);
        m_pSchemaMap->SetAt(pClassRef, (void*)(DWORD_PTR)nSchema);
       }
      }
      CheckCount();
      m_pLoadArray->InsertAt(m_nMapCount++, pClassRef);
     }
     else
     {
       ...//
            }

     // check for correct derivation
     if (pClassRefRequested != NULL &&
      !pClassRef->IsDerivedFrom(pClassRefRequested))
     {
      AfxThrowArchiveException(CArchiveException::badClass, m_strFileName);
     }

     // store nSchema for later examination
     if (pSchema != NULL)
      *pSchema = nSchema;
     else
      m_nObjectSchema = nSchema;

     // store obTag for later examination
     if (pObTag != NULL)
      *pObTag = obTag;

     // return the resulting CRuntimeClass*
     return pClassRef;
      }
    --->
     CRuntimeClass* PASCAL CRuntimeClass::Load(CArchive& ar, UINT* pwSchemaNum)
     // loads a runtime class description
    {
     WORD nLen;
     char szClassName[64];

     WORD wTemp;
     ar >> wTemp; *pwSchemaNum = wTemp;//读入版本号
     ar >> nLen;//读入类名称字符串中的字符个数

     // load the class name,读入类名称
     if (nLen >= _countof(szClassName) ||
      ar.Read(szClassName, nLen*sizeof(char)) != nLen*sizeof(char))
     {
      return NULL;
     }
     szClassName[nLen] = '\0';

     // match the string against an actual CRuntimeClass
     CRuntimeClass* pClass = FromName(szClassName);
     if (pClass == NULL)
     {
      // not found, trace a warning for diagnostic purposes
      TRACE(traceAppMsg, 0, "Warning: Cannot load %hs from archive.  Class not                         defined.\n",
       szClassName);
     }

     return pClass;
     }
     
    ---〉在CArchive::ReadObject函数中有pOb->Serialize(*this);即调用CStroke类的Serialize函数。以此类推。
      总结:我们看看本程序到底从硬盘中读了什么?
       按顺序应该是:CTypedPtrList中的元素个数---〉新旧类标志---〉版本号(m_wSchema)---〉
                    类名称字符串中的字符个数----〉类名称(ANSI码)---〉调用其它成员的                                 Serialize 函数。---〉以此类推。
      由此可以看出读入数据的顺序与写入数据时的顺序完全相同。
                           /////////////////////////////////////
                           /*         4.结局             */
                           /////////////////////////////////////
       至此,MFC技术内幕系列文章都已结束,文章中肯定有很多纰漏和错误,希望读者们批评指点。

  • 相关阅读:
    团队开发冲刺日(十三)
    第十周总结
    团队开发冲刺日(十二)
    团队开发冲刺日(十一)
    团队开发冲刺日(十)
    团队开发冲刺日(九)
    团队开发冲刺日(八)
    团队开发冲刺日(七)
    团队开发冲刺日(六)
    课后作业1
  • 原文地址:https://www.cnblogs.com/superanyi/p/1929182.html
Copyright © 2020-2023  润新知