通俗理解页面生命周期

通俗理解页面生命周期

丽水市汽车运输集团股份有限公司信息中心 苟安廷 

ASP.NET开发初学者经常问的一个问题是:为什么我一点分页按钮,表格里面的数据就没有了?其实,类似这样的问题,大多是没有对页面的生命周期(Page类的编程模型)正确理解。本文拟使用通俗易懂的描述,帮助初学者理解页面的生命周期,目的在于帮助初学者理解,因此,用词可能不严谨。

为便于后面的理解,我们需要先复习一些基础知识。

u    浏览器的能力

ASP.NET开发的软件属B/S软件,B就是Brower,即浏览器,也就是说,我们写的东西,最终是需要浏览器来展示的。我们可以这样认为,浏览器只认识3个东西:htmlcssJavaScripthtml主要用于描述控件的类别,如<inputid="Text1"type="text"/>表示一个文本框,css用于描述html控件的外观,当然,html也可以直接描述外观,但为了使html代码简单清晰,同时便于多个html控件共享外观,通常将外观描述部分分离出来,单独放到一个地方,这就是cssJavaScript是编程语言,用于对html控件进行操作。当然,借助插件,浏览器还可以认识视频、flash,甚至U盾等等,不过,这些都是插件的功劳,并不是浏览器与生俱来的,有关插件的相关知识超出了本文的范畴,建议参照其他相关资料。

既然浏览器只认识htmlcssJavaScript,那么,我们在ASPX页面中布局的服务器控件、在cs代码中修改的东西等等,发送到客户端时,最终都必须转换成标准的htmlcssJavaScript,而我们在网页上看到的光怪陆离的效果,其实,都是标准的html控件“模拟”出来的,为此,我们做一个试验,新建一个aspx页面,拖一个文本框上去,在aspx页面中,对应的代码(其他无关代码省略了)是:

<asp:TextBoxID="TextBox1"runat="server"></asp:TextBox>

运行后,在浏览器上点右键,查看源文件,发现浏览器收到的实际代码是:

<inputname="TextBox1"type="text"id="TextBox1" />

我们可以看到,服务器控件到了客户端,会被转换成标准的HTML控件,如果我们新建一个HTML文件,直接把服务器控件的描述(<asp:TextBox/>放进去,浏览器是不认的。

u    服务器事件

当我们点一下页面上的按钮,页面好像自动调用了服务器上的Click方法,同样,其他很多操作,如下拉框(设置AutoPostBackTrue)的当前选择项更改时,都自动调用了后台对应的方法,貌似和普通C/S软件差不多,其实,这些也是“模拟”的,可以说,为了让B/S软件开发起来和C/S差不多,“模拟”功不可没,服务器真正收到的,只有一个事件:页面请求。

u     页面访问方式

我们知道,页面访问有两种方式:GetPost,那么,到底该如何区分呢?我们来看两种情况:

1.直接输入网址,如http://www.lqjt.com,当然,还可以输入一些参数,如:http://www.lqjt.com?id=5,当我们直接在浏览器地址栏里面输入地址(包括带参数的地址),然后按回车就可以访问页面了,而此时,服务器收到的,仅仅是一个网址名称,这种轻量级的访问,就是我们所说的Get

2.当页面已经打开时,假如页面上有一个按钮,我们点击该按钮,页面会回传,也就是重新读取页面,这时,浏览器就不是简单把网址发给服务器了,而是将当前页面中的内容作为附加信息,连同网址一起发送给服务器,也就是说,服务器收到的信息,包括网址和附加信息,这就是Post方式。

也就是说,Get方式收到的仅仅是网址,没有附加信息,而Post方式,除了网址,还要当前页面的相关内容信息,注意,网址后面的“?id=5可以认为是网址的一部分,和Post访问中的附加信息是两回事,最直观的区别是,一个是直接在地址栏输入地址访问,另外一个是页面回传。

u     服务器端和客户端的顺序

总有人问:C#能否调用客户端的JavaScript啊,或者JavaScript能否调用C#的方法啊,其实服务器端和客户端是“接力棒”的关系,服务器端完成任务后,立即就释放了,也就是说,浏览器收到服务器返回的信息时,服务器端的页面已经不存在了,因此,这种互相访问是不存在的,或许你会说,为什么Ajax方法可以呢?需要说明的是,Ajax访问服务器时,访问的也是新建立的页面,而不是“刚才”的页面,浏览器接收到新页面后,刚才的页面内容全部扔了,也是典型的“喜新厌旧”,因此,你也不要指望新页面达到后,使用老页面的控件、变量等,如果需要保存变量,一般采用cookie

有了上面的准备,我们开始进入正题。我们说了,服务器收到的,其实只有一个事件:页面请求,各种事件都是模拟的,而服务器返回的,是标准的HTML。当我们向服务器发送页面请求时,服务器新建一个页面,然后进行处理,处理完成后,把最终结果返回给浏览器,同时释放刚刚生成的页面,整个页面从诞生到消亡的整个过程,我们称之为页面生命周期,或者叫Page类编程模型,页面的生命周期是非常短暂的,每次请求,就产生一个独立的、全新的页面,本次请求结束后,立即被释放。因为每次都是全新的,因此,不要期望页面保存“上一次请求”的信息,这就是所谓的“无状态”。为了“模拟”C/SASP.NET将页面生命周期分成40多个阶段,不过常用也就几个,比如我们最熟悉的“Page_Load”阶段,每个阶段都有特定的目的,为了便于描述,我们假如整个服务器端就是一个方法,当然,并不是服务器上真的有该方法,而是我们希望用大家熟悉的C#来描述整个过程。

我们定义这样一个方法来描述整个页面生命周期:

///<summary>

   ///模拟页面生命周期

   ///</summary>

   ///<paramname="url">页面的网址</param>

   ///<paramname="Content">附加信息</param>

   ///<returns>浏览器能解析的标准HTML</returns>

   publicstring AspWebService(stringurl,byte[] Content)

   {

       string strHTML ="";

       return strHTML;

   }

我们创建了上面一个公共方法来模拟整个页面生命周期,浏览器调用AspWebService方法,传入页面的地址(url)和当前页面的信息(Content),服务器创建新的页面,并完成各个阶段,最后,产生一个标准的HTML返回给浏览器,浏览器接收以后,把标准的HTML显示出来,下面,我们将几个关键的过程完成,看看最终页面是如何生成的。

当我们在地址栏里面输入网址并回车,页面请求就开始了,浏览器调用服务器的AspWebService方法。因为我们直接输入的地址,没有附加消息,属Get方式访问,Content当然就是null为此,我们需要声明一个变量,来表示是否有附加消息

       bool IsPostBack;

       if (Content ==null)

           IsPostBack =false;

       else

           IsPostBack =true;

IsPostBack大家已经非常熟悉了,如果是第一次打开页面,IsPostBack为False,否则,为True,显然,第一次输入网站访问时,IsPostBackFalse,为减少篇幅,把上面的代码写到一句话里面,完成后的方法为:

   publicstring AspWebService(stringurl,byte[] Content)

   {

       bool IsPostBack=(Content ==null)? false: true;

       string strHTML ="";

       return strHTML;

   }

接下来,读入aspx页面中的内容,并根据aspx页面中定义的控件,转换成对应的C#内存变量,这里,我们用一个字符串变量表示读入的aspx页面信息:

string strAspxLayout ="......<asp:ButtonID='Button1' runat='server' Text='Button' />....";

当然,一个aspx页面的内容非常多,我们只截取其中的一个按钮,前后的其他控件部分用省略号代替,根据aspx的布局,创建对应的变量:

Button btn =newButton();

btn.ID ="Button1";

btn.Text ="Button";

显然,上面的办法是将aspx页面中的控件“翻译”成C#能认识的控件,便于我们操作,现在的代码变成:

publicstring AspWebService(string url,byte[]Content)

   {

      bool IsPostBack=(Content ==null)?false:true;

 

string strAspxLayout ="…….<asp:ButtonID='Button1' runat='server' Text='Button' />……";

       if (strAspxLayout.Length > 0)

       {

           //根据<asp:Button>创建按钮,其他控件类推

           Button btn =newButton();

           btn.ID ="Button1";

           btn.Text ="Button";

       }

 

       string strHTML ="";

       return strHTML;

   }

控件创建好以后,将调用我们常用的第一个方法:Page_Init();该方法的具体用处我们后面会讲到。

接下来,根据是否是页面回传做必要的处理,因为我们本次是用Get方式访问的,因此,这一步我们暂时空着,等下次页面回传时再逐步完善,现在的代码如下:

publicstring AspWebService(string url,byte[]Content)

   {

      bool IsPostBack=(Content ==null)?false:true;

       string strAspxLayout ="......<asp:ButtonID='Button1' runat='server' Text='Button' />....";

       if (strAspxLayout.Length > 0)

       {

           Button btn =newButton();

           btn.ID ="Button1";

           btn.Text ="Button";

       }

 

       //调用常用的第一个方法

       Page_Init(this,newEventArgs());

       //如果是页面回传,进行必要处理,暂时空着

       if (IsPostBack)

       {

       }

 

       string strHTML ="";

       return strHTML;

   }

接下来,调用第二个方法,也就是我们最熟悉的方法:Page_Load,我们很多工作都是在Page_Load里面做的

//调用最常用的方法

Page_Load(this,newEventArgs());

接下来,看看有没有其他事件需要响应,比如button1_Click什么的现在的代码如下:

publicstringAspWebService(string url,byte[] Content)

   {

      bool IsPostBack=(Content ==null)?false:true;

       string strAspxLayout ="......<asp:ButtonID='Button1' runat='server' Text='Button' />....";

       if (strAspxLayout.Length > 0)

       {

           Button btn =newButton();

           btn.ID ="Button1";

           btn.Text ="Button";

       }

       Page_Init(this,newEventArgs());

       if (IsPostBack)

       {

       }

 

       //调用Page_Load方法

       Page_Load(this,newEventArgs());

       //如果有其他事件需要执行,在这里调用

       Button1_Click(this,newEventArgs());

 

       string strHTML ="";

       return strHTML;

   }

服务器端该做的事情差不多了,接下来,要准备发送到客户端了,前面说过,浏览器只认识HTMLCSSJavaScript,“Button btn=new Button()”这样的显然是不行的,因此,我们要把当前内存中的服务器控件转换成浏览器能认识的标准HTML控件,比如,把按钮转换成<inputtppe=’submit’/>之类。该过程需要我们调用Render方法,在一个真实的页面中,我们可以重载下面的方法修改默认的HTML

protectedoverridevoid Render(HtmlTextWriterwriter)

   {

       base.Render(writer);

   }

如果没有重载,将使用默认的,在这里,我们就是要模拟Render方法,也就是说,我们要准备strHTML变量的内容,HTML其他部分我们忽略,这里示意一下按钮对应的HTML代码:

//准备浏览器能认识的HTML文本

string strHTML ="......<inputtype='submit' name='Button1' value='Button' id='Button1' />....";

内存中的控件和标准的HTML控件并不是可以完全互相转换的,在很多方面差别还很大,尤其是C#控件有大量属性,而HTML控件相对简单,在经过一系列操作(尤其是Page_LoadButton1_Click等方法处理后,或者动态添加、删除了部分控件),内存中的TextBox等控件已经面目全非了,前面说过了,B/S是无状态的,本次结束后,内存中的全部东西都会被释放,为了便于下次能“还原”到当前的样子,我们需要把内存中的控件等信息保存起来,这就是ViewState。例如一个文本框,有宽度、高度、文本内容、颜色等信息,我们把这些东西序列化,或者说,转换成一个字符串,然后和上面的html放一起,作为返回值的一部分。为此,我们创建一个隐藏域,值就是序列化以后的内容:

string strViewState ="<inputtype='hidden' name='__VIEWSTATE' id='__VIEWSTATE'value='/wEPDwUKLTExMjgzODMzMWRk+oeVKURRouiTt3dBl+gaw3M+9Ds=' />";

和上面的HTML组合起来:

       strHTML += strViewState;

显然,strViewState中,value的内容就是当前页面各个控件(严格来说,用户可以在里面保存任何东西,比如变量值等等)转换而来的,我们把ViewState放到HTML里面,作为HTML的一部分,我们编写的模拟Render方法如下:

privatestring Render()

   {

       string strHTML ="......<inputtype='submit' name='Button1' value='Button' id='Button1' />....";

       string strViewState ="<inputtype='hidden' name='__VIEWSTATE' id='__VIEWSTATE'value='/wEPDwUKLTExMjgzODMzMWRk+oeVKURRouiTt3dBl+gaw3M+9Ds=' />";

       strHTML += strViewState;

       return strHTML;

}

也就是说,生成的标准的HTML控件代码,是给浏览器看的,且用户可以更改(比如输入文字),而将内存中的控件序列化后,放隐藏域里面的目的是便于下一次还原,达到持久化的目的,且用户无法修改,但我们返回给客户端的只能是一个字符串,而当前内存控件很快要被释放,为此,我们将内存控件序列化后得到的字符串附加到前面的HTML中去,将HTML作为一个临时存放的场所。

然后在主程序里面调用上面的Render方法,得到的代码如下:

publicstringAspWebService(string url,byte[] Content)

   {

       bool IsPostBack=(Content ==null)?false:true;

       string strAspxLayout ="......<asp:ButtonID='Button1' runat='server' Text='Button' />....";

       if (strAspxLayout.Length > 0)

       {

           Button btn =newButton();

           btn.ID ="Button1";

           btn.Text ="Button";

       }

       Page_Init(this,newEventArgs());

       if (IsPostBack)

       {

       }

       Page_Load(this,newEventArgs());

       Button1_Click(this,newEventArgs());

 

       //准备浏览器能认识的HTML文本

       string strHTML = Render();

 

       return strHTML;

   }

最后一步,很简单,就是把strHTML返回给客户端,然后再释放整个页面占用的内存,至此,第一轮的整个页面周期结束,浏览器得到strHTML后,呈现给用户,用户看到的就是漂亮的页面了。

前面讲的是Get方式访问页面的整个生命周期,其中,还留了一点没有做,下面我们来看第二种方式:Post方式是如何进行的,两种方式基本上一样,只不过Post要多一点点东西,就是我们上面未完成的。

现在,页面上已经有很多东西,用户输入完信息,点“提交”按钮,整个页面会被回传,新的页面生命周期又开始了,会重复上面的过程,不过,这次是按钮提交的,不是手工输入的网址,除了网址外,还包括当前页面的信息,比如文本框中用户输入的内容,下拉框当前选择等等,我们把这些保存在Content参数中,也就是说,这一次访问,Content是有内容的,内容就是当前客户端页面的全部信息,而不是null,我们来看看是如何进行的。

1.     <![endif]>设置IsPostback变量,这一步完全一样

2.     <![endif]>读入aspx页面内容,并转换成内存变量,这一步也是一样的

3.     <![endif]>调用Page_Init方法,也一样

4.     <![endif]>处理回传内容,也就是上面的if(IsPostBack)等待我们完成,现在我们就来完成

根据Content参数的内容,重新设置一下前面3步创建的控件,比如,页面设计时,默认的文本框内容是空的,现在,用户输入了内容,那么,就必须执行this.TextBox1.Text="用户输入的新内容",通过aspx页面的布局,结合客户端返回的值(保存在Content中),可以创建一个客户端当前页面的信息。

还记得前面的ViewState吗?那是保存上一次页面在服务器时的状态的,该变量也是在Content的一部分,我们把ViewState取出来,再根据ViewState新建一个页面,这时,我们是不是得到了前一次页面的样子了?

换句话说,我们根据Content中的内容,创建了一个客户端当前页面,根据ViewState还原了上一次服务器端的页面,这样,我们就有两个页面了,一个是当前页面,也就是经过客户端用户操作以后的页面,一个是上一次在服务器端的页面,也就是通过ViewState还原的页面,接下来,我们比较一下两个页面,假如文本框内容不一样,那么,我们就登记一个事件,TextBoxChanged事件,只不过,该事件不会马上执行,我们先记着,同样,也要记着可能产生的其他事件,比如下拉框当前选项改变等等,而Content中除了有控件当前状态等信息,还有一个信息就是,该页面回传是谁产生的,比如是按钮产生的,那么,也要登记一个Click事件,因此,这部分代码我们可以用下面的方法模拟:

if (IsPostBack)

       {

           Page page_Cur =this;

           //根据Content还原成客户端的样子

           this.TextBox1.Text ="new value";

           //还原上一次页面的样子

           Page page_Last =newPage();

           string strViewState ="内容从Content中的ViewState隐藏域提取";

           if (strViewState.Length > 0)

           {

               //根据strViewState创建控件并添加到page_Last

           }

           //比较两个页面和根据事件源登记事件

           foreach (Controlctlin page_Cur.Form.Controls)

           {

               foreach (Controlctl2inpage_Last.Controls)

               {

                   if (ctl.ID == ctl2.ID)

                   {

                       //如果有变动,登记事件

                   }

               }

           }

       }

后面的方法完全一样了。

最后,我们来看看整个模拟代码:

publicstringAspWebService(string url,byte[] Content)

{

      //1.根据是否有附加信息,设置IsPostBack

       bool IsPostBack=(Content ==null)?false:true;

             //2.根据aspx页面布局,创建页面及控件

       string strAspxLayout ="......<asp:ButtonID='Button1' runat='server' Text='Button' />....";

       if (strAspxLayout.Length > 0)

       {

           Button btn =newButton();

           btn.ID ="Button1";

           btn.Text ="Button";

       }

             //3.调用Page_init方法

       Page_Init(this,newEventArgs());

             //4.如果是页面回传,根据Content恢复客户端页面,根据ViewState恢复上一次页面,并比较,然后登记事件

       if (IsPostBack)

       {

           Page page_Cur =this;

           this.TextBox1.Text ="new value";

 

           Page page_Last =newPage();

           string strViewState ="内容从Content中提取隐藏域";

           if (strViewState.Length > 0)

           {

               //根据strViewState创建控件并添加到page_Last

           }

 

           foreach (Controlctlin page_Cur.Form.Controls)

           {

               foreach(Control ctl2inpage_Last.Controls)

               {

                   if (ctl.ID == ctl2.ID)

                   {

                       //如果有变动,登记事件

                   }

               }

           }

       }

//5.调用Page_Load方法

       Page_Load(this,newEventArgs());

//6.根据前面登记的事件,调用对应的方法

       Button1_Click(this,newEventArgs());

             //7.调用Render方法完成HTML字符串,HTML包含了ViewState

       string strHTML = Render();

       return strHTML;

}

 

下面,我们来讨论几个相关问题:

1.       数据绑定方式如下,为什么一点分页按钮,就没有数据了?

protectedvoid Page_Load(object sender,EventArgse)

   {

       if (!IsPostBack)

       {

           GridView1.DataSource =……;

           GridView1.DataBind();

       }

   }

分析:前面说了,每次页面都是全新的,当页面回传时,IsPostBacktrue,上面的代码就不会绑定数据了,当然是空的。

解决方法:把if(!IsPostBack)去掉。

2.       动态添加控件是不是在Page_Load方法里面?

分析:在页面的生命周期中,按照这样的先后顺序处理:读取ASPX中的控件、创建内存控件、调用Page_Init、恢复客户端控件和ViewState、调用Page_Load,显然,最合适的地方是Page_Init方法中,这样,和“原生”的控件没什么区别了。

3.       能否查看、修改发送给客户端的HTML

分析:显然,只需重载一下Render方法。

   protectedoverridevoidRender(HtmlTextWriter writer)

   {

       StringWriter sw =newStringWriter();

       HtmlTextWriter htmlw =newHtmlTextWriter(sw);

       base.Render(htmlw);

       htmlw.Flush();

       htmlw.Close();

//这就是发送给客户端的内容,你可以随便加工,比如去掉多余的空格等等

       string strConn = sw.ToString();

             //这句话别忘了

       Response.Write(strConn);

   }

 

4.       能否减小ViewState以节约带宽

我们知道,ViewState保存了本次页面的信息,附加到html中,页面回传时,再由服务器处理(根据ViewState还原上一次页面的信息),仅仅用于服务器端,而客户端仅仅起临时保存用,但发送到客户端再回传到服务器,一个来回两次占用带宽,的确是一笔不小的开支,尤其是当控件很多时,甚至占到整个HTML内容的1/3以上,既然ViewState仅仅是用于服务器端,那么我们可以想办法让ViewState留在服务器上,具体思路是:

1)在数据库中创建一个两列的表,一列是主键ID,一个列存放ViewState内容,ViewState生成以后(假如为string strViewState),我们拦截下来,用一个新的ID,把strViewState保存到数据库中,并用该ID替换strViewState返回,这样一来,HTML中的ViewState内容并不是真正的ViewState,而是刚刚生成的ID,体积当然非常小了。

2)页面回传后,把客户端传回来的ViewState取出来,显然,这时得到的是第(1)步存放的ID而不是真正的ViewState,我们根据ID,从数据看里面查询出真正的ViewState,同时,顺便把ID对应的记录删除。

这样一来,就成功将真正的ViewState留在了服务器,不过,需要占用一定的服务器空间,也要记得清理过期的数据(只有页面回传时,上一次的数据才会被删除),这就是所谓的用空间换时间(用服务器空间换取网络传送时间),有人可能会说,服务器读写ViewState也是要时间的,但别忘了,IIS和数据库一般是同一台服务器,即使是多台,也在一个局域网内,速度很快,这个速度远远快于通过http协议传送页面内容,实现的关键步骤如下:

 

   ///<summary>

   ///将试图持久化到数据库中

   ///</summary>

   ///<paramname="state">本页面ViewState</param>

   protectedoverridevoidSavePageStateToPersistenceMedium(object state)

   {

       //1.获取一个ID

       string strViewState =……;

       //2.IDstate保存到服务器,注意state可以序列化成byte[],方法略

             SaveState(strViewState, state);

       //3.ID替换到真正的ViewState

       base.SavePageStateToPersistenceMedium(strViewState);

   }

   ///<summary>

   ///载入通过数据库持久化的视图

   ///</summary>

   ///<returns>真正的ViewState</returns>

   protectedoverrideobjectLoadPageStateFromPersistenceMedium()

   {

       //1.ViewState中提取ID

       string strViewState = (string)((Pair)base.LoadPageStateFromPersistenceMedium()).Second;

       //2.根据ID查询真正的ViewState,同时将原来的从数据库删除,具体方法略

byte [] states=…..;

       //3.返回真正的ViewState

       return states;

   }

5.       Ajax貌似可以前台后台同时存在

Ajax访问页面和普通访问差别不是很大,关键是页面返回信息的处理方式不一样,普通页面信息返回后,浏览器把老的页面信息扔掉,显示新返回来的页面信息,而通过Ajax得到新的信息后,并不是由浏览器直接替换老的页面,而是交给JavaScript的一个函数(也就是回调函数),该函数根据返回的信息,做一些处理,然后对原来的页面进行有选择性的操作,就是所谓的“局部更新”,或者说,JavaScript回调函数取得了返回的字符串,然后根据该字符串做自己想做的事情,也可以什么都不做,直接扔掉。既然是通过回调函数处理得到的字符串,那么,字符串就不一定必须是HTML了,可以是任何字符串,回调函数收到后,可以进行任何处理,而没必要一定去新某个HTML控件,比如我们在服务器端这么处理:

protectedoverridevoid Render(HtmlTextWriterwriter)

   {

       string strName ="";

       switch (Request["WorkNo"])

       {

           case"1001":

               strName ="张三";

               break;

           case"1002":

               strName ="李四";

               break;

       }   

 

       Response.Write(strName);

   }

显然,重载了Render方法,根据工号返回对应的姓名,客户端收到以后,可以简单用alert(text)来提示一下,而页面内容不会改变,是不是有点WebService的味道?我们知道,网站可以压缩(主要针对页面、脚本等),而WebService返回的信息难以压缩,因此,需要返回大量数据时,用aspx页面代替WebService也是不错的思路。

6.       书籍推荐

要想对ASP.NET原理深入理解,掌握服务器控件开发是一种捷径,通过服务器控件的开发,可以弄明白服务器控件到底是如何工作的,也可以将常用的东西做成dll,供不同的页面、项目调用。不过,服务器控件开发难度较大,学习起来很吃力,需要很好的耐性,这里推荐两本书给大家参考,申明一下,本人和该书作者或出版社无任何关系,仅仅是因为本人阅读了这两本书后受益匪浅:

《道不远人--深入解析ASP.NET 2.0控件开发》

《庖丁解牛:纵向切入ASP.NET3.5控件和组件开发技术》

这两本书的讲解都很到位,互为补充,但刚阅读时非常晦涩难懂,必须要反复看才能明白,古人云:半部论语治天下,我想,这两本只要弄懂了前面几章,写ASP.NET软件就游刃有余了。

 

  • 4
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值