浏览器编程之二IE控件与JS交互篇

来源:互联网 发布:风云绝世坐骑进阶数据 编辑:程序博客网 时间:2024/05/29 19:34

              首先还是谈一下IE,虽然IE已被很多人深恶痛绝,但毕竟是微软已经出了这么多年的东东,并且绝大多数人的PC电脑上都有IE控件,使得基于IE发布程序的体积很小,但使用用户电脑上自带的IE控件带来的问题是烦琐的IE版本兼容问题,在这里鄙视一下微软,同一个系列的产品竟然不能做到完全向前兼容。html和css虽然不兼容,但是IE编程接口是完全一样的,这得益于微软的com库的结构化设计和实现。所以与IE交互,必须得先说一下com,因为所有的接口都是com式接口。

             com的基本思想很简单,所有的组件模块都提供一个最根本的接口, IUnkown,它有三个方法,AddRef和Release实现了引用计数,QueryInterface实现了根据接口id查询另外的接口,所有的接口都从IUnkown派生。详细的com理论有很多专业的书籍论述,这里不再赘述。与IE交互,有一个接口是最关键的,IDispatch。它有一个方法是最关键的Invoke,只要弄清楚了IDispatch的Invoke方法,和IE交互的绝大部分任务都可以完成了。下面看一下这个关键的方法的原型:


             IDispatch:public IUnkown

           {

           ...

        HRESULT Invoke( DISPID dispIdMember,  REFIID riid,   LCID lcid,  WORD wFlags,  DISPPARAMS FAR* pDispParams,  
              VARIANT FAR* pVarResult,  EXCEPINFO FAR* pExcepInfo,   unsigned int FAR* puArgErr );

          ...

          }

         这个方法每个参数的意义msdn上有详细的阐述,就我的理解而看,它作为一个组件, 向外提供了一个万能接口,据此可以实现两个很有用的功能:

         1. 获取和设置组件的属性变量.    对应wFlags的DISPATCH_PROPERTYGET和DISPATCH_PROPERTYPUT

         2.以任意参数调用任意一个被支持的方法..对应wFlags的DISPATCH_METHOD

         用面向对象的观点来看,有了这两个功能,任意一个实现该接口的组件就抽象成同一个接口实现的万能对象,可以通过指定的字符串名字获取设置属性和调用方法。如果有过脚本编程经验的c++开发人员一定会发现,脚本恰恰就是如此,c++书写代码的时候也是通过名字访问对象,但编译好后变成二进制代码后就没有名字的概念了,只有偏移和地址。而脚本里头书写代码的时候是通过名字,解释执行的时候也是通过名字。脚本和native语言的最大区别是脚本对象的所有属性和方法是动态的,在执行的时候还可以修改。看到这里,很容易联想到实现了IDispatch的组件对象具有了脚本的特性,c++对象被脚本化了!这就意味着你可以把原来用c++写的类的所有属性和方法都通过Invoke来执行,在脚本里头可以直接访问!相当于给脚本增加了native的扩展。这里所说的脚本就指的Javascript,它是IE内置的脚本引擎。IDispatch接口很重要的一个功能就是如此,微软通常所说的双接口就是这个意思。com的设计理念是很好的,但是在实现的时候绑上了太多的windows因素,从而使得很多人对其都望而生畏,对IDispatch接口也觉得高深莫测,其实它的原理一点都不复杂。了解了这些,接下来要和JS脚本交互就比较容易了。

        在谈这个之前,先说一说IE编程中经常要用到的接口。首先是IWebbrowser2接口,创建IE控件的时候就得到这个指针,它对应着浏览器的一个实例,但对于有多个iframe的页面来说,每个子iframe也有一个IWebbrowser2指针,这就有了层级之分,最顶层的IWebbrowser2只有一个,对应于创建com实例时返回的那个。IWebbrowser2控制浏览窗口的一些常见行为,最重要的就是根据一个url打开一个新页面了。通过IWebbrowser2指针我们可以得到很多其他有用的接口,IHTMLWindow2对应于一个frame的视图,IHTMLDocument2是IHTMLWindow2渲染文档,对应着dom树结构。这个层级还是很清晰的。这三个接口都是从IDispatch派生的,意味着它们已经被脚本化了。在js中有两个全局对象window和document,应该是分别对应着IHTMlWindow2和IHTMLDocument2。(这一点是推测,但可以从js代码验证,当从js中调用一个你自己实现的natvie方法时,如果用window和document作为参数在c++代码中就变成IDispatch接口,从中能QueryInterface得到IHTMLWindow2和IHTMLDocument2)。要完成c++和js交互,可以分解成两个任务,一是c++调用js代码;二是js调用c++代码,这其实也所有脚本和natvie交互的两个基本任务。其实网上也有很多示例代码,本文主要根据自己的理解从设计开发的角度去阐述为什么要这么做。

        先说c++调用js。每段js执行代码都有它自己的执行环境,在IE里面可以看做是IHTMLWindow2。我用到的有两种方案,一种是直接调用IHTMLWindow2的execScript方法.

      HRESULT execScript( BSTR code,    BSTR language, VARIANT *pvarRet);

      先拿到IHTMLWindow2指针,直接把js代码写在这里就好了,不过注意这里是BSTR的字符串,可以用SysAllocString来分配。还有就是pvarRet貌似没什么用,msdn上说它总是返回VT_EMPTY,实际上你执行了若干行语句,本来就没有返回值的,返回值通常是说函数调用。所以次接口通常用来插入一段js代码,不需要关心返回值的。
      第二种方案是使用IHTMLDocument的get_Script()方法。它能得到一个IDispatch指针,这个IDispatch就是整个js的执行环境,当然是对应于该IHTMLDocument的。按照前面介绍的IDispatch的使用,你通过它就可以调用任意js函数了。例如要执行一个js中的函数 func1,那么就先
           OLECHAR *  Names= L"func1" ;
           DISPID dispID=0;
           pScriptDisp->GetIDsOfNames(IID_NULL,&Names,1,LOCALE_SYSTEM_DEFAULT, &dispID);
          接着再调用它:
          pScriptDisp->Invoke(dispID,,IID_NULL ,LOCALE_SYSTEM_DEFAULT,DISPATCH_METHOD,NULL,NULL,NULL,NULL);
         这里func1是js里面的一个全局函数。这里可以看到Invoke并没有直接把字符串名字拿过来用,而是通过另一个方法GetDispofNames做了一个映射,字符串映射到一个int的整数,可能是因为整数id查找更快吧。通过IHTMLDocument得到的script接口对应着该页面的全局js环境,从中可以通过多次invoke得到任意一个全局变量,函数,从而能够得到对象的成员变量或成员方法。
         第二种方案就是通过Invoke调用来实现在c++中存取js变量和调用函数。这和第一种方案的区别很明显,一个是在用c++写js代码,有点类似自己在解析执行js了,而前者更简单,再复杂的js调用序列,一个字符串全部搞定。
          要做到c++和脚本交互有一个基本的问题要做好,就是脚本中的数据类型和c++中的数据类型如何对应起来。众所周知,js中有很多类型,Boolean,
Number, String, Object,Array , Function等。写到这里,插一句,基本所有的语言里头都有字符串和数字这两种基本的数据类型(c/c++中仅为以\0结尾的字符数组),面向对象的语言中还会有Object这样的复合数据类型。在Invoke调用参数中, VARAINT就代表了c中的基本数据类型,js中的数字会转换成VT_I4或者VT_R4或VT_R8。字符串会转换成VT_BSTR类型的bstr(这是微软com标准里使用的字符串类型),其他所有的复合类型包括对象数组函数在c中都对应着VT_DISPATCh的一个IDispatch指针。有了IDispatch指针,你就可以按照前面的方法任意存取对象的属性,也可以发起函数调用并获得返回值。了解了这些,就可以进行c与js的交互了,它们都通过IDispatch的invoke调用来完成。
          再回到前面讨论的,我们还有一个任务没有完成,js调用c++代码。按照前面所说的IDispatch的用途,就可以推断出如何做到这一点了,自定义一个c++类,实现一个IDispatch的接口,把它的指针通过某次js调用作为返回值返回给js,那么js代码中就持有该对象了,就可以像使用普通js对象一样的使用它。问题是,一开始js啥都没有,怎么直接调到c++里头从而返回c++对象呢?IE已经考虑好了这个问题,它对于每个IWebbrowser2实例(顶层)有一个内置的IDispatch对象,该对象可以在创建浏览器控件实例之后在c++中自己制定,而在js中则使用window.external来访问。也就是说每个js环境都已经内置了一个全局对象external,并且它对应的c++中的IDispatch可以由程序员自己指定。下面谈一下如何来设置这个对象实例。
          在windows中要自己host一个active控件,如果用sdk自己写,那是一件很头疼的事情,我一直也没搞清楚它提了好多概念接口出来让宿主去实现。其中有一个接口叫IDocHostUIHandler ,它有一个方法GetExternalDisp用以向宿主查询一个IDispatch对象,就直接对应着js中的external脚本对象。IDocHostUIHandler 还有一个有用的方法ShowContextMenu,当要show菜单的时候这个方法会被回调,应用程序就可以自定义菜单了。MFC也可以很方便的host一个IE控件,但它的类库太庞大了,幸亏微软又出了ATL,提供了一个轻量级的方法让你可以达到同样的效果。下面直接贴一些我使用的代码片段吧.
           class CWebBrowser : public CAxHostWindow
         {
            private:
              CComPtr<IWebBrowser2> m_pWebBrowser; //保存创建出来的浏览器控件实例
           BEGIN_MSG_MAP(CWebBrowser)
          MESSAGE_HANDLER(WM_CREATE,OnCreate)
          CHAIN_MSG_MAP(CAxHostWindow)
          END_MSG_MAP()

LRESULT OnCreate(UINT uMsg, WPARAM wParam, LPARAM lParam, BOOL& /*bHandled*/)
{


// Create WebBrowser object

LPOLESTR pName=NULL;

StringFromCLSID(CLSID_WebBrowser,&pName);
CComPtr<IDispatch>disp;
CComPtr<IUnknown> p;
_InternalQueryInterface(IID_IDispatch,(void**)&disp);
CreateControlEx(pName,m_hWnd,NULL,&p,DIID_DWebBrowserEvents2,disp);  // 创建 WebBrowser
CoTaskMemFree(pName);
// Connect event
HRESULT hRet = QueryControl(IID_IWebBrowser2, (void**)&this->m_pWebBrowser); 
                // 查询IWebBrowser2 接口,用于控制

return m_pWebBrowser?S_OK:-1;
}

};
         CWebBrowser 是用户自己的宿主窗口,在它的OnCreate里头创建com对象,一个浏览器窗口就出来了,这个代码是不是很简洁?CAxHostWindow为我们做了很多事情,包括IDocHostUIHandler也被实现,所以我们从它派生就天然的拥有了很多控制IE控件的能力,当然都是通过com接口来完成的。以后如果有定制需求,大可重写父类的虚函数来达到目的。CAxHostWindow还封装了一个方法
SetExternalDispatch,到这里一切都可以暂时告一段落了,你可以在CWebBrowser中实现IDispatch也可以单独用一个类来实现,然后把IDispatch接口设进去就可以了。有兴趣研究这个寄宿控件过程的童鞋们可以看CAxHostWindow的代码实现,全在一个头文件中。
        下面还是列一个具体的例子吧,假设你的external提供了一个函数创建对象   function newMyObject,在js中
         var newObject=window.external.newMyObject(); //通过external构建一个c++对象交给js持有
         alert(newObject.name);       //访问该对象的属性
         alert(newObject.GetValue())  //调用该对象的方法
        那么你需要做的事情其实还是关注Invoke就可以了.在external的IDispatch的Invoke实现中
        STDMETHODIMP CWebBrowserDisp::Invoke(
DISPID dispIdMember,
REFIID riid,
LCID lcid,
WORD wFlags,
DISPPARAMS* pDispParams,
VARIANT* pVarResult,
EXCEPINFO* pExcepInfo,
unsigned int* puArgErr
)
       {
   HRESULT nRet = S_OK;
   if(wFlags&DISPATCH_METHOD) //属于方法调用
  {
                 if(dispIdmember== DISPID_newMyObject) //给newMyObject分配的id,字符串名字映射
                 {
                           IDispatch* pMyObject=NULL;
                           CreateMyObject(&pMyObject);  //创建c++对象并获取其IDispatch接口
                           pVarResult->vt=VT_DISPATCH;
                           pVarResult->pdispVal=pMyObject;   //作为返回值传递给js
                 }
  }
         return 0;
      }
         
      这个代码也很简洁。据此可以看出,要把c++对象导出到js中,那么该对象必须要实现IDispatch接口,只需要把这个接口作为Invoke的返回值传给js即可。它有引用计数,不必担心内存的释放问题,在js的垃圾回收被触发的某个时刻自然会被销毁。接下来,MyObject有哪些属性和方法可以被js调用,那就又归它自己的IDispatch的Invoke实现来关心了。
       综上所述,在IE中和js交互,IDispatch扮演了很重要的角色,理解好了它你就可以随心所欲的c++和js的混合编程了。那么IDispatch究竟容不容易实现呢?首先它是一个com对象的接口,大家都知道com对象的创建在MFC和ATL中总是很复杂,被很多代码包装起来,让人有点发怵,知道怎么用,却难以了解其内部机制,流程。其实,在前面所讲的过程中,IDispatch是自己的代码创建的,和系统完全无关。从c++的语法看,它就是继承了一个虚基类,实现其全部方法而已,还有就是引用计数。所以,我们完全可以用很简单的c++代码来写自己的IDispatch,不必去理会那么多的com特性。js执行环境总是在主线程,所以你要知道一点你的对象的方法也总是在主线程被调用。
     class CMyJsObject:public CMyObjectHelpler,public IDispatch
    {
volatile long _refCount; //引用计数

public:
virtual ULONG WINAPI AddRef() { return InterlockedIncrement(&_refCount);}
virtual ULONG WINAPI Release() { long refs=InterlockedDecrement(&_refCount); if(!refs)delete this;return refs;}


virtual HRESULT WINAPI QueryInterface(REFIID riid, __RPC__deref_out void **ppvObject)
{
      *ppvObject=NULL;
  if(IsEqualIID(riid,IID_IUnknown))
  *ppvObject=(IUnknown*)this;
  else if(IsEqualIID(riid,IID_IDispatch))
  *ppvObject=(IDispatch*)this;
  else
  return E_NOINTERFACE;
  AddRef();
  return 0;
}


CMyJsObject():_refCount(0)
{


}


virtual HRESULT STDMETHODCALLTYPE GetTypeInfoCount( 
/* [out] */ __RPC__out UINT *pctinfo)
{
return E_NOT_IMPL;
}


virtual HRESULT STDMETHODCALLTYPE GetTypeInfo( 
/* [in] */ UINT iTInfo,
/* [in] */ LCID lcid,
/* [out] */ __RPC__deref_out_opt ITypeInfo **ppTInfo) 
{
return E_NOT_IMPL;
}


virtual HRESULT STDMETHODCALLTYPE GetIDsOfNames( //做字符串名字映射到自定义整数(注意要大于0,负数有特殊用途)
/* [in] */ __RPC__in REFIID riid,
/* [size_is][in] */ __RPC__in_ecount_full(cNames) LPOLESTR *rgszNames,
/* [range][in] */ UINT cNames,
/* [in] */ LCID lcid,
/* [size_is][out] */ __RPC__out_ecount_full(cNames) DISPID *rgDispId) 
{
   while(cNames)
{
                                 if(wcscmp(*rgszNames,L"testName1")==0)
*rgDispId=1;
else if(wcscmp(*rgszNames,L"testName2")==0)
*rgDispId=2;
else if(wcscmp(*rgszNames,L"testFunc1")==0)
*rgDispId=3;
else if(wcscmp(*rgszNames,L"testFunc2")==0)
*rgDispId=4;
                                else
                   return DISP_E_UNKNOWNNAME;
                                rgDispId++;
rgszNames++;
cNames--;
}
   return S_OK;
}
virtual /* [local] */ HRESULT STDMETHODCALLTYPE Invoke( 
/* [in] */ DISPID dispIdMember,
/* [in] */ REFIID riid,
/* [in] */ LCID lcid,
/* [in] */ WORD wFlags,
/* [out][in] */ DISPPARAMS *pDispParams,
/* [out] */ VARIANT *pVarResult,
/* [out] */ EXCEPINFO *pExcepInfo,
/* [out] */ UINT *puArgErr) ;
static int newMyJsObject(IDispatch**ppDisp); // 创建MyObject
       {
             *ppDisp=new CMyJsObject;
             (*ppDisp)->AddRef();
             return 0;
       }

};
               
        这就是一个完整的IDispatch的sdk实现,代码很简单,大部分接口都不需要实现,只关注GetIDsOfNames和Invoke。得益于微软的这种统一模型,IE中js和c++交互变得相当简单,都是通过IDispatch的Invoke,所以说它是万能接口。如果背上微软实现com的复杂性,那就会使得问题变的很复杂,你可能要考虑套间,线程,RPC通信等一堆在你的程序中根本用不到的东西;如果从实用的角度抛开这一切,就会发现IE控件虽然很复杂,但是它的接口设计理念是相当的简单。
        整个js和c++互相调用的交互完成了,但我在实际项目中发现有个细节点,就是内存问题。c++中创建的对象被js所持有,那么它的生命周期就归js所有,但js的内存生命周期不确定,可能窗口都已经关闭了,内存还没有释放。对于有垃圾收集器的语言开发者来说这很正常,永远只需要new,不需要delelete,但是对于c++开发者就有点纠结,无用的内存得不到及时释放,会造成内存占用过大,而且也不确定真的有没有内存泄露。要彻底解决这个问题不容易,但是有个变通的方法,就是每次c中new新对象返回给js时, 也同时登记到一个js全局数组中,然后在页面卸载或js环境销毁的时候从这个js数组中依次取出逐个显示调用释放的函数。我并没有在IE控件中试验过,这样做也可能会带来新的问题,有些局部变量反而不能及时释放了,因为在全局变量表中增加了它的引用计数。
        c++和IE控件交互还有一个重要的点是接收IE控件的各种事件,并且做出响应。这也是通过IDispatch的Invoke来完成。IE控件将相关的一批事件划分到一个集合中,用一个GUID来标识,需要处理该种事件的宿主就通过连接点来监听这些事件,有点像回调。但它这个运作机制比较复杂,甚至于可以监听跨进程的IE控件,并做相关处理。下面列一些常用的比较重要的事件。
        首先是浏览器的基本事件,当浏览器加载一个页面时,我们可以想象一下应该会有加载前事件,加载完成事件,加载错误事件,当有弹出窗口时也应该能收到事件并且控制是否弹出。在 IE中,这一组基本的事件被命名为: DIID_DWebBrowserEvents2,具体有哪些事件可以参见MSDN介绍。具体代码如下:
        CComPtr<IConnectionPointContainer> pCPC;
CComPtr<IConnectionPoint> pCP;
HRESULT hRes = pWebBrowser2->QueryInterface(__uuidof(IConnectionPointContainer), (void**)&pCPC);
if (SUCCEEDED(hRes))
hRes = pCPC->FindConnectionPoint(DIID_DWebBrowserEvents2, &pCP);
if (SUCCEEDED(hRes))
hRes = pCP->Advise(pUnk, pdw);
       这里要用到com连接点的技术,从IWebbrowser2中先找到IConnectionPointContainer的连接点管理器,再从中找到对应GUID的IConnectionPoint指针,连接这个动作是通过IConnectionPoint的Advise方法来实现,它就把相应的事件传到指定的IDispatch事件接收器中,同时它会返回一个cookie,用以在断开连接的时候用,这通常发生在页面关闭的时候。AtlAdvise封装了一个更为清晰的接口:
        HRESULT AtlAdvise(   IUnknown*pUnkCP,   IUnknown*pUnk,   const IID&iid,   LPDWORDpdw);
      为pUnkCP组件连接一个iid指定的事件处理器pUnk(就是实现了IDispatch的Invoke),cookie从pdw返回,供UnAdvise调用。
       创建好连接点后,剩下的事情还是IDispatch的 Invoke实现,接下来所有的浏览器事件都会回调到你的Invoke中来,通过IE预置的一大堆DISPID来具体区分是哪种事件,不同的事件会有不同的参数,详情可以参考MSDN。
       对于前面介绍的CWebBrowser从CAxHostWindow派生,就更简单了,直接重写虚函数Invoke即可。对于invoke实现还是贴一点代码吧:
       switch(dispIdMember)  //根据不同的id区分不同的事件
       {
           case DISPID_BEFORENAVIGATE2: 
  if(pDispParams->cArgs==7)
  {
          string strUrl=pDispParams->rgvarg[5].pvarVal->bstrVal;
  string strTargetFrame=pDispParams->rgvarg[3].pvarVal->bstrVal;
 atstring strHeader=pDispParams->rgvarg[1].pvarVal->bstrVal;
 BOOL bCancel=FALSE;
     m_pEventSink->OnBeforeNavigate2(pDispParams->rgvarg[6].pdispVal,strUrl,pDispParams->rgvarg[4].pvarVal->intVal,
strTargetFrame,pDispParams->rgvarg[2].pvarVal,strHeader, &bCancel);
 *pDispParams->rgvarg[0].pboolVal=bCancel?VARIANT_TRUE:VARIANT_FALSE;
  }
  break;


  case DISPID_NAVIGATECOMPLETE2: 
  if(pDispParams->cArgs==2)
  {
   atstring strUrl=pDispParams->rgvarg[0].pvarVal->bstrVal;
m_pEventSink->OnNavigateComplete2(pDispParams->rgvarg[1].pdispVal,strUrl);
  }
  break;
  case DISPID_DOCUMENTCOMPLETE: 
  if(pDispParams->cArgs==2)
  {
   string strUrl=pDispParams->rgvarg[0].pvarVal->bstrVal;
  m_pEventSink->OnDocumentComplete(pDispParams->rgvarg[1].pdispVal,strUrl);
  }
  break;
  case DISPID_NEWWINDOW2: 
  if(pDispParams->cArgs==2)
  {
  BOOL bCancel=FALSE;
  m_pEventSink->OnNewWindow2(pDispParams->rgvarg[1].ppdispVal,&bCancel);
  *pDispParams->rgvarg[0].pboolVal=bCancel?VARIANT_TRUE:VARIANT_FALSE;
  }
  break;
  case DISPID_NAVIGATEERROR:
  if(pDispParams->cArgs==5)
  {
  BOOL bCancel=FALSE;
   string strFrameName=pDispParams->rgvarg[2].pvarVal->bstrVal;
   string strURL=pDispParams->rgvarg[3].pvarVal->bstrVal;
  DWORD statusCode=pDispParams->rgvarg[1].pvarVal->intVal;
  m_pEventSink->OnNavigateError(pDispParams->rgvarg[4].pdispVal,strURL,strFrameName,statusCode,&bCancel);
                  *pDispParams->rgvarg[0].pboolVal=bCancel?VARIANT_TRUE:VARIANT_FALSE;
  }
  break;
            }
           这里没什么技巧,全靠MSDN了,记得要检查参数个数和类型。m_pEventSink是我又封装的比Invoke更清晰一点接口:
           class IWebbrowserSink
       {
          public:
virtual void OnBeforeNavigate2(IDispatch*pDisp,LPCTSTR lpszURL,DWORD nFlags,LPCTSTR lpszTargetFrameName,
VARIANT* PostedData,LPCTSTR lpszHeaders,BOOL* pbCancel )=0;
virtual  void OnNavigateComplete2(IDispatch*pDisp,LPCTSTR strURL )=0;

virtual void OnNewWindow2(IDispatch**ppDisp,BOOL*pBCancel)=0;


virtual void OnDocumentComplete(IDispatch*pDisp,LPCTSTR lpszURL)=0;


virtual void OnNavigateError(IDispatch *pDisp,LPCTSTR lpszURL,LPCTSTRTargetFrameName,
DWORD StatusCode,BOOL *pBCancel)= 0 ;
     };
       对于IE,html中的dom事件也可以被捕获,原理和以上类似,不过你要先得到IHTMLDocument2,然后再连接它实现的DIID_HTMLDocumentEvents2,然后就又是Invoke中处理了。
       总结一下,在IE控件编程中,IDispatch是很重要的一个接口,你要导出一个对象给js使用,先要导出external的一个IDispatch,然后在external的方法中
返回一个自己实现的IDispatch;js中的一个对象(Array,Function,Object)就对应着native的一个IDispatch对象。要监听事件,先了解是哪个组件提供的,再QueryInterface得到相应的连接点,再Advise相应的事件的GUID。
       与IE控件的基本交互就谈到这里,当然还有很多的"陷阱"在里头,IE真是让人又爱又恨,编程接口很简单,但是设计的过于复杂,接口太多,有时候要定制一个小功能可能得要google半天,这些过来的经验留待以后有空再写。它的好处就是文档很全,MSDN的信息相当丰富,甚至让人觉得太丰富了,有大公司支援的项目文档那是不用说的,而且接口能够保持向后兼容。



       


     
       
        




原创粉丝点击