MFC 序列化与 CArchive 源码解析(VC2015)

MFC 专栏收录该内容
2 篇文章 0 订阅

MFC 实现了一套完整的序列化和反序列化机制,这套机制的核心是 CArchive 类。CArchive 类同时实现了序列化和反序列化的功能,由于反序列化是序列化的反向操作,故本文只关注序列化的实现。

CArchive 实现的是二进制序列化,现在来看这有些过时了(毕竟 MFC 太古老了)。比如在 .NET 框架里,虽然也支持二进制序列化,但主推的已经是 XML 序列化了。二进制序列化可读性差,不易反序列化。CArchive 序列化的成果,使用 CArchive 反序列化是很容易的,但要不使用 CArchive 或根本就不使用 C++ 语言进行反序列化,就不是那么简单的了。

CArchive 作为 MFC 序列化的核心,支持所有数据类型的序列化,包括基本数据类型、数组、字符串、MFC 类及派生类。下面逐一解析。

基本数据类型

基本数据类型使用 << 运算符序列化,使用起来简单方便。都有哪些基本类型可以通过 << 序列化,只要打开 MFC 源文件看看就知道了。

MFC 源文件通常位于类似这样的文件夹里:C:\Program Files (x86)\Microsoft Visual Studio 14.0\VC\atlmfc

打开 ...\VC\atlmfc\include\afx.h 文件,在 class CArchive 的声明里可以找到下面的语句:

	CArchive& operator<<(BYTE by);
	CArchive& operator<<(WORD w);
	CArchive& operator<<(LONG l);
	CArchive& operator<<(DWORD dw);
	CArchive& operator<<(float f);
	CArchive& operator<<(double d);
	CArchive& operator<<(LONGLONG dwdw);
	CArchive& operator<<(ULONGLONG dwdw);

	CArchive& operator<<(int i);
	CArchive& operator<<(short w);
	CArchive& operator<<(char ch);
#ifdef _NATIVE_WCHAR_T_DEFINED
	CArchive& operator<<(wchar_t ch);
#endif
	CArchive& operator<<(unsigned u);

	CArchive& operator<<(bool b);

以上是一些基本数据类型,还有一些类也是使用 << 运算符序列化,后面会讲到。

如果要看这些声明的实现代码,可以打开 ...\VC\atlmfc\include\afx.inl 文件,很容易就能找到,比如:

_AFX_INLINE CArchive& CArchive::operator<<(LONG l)
{ 
	if(!IsStoring())
		AfxThrowArchiveException(CArchiveException::readOnly,m_strFileName);
	if (m_lpBufCur + sizeof(LONG) > m_lpBufMax) Flush();
		*(UNALIGNED LONG*)m_lpBufCur = l; m_lpBufCur += sizeof(LONG); return *this; 
}
_AFX_INLINE CArchive& CArchive::operator<<(DWORD dw)
{ 
	if(!IsStoring())
		AfxThrowArchiveException(CArchiveException::readOnly,m_strFileName);
	if (m_lpBufCur + sizeof(DWORD) > m_lpBufMax) Flush();
		*(UNALIGNED DWORD*)m_lpBufCur = dw; m_lpBufCur += sizeof(DWORD); return *this; 
}

CPoint、CRect、CSize

这三个类也是使用 << 运算符序列化,但在 CArchive 类里却找不到类似基本类型那样的运算符函数。

其实这三个类很特殊。这三个类不是纯正的 MFC 类,MFC 类通常都是继承自 CObject,而这三个类分别继承自 POINTRECTSIZE 三个 Win32 结构。这三个类属于 MFC 和 ATL 共享的类

这三个类的序列化,实际是通过它们的基类实现的。

打开 ...\VC\atlmfc\include\afxwin.h 文件,可以找到下面三个全局 << 运算符函数,正是这三个函数实现了 CPointCRectCSize 的序列化。

CArchive& AFXAPI operator<<(CArchive& ar, SIZE size);
CArchive& AFXAPI operator<<(CArchive& ar, POINT point);
CArchive& AFXAPI operator<<(CArchive& ar, const RECT& rect);

这三个函数的实现,可以在 ...\VC\atlmfc\include\afxwin1.inl 文件里找到。

_AFXWIN_INLINE CArchive& AFXAPI operator<<(CArchive& ar, SIZE size)
	{ ar.Write(&size, sizeof(SIZE)); return ar; }
_AFXWIN_INLINE CArchive& AFXAPI operator<<(CArchive& ar, POINT point)
	{ ar.Write(&point, sizeof(POINT)); return ar; }
_AFXWIN_INLINE CArchive& AFXAPI operator<<(CArchive& ar, const RECT& rect)
	{ ar.Write(&rect, sizeof(RECT)); return ar; }

CString

CString 类也是使用 << 运算符序列化,同样也找不到 CString<< 运算符函数。

CString 与 CPoint、CRect、CSize 一样,也是 MFC 和 ATL 共享的类

实际上 CStringCStringT 的别名。打开 ...\VC\atlmfc\include\afxstr.h 文件,在底部可以发现这样的语句:

typedef ATL::CStringT< wchar_t, StrTraitMFC_DLL< wchar_t > > CStringW;
typedef ATL::CStringT< char, StrTraitMFC_DLL< char > > CStringA;
typedef ATL::CStringT< TCHAR, StrTraitMFC_DLL< TCHAR > > CString;

而在 ...\VC\atlmfc\include\afx.h 文件里,在 class CArchive 的声明里可以找到这样的语句:

template< typename BaseType, class StringTraits >
CArchive& operator<<(const ATL::CStringT<BaseType, StringTraits>& str);

这个函数的实现,可以在 ...\VC\atlmfc\include\afx.inl 文件里找到:

template< typename BaseType, class StringTraits >
CArchive& CArchive::operator<<(const ATL::CStringT<BaseType, StringTraits>& str)
{
	AfxWriteStringLength(*this, str.GetLength(), sizeof(BaseType) == sizeof(wchar_t));
	Write(str, str.GetLength()*sizeof(BaseType));
	return *this;
}

这应该就是 CString 类的序列化函数了。

CObject

CObject 是几乎所有 MFC 类的根(基类),CObject 提供了一个名为 Serialize 的虚函数,所有 CObject 的派生类都是通过重写这个虚函数实现序列化的。

virtual void Serialize(CArchive& ar);

上面这行语句可以在 ...\VC\atlmfc\include\afx.h 文件的 CObject 类声明里找到。

另外,在 ...\VC\atlmfc\include\afx.h 文件的 class CArchive 的声明里可以发现下面这样的语句:

// Object I/O is pointer based to avoid added construction overhead.
// Use the Serialize member function directly for embedded objects.
friend CArchive& AFXAPI operator<<(CArchive& ar, const CObject* pOb);

这个函数的实现可以在 ...\VC\atlmfc\include\afx.inl 文件里找到:

_AFX_INLINE CArchive& AFXAPI operator<<(CArchive& ar, const CObject* pOb)
	{ ar.WriteObject(pOb); return ar; }

这说明,CObject 派生类还可以使用 << 运算符序列化。CObArray::Serialize 函数里,就是使用的 << 运算符。

要想使用 << 运算符序列化 CObject 派生类对象,CObject 派生类必须添加 DECLARE_SERIALIMPLEMENT_SERIAL 宏。这在后面 定义可序列化的类 一节会讲到。

数组

CByteArray、CWordArray、CDWordArray、CUIntArray

这四个数组类型的共同特点是:1、都继承自 CObject;2、存储的都是基本数据类型。

这四个数组类中,CByteArrayCWordArrayCDWordArray 都通过重写 CObject::Serialize 函数实现了序列化,但 CUIntArray 却没有重写这个函数,很奇怪。

这四个类的声明可以在 ...\VC\atlmfc\include\afxcoll.h 文件里找到,可见 CByteArrayCWordArrayCDWordArray 的类声明里都有下面这样的语句:

void Serialize(CArchive&);

CUIntArray 类的声明里却找不到这样的语句。

这四个类的实现代码可以分别在 ...\VC\atlmfc\src\mfc 文件夹里的 array_b.cpparray_w.cpparray_d.cpparray_u.cpp 文件里找到。

CStringArray

同样可以在 ...\VC\atlmfc\include\afxcoll.h 文件里找到 CStringArray 类的声明:

class CStringArray : public CObject

可见 CStringArray 继承自 CObject 类,类声明里可以发现有序列化函数:

void Serialize(CArchive&);

CStringArray 类的实现在 ...\VC\atlmfc\src\mfc\array_s.cpp 文件里。

void CStringArray::Serialize(CArchive& ar)
{
	ASSERT_VALID(this);

	CObject::Serialize(ar);

	if (ar.IsStoring())
	{
		ar.WriteCount(m_nSize);
		for (INT_PTR i = 0; i < m_nSize; i++)
			ar << m_pData[i];
	}
	else
	{
		DWORD_PTR nOldSize = ar.ReadCount();
		SetSize(nOldSize);
		for (INT_PTR i = 0; i < m_nSize; i++)
			ar >> m_pData[i];
	}
}

CObArray、CPtrArray

这两个是指针数组,同样在 ...\VC\atlmfc\include\afxcoll.h 文件里声明。

CObArrayCObject* 指针数组,CPtrArrayvoid* 指针数组,这两个类都继承自 CObject

由类声明可见,只有 CObArray 重写了 CObject::Serialize 函数,CPtrArray 并没有实现序列化函数。其实这是可以理解的,CPtrArray 数组的成员是 void* 指针类型,也就是说,其数组成员实际是什么类型,是不清楚的,无法了解数据长度,所以也就没办法序列化。

这两个类的实现代码分别在 ...\VC\atlmfc\src\mfc 文件夹的 array_o.cpparray_p.cpp 文件里。

void CObArray::Serialize(CArchive& ar)
{
	ASSERT_VALID(this);

	CObject::Serialize(ar);

	if (ar.IsStoring())
	{
		ar.WriteCount(m_nSize);
		for (INT_PTR i = 0; i < m_nSize; i++)
			ar << m_pData[i];
	}
	else
	{
		DWORD_PTR nOldSize = ar.ReadCount();
		SetSize(nOldSize);
		for (INT_PTR i = 0; i < m_nSize; i++)
			ar >> m_pData[i];
	}
}

注意这里使用 << 运算符序列化对象,因此如果要使用 CObArray::Serialize 函数序列化,则类必须添加 DECLARE_SERIALIMPLEMENT_SERIAL 宏。

CArray

CArray 是模板数组,继承自 CObject,在 ...\VC\atlmfc\include\afxtempl.h 文件里声明。

template<class TYPE, class ARG_TYPE = const TYPE&>
class CArray : public CObject

虽然 CArray 的标准用法是 CArray<TYPE, ARG_TYPE>,但由于 ARG_TYPE 有缺省值 ARG_TYPE = const TYPE&,所以一般来说,声明 CArray 对象时,只需指定 TYPE 就可以了,如 CArray<CPoint> ptArray;

CArray 重写了 CObject::Serialize 函数,CArray::Serialize 的实现也在 ...\VC\atlmfc\include\afxtempl.h 文件里。

template<class TYPE, class ARG_TYPE>
void CArray<TYPE, ARG_TYPE>::Serialize(CArchive& ar)
{
	ASSERT_VALID(this);

	CObject::Serialize(ar);
	if (ar.IsStoring())
	{
		ar.WriteCount(m_nSize);
	}
	else
	{
		DWORD_PTR nOldSize = ar.ReadCount();
		SetSize(nOldSize, -1);
	}
	SerializeElements<TYPE>(ar, m_pData, m_nSize);
}
template<class TYPE>
void AFXAPI SerializeElements(CArchive& ar, TYPE* pElements, INT_PTR nCount)
{
	ENSURE(nCount == 0 || pElements != NULL);
	ASSERT(nCount == 0 ||
		AfxIsValidAddress(pElements, (size_t)nCount * sizeof(TYPE)));

	// default is bit-wise read/write
	if (ar.IsStoring())
	{
		TYPE* pData;
		UINT_PTR nElementsLeft;

		nElementsLeft = nCount;
		pData = pElements;
		while( nElementsLeft > 0 )
		{
			UINT nElementsToWrite;

			nElementsToWrite = UINT(__min(nElementsLeft, INT_MAX/sizeof(TYPE)));
			ar.Write(pData, nElementsToWrite*sizeof(TYPE));
			nElementsLeft -= nElementsToWrite;
			pData += nElementsToWrite;
		}
	}
	else
	{
		TYPE* pData;
		UINT_PTR nElementsLeft;

		nElementsLeft = nCount;
		pData = pElements;
		while( nElementsLeft > 0 )
		{
			UINT nElementsToRead;

			nElementsToRead = UINT(__min(nElementsLeft, INT_MAX/sizeof(TYPE)));
			ar.EnsureRead(pData, nElementsToRead*sizeof(TYPE));
			nElementsLeft -= nElementsToRead;
			pData += nElementsToRead;
		}
	}
}

CArray::Serialize 的实现代码来看,如果 CArray 存储的是 CObject 的派生类,则似乎不适合使用 CArray::Serialize 序列化,而应该调用类自身的 Serialize 函数序列化。

CTypedPtrArray

CTypedPtrArray 也是模板数组,也在 ...\VC\atlmfc\include\afxtempl.h 文件里声明。

template<class BASE_CLASS, class TYPE>
class CTypedPtrArray : public BASE_CLASS

从类声明看,CTypedPtrArray 继承自 BASE_CLASS。对于 BASE_CLASS,微软文档说明:必须是数组类 CObArrayCPtrArray

微软文档对 CTypedPtrArray 的说明是:CPtrArray 类或 CObArray 类的对象提供类型安全的“包装器”。

我想可以这样理解,实际编码中,我们尽量不直接使用 CPtrArrayCObArray,而是使用 CTypedPtrArray

CTypedPtrArray 本身没有重写 CObject::Serialize 函数,所以 CTypedPtrArray 的序列化是通过 BASE_CLASSSerialize 函数实现的。

由于 CPtrArray 类没有 Serialize 函数,所以只有当 BASE_CLASSCObArray 类时,CTypedPtrArray 才有序列化功能。

Lists 和 Maps

  • Lists:CPtrListCObListCStringListCListCTypedPtrList
  • Maps:CMapWordToObCMapWordToPtrCMapPtrToWordCMapPtrToPtrCMapStringToPtrCMapStringToObCMapStringToStringCMapCTypedPtrMap

以上这些 List 类和 Map 类的声明和实现都在 ...\VC\atlmfc\include 文件夹的 afxcoll.hafxtempl.h 文件里。

定义可序列化的类

定义我们自己的类时,如果想使用 MFC 的序列化机制,则需要按下面步骤来做:

  1. CObject 类派生(或者从 CObject 的派生类派生)。
  2. 重写 Serialize 成员函数。
  3. 在类声明中使用 DECLARE_SERIAL 宏。
  4. 定义没有参数的构造函数。
  5. 在类的实现中使用 IMPLEMENT_SERIAL 宏。

如果直接调用 Serialize序列化,而不是通过 CArchive>><< 运算符调用,则不需要最后三个步骤。

也就是说,DECLARE_SERIALIMPLEMENT_SERIAL 宏是为了使自定义类支持 << 运算符序列化才需要添加的,如果自定义类不需要支持序列化,或者仅需要支持 Serialize序列化,则不需要添加这两个宏。

比如,CObArray::Serialize 就是使用 << 序列化的。如果自定义类要添加到 CObArrayCTypedPtrArray 中,并通过数组对象的 Serialize 函数序列化,那么自定义类就必须添加 DECLARE_SERIALIMPLEMENT_SERIAL 宏。

参见:序列化:定义可序列化的类

DECLARE_SERIAL

...\VC\atlmfc\include\afx.h 文件里可找到 DECLARE_SERIAL 的定义:

#ifdef _AFXDLL
#define _DECLARE_DYNAMIC(class_name) \
protected: \
	static CRuntimeClass* PASCAL _GetBaseClass(); \
public: \
	static CRuntimeClass class##class_name; \
	static CRuntimeClass* PASCAL GetThisClass(); \
	virtual CRuntimeClass* GetRuntimeClass() const; \
#else
#define _DECLARE_DYNAMIC(class_name) \
public: \
	static CRuntimeClass class##class_name; \
	virtual CRuntimeClass* GetRuntimeClass() const; \
#endif

#define _DECLARE_DYNCREATE(class_name) \
	_DECLARE_DYNAMIC(class_name) \
	static CObject* PASCAL CreateObject();

#define DECLARE_SERIAL(class_name) \
	_DECLARE_DYNCREATE(class_name) \
	AFX_API friend CArchive& AFXAPI operator>>(CArchive& ar, class_name* &pOb);

IMPLEMENT_SERIAL

IMPLEMENT_SERIAL 宏也是在 ...\VC\atlmfc\include\afx.h 文件里定义的:

// Helper macros
#define _RUNTIME_CLASS(class_name) ((CRuntimeClass*)(&class_name::class##class_name))
#ifdef _AFXDLL
#define RUNTIME_CLASS(class_name) (class_name::GetThisClass())
#else
#define RUNTIME_CLASS(class_name) _RUNTIME_CLASS(class_name)
#endif

#ifdef _AFXDLL
#define _IMPLEMENT_RUNTIMECLASS(class_name, base_class_name, wSchema, pfnNew, class_init) \
	CRuntimeClass* PASCAL class_name::_GetBaseClass() \
		{ return RUNTIME_CLASS(base_class_name); } \
	AFX_COMDAT CRuntimeClass class_name::class##class_name = { \
		#class_name, sizeof(class class_name), wSchema, pfnNew, \
			&class_name::_GetBaseClass, NULL, class_init }; \
	CRuntimeClass* PASCAL class_name::GetThisClass() \
		{ return _RUNTIME_CLASS(class_name); } \
	CRuntimeClass* class_name::GetRuntimeClass() const \
		{ return _RUNTIME_CLASS(class_name); }
#else
#define _IMPLEMENT_RUNTIMECLASS(class_name, base_class_name, wSchema, pfnNew, class_init) \
	AFX_COMDAT CRuntimeClass class_name::class##class_name = { \
		#class_name, sizeof(class class_name), wSchema, pfnNew, \
			RUNTIME_CLASS(base_class_name), NULL, class_init }; \
	CRuntimeClass* class_name::GetRuntimeClass() const \
		{ return RUNTIME_CLASS(class_name); }
#endif

#define IMPLEMENT_SERIAL(class_name, base_class_name, wSchema) \
	CObject* PASCAL class_name::CreateObject() \
		{ return new class_name; } \
	extern AFX_CLASSINIT _init_##class_name; \
	_IMPLEMENT_RUNTIMECLASS(class_name, base_class_name, wSchema, \
		class_name::CreateObject, &_init_##class_name) \
	AFX_CLASSINIT _init_##class_name(RUNTIME_CLASS(class_name)); \
	CArchive& AFXAPI operator>>(CArchive& ar, class_name* &pOb) \
		{ pOb = (class_name*) ar.ReadObject(RUNTIME_CLASS(class_name)); \
			return ar; }

参阅:运行时对象模型服务

CArchive::WriteObject

CObject<< 运算符函数内部就是调用 CArchive::WriteObject 函数序列化的。

所有 CObject 的派生类都可以使用 CArchive::WriteObject 函数序列化,但前提是 CObject 派生类必须添加 DECLARE_SERIALIMPLEMENT_SERIAL 宏。

CArchive::WriteObject 函数的实现可以在 ...\VC\atlmfc\src\mfc\arcobj.cpp 文件里找到:

void CArchive::WriteObject(const CObject* pOb)
{
	// object can be NULL

	ASSERT(IsStoring());    // proper direction

	if (!IsStoring()) 
	{
		AfxThrowArchiveException(CArchiveException::readOnly, m_strFileName);
	}

	DWORD nObIndex;
	ASSERT(sizeof(nObIndex) == 4);
	ASSERT(sizeof(wNullTag) == 2);
	ASSERT(sizeof(wBigObjectTag) == 2);
	ASSERT(sizeof(wNewClassTag) == 2);

	// 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);
	}
}

CArchive::WriteObject 内部又调用了 CArchive::WriteClass 函数,最后调用对象自身的 Serialize 函数完成序列化。

void CArchive::WriteClass(const CRuntimeClass* pClassRef)
{
	ASSERT(pClassRef != NULL);
	ASSERT(IsStoring());    // proper direction

	if (pClassRef == NULL) 
	{
		AfxThrowArchiveException(CArchiveException::badClass, m_strFileName);
	}

	if (!IsStoring()) 
	{
		AfxThrowArchiveException(CArchiveException::genericException, m_strFileName);
	}

	if (pClassRef->m_wSchema == 0xFFFF)
	{
		TRACE(traceAppMsg, 0, "Warning: Cannot call WriteClass/WriteObject for %hs.\n",
			pClassRef->m_lpszClassName);
		AfxThrowNotSupportedException();
	}

	// 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++;
	}
}

使用 CArchive::WriteObject 序列化对象,当同一个对象反复请求序列化时,为了避免重复存储相同的信息,WriteObject 对于同一个对象,只存储一次。这是如何实现的呢?

...\VC\atlmfc\include\afx.h 文件的 class CArchive 的类声明里可以发现下面这样的语句:

// array/map for CObject* and CRuntimeClass* load/store
UINT m_nMapCount;
union
{
	CPtrArray* m_pLoadArray;
	CMapPtrToPtr* m_pStoreMap;
};

正是 m_pStoreMapm_nMapCount 合作实现了这个机制。

CArchive::WriteObject 函数里,在调用被序列化对象的 Serialize 函数前,有这样一行语句:

(*m_pStoreMap)[(void*)pOb] = (void*)(DWORD_PTR)m_nMapCount++;

可见,每次序列化前,都把对象的指针和当前已存储对象的顺序号(索引号)存到了 m_pStoreMap 里。

那么这有什么用呢?以上代码是在一个 else 里,在 else 上面还有一个 else if,如下:

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;
	}
}

从这段代码可发现,在每次序列化前,都先从 m_pStoreMap 里获取该对象指针的索引号,如果索引号存在,说明该对象曾经序列化过,那么就只序列化该索引号,否则,序列化整个对象。

CArchive::WriteClass 函数里,也有类似的机制,可避免相同的类信息被反复存储。

(*m_pStoreMap)[(void*)pClassRef] = (void*)(DWORD_PTR)m_nMapCount++;
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);
	}
}

参考

  • 0
    点赞
  • 1
    评论
  • 2
    收藏
  • 打赏
    打赏
  • 扫一扫,分享海报

评论 1 您还未登录,请先 登录 后发表或查看评论
©️2022 CSDN 皮肤主题:书香水墨 设计师:CSDN官方博客 返回首页

打赏作者

blackwood-cliff

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值