文章來(lái)源:http://blog.sina.com.cn/s/blog_621e24e201015r29.html
總結(jié):1、control.Invoke 和 Control.BeginInvoke都是運(yùn)行在UI線程下的,也就是主線程吹截,與一般異步不同
2瘦陈、BeginInvoke的處理就是直接回調(diào),Invoke卻在等待異步函數(shù)執(zhí)行完后波俄,才繼續(xù)執(zhí)行晨逝,也就是假如在循環(huán)中調(diào)用,BeginInvoke會(huì)提前返回懦铺,繼續(xù)異步循環(huán)捉貌,而Invoke會(huì)等待一個(gè)循環(huán)完成才進(jìn)行下一個(gè)循環(huán),相當(dāng)于是同步
3冬念、文中的高刷新的例子表明趁窃,界面從1到10000,用BeginInvoke執(zhí)行飛快急前,但保證不了界面數(shù)據(jù)的連貫性醒陆,從1-10000不是連貫的,而且界面出現(xiàn)假死裆针,而調(diào)用Invoke刨摩,從1-10000是連貫的,但是執(zhí)行效率很慢世吨,界面閃爍一直刷新澡刹,但是不會(huì)假死,滾動(dòng)條還是可以滾動(dòng)的
4另假、解決界面閃爍
// 打開(kāi)控件的雙緩沖
SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint, true);
正文如下:
異步調(diào)用是CLR為開(kāi)發(fā)者提供的一種重要的編程手段,它也是構(gòu)建高性能怕犁、可伸縮應(yīng)用程序的關(guān)鍵边篮。在多核CPU越來(lái)越普及的今天,異步編程允許使用非常少的線程執(zhí)行很多操作奏甫。我們通常使用異步完成許多計(jì)算型戈轿、IO型的復(fù)雜、耗時(shí)操作泌射,去取得我們的應(yīng)用程序運(yùn)行所需要的一部分?jǐn)?shù)據(jù)拌禾。在取得這些數(shù)據(jù)后寥茫,我們需要將它們綁定在UI中呈現(xiàn)。當(dāng)數(shù)據(jù)量偏大時(shí)色乾,我們會(huì)發(fā)現(xiàn)窗體變成了空白面板。此時(shí)如果用鼠標(biāo)點(diǎn)擊领突,窗體標(biāo)題將會(huì)出現(xiàn)”失去響應(yīng)”的字樣暖璧,而實(shí)際上UI線程仍在工作著,這對(duì)用戶(hù)來(lái)說(shuō)是一種極度糟糕的體驗(yàn)君旦。如果你希望了解其中的原因(并不復(fù)雜:))澎办,并徹底解決該問(wèn)題嘲碱,那么花時(shí)間讀完此文也許是個(gè)不錯(cuò)的選擇。
一般來(lái)說(shuō)局蚀,窗體阻塞分為兩種情況麦锯。一種是在UI線程上調(diào)用耗時(shí)較長(zhǎng)的操作,例如訪問(wèn)數(shù)據(jù)庫(kù)琅绅,這種阻塞是UI線程被占用所導(dǎo)致扶欣,可以通過(guò)delegate.BeginInvoke的異步編程解決;另一種是窗體加載大批量數(shù)據(jù)奉件,例如向ListView宵蛀、DataGridView等控件中添加大量的數(shù)據(jù)。本文主要探討后一種阻塞县貌。
基礎(chǔ)理論
這部分簡(jiǎn)單介紹CLR對(duì)跨線程UI訪問(wèn)的處理术陶。作為基礎(chǔ)內(nèi)容,相信大部分.NET開(kāi)發(fā)者對(duì)它并不陌生煤痕,讀者可根據(jù)實(shí)際情況略過(guò)此處梧宫。
控件的線程安全檢測(cè)
在傳統(tǒng)的窗體編程中,UI中的控件元素與其他工作線程互相隔離摆碉,每次我們?cè)L問(wèn)一個(gè)UI控件塘匣,實(shí)際上都是在UI線程中進(jìn)行。如果嘗試在其他線程中訪問(wèn)控件巷帝,CLR針對(duì)不同的.NET Framework版本忌卤,會(huì)有不同的處理。在Framework1.x中楞泼,CLR允許應(yīng)用程序以跨線程的方式運(yùn)行驰徊,而在Framework2.0及以后版本中,System.Windows.Form.Control新增了CheckForIllegalCrossThreadCalls屬性堕阔,它是一個(gè)可讀寫(xiě)的bool常量棍厂,標(biāo)記我們是否需要對(duì)非UI線程對(duì)控件的調(diào)用做出檢測(cè)。如果指定true超陆,當(dāng)以其他線程訪問(wèn)UI牺弹,CLR會(huì)跑出一個(gè)”InvalidOperationException:線程間操作無(wú)效,從不是創(chuàng)建控件***的線程訪問(wèn)它”时呀;如果為false张漂,則不對(duì)該錯(cuò)誤線程的調(diào)用進(jìn)行捕獲,應(yīng)用程序依然運(yùn)行谨娜。
在Framework1.x版本中鹃锈,這個(gè)值默認(rèn)是false。問(wèn)什么之后的版本會(huì)加入這個(gè)屬性來(lái)約束我們的UI呢瞧预?實(shí)際上官方對(duì)此的解釋是當(dāng)有多個(gè)并發(fā)線程嘗試對(duì)UI進(jìn)行讀寫(xiě)時(shí)屎债,容易造成線程爭(zhēng)用資源帶來(lái)的死鎖仅政。所以,CLR默認(rèn)不允許以非UI線程訪問(wèn)控件盆驹。
然而圆丹,我們常常需要在窗體中使用異步線程來(lái)處理一些操作,例如IO和Socket通訊等躯喇。這時(shí)跨線程的UI訪問(wèn)又是必須的辫封,對(duì)此,.NET給我們的補(bǔ)充方案就是Control的Invoke和BeginInvoke廉丽。
Control的Invoke和BeginInvoke
對(duì)于這兩個(gè)方法倦微,首先我們要有以下的認(rèn)識(shí):
1.Control.Invoke,Control.BeginInvoke和delegate.Invoke正压,delegate.BeginInvoke是不同的欣福。 2.Control.Invoke中的委托方法,執(zhí)行在主線程焦履,也就是我們的UI線程拓劝。而Control.BeginInvoke從命名上來(lái)看雖然具有異步調(diào)用的特征(Begin),但也仍然執(zhí)行在UI線程嘉裤。 3.如果在UI線程中直接調(diào)用Invoke和BeginInvoke郑临,數(shù)據(jù)量偏大時(shí),依然會(huì)造成UI的假死屑宠。
有很多開(kāi)發(fā)者在初次接觸這兩個(gè)函數(shù)時(shí)厢洞,很容易就將它們同異步聯(lián)系起來(lái)、有些人會(huì)認(rèn)為他們是獨(dú)立于UI線程之外的工作線程典奉,實(shí)際上躺翻,他們都被這兩個(gè)函數(shù)的命名所蒙蔽了。如果以傳統(tǒng)調(diào)用異步的方式秋柄,直接調(diào)用Control.BeginInvoke获枝,與同步函數(shù)的執(zhí)行無(wú)異蠢正,UI線程還是會(huì)處理所有辛苦的操作骇笔,造成我們的應(yīng)用程序阻塞。
Control.Invoke的調(diào)用模型很明確:在UI線程中以代碼順序同步執(zhí)行嚣崭,因此笨触,拋開(kāi)工作線程調(diào)用UI元素的干擾,我們可以將Control.Invoke視為同步雹舀,本文不做過(guò)多介紹芦劣。
很多開(kāi)發(fā)者在接觸異步后,再來(lái)處理窗體假死的問(wèn)題说榆,很容易想當(dāng)然的將Control.BeginInvoke視為WinForm封裝的異步虚吟。所以我們重點(diǎn)關(guān)注這個(gè)方法寸认。
體驗(yàn)BeginInvoke
前面說(shuō)過(guò),BeginInvoke除了命名上來(lái)看像異步串慰,其實(shí)很多時(shí)候我們調(diào)用起來(lái)根本沒(méi)有異步的”非阻塞”特性偏塞,我用下面這個(gè)例子簡(jiǎn)單的嘗試一次對(duì)BeginInvoke的調(diào)用。
如你所見(jiàn)邦鲫,我現(xiàn)在創(chuàng)建了一個(gè)簡(jiǎn)陋的Form灸叼,其中放置了一個(gè)Lable控件lable1,一個(gè)Button控件btn_Start,下面庆捺,開(kāi)始code:
private void btn_Start_Click(object sender, EventArgs e) { // 儲(chǔ)存UI線程的標(biāo)識(shí)符 int curThreadID = Thread.CurrentThread.ManagedThreadId;
new Thread((ThreadStart)delegate() { PrintThreadLog(curThreadID); }) .Start(); }
private void PrintThreadLog(int mainThreadID) { // 當(dāng)前線程的標(biāo)識(shí)符 // A代碼塊 int asyncThreadID = Thread.CurrentThread.ManagedThreadId;
// 輸出當(dāng)前線程的扼要信息古今,及與UI線程的引用比對(duì)結(jié)果 // B代碼塊 label1.BeginInvoke((MethodInvoker)delegate() { // 執(zhí)行BeginInvoke內(nèi)的方法的線程標(biāo)識(shí)符 int curThreadID = Thread.CurrentThread.ManagedThreadId;
label1.Text = string.Format("Async Thread ID:{0},Current Thread ID:{1},Is UI Thread:{2}", asyncThreadID, curThreadID, curThreadID.Equals(mainThreadID)); });
// 掛起當(dāng)前線程3秒,模擬耗時(shí)操作 // C代碼塊 Thread.Sleep(3000); }
這段代碼在新的線程中訪問(wèn)了UI滔以,所以我們使用了label1.BeginInvoke函數(shù)捉腥。新的線程中,我們?nèi)〉昧水?dāng)前工作線程的線程標(biāo)識(shí)符醉者,也取得了BeginInvoke函數(shù)內(nèi)的線程但狭。然后,將它與UI線程的標(biāo)志符作比對(duì)撬即,將結(jié)果輸出于Label1控件上立磁。最后,我們掛起當(dāng)前工作線程3秒剥槐,用于模擬一些常見(jiàn)的耗時(shí)操作唱歧。
為了便于區(qū)分,我們將這段代碼分為A粒竖、B颅崩、C三個(gè)代碼塊。
運(yùn)行結(jié)果:
我們能得到以下結(jié)論:
PrintThreadLog函數(shù)主體(A蕊苗、C代碼塊)執(zhí)行在新的線程沿后,它執(zhí)行了不被BeginInvoke所包含的其他代碼。 當(dāng)我們調(diào)用了Control.BeginInvoke之后朽砰,線程調(diào)度權(quán)回歸到了UI線程尖滚。也就是說(shuō),BeginInvoke內(nèi)部的代碼(B代碼塊)均執(zhí)行在UI線程瞧柔。 在UI線程執(zhí)行BeginInvok中封裝的代碼時(shí)漆弄,工作線程內(nèi)的剩余代碼(C代碼塊)同時(shí)進(jìn)行。它與BeginInvoke中的UI線程并行執(zhí)行造锅,互不干擾撼唾。 由于Thread.Sleep(3000)是隔離在UI線程外的工作線程,因此這行代碼帶來(lái)的線程阻塞實(shí)際上阻塞了工作線程哥蔚,不會(huì)給UI帶來(lái)任何影響倒谷。
Control.BeginInvoke的真正含義
既然Control.BeginInvoke其中的委托函數(shù)仍執(zhí)行在UI線程內(nèi)蛛蒙,那這個(gè)”異步”到底指的是什么?話題回到本文最初:我們?cè)谏衔囊呀?jīng)提到了”控件的線程安全檢測(cè)”概念渤愁,相信大家對(duì)這種工作線程內(nèi)調(diào)用Control.BeginInvoke的做法已經(jīng)太熟悉了宇驾。我們也提到了”CLR不喜歡工作線程調(diào)用UI元素”。微軟的決心如此之大猴伶,以至于CLR團(tuán)隊(duì)在.NET Framework2.0中添加了CheckForIllegalCrossThreadCalls和Control.Invoke课舍、Control.BeginInvoke方法。這是一次相當(dāng)重大的改革他挎,CLR團(tuán)隊(duì)希望達(dá)到這樣的效果:
如果不申明CheckForIllegalCrossThreadCalls = false;這樣的”不安全”代碼筝尾,你就只能使用Control.Invoke和Control.BeginInvoke;而只要使用后兩者办桨,不論它們的上下文運(yùn)行環(huán)境是其它工作線程還是UI線程筹淫,它們封裝的代碼都會(huì)執(zhí)行在UI線程內(nèi)。所以呢撞,msdn對(duì)Control.BeginInvoke給出了這樣的解釋?zhuān)涸趧?chuàng)建控件的基礎(chǔ)句柄所在線程上異步執(zhí)行指定委托损姜。
它的真正含義是:BeginInvoke所謂的異步,是相對(duì)于調(diào)用線程的異步殊霞,而不是相對(duì)于UI線程的異步摧阅。
CLR把Control.BeginInvoke(delegate method)中的異步函數(shù)執(zhí)行在UI內(nèi),如果你像我上文那樣用新線程調(diào)用BeginInvoke绷蹲,那么method相對(duì)于這個(gè)新線程內(nèi)的其他函數(shù)是異步的棒卷。畢竟method執(zhí)行在了UI線程,新線程立即回調(diào)祝钢,不必等待Control.BeginInvoke的完成比规。所以,這個(gè)后臺(tái)線程充分享受了”異步”的好處拦英,不再阻塞蜒什,只是我們看不到而已;當(dāng)然疤估,如果你在BeginInvoke內(nèi)執(zhí)行一段耗時(shí)的代碼灾常,無(wú)論是從遠(yuǎn)程服務(wù)器獲取數(shù)據(jù)庫(kù)資料、IO讀取做裙,還是在控件內(nèi)加載一大批數(shù)據(jù)岗憋,UI線程還是阻塞的肃晚。
正如傳統(tǒng)的Delegate.BeginInvoke的異步工作線程取自于.NET線程池锚贱,Control.BeginInvoke的異步工作線程就是UI線程。
現(xiàn)在您明白兩種BeginInvoke的區(qū)別了嗎关串?
Control.Invoke拧廊、BeginInvoke與Windows消息
實(shí)際上监徘,Invoke和BeginInvoke的原理是將調(diào)用的方法Marshal成消息,然后調(diào)用Win32Api的RegisterWindowMessage()向UI發(fā)送消息吧碾。我們使用Reflector凰盔,可以看到以下代碼:
Control.Invoke:
public object Invoke(Delegate method, params object[] args) { using (new MultithreadSafeCallScope()) { return this.FindMarshalingControl().MarshaledInvoke(this, method, args, true); } }
Control.BeginInvoke:
[EditorBrowsable(EditorBrowsableState.Advanced)] public IAsyncResult BeginInvoke(Delegate method, params object[] args) { using (new MultithreadSafeCallScope()) { return (IAsyncResult)this.FindMarshalingControl().MarshaledInvoke(this, method, args, false); } }
在以上代碼中我們看到Control.Invoke和BeginInvoke的不同之處,在于調(diào)用MarshaledInvoke時(shí)倦春,Invoke向最后一個(gè)參數(shù)傳遞了false户敬,而B(niǎo)eginInvoke則是true。
MarshaledInvoke的結(jié)構(gòu)是這樣的:
private object MarshaledInvoke(Control caller, Delegate method, object[] args, bool synchronous)
很明顯睁本,最后一個(gè)參數(shù)synchronous表示是否按照同步處理尿庐。MarshaledInvoke內(nèi)部這樣處理這個(gè)參數(shù):
if (!synchronous) { return entry; } if (!entry.IsCompleted) { this.WaitForWaitHandle(entry.AsyncWaitHandle); }
所以,BeginInvoke的處理就是直接回調(diào)呢堰,Invoke卻在等待異步函數(shù)執(zhí)行完后抄瑟,才繼續(xù)執(zhí)行。
到此為止枉疼,Invoke和BeginInvoke的工作就結(jié)束了皮假,其余的工作就是UI對(duì)消息的處理,它由Control的WndProc(ref Message m)來(lái)執(zhí)行骂维。消息處理到底會(huì)給我們的UI帶來(lái)什么樣的影響惹资?接著來(lái)看Application.DoEvents()函數(shù)。
Application.DoEvents
Application.DoEvents()函數(shù)是WinForm編程中極為重要的函數(shù)航闺,但實(shí)際編程中布轿,大多數(shù)開(kāi)發(fā)者極少調(diào)用它。如果您對(duì)這個(gè)函數(shù)缺乏了解来颤,那很可能會(huì)在以后長(zhǎng)期的編程中對(duì)“窗體假死”這樣的現(xiàn)象陷入迷惑汰扭。
當(dāng)運(yùn)行 Windows 窗體時(shí),它將創(chuàng)建新窗體福铅,然后該窗體等待處理事件萝毛。該窗體在每次處理事件時(shí),均將處理與該事件關(guān)聯(lián)的所有代碼滑黔。所有其他事件在隊(duì)列中等待笆包。當(dāng)代碼處理事件時(shí),應(yīng)用程序不會(huì)響應(yīng)略荡。例如庵佣,如果將甲窗口拖到乙窗口之上,則乙窗口不會(huì)重新繪制汛兜。
如果在代碼中調(diào)用 DoEvents巴粪,則您的應(yīng)用程序可以處理其他事件。 例如,如果您有向ListBox添加數(shù)據(jù)的窗體肛根,并將 DoEvents 添加到代碼中辫塌,那么當(dāng)將另一窗口拖到您的窗體上時(shí),該窗體將重新繪制派哲。如果從代碼中移除 DoEvents臼氨,那么在按鈕的單擊事件處理程序執(zhí)行結(jié)束以前,您的窗體不會(huì)重新繪制芭届。
因此储矩,如果我們?cè)诖绑w執(zhí)行事件時(shí),不處理消息隊(duì)列中的windows消息褂乍,窗體必然會(huì)失去響應(yīng)椰苟。而上文已經(jīng)介紹過(guò),Control.Invoke和BeginInvoke都會(huì)向UI發(fā)送消息树叽,造成UI對(duì)消息的處理舆蝴,因此,這為我們解決窗體加載大量數(shù)據(jù)時(shí)的假死提供了思路题诵。
解決方案
嘗試”無(wú)假死”
這次我們使用開(kāi)發(fā)中出現(xiàn)頻率極高的ListView控件洁仗,體驗(yàn)一次理想的”異步刷新”,窗體中有一個(gè)ListView控件命名為listView1,并將View設(shè)置為Detail,添加兩個(gè)ColumnHeader性锭;一個(gè)Button命名為btn_Start赠潦,設(shè)計(jì)視圖如下:
開(kāi)始code:
private readonly int Max_Item_Count = 10000;
private void button1_Click(object sender, EventArgs e) { new Thread((ThreadStart)(delegate() { for (int i = 0; i < Max_Item_Count; i++) { // 此處警惕值類(lèi)型裝箱造成的"性能陷阱" listView1.Invoke((MethodInvoker)delegate() { listView1.Items.Add(new ListViewItem(new string[] { i.ToString(), string.Format("This is No.{0} item", i.ToString()) })); }); }; })) .Start(); }
代碼運(yùn)行后,你將會(huì)看到一個(gè)飛速滾動(dòng)的ListView列表草冈,在加載的過(guò)程中她奥,列表以令人眼花繚亂的速度添加數(shù)據(jù),此時(shí)你嘗試?yán)瓌?dòng)滾動(dòng)條怎棱,或者移動(dòng)窗體哩俭,都會(huì)發(fā)現(xiàn)這次的效果與以往的”白板”、”假死”截然不同拳恋!這是一個(gè)令人欣喜的變化凡资。
運(yùn)行過(guò)程:
從我的截圖中可以看出,窗體在加載數(shù)據(jù)的過(guò)程中谬运,依然繪制界面隙赁,并沒(méi)有出現(xiàn)”假死”。
如果上述代碼調(diào)用的是Control.BeginInvoke梆暖,程序會(huì)發(fā)生些奇怪的現(xiàn)象伞访,想想是為什么?
好吧轰驳,到了現(xiàn)在厚掷,我們終于可以松了一口氣了弟灼,界面響應(yīng)的問(wèn)題已經(jīng)被解決,一切美好蝗肪。但是,這樣的窗體還是暴漏出兩個(gè)大問(wèn)題: 1. 比起傳統(tǒng)加載蠕趁,”無(wú)假死窗體”加載速度明顯減慢薛闪。 2. 加載數(shù)據(jù)過(guò)程中,窗體發(fā)生劇烈閃爍現(xiàn)象俺陋。
問(wèn)題分析
我們?cè)谡{(diào)用Control.Invoke時(shí)豁延,強(qiáng)迫窗體處理消息,從而使界面得到了響應(yīng)腊状,同時(shí)也產(chǎn)生了一些副作用诱咏。其中之一就是消息處理使得窗體發(fā)生了在循環(huán)中發(fā)生了重繪,”閃爍”現(xiàn)象就是窗體重繪引發(fā)的缴挖,有過(guò)GDI+開(kāi)發(fā)經(jīng)驗(yàn)的開(kāi)發(fā)者應(yīng)該比較熟悉袋狞。同時(shí),每次調(diào)用Invoke都會(huì)使UI處理消息映屋,也直接增加了控件對(duì)數(shù)據(jù)處理的時(shí)間成本苟鸯,導(dǎo)致了性能問(wèn)題。
對(duì)于”性能問(wèn)題”棚点,我并沒(méi)有什么解決方案(有自己見(jiàn)解的朋友歡迎提出)早处。有些控件(ListView、ListBox)具有BeginUpdate和EndUpdate函數(shù)瘫析,可以臨時(shí)掛起刷新砌梆,加快性能。但畢竟我們這里創(chuàng)建了一個(gè)會(huì)滾動(dòng)的界面贬循,這種數(shù)據(jù)的”動(dòng)態(tài)加載”方式是前者無(wú)法比擬的咸包。
對(duì)于”閃爍”,我先來(lái)解釋問(wèn)題的原因杖虾。通常诉儒,控件的繪制包括兩個(gè)環(huán)節(jié):擦出原對(duì)象與繪制新對(duì)象。首先windows發(fā)送一個(gè)消息亏掀,通知控件擦除原圖像忱反,然后進(jìn)行繪制。如果要在控件面板上以SolidBrush繪制滤愕,控件就會(huì)在其面板上直接繪制內(nèi)容温算。當(dāng)用戶(hù)改變了控件尺寸,Windows將會(huì)調(diào)用很多繪制回收操作间影,當(dāng)每次回收和繪制發(fā)生時(shí)注竿,由于”繪制”較”擦除”更為延后,才會(huì)給用戶(hù)帶來(lái)”閃爍”的感覺(jué)。以往我們?yōu)榻鉀Q此類(lèi)問(wèn)題巩割,往往需要在Control.WndProc中作出復(fù)雜的處理裙顽。而.NET Framework為我們提供了更為優(yōu)雅的一種方案,那就是雙緩沖宣谈,我們直接調(diào)用它即可愈犹。
最終方案
1.新建Windows組件DBListView.cs,讓它繼承自ListView。 2.在控件中添加如下代碼: public DBListView() {
// 打開(kāi)控件的雙緩沖 SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint, true); }
將項(xiàng)目重新生成闻丑,然后從工具箱中拖出新增的組建DBListView到窗體上漩怎,命名為dbListView1,執(zhí)行以下代碼: private void button1_Click(object sender, EventArgs e) { new Thread((ThreadStart)(delegate() { for (int i = 0; i < Max_Item_Count; i++) { // 此處警惕值類(lèi)型裝箱造成的"性能陷阱" dbListView1.Invoke((MethodInvoker)delegate() { dbListView1.Items.Add(new ListViewItem(new string[] { i.ToString(), string.Format("This is No.{0} item", i.ToString()) })); }); }; })) .Start(); } >
現(xiàn)在”閃爍”的問(wèn)題是不是已經(jīng)得到了解決嗦嗡?
對(duì)于DataGridView來(lái)說(shuō)勋锤,也是每一行一行的添加,
for (int i = 0; i < Max_Item_Count; i++) {
//創(chuàng)建行 DataGridViewRow dr = new DataGridViewRow(); foreach (DataGridViewColumn c in dataGridViewAllInfo.Columns) { dr.Cells.Add(c.CellTemplate.Clone() as DataGridViewCell); } //累加序號(hào) dr.Cells[0].Value = i++;
try { dataGridViewAllInfo.Invoke((MethodInvoker)delegate() { dataGridViewAllInfo.Rows.Add(dr); }); } catch (Exception ex) { //如果插入出現(xiàn)異常侥祭,直接跳出 return; }
}