Embed an HTML control in your own window using plain C

 
76 votes for this article.
Popularity: 9.14. Rating: 4.86 out of 5.

Sample Image - cwebpage.jpg

Introduction

There are numerous examples that demonstrate how to embed Internet Explorer as an OLE/COM object in your own window. But these examples typically use Microsoft Foundation Classes (MFC), .NET, C#, or at least Windows Template Library (WTL) because those frameworks have pre-fabricated "wrappers" to easily give you an "HTML control" to embed in your window. If you're trying to use plain C, without MFC, WTL, .NET, C#, or even any C++ code at all, then there is a dearth of examples and information how to deal with COM objects such as IE's IWebBrowser2. Here is an article and working example in C to specifically show you what you need to do in order to embed IE in your own window, and more generally, show you how to interact with OLE/COM objects and create your own objects in plain C. The latter is useful for other tasks such as creating your own script engine, using COM and ActiveX components, and embedding other OLE objects, in plain C.

In fact, I've even wrapped up the example C code (to embed IE in your own window) into a Dynamic Link Library (DLL) so that you can simply call one function to display a web page or some HTML string in a window you create. You won't even need to get your hands dirty with OLE/COM (unless you plan to modify the source of the DLL).

With standard Win32 controls such as a Static, Edit, Listbox, Combobox, etc, you obtain a handle to the control (ie, an HWND) and pass messages (via SendMessage) to it in order to manipulate it. Also, the control passes messages back to you (ie, by putting them in your own message queue, and you fetch them with GetMessage) when it wants to inform you of something or give you some data.

Not so with an OLE/COM object. You don't pass messages back and forth. Instead, the COM object gives you some pointers to certain functions that you can call to manipulate the object. For example, the IWebBrowser2 object will give you a pointer to a function you can call to cause the browser to load and display a web page in one of your windows. And if the COM object needs to notify you of something or pass data to you, then you will be required to write certain functions in your program, and provide (to the COM object) pointers to those functions so the object can call those functions when needed. In other words, you need to create your own COM object(s) inside your program. Most of the real hassle in C will involve these embedded COM objects that you may need to provide so some other object can call your functions to fully interact with your program.

 

In conclusion, you call functions in the COM object to manipulate it, and it calls functions in your program to notify you of things or pass you data or interact with your program in some way. This scheme is analogous to calling functions in a DLL, but as if the DLL is also able to call functions inside your C program -- sort of like with a "callback". But unlike with a DLL, you don't use LoadLibrary() and GetProcAddress() to obtain the pointers to the COM object's functions. As we'll soon discover, you instead use a different operating system function to get a pointer to an object, and then use that object to obtain pointers to its functions.

An OLE/COM object and its VTable

So at its simplest, a COM object itself is really just a C structure that contains pointers to functions that anyone may call. These pointers to functions must be the first thing inside of the structure. There can be other data elements in the structure later on, but the pointers must be first. This is a very important thing to note (because we'll be creating our own COM objects inside our C example, and you'll need to understand how to declare and set up such a COM "structure"). Actually, the first thing inside of the object will be a pointer to another structure that actually contains the pointers to the functions. In essence, this second structure is just an array of pointers to various functions. We refer to this array as a "VTable". Also, the first 3 pointers in the VTable must be to 3 specific functions. We'll call them QueryInterface(), AddRef() and Release(). (When you create your own objects, you can name the functions anything you want, but they must take certain args, and do certain things, and return a certain value. We'll get into that later). Here are the function definitions for the 3 functions:

HRESULT STDMETHODCALLTYPE QueryInterface(IUnknown FAR* This, REFIID riid, LPVOID FAR* ppvObj);
HRESULT STDMETHODCALLTYPE AddRef(IUnknown FAR* This);
HRESULT STDMETHODCALLTYPE Release(IUnknown FAR* This);
Right now, let's not worry about the details of what these functions do, what those args are, and what they return.

The first thing inside of a COM object must be a pointer to a VTable which contains pointers to at least those 3 functions, and the pointers must be named QueryInterface, AddRef, and Release. (These 3 are referred to as the IUnknown interface). And QueryInterface must be defined as a pointer to the QueryInterface function we defined above. AddRef must be defined as a pointer to the AddRef function. And Release must be defined as a pointer to the Release function.

So here is the most simple example of the structure for a OLE/COM object which I'll just call "MYOBJECT". We'll first define its VTable in a structure called MYOBJECT_VTBL, and then define the structure of the MYOBJECT object.

/* This is the VTable for MYOBJECT. It must start out with at least the
 * following 3 pointers.
 */
struct MYOBJECT_VTBL {
   (HRESULT STDMETHODCALLTYPE *QueryInterface)(IUnknown FAR* This, REFIID riid, LPVOID FAR* ppvObj);
   (HRESULT STDMETHODCALLTYPE *AddRef)(IUnknown FAR* This);
   (HRESULT STDMETHODCALLTYPE *Release)(IUnknown FAR* This);
   /* There would be other pointers here if this object had more functions. */
}

/* This is MYOBJECT structure */
struct MYOBJECT {
    /* The first thing in the object <U>must</U> be a pointer to its VTable! */
    struct MYOBJECT_VTBL *lpVtbl;

    /* The Object may have other embedded objects here, or some private data.
     * But such extra stuff must be <U>after</U> the above VTable pointer.
     */
}
As you can see, a COM object always starts with a pointer to its VTable. And the first 3 pointers in the VTable will always be named QueryInterface, AddRef, and Release. What additional functions may be in its VTable, and what the name of their pointers are, depends upon what type of object it is. For example, the browser object will undoubtably have different functions than some object that plays music. But all COM objects begin with a pointer to their VTable, and the first 3 VTable pointers are to the object's QueryInterface, AddRef, and Release functions. That is the law. Obey it.

Of course, when you create your own COM Object, you'll put the 3 "IUnknown" functions inside your program. For example, maybe you'll have 3 functions named MyQueryInterface(), MyAddRef(), and MyRelease() as so:

HRESULT STDMETHODCALLTYPE MyQueryInterface(IUnknown FAR* This, REFIID riid, LPVOID FAR* ppvObj)
{
   return(S_OK);
}

HRESULT STDMETHODCALLTYPE MyAddRef(IUnknown FAR* This)
{
   return(1);
}

HRESULT STDMETHODCALLTYPE MyRelease(IUnknown FAR* This)
{
   return(1);
}
And of course, you need to initialize your COM object to store pointers to those functions in its VTable. Here's an example of us declaring a MYOBJECT struct named Example with its VTable named ExampleTable, and initializing it:
int main()
{
   struct MYOBJECT      Example;
   struct MYOBJECT_VTBL ExampleTable;

   ExampleTable.QueryInterface = MyQueryInterface;
   ExampleTable.AddRef = MyAddRef;
   ExampleTable.Release = MyRelease;
   Example.lpVtbl = &ExampleTable;
}
We have now created a COM object ( ie, Example is that object), fully initialized with a VTable containing pointers to its functions. Now, all we need do is pass a pointer to this struct to some operating system function, and then some other object (like the browser object) will be able to call MyQueryInterface(), MyAddRef(), and MyRelease() within our C executable. That's not so bad, right?

QueryInterface(), AddRef(), and Release()

Well, there is more to know. Let's take a look at the definitions of those 3 functions. You'll notice that the first arg to each is a pointer to an IUnknown struct. That's the generic template. When we define a specific COM object, we need to redefine this arg. So for our MYOBJECT object, we need to change the definitions as so:

HRESULT STDMETHODCALLTYPE MyQueryInterface(MYOBJECT FAR* This, REFIID riid, LPVOID FAR* ppvObj);
HRESULT STDMETHODCALLTYPE MyAddRef(MYOBJECT FAR* This);
HRESULT STDMETHODCALLTYPE MyRelease(MYOBJECT FAR* This);
And our MYOBJECT_VTBL needs to be as follows:
struct MYOBJECT_VTBL {
   (HRESULT STDMETHODCALLTYPE *QueryInterface)(MYOBJECT FAR* This, REFIID riid, LPVOID FAR* ppvObj);
   (HRESULT STDMETHODCALLTYPE *AddRef)(MYOBJECT FAR* This);
   (HRESULT STDMETHODCALLTYPE *Release)(MYOBJECT FAR* This);
}
You may ask, "Are you telling me that the first arg passed to each of my 3 functions is going to be a pointer to some MYOBJECT struct?". Yes indeed. For example, when we give the browser object a pointer to our Example MYOBJECT, and the browser object uses that struct's VTable to call MyQueryInterface(), then the first arg passed will be that pointer to Example. In this way, we know which exact struct was used to call MyQueryInterface(). Furthermore, we could add data fields to the end of the struct in order to store per-instance data for the struct. So we never need reference any global data within our functions, and can make them fully re-entrant -- able to be used with plenty of MYOBJECT structs, should we need more than one.

"Wait a minute! This is starting to look suspiciously like C++! It looks like the invisible 'this' pointer of a C++ class!", you scream. Damn right. That's exactly what COM is built upon. So, you're effectively creating C++ classes in your C code (but without some of the other baggage/bloat of C++).

 

In conclusion, when one of your functions is called by somebody, the very first arg is always a pointer to whatever object ( ie, structure) was used to obtain your VTable. This mimics the behavior of a C++ class, and a COM Object is analogous to that.

After you obtain a pointer to some COM object (ie, structure), such as the browser object, you're going to do the same thing when you call the object's functions. You'll find a pointer to some desired function somewhere within that object's VTable. And when you call the function, the first arg you pass will always be the pointer to that COM object.

Generic Datatypes (ie, BSTR, VARIANT)

You may be thinking that things are a little complex, but not too bad. Well, there's another wrinkle. Most OLE/COM objects are designed to be called by a program written in most any language. To that end, the object tries to abstract datatypes. What do I mean by this? Take a string in ANSI C. A C string is a series of 8-bit bytes ending with a 0 byte. But that isn't how strings are stored in Pascal. A Pascal string starts with a byte that tells how many more bytes follow. In other words, a Pascal string starts with a length byte, and then the rest of the bytes. There is no terminating 0 byte. And what about UNICODE versus ANSI? With UNICODE, every character in the string is actually 2 bytes (ie, a short).

So in order to support any language (as well as extensions in each language such as UNICODE), most COM objects instead employ generic datatypes that accommodate most every language. For example, if a COM object is passed a string, then the string will often take the form of a BSTR. What is a BSTR? Well, it is sort of a UNICODE Pascal string. Every character is 2 bytes, and it starts with an unsigned short that tells how many more shorts follow. This accomodates the "string" datatype of most every language/extension. But it also means that, sometimes you'll need to reformat your C strings to a BSTR when you want to pass a string to some COM object's functions. Fortunately, there is an operating system function called SysAllocString to help do that.

And there are other "generic datatypes" too, such as a generic structure that holds a numeric (for example, DWORD) datatype in a certain way that accommodates just about any language.

In fact, some COM objects' functions can operate upon a variety of datatypes, so they employ another structure called a VARIANT. For example, let's say you have a Printer object which has a Print() function. And let's say that this Print() function can be passed either a string, or a DWORD, and maybe a variety of other datatypes, and it will print whatever it is passed regardless. For example, if passed a string, it will print the characters of that string. If passed a DWORD, it will first do something akin to calling sprintf(myBuffer, "%d", myDword) and printing out the resulting string. Now, this Print() function needs some way to know whether it is being passed a string or a DWORD. So, we wrap the string (ie, BSTR), or the generic structure for a numeric value, into a VARIANT struct. Then we set the first field of this VARIANT struct to VT_BSTR if we wrapped a BSTR, or we set it to VT_DECIMAL if we wrapped a DWORD. That way, the Print() function can be written to support being passed many different types of data, and it can determine what type of data is being passed to it (by inspecting the VARIANT's vt field).

 

In conclusion, when dealing with objects like the browser object, some of its functions may require you to convert/stuff your data into one of these generic datatypes (structs), and then also perhaps wrap that in a VARIANT struct.

Your IStorage/IOleInPlaceFrame/IOleClientSite/IOleInPlaceSite objects

Now that you've got some background on COM objects, let's examine what we need to host the browser object. You may wish to peruse the source code file (Simple.c in the Simple directory) as you read the following discussion.

First of all, the browser object expects us to provide (at least) 4 objects. We need an IStorage, IOleInPlaceFrame, IOleClientSite, and an IOleInPlaceSite object. That's 4 structs. And each has its own VTable. All of these objects (and their VTables) are defined in include files with the C interpreter. So, they each have their own specific pre-defined set of functions in the VTable.

Let's just examine the IStorage object. It has a VTable which is defined as a IStorageVtbl struct. Essentially, it's an array of 18 pointers to functions that we must supply in our program. (ie, We have to write 18 specific functions just for our IStorage object alone. That's why people use things like MFC, .NET, C#, and WTL to ease this job). Of course, the first 3 functions will be the QueryInterface(), AddRef(), and Release() functions for your IStorage object. In Simple.c, I've named those three functions Storage_QueryInterface(), Storage_AddRef(), and Storage_Release(). In fact, I've named the other 15 functions starting with Storage_. They have names like Storage_OpenStream(), Storage_CopyTo(), etc. Our IStorage functions are called by the browser object to manage storing/loading data to disk. What the specific purpose of each of those functions is, and what arguments are passed to it, you can check for yourself by looking through the documentation on MSDN about the IStorage object.

So, to create the VTable for my IStorage object, the easiest thing to do is just declare it as a global, and initialize it with the pointers to our 18 functions. Here is how we do that in our C source:

IStorageVtbl MyIStorageTable = {Storage_QueryInterface,
Storage_AddRef,
Storage_Release,
Storage_CreateStream,
Storage_OpenStream,
Storage_CreateStorage,
Storage_OpenStorage,
Storage_CopyTo,
Storage_MoveElementTo,
Storage_Commit,
Storage_Revert,
Storage_EnumElements,
Storage_DestroyElement,
Storage_RenameElement,
Storage_SetElementTimes,
Storage_SetClass,
Storage_SetStateBits,
Storage_Stat};
So we now have a global variable named MyIStorageTable which is a properly initialized VTable for our IStorage object.

Next, we need to create our IStorage object. Again, the easiest thing to do is just declare it as a global and initialize it. Since there is only one field in an IStorage, and that's the pointer to its VTable, here it is:

IStorage MyIStorage = { &MyIStorageTable };
So we now have a global variable named MyIStorage which is a properly initialized IStorage object. It is ready to be passed to some operating system function that will give it to the browser object so it can call any of the above 18 functions. Of course, you'll find those 18 functions in Simple.c too. (But mostly, they do nothing because these functions aren't actually utilized by the browser object. Nevertheless, we really do have to provide at least some stubs just in case some wiseguy takes our IStorage object and tries to call our functions).

In Simple.c, you'll see that we also declare our other objects' VTables as globals. But we do not declare our objects themselves as globals. We are going to add some extra fields to some of those other objects for our own private data. For example, instead of a just an ordinary IOleInPlaceFrame, we're going to define our own _IOleInPlaceFrameEx which contains an embedded IOleInPlaceFrame plus an extra HWND where we can store the handle to our own window. Notice that this extra HWND field is added to the end of the struct, after the IOleInPlaceFrame. That is very important. The IOleInPlaceFrame (with its VTable pointer) must come first. And (unlike with the IStorage object which has no extra data), our extra data is window-specific. In other words, we'll need a different IOleInPlaceFrame, IOleClientSite, and IOleInPlaceSite struct per each window that has an embedded browser object. For this reason, we'll allocate them when we create a window, instead of declaring them as globals.

You can consult your MSDN documentation to learn what the functions in your IOleInPlaceFrame, IOleClientSite, and IOleInPlaceSite VTables are supposed to do, and what is passed to them. In Simple.c, we employ only as much functionality as is needed to display a web page in a window of our own creation.

As mentioned, the browser object expects us to supply at least the 4 above objects. But there are other objects we may optionally implement in our program in order to support additional interaction with the browser object. In particular, a IDocHostUIHandler is very useful. It lets us control certain user interface features, such as being able to replace/disable the pop-up context menu when the user right-clicks on the embedded browser object, or determine whether scroll bars or borders or other such things are rendered, or prevent embedded scripts in the web page from running, or have a new browser window automatically open if the user clicks on any link, etc. Because such an object is so useful, we also implement an IDocHostUIHandler interface in our example C code. (ie, We have a IDocHostUIHandler struct, 18 IDocHostUIHandler functions, and a VTable containing pointers to those 18 functions).

The browser object

After we've set up those above objects, we're ready to obtain a browser object. We can do that with a call to the operating system function OleCreate(). (But first, we should call OleInitialize() once to make sure that the OLE system is initialized for our process).

Our function EmbedBrowserObject() is where we obtain a browser object and embed it into a particular window. We need do this only once, so we call EmbedBrowserObject right when we create the window.

OleCreate() is passed numerous args, one of them being a pointer to our IOleClientSite object (which must be fully initialized before we pass it to OleCreate), and another being a pointer to our initialized IStorage object. When we pass these two objects to OleCreate, it will give these pointers to the browser object. So then, the browser object will be able to call any of our IStorage and IOleClientSite functions. (What about the IOleInPlaceFrame, IOleInPlaceSite, and IDocHostUIHandler objects/functions? We'll get to those later).

The first 2 args we pass to OleCreate tell it that we want a browser object created and returned to us. (All objects have a specific, unique GUID. A GUID is just a series of 16 bytes. We're passing the particular GUID that corresponds to a web browser OLE object). We also pass a handle to where we want OleCreate to return the pointer to the browser object.

If all goes well, OleCreate will return a pointer to a newly created browser object that we can embed in our window. The object is not yet embedded. It is merely created.

So how do we embed the browser object? We need to call one of the browser object's functions. No problem. We just got a pointer to its object, and we know that the first field in the object (ie, lpVtbl) is a pointer to its VTable. So we just grab the pointer to the desired function (which is DoVerb), and use it to call that function. In fact, we call several browser object functions that way. We call SetHostNames() to pass the browser the name of our application (so it can display that in its own message boxes). Then we call DoVerb() to send it a command that tells it to embed itself in our window (OLEIVERB_SHOW). Of course, we also pass our window handle to DoVerb. Now, while we're inside of this call to DoVerb, the browser object is going to call some of our IOleClientSite functions. It will have called several of them before DoVerb returns.

The browser object (ie, struct) has another object called an IWebBrowser2 associated with it. And the IWebBrowser2 has its own VTable of functions. We want to get a pointer to this other object so we can get its VTable and call some of its functions. Needless to say, the very first field in the IWebBrowser2 object will be a pointer to its VTable. So how do we get a pointer to the browser object's IWebBrowser2 object? Is it embedded inside of the browser object (struct)? Maybe, but maybe not. Is it contained in some internal list inside the browser object? Maybe, but maybe not. So how do we get access to it? We ask the browser object to give us the pointer to it. And how do we do that? We call the browser object's QueryInterface function, and ask it to return a pointer to its associated IWebBrowser2 object. And that is the whole purpose of an object's QueryInterface function -- to return other objects associated with that object. Remember that all COM objects have a QueryInterface function. It's the first function in the object's VTable. In fact, the IWebBrowser2 object will have its own QueryInterface function to return pointers to other objects associated with it. The second arg we pass to QueryInterface tells what type of object we wish returned. (Again, the IWebBrowser2 has a unique GUID). Here we want a type of IID_IWebBrowser2.

And so we ask the browser object to return a pointer to its IWebBrowser2 object, and then we use the IWebBrowser2 object's VTable to call a few of its functions to position the embedded browser object.

 

In conclusion, to get a pointer to a "large object" such as a browser object, you usually call some Ole or Com function in the Windows operating system. To get other objects that are somehow associateed with that "large" object, you will call the large object's QueryInterface function to ask it to return a pointer to the particular type of object you want.

This is also true of your own COM objects in your program. Your IStorage and IOleClientSite objects are considered "large objects" that are fairly independent of each other. (Their functions mostly have entirely different duties. The IStorage is to save/load data from permanent storage. The IOleClientSite is to control the display of HTML). And we pass pointers to them to OleCreate, so they are given directly to the browser object. But the browser object considers your IOleInPlaceFrame,IOleInPlaceSite, and IDocHostUIHandler objects to be associated with your IOleClientSite object. So, when the browser wants/needs a pointer to one of those, it is going to call one of your IOleClientSite functions to ask you to return a pointer to it. Usually the function it calls is your IOleClientSite's QueryInterface function. (But for some objects, such as your IOleInPlaceFrame object, the browser will request the pointer by calling a different IOleClientSite function. That's just the way it is). The implication here is that your IOleClientSite functions will need to have access to your IOleInPlaceFrame,IOleInPlaceSite, and IDocHostUIHandler objects so that your IOleClientSite functions can return pointers to any one (when asked to do that by the browser). For this reason, some of the extra data fields we have chosen to add to our _IOleClientSiteEx object are an embedded IOleInPlaceSite object (ie, it's damn easy to return a pointer to it when it's part of our "extended" IOleClientSite object), an embedded IDocHostUIHandler, and an embedded IOleInPlaceFrame object.

So what we do in EmbedBrowserObject is the simple task of creating and embedding a browser object in our window. We haven't yet displayed a web page. We have another function we can call to do that (after we're finished with EmbedBrowserObject). We can call DisplayHTMLPage() to display a URL or HTML file on disk. What we do in DisplayHTMLPage is very similiar to what we do in EmbedBrowserObject. We use the browser object's QueryInterface() to grab pointers to other objects associated with it, and use the VTables of those other objects to call their functions in order to display a URL or HTML file on disk. Again, you can consult the MSDN documentation to learn more about the objects we're asking for and their functions we're calling.

There is one more thing to note about what we do in EmbedBrowserObject. You'll notice at the end that we call the IWebBrowser2 object's Release function. When you're done using an object, you should always call its Release function. This frees up any resources that the object may have allocated as a result of you asking for a pointer to it. For example, if the object itself was dynamically allocated when you asked for a pointer to it, then it may free itself when you call its Release function. Failure to follow this rule could result in memory leaks. Of course, after you Release an object, you always should assume that the pointer to it is no longer valid. You'll have to ask for the pointer again if you need it. And then you'll need to Release it again. See how that works? Now you know why we don't Release the browser object itself until we're finally done with its pointer. (ie, We don't call its Release function in EmbedBrowserObject. Instead, we defer that later in UnEmbedBrowserObject -- when we're finally done using the browser object).

In fact, you can create several browser objects if desired, for example, if you wanted several windows -- each hosting its own browser object so that each window could display its own web page. In fact, Simple.c creates two windows that each host a browser object. (So we call EmbedBrowserObject once for each window). In one window, we call DisplayHTMLPage to display Microsoft's web page. In another window, we call DisplayHTMLStr() to display some HTML string in memory.

Indeed, after we've embedded a browser object, we can call DisplayHTMLPage or DisplayHTMLStr repeatedly to change what is being displayed.

When we're finally done with the browser object, we need to Release it to free any resources it used. We do that in UnEmbedBrowserObject(). Since this needs to be done only once, so we do it right when the window is being destroyed. And we need to call OleUninitialize() before our program exits.

Using the code

The Simple directory contains a complete C example with everything in one source file. Study this to familiarize yourself with the technique of using the browser object in your own window. It demonstrates how to display either an HTML file on the web or disk, or an HTML string in memory, and creates 2 windows to do such.

The Browser directory also contains a complete C example. It demonstrates how to add "Back", "Forward", "Home", and "Stop" buttons. It creates a child window (inside of the main window) into which the browser object is embedded.

The Events directory also contains a complete C example. It demonstrates how to implement your own special link to display a web page with links to other HTML strings (in memory). You could use this technique to define other specialized types of "links" that can send messages to your window when the user clicks upon the link.

The DLL directory contains a DLL that has the functions EmbedBrowserObject, UnEmbedBrowserObject, DisplayHTMLPage, DisplayHTMLStr, and DoPageAction in it. The DLL also contains all of the IStorage, IOleInPlaceFrame, IOleClientSite, IOleInPlaceSite, and IDocHostUIHandler VTables and their functions. The DLL also calls OleInitialize and OleUninitialize on your behalf. So to use this DLL, you don't need to put any OLE/COM coding in your C program at all. It's all in the DLL instead. And there is a small example called Example.c that uses the DLL. It's just Simple.c with all the OLE/COM stuff ripped out of it and replaced with calls to use the DLL.

History

Initial release upon December 1, 2002.

Update upon December 6, 2002. Added IDocHostUIHandler interface, and the function DoPageAction. Applied a fix to UnEmbedBrowserObject() and DisplayHTMLStr(). Revamped the comments in the code to be more explicit and clear. Added the Browser.c and Events.c examples.

Jeff Glatt

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值