平台+插件软件设计思想基于COM原型实现的代码剖析

平台+插件软件设计思想基于COM原型实现的代码剖析

337人阅读 评论(0) 收藏 举报

为了帮助大家更好地理解平台+插件软件设计思想及基于COM原型实现的框架结构及相关代码,本文将具体介绍与分析各关键部分的代码实现。有关平台+插件软件设计思想的基本观点及全部源代码请参见CSDN的文档中心的http://blog.csdn.net/goldboar/archive/2003/09/29/21595.aspx文档和软件中心平台+插件原型实现源代码http://download.csdn.net/source/2842576。作者在此声明,此观点的相关文档及源代码只能用于学习与研究,未经作者同意不能用于商业用途。
一、开发运行环境与项目文件
原型实现的开发环境为Windows XP,使用Visual Studio 2008开发工具,程序所使用的开发库包括MFC和ATL,原型程序在Windows XP系统下测试运行已通过。
源程序的解决方案由3个项目组成,解决方案文件位于Platform目录下,文件名为:PluginDemo.sln,3个项目文件分别位于Platform、Plugin、Plugin2目录下,文件名分别为:platform.vcproj、plugin.vcproj、plugin2.vcproj。其中platform是基于MFC的单文档项目,plugin和plugin2是ATL项目,项目platform是设计实现的主框架(平台)程序,项目plugin和项目plugin2是设计实现的插件(均为进程内COM对象),它们都实现了设计中所定义的IPlugin COM接口。
二、程序运行与使用
将源程序编译通过后,运行Platform项目,之后就可以测试与使用原型的实现功能。在此提示编译的过程不只是生成了目标文件,同时还将生成插件的COM信息注册,如果没有注册,则框架程序运行后找不到注册的插件信息。插件信息注册可以手工完成,其命令为regsvr32.exe。
Platform为单文档程序,运行主界面见图1。程序启动后并没有注册插件,为了体现功能,请选择测试菜单下的插件注册命令,程序运行搜索系统注册信息,若系统已安装并注册了插件则将插件功能生成插件菜单,同时也生成了与菜单项相对应的工具栏,参见图2插件注册以后程序界面变化。插件菜单下由分隔条分为两部分,每一个部分对应于一个插件,即第一个插件有简单功能1、简单功能2和高级功能3项功能,第二个插件有Scribble和功能调用2项功能,生成的工具栏与此菜单一一对应。每个插件生成一个工具栏,在视图菜单下有控制插件工具栏显示的2个菜单项:测试插件和图形插件,此2项菜单命令控制工具栏显示的开与关。

 

图1 Platform主界面(未注册插件)

 


图2  注册了插件的程序主界面

 


图3 生成的插件菜单
插件注册以后,就可通过菜单或工具栏命令调用。如图3所示生成的插件菜单项,插件1的功能比较简单,共有3项功能,其中简单功能1为弹出一个无模式信息提示窗口,简单功能2为弹出一个有模式的信息提示窗口,高级功能为调用一个无模式的对话框。插件2的功能比较复杂,其包括scribble和功能调用两项功能,Scribble为调用一个无模式的对话框,功能调用为调用一个有模式的对话框。
Scribble功能调用体现了设计思想的基本消息传递、资源传递、数据传递等功能实现。调用以后,当鼠标在主框架程序中移动时,此功能可以捕获主框架程序的鼠标消息,此原型程序实现了鼠标的移动、左键单击和双击事件消息传递,鼠标消息变化显示在对话框的上方的编辑条中,可参见图4所示。对话框上方的画图1和画图2按钮为在主框架程序的用户窗口中分别画出2个不规则图形。Scribble程序测试为选中启动白板功能复选框后,可以在主框架程序用户界面中,按下鼠标左键不放,拖动鼠标进行画图,相当于一个“白板”,画线颜色为红色,若关闭白板功能,只是关闭启动白板功能复选框,之后就不能够在主框架程序中画图了。也许你已经看到前面的介绍功能在主框架程序上画完或显示后,主框架程序并没有保存信息,下面就介绍比较复杂数据传递和重画界面的功能——画圆功能。

 

 

图4  插件的Scribble功能调用对话框
如图4所示,选中对话框下方的启动画圆功能复选框,之后可调整比率值和线宽,还可以选择红色、绿色和蓝色3种不同颜色,之后用鼠标在主框架程序用户界面中双击鼠标,则可在主框架中画出1个一定大小、线宽和颜色的圆,可选用不同比率、线宽和颜色画出多个圆,之后选择主框架程序的新建命令,则会提示是否保存文件,这是因为画圆功能的数据已传送到主框架程序中,并可将此数据存盘。可是当界面刷新以后,刚刚画的圆并没有被重画,这时可选中启动图形显示功能复选框,之后当界面刷新后,前边被画圆可被程序重画。
这里需要强调的是:前面所述各项功能全部是由插件实现的,主框架程序只向插件提供各种消息、资源以及内部存贮数据,具体内容将在下面代码分析中介绍。图5为启动画圆和Scribble功能后的主框架程序界面。

 

图5  画圆和Scribble后的主框架程序界面
三、插件注册
为了管理插件首先要解决的就是如何注册插件。此原型使用COM的标准组件目录(Component Category)接口实现插件的标识与注册管理。组件目录就是将一组COM组件归类,并分配唯一的GUID编号,组件目录的CLSID号为CLSID_StdComponentCategoriesMgr,组件目录的IID号为IID_ICatInformation,而同一类组件标识也为一个GUID,此编号由用户定义生成。组件目录创建代码如下:
 

    //创建标准的组件目录
         ICatInformation* pICatInformation = NULL ;

         HRESULT hr = ::CoCreateInstance(CLSID_StdComponentCategoriesMgr,

                                                                 NULL, CLSCTX_ALL, IID_ICatInformation,

                                                                 (void**)&pICatInformation) ;

         if (FAILED(hr))  return FALSE;

         // 定义目录数组
         int cIDs = 1 ;

         CATID IDs[1] ;

         IDs[0] = catID; //组件目录的GUID

         // 查找所有的分类号catID组件
         IEnumCLSID* pIEnumCLSID = NULL ;

         hr = pICatInformation->EnumClassesOfCategories(cIDs, IDs, 0, NULL, &pIEnumCLSID) ;

         ASSERT(SUCCEEDED(hr)) ;

 

其中catID为需要查找的组件分类标识号(GUID),此GUID由用户生成使用,此标识GUID定义形式如下:
//{F5E719CC-4AFF-411b-9CE0-CCC5CDB9333B}

DEFINE_GUID(CATID_LMHPluginCategory,       //组件目录GUID标识号
0xf5e719cc, 0x4aff, 0x411b, 0x9c, 0xe0, 0xcc, 0xc5, 0xcd, 0xb9, 0x33, 0x3b);

为了每组件设置标识的方法非常简单,使用手工方法在注册文件(项目中的.rgs文件)加入如下语句(以CATID_LMHPluginCategory为例):
      'Implemented Categories'

          {

                   {F5E719CC-4AFF-411b-9CE0-CCC5CDB9333B}

               }
组件目录创建以后,对组件目录下的所有组件进行遍历,便可得到每一个组件的CLSID,并可使用此CLSID创建需要的组件对象,相关实现代码如下:
     CLSID clsid ;

         IPlugin * pPlugin;   //每个插件所实现的的IID
         while ((hr = pIEnumCLSID->Next(1, &clsid, NULL)) == S_OK)

         {      

                   //---------创建查询到的COM------------------------

                   hr = ::CoCreateInstance(clsid,                                     //COM类的ID

                                                                           NULL,

                                                                           CLSCTX_INPROC_SERVER,

                                                                           IID_IPlugin,                 //统一接口定义
                                                                           reinterpret_cast<void**>(&pPlugin)) ; //接口指针
                   if (FAILED(hr))

                   {

                            AfxMessageBox("组件创建失败!") ;

                            return FALSE ;

                   }

        … ….   
     }

原型代码中的函数BOOL CPlatformView::CheckInPlugin(GUID catID)实现插件的注册。
四、插件管理与调用
插件的注册过程中,为每一个插件生成管理与调用机制,即为每个插件的每一项功能生成一个动态菜单项,也包括工具栏。在动态菜单及工具栏的生成过程,在主框架程序中定义一个动态菜单管理类,其定义如下:
class CMenuItem

{            

public:

         UINT m_menuID;                 //菜单的ID号
         CLSID m_classID;        //插件的CLSID

         short m_menuAction;      //功能编号
public:

         CMenuItem(void);

         CMenuItem(UINT menuID,CLSID classID,short menuAction);

         ~CMenuItem(void);

};            

typedef CTypedPtrList <CPtrList,CMenuItem*> CMenuList;  //动态数据
class CMenuPlugin                //动态菜单管理类
{    

public:

         CMenuPlugin(void);

         ~CMenuPlugin(void);

         CMenuList m_menuList;

private:

         UINT m_firstMenuID,m_CurrentMenuID;

         int m_menuCount;

public:

         void InitMenu(UINT menuID);

         BOOL Add(CLSID classID, short menuAction);

         CLSID Find(UINT menuID,short& actionID);

         void ClearAll(void);

};    

其中菜单项的名字和工具栏的位图均由插件提供。主框架只负责管理和使用。菜单及工具栏生成后在程序运行中就可调用插件功能,插件的菜单调用过程在OnCmdMsg()函数中实现,其代码如下:
BOOL CPlatformView::OnCmdMsg(UINT nID, int nCode, void* pExtra, AFX_CMDHANDLERINFO*  HandlerInfo)

{                         

         if (pHandlerInfo == NULL)

         {                    

                   if (nCode == CN_COMMAND)  //处理菜单命令
                   {

              … …

                            if ((nID>=m_basicMenuID) && (nID<m_currentMenuID))  //动态菜单的ID范围
                            {

                                     CLSID clsID;

                                     short actionID;

                                     clsID=m_MenuPluginList.Find(nID,actionID);

                                     //--------------------------------------------

                                     CallPlugin(clsID,actionID);                 //调用插件功能
                                     //--------------------------------------------

                                     return TRUE;

                            }      

                   }

                   else if (nCode == CN_UPDATE_COMMAND_UI)      
                   {

                            … …

                            //---激活PLUGIN菜单项------------------------

                            if ((nID>=m_basicMenuID) && (nID<m_currentMenuID))

                            {

                                     ((CCmdUI*)pExtra)->Enable(TRUE);          //开启各菜单命令
                                     return TRUE;

                            }

                   }

         }

         return CScrollView::OnCmdMsg(nID, nCode, pExtra, pHandlerInfo);

}   

其中m_MenuPluginList为动态菜单管理类CMenuPlugin。函数CallPlugin()调用插件功能,其实现代码如下:
BOOL CPlatformView::CallPlugin(CLSID classID, short actionID)

{               

         HRESULT hr ;

         IPlugin * pPlugin;

         pPlugin=(IPlugin*)m_interfaceList.Find(classID);  //查找插件是否已运行
         if (pPlugin==NULL)

         {

                   //创建查询到的COM

                   hr = ::CoCreateInstance(classID,                       //COM类的ID

                                                                           NULL,

                                                                           CLSCTX_INPROC_SERVER,

                                                                           IID_IPlugin,//统一接口定义
                                                                           reinterpret_cast<void**>(&pPlugin)) ; //接口指针
                   if (FAILED(hr))

                   {

                            AfxMessageBox("Failed to create the component.") ;

                            return FALSE ;

                   }

                   m_interfaceList.Add(classID,pPlugin);

         }

         CDC* cDC=GetDC( );

         pPlugin->PassHDC(cDC->m_hDC);

         CWnd* cWND=AfxGetMainWnd();

         pPlugin->PassHWND(cWND->m_hWnd);

         pPlugin->SetServer(((IUnknown*)m_pServer));

         pPlugin->DoFunction(actionID);  //调用插件功能项
         return TRUE;

}              
其中m_interfaceList为插件的接口管理类,如果一个插件已经被调用,那在调用另外的功能时就不需要创建新组件。此类的实现代码如下:
class CInterfaceItem

{

public:

         CLSID m_classID;

         IUnknown* m_interface;

public:

         CInterfaceItem(void);

         ~CInterfaceItem(void);

};

typedef CTypedPtrList <CPtrList,CInterfaceItem*> CInterfaceList;

                                                     

class CInterfaceDB

{

public:

         CInterfaceDB(void);

         ~CInterfaceDB(void);

         CInterfaceList      m_interfaceList;

         BOOL Add(CLSID classID, IUnknown* interfaceName);

         IUnknown* Find(CLSID classID);

         void ClearAll(void);

};

五、平台扩展接口和插件接口
主框架程序所实现的平台扩展接口为IServer,此接口主要实现插件与主框架之间的内部数据传递,即传递画圆数据信息,其定义如下:
interface IServer : IUnknown{

         [helpstring("方法GetDataPoint")] HRESULT GetDataPoint([out] myGraph** dPointer);

         [helpstring("方法NewDataCircle")] HRESULT NewDataCircle([in] myGraph* pGraph);

};                      

其中结构myGraph用于记录画圆数据,其定义为:
struct _myGraph

{            

         int mWidth;                   //线的宽度
         unsigned long mRed,mGreen,mBlue;//线的颜色
         long x, y;        //对于圆来说,为圆心点
         int mRate;            //{1,2,3}三种比率
         struct _myGraph * next;  //链接指针
};           

typedef struct _myGraph  myGraph;

IServer的方法GetDataPoint()为获得内部数据地址,即画圆数据的地址,方法NewDataCircle()为创建新的画圆数据。通过这2个方法,插件可得到主框架程序的内部数据,并可申请分配新的数据存储空间,IServer实现主框架与插件间的内部数据交换。
插件所实现的插件接口为IPlugin,此接口为所有插件必须实现的接口,完全由插件实现,主框架只是使用,此接口定义为:
 

interface IPlugin : IUnknown{

         [helpstring("方法GetPluginName")] HRESULT GetPluginName([out] CHAR* name);

         [helpstring("方法GetFunctionCount")] HRESULT GetFunctionCount([out] SHORT* pCount);

         [helpstring("方法GetFunctionName")] HRESULT GetFunctionName([in] SHORT index, [out] CHAR* name);

         [helpstring("方法DoFunction")] HRESULT DoFunction([in] SHORT index);

         [helpstring("方法SendMouseMessage")] HRESULT SendMouseMessage(UINT Message, UINT flags, int x, int y);

         [helpstring("方法PassHDC")] HRESULT PassHDC(HDC hDC);

         [helpstring("方法PassHWND")] HRESULT PassHWND(HWND mHWND);

         [helpstring("方法SetServer")] HRESULT SetServer(IUnknown* iServer);

         [helpstring("方法ReDraw")] HRESULT ReDraw(void);

         [helpstring("方法GetToolBarBitmap")] HRESULT GetToolBarBitmap([out] HBITMAP* hBitmap);

};   
其中方法GetPluginName()为得到插件的名称, 方法GetFunctionCount()得到插件所实现功能数量,方法   GetFunctionName()得到插件每一个功能的名称,方法DoFunction()调用插件功能,方法SendMouseMessage()向插件传递鼠标消息,方法 PassHDC()向插件传递设备上下文句柄,方法PassHWND()向插件传递主框架的窗口句柄,方法SetServer()向插件传递主框架的IServer接口,方法ReDraw()为窗口重画操作,方法GetToolBarBitmap()得到插件所提供的工具栏位图。
在此说明一下,主框架的动态菜单和工具栏生成过程:主框架得到插件CLSID后,创建插件接口IPlugin,之后调用GetFunctionCount()得到插件所能提供功能数量,然后循环调用GetFunctionName()得到每一个功能的名称,这个名称用于菜单项名称,插件的功能排序从0开始,也就是菜单管理类中的功能编号,调用GetPluginName()得到插件的名称,此名称用于视图下的菜单项名称,调用GetToolBarBitmap()得到工具栏位图,此后主框架程序使用这些从插件得到的信息与资源生成动态菜单与工具栏,这一过程代码如下:
BOOL CPlatformView::CheckInPlugin(GUID catID)

{     

         // Create the standard COM Category Manager

         ICatInformation* pICatInformation = NULL ;

         HRESULT hr = ::CoCreateInstance(CLSID_StdComponentCategoriesMgr,

                                                                  NULL, CLSCTX_ALL, IID_ICatInformation,

                                                                  (void**)&pICatInformation) ;

         if (FAILED(hr))

         {

                   ASSERT(hr) ;

                   return FALSE;

         }

         // Array of Categories

         int cIDs = 1 ;

         CATID IDs[1] ;

         IDs[0] = catID; //组件目录的GUID

         // Get the IEnumCLSID interface.

         IEnumCLSID* pIEnumCLSID = NULL ;

         hr = pICatInformation->EnumClassesOfCategories(cIDs, IDs, 0, NULL, &pIEnumCLSID) ;

         ASSERT(SUCCEEDED(hr)) ;

         CLSID clsid ;

         IPlugin * pPlugin;

         BOOL firstPlugin=true;

         while ((hr = pIEnumCLSID->Next(1, &clsid, NULL)) == S_OK)

         {      

                   //---------创建查询到的COM------------------------

                   hr = ::CoCreateInstance(clsid,                            //COM类的ID

                                                         NULL,

                                                         CLSCTX_INPROC_SERVER,

                                                         IID_IPlugin,               //统一接口定义
                                                        reinterpret_cast<void**>(&pPlugin)) ; //接口指针
                   if (FAILED(hr))

                   {

                            AfxMessageBox("Failed to create the component.") ;

                            return FALSE ;

                   }

                   int i;

                   short nCount;

                   char funName[80];

                   pPlugin->GetFunctionCount(&nCount);   //得到功能数量
                   HBITMAP hBitmap;

                   pPlugin->GetToolBarBitmap(&hBitmap);  //得到工具栏位图
                   char pluginName[80];

                   pPlugin->GetPluginName(pluginName);   //得到插件的名称
         ((CMainFrame*)AfxGetMainWnd())->AddPluginToolbar(hBitmap,m_currentMenuID,nCount,pluginName);                   //生成插件的工具栏
                   for (i=0;i<nCount;i++)                   //规定动作序号从0开始
                   {

                            m_MenuPluginList.Add(clsid,i);

                            pPlugin->GetFunctionName(i,funName);  //得到每个功能项的名称
                            if ((!firstPlugin) && (i==0))

                                     RefreshChildMenu(m_child1Menu,true,funName);   //生成菜单项
                            else

                                     RefreshChildMenu(m_child1Menu,false,funName);

                   }

                   pPlugin->Release();

                   firstPlugin=false;

         }

         pICatInformation->Release() ;

         m_pServer= new CComObject<CServer>;

         _ASSERT(m_pServer != NULL);

         CPlatformDoc* pDoc = GetDocument();

         m_pServer->m_pDoc=pDoc;

         m_pServer->m_pView=this;

         ((IServer*)m_pServer)->AddRef();

         return TRUE;

  }  

 

方法DoFunction()的使用请参见函数CallPlugin()定义。
六、消息、资源与数据传递
原型代码主要实现了鼠标信息的传递,此方法定义在IPlugin接口的SendMouseMessage()中,此方法的具体实现由插件完成,其参数(UINT Message, UINT flags, int x, int y)含义与Windows的鼠标消息一致。Plugin2的SendMouseMessage()部分代码如下:
STDMETHODIMP CPlugin::SendMouseMessage(UINT Message, UINT flags, int x, int y)

{    

         if (m_myDlg!=NULL)

         {

                   HWND hwndStatus =m_myDlg->GetDlgItem(IDC_EDIT1);

                   TCHAR szCookieItem[100];

                   switch(Message)

                   {

                   case WM_MOUSEMOVE:   //处理鼠标移动消息
                            _stprintf(szCookieItem, _T("MouseMove( x position %d,y position%d) "), x,y);

                            if (m_Paint){

                                     m_Target.x=x;m_Target.y=y;

                                     m_myDlg->DrawTo(m_Source,m_Target);

                                     m_Source.x=m_Target.x;m_Source.y=m_Target.y;

                            }

                            if ((m_myDlg->m_bPaint) && m_Paint)

                            ///*if (m_Paint)

                            {

                                     ::SetCursor(::LoadCursor(NULL,IDC_CROSS));

                            }else

                            {

                                     ::SetCursor(::LoadCursor(NULL,IDC_ARROW));

                            }

                            break;

                    …`…

         return S_OK;

    }
而主框架需要向插件传递鼠标消息时,只需要在鼠标事件处理中调用一次插件的SendMouseMessage()方法。
资源传递可包括Windows各种资源。主框架程序调用插件的PassHDC()方法向插件传递设备上下文句柄,调用方法PassHWND()向插件传递窗口句柄。主框架程序通过调用方法GetToolBarBitmap(),可得到插件传递的位图句柄。
原型中所实现的内部数据传递主要通过IServer的GetDataPoint()和NewDataCircle()方法实现。插件调用方法GetDataPoint()可得到主框架程序中的myGraph数据指针,调用方法NewDataCircle()在主框架中创建新的myGraph数据指针。主框架所实现的这2个方法代码如下:
 

STDMETHODIMP CServer::GetDataPoint(myGraph** dPointer)

{              

         AFX_MANAGE_STATE(AfxGetAppModuleState());

         *dPointer=m_pDoc->m_GraphHead;

         return S_OK;

}             

 

STDMETHODIMP CServer::NewDataCircle(myGraph* pGraph)

{             

         AFX_MANAGE_STATE(AfxGetAppModuleState());

         myGraph* mGraph;

         if (m_pDoc->m_GraphHead==NULL)

         {        

                   m_pDoc->m_GraphHead=new myGraph;

                   m_pDoc->m_GraphHead->mWidth =pGraph->mWidth;

                   m_pDoc->m_GraphHead->mRed=pGraph->mRed;

                   m_pDoc->m_GraphHead->mGreen=pGraph->mGreen;

                   m_pDoc->m_GraphHead->mBlue=pGraph->mBlue;

                   m_pDoc->m_GraphHead->x=pGraph->x;

                   m_pDoc->m_GraphHead->y=pGraph->y;

                   m_pDoc->m_GraphHead->mRate=pGraph->mRate;

                   m_pDoc->m_GraphHead->next=NULL;

                   m_pDoc->m_GraphTail=m_pDoc->m_GraphHead;

         } 

         else

         {       mGraph=new myGraph;

                   m_pDoc->m_GraphTail->next=mGraph;

                   m_pDoc->m_GraphTail=mGraph;

                   m_pDoc->m_GraphTail->mWidth=pGraph->mWidth;

                   m_pDoc->m_GraphTail->mRed=pGraph->mRed;

                   m_pDoc->m_GraphTail->mGreen=pGraph->mGreen;

                   m_pDoc->m_GraphTail->mBlue=pGraph->mBlue;

                   m_pDoc->m_GraphTail->x=pGraph->x;

                   m_pDoc->m_GraphTail->y=pGraph->y;

                   m_pDoc->m_GraphTail->mRate=pGraph->mRate;

                   m_pDoc->m_GraphTail->next=NULL;

         }  

         m_pDoc->m_countGraph++;

         m_pDoc->SetModifiedFlag();

         return S_OK;

   }   
 

Plugin2在方法ReDraw()实现中使用方法GetDataPoint()获得主框架程序内部的图形数据首指针。方法ReDraw()实现代码如下:
STDMETHODIMP CPlugin::ReDraw(void)

{          

         if (m_myDlg==NULL) return S_OK;

         if (!(m_myDlg->m_bDraw)) return S_OK;

         m_pServer->GetDataPoint(&m_pGraph);    //得到图形数据的头指针
         myGraph *mGraph;

         mGraph=m_pGraph;

         while (mGraph!=NULL)

         {       //创建为空刷子,以使圆透明
                   LOGBRUSH logBrush;

                   logBrush.lbStyle = BS_NULL;

                   logBrush.lbColor = RGB(0, 192, 192);

                   logBrush.lbHatch = HS_CROSS;

                   HBRUSH brush=::CreateBrushIndirect(&logBrush);

                   HBRUSH oldBrush=(HBRUSH)::SelectObject(m_hDC,brush);

         HPEN hPen = ::CreatePen(PS_SOLID,mGraph->mWidth,RGB(mGraph->mRed,

                                                                         mGraph->mGreen,mGraph->mBlue) ) ;

                   HPEN hOldPen = (HPEN) ::SelectObject(m_hDC, hPen) ;

                   ::Ellipse(m_hDC,mGraph->x-20*mGraph->mRate, mGraph->y-20*mGraph->mRate,

                                               mGraph->x+20*mGraph->mRate,mGraph->y+20*mGraph->mRate);

                   ::SelectObject(m_hDC,hOldPen);

                   ::SelectObject(m_hDC,oldBrush);

                   mGraph=mGraph->next;

         }

         return S_OK;

}   

(原著于2003年10月7日,2010年12月15日修改)


本文来自CSDN博客,转载请标明出处:http://blog.csdn.net/goldboar/archive/2010/12/15/6077902.aspx


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值