采用VSTO或者Shared Add-in等技術開發Excel插件,其實是在與Excel提供的API在打交道,Excel本身的組件大多數都是COM組件,也就是說通過Excel PIA來與COM進行交互。這其中會存在一些問題,這些問題如果處理不好,通常會導致在運行的時候會拋出難以調試的COM異常,從而導致我們開發出的Excel插件的不穩定。
和普通的WinForm程序一樣,Excel也是一種STA(Single Thread Apartment)線程的應用程序,Excel插件是寄宿在Excel中運行的,這也就意味著插件也是一種STA線程的應用程序。插件在操作Excel的時候,如果是在Excel的主線程中,可以直接獲取Excel對象進行操作,比如寫入單元格值,對單元格進行格式化等操作。但是通常,我們會在多線程或者後台工作線程中去處理一系列復雜的數據或者邏輯,待處理完成獲得結果之後,再像WinForm那樣,回到UI線程中,去更新界面信息,對於Excel插件來說,就是回到Excel的主線程上來,然後再更新界面。但是Excel又是一種不同於一般Winform 類型的STA,它是COM並且Excel插件是寄宿在其上的,所以還有一些需要注意的地方。
本文首先介紹什麼是STA應用程序及其工作原理,然後介紹一般的Winform程序的界面刷新邏輯,以及在這其中非常重要的一個名為SynchronizationContext對象,最後介紹在Excel插件中如何獲取Excel主線程,以及這其中需要注意的地方。
Excel插件的最難處理的地方在於其應用程序的穩定性,了解了Excel中的線程以及其機制對增強系統的穩定性會有很大的幫助。
1. STA(Single Thread Apartment)
COM組件的線程模型被稱之為Apartment模型,即COM對象初始化時其執行上下文(Execution Context),他要麼和單個線程關聯STA(Single Thread Apartment ) 要麼和多個線程關聯MTA(Multi Thread Apartment)。
通常COM對象為了保護其自身維護的數據不被破壞,需要運行時來保證其不被多個線程同時調用;另外也需要運行時來保證對COM對象的調用不會阻塞UI線程。Apartment 就是COM對象生存的地方,一個Apartment可以包含一個或者多個線程。對一個COM對象的調用可以由該COM生存的Apartment中的任何一個線程接受和處理。如果一個Apartment中只有一個線程,那麼就是STA線程,否則就是MTA,這個是在程序初始化COM組件的時候即確定下來的。一個進程可以包含多個STA,但是只有一個MTA。
STA模型是COM對象使用的一種非線程安全的模型,這意味著他不能處理自己的線程同步,通常在UI組件中使用這種模型。因此,如果其他線程需要和UI對象進行交互,需要將消息封送(marshall)到STA線程中。在Windows 窗體應用程序中,這一過程是通過窗口消息隊列 (message pumping system)來實現的。當客戶線程以STA 模式啟動時,系統將為STA創建一個隱藏窗口類,所有的對COM對象的調用都會放到這個隱藏窗口的消息隊列中。
如果COM對象能夠處理其本身的同步邏輯,那麼就是MTA模型了,他似的多個線程能夠同時和對象進行交互,而不需要進行消息調用的封送。
COM組件在創建的時候采用哪種模型,可以在注冊表項的ThreadingModel值中指定:
COM組件在注冊表項中的ThreadingModel屬性中會有一下四個屬性:
Main thread. COM對象創建 在宿主程序的主UI線程上,所有的調用必須封送到 主UI線程上 .
Apartment. 表示該COM對象能夠運行在任何但單線程模型的線程上,如果該線程是STA線程創建的,則對象運行在該STA線程上,否則該對象運行在主STA線程上,如果主STA線程不存在,系統則會自動創建一個。
Free. 表示該COM對象運行在MTA上。
Both. 表示該COM對象在那個模型上取決於創建Apartment的類型。
對於.NET Framework來說,通常在任何創建UI的線程上使用[STAThread]自定義屬性來標識其為STA線程。工作線程通常使用MTA模型,但是如果該工作線程需要與表示為Apartment的COM組件一起使用,那就需要標識為STAThread。
我們可以給Thread對象的ApartmentState屬性指定ApartmentState枚舉類型來給定該Thread屬於那種類型的線程。
那麼如何在其他線程中往STA線程中封送消息呢?這個就要使用SynchronizationContext對象了。
2. SynchronizationContext
關於SynchronizationContext類,Understanding SynchronizationContext (Part I) 這篇文章講解的比較好,建議直接閱讀原文。這裡簡要說一下,為後面講解做鋪墊。 SynchronizationContext類主要是用來進行線程間進行通訊的, 比如我有Thread1和Thread2,Thread1在做一些事情,完了之後,Thread1希望將結果傳遞給Thread2,希望在Thread2上執行操作。一種可行的方式是獲取Thread2的SynchronizationContext對象,然後在Thread1中調用SynchronizationContext的Send或者Post方法,這樣需要做的操作就會在Thread2上執行的。需要注意的是,並不是所有的線程都有一個SynchronizationContext與之聯系,只有UI線程上才有SynchronizationContext,通常是在線程中,第一次創建UI控件的時候,就會將SynchronizationContext對象附加到當前的線程中。
在進行Winform開發的時候,我們知道不應該在UI線程上執行耗時的操作,因為UI線程是一種STA線程,是通過消息隊列來實現的,如果某一操作耗時的話會阻塞其他的消息處理,影像用戶交互。所以我們一般需要將一些耗時操縱放到後台線程中去處理,完了之後將結果Post回UI線程來進行界面刷新,我們常在非UI線程中使用Control的Invoke和BeginInvoke來實現UI界面的刷新。而Invoke和BeginInvoke在內部其實是通過繼承自SynchronizationContext的對象來發送消息實現的。
通常,可以通過SynchronizationContext.Current的靜態屬性來獲取當前線程的SynchronizationContext對象
有了UI線程的SynchronizationContext對象我們就可以在其他線程上通過該對象將我們需要在UI線程上進進行的操作Post到UI所在的線程上的消息隊列中了。
下面的代碼中我們在button2中新建了一個新的進程,然後在該進行的方法中傳入了當前UI線程的SynchronizationCotext對象, 然後在工作線程中通過該SynchronizationContext對象的Post方法更新UI界面上的Combox對象:
private void button2_Click(object sender, EventArgs e) { // let's see the thread id int id = Thread.CurrentThread.ManagedThreadId; Trace.WriteLine("Button click thread: " + id); // grab the sync context associated to this // thread (the UI thread), and save it in uiContext // note that this context is set by the UI thread // during Form creation (outside of your control) // also note, that not every thread has a sync context attached to it. SynchronizationContext uiContext = SynchronizationContext.Current; // create a thread and associate it to the run method Thread thread = new Thread(Run); // start the thread, and pass it the UI context, // so this thread will be able to update the UI // from within the thread thread.Start(uiContext); } private void Run(object state) { // lets see the thread id int id = Thread.CurrentThread.ManagedThreadId; Trace.WriteLine("Run thread: " + id); // grab the context from the state SynchronizationContext uiContext = state as SynchronizationContext; for (int i = 0; i < 10; i++) { // normally you would do some code here // to grab items from the database. or some long // computation Thread.Sleep(10); // use the ui context to execute the UpdateUI method, // this insure that the UpdateUI method will run on the UI thread. uiContext.Post(UpdateUI, "line " + i.ToString()); } } /// <summary> /// This method is executed on the main UI thread. /// </summary> private void UpdateUI(object state) { int id = Thread.CurrentThread.ManagedThreadId; Trace.WriteLine("UpdateUI thread:" + id); string text = state as string; comboBox1.Items.Add(text); }
運行結果如下,我們可以看到Button以及UpdateUI的方法都是在UI線程上運行的,他們具有相同的線程ID 9,而我們新建的工作線程ID為10。
Button click thread: 9
Run thread: 10
UpdateUI thread:9
UpdateUI thread:9
UpdateUI thread:9
UpdateUI thread:9
UpdateUI thread:9
UpdateUI thread:9
UpdateUI thread:9
UpdateUI thread:9
UpdateUI thread:9
UpdateUI thread:9
本欄目
SynchronizationContext對象有Send和Post兩個方法可以被我們調用。Send方法是同步的,他會等待Send進去的代理方法執行完成之後,再執行後面的代碼,而Post方法則是異步的,Post之後會繼續執行後續的代碼,Post和Send方法會異常的捕獲。其內部的實現大致如此:
public virtual void Send(SendOrPostCallback d, Object state) { d(state); } public virtual void Post(SendOrPostCallback d, Object state) { ThreadPool.QueueUserWorkItem(new WaitCallback(d), state); }
實際上在Winform以及WPF中,我們獲取到的是繼承自SynchronizationContext的對象,在Winform中是System.Windows.Forms.WindowsFormsSynchronizationContext在WPF中則是 System.Windows.Threading.DispatcherSynchronizationContext。比如在Winform中是Control.BeginInvoke,在WPF或者Silverlight中是Dispatcher.BeginInvoke,這些類都重寫了Post和Send方法。並提供了各自的“消息隊列”(message pump)機制比如Windows API中的SendMessage 和PostMessage方法來實現各自的消息分發和處理。我們在上面代碼中通過SynchronizationContext的Current獲取到的實際上是一個WindowsFormsSynchronizationContext對象。真實的SynchronizationContext類不做任何實現,他更應該是一個虛類。所以通過手動new一個SynchronizationContext,然後賦予當前的線程是沒有任何意義的。
3. Excel中的線程同步
前面講過STA以及SynchronizationContext,這是因為Excel也是一種STA線程的應用程序,寄宿在Excel之上的Automation程序也是STA的,了解這一點非常重要。
通常在Excel的插件開發中,我們的業務邏輯可能比較復雜,這些復雜的計算一般不應該放到Excel的主UI線程中,我們需要新建工作線程,然後在裡面進行計算。獲得了結果之後,我們應該在回到Excel的UI線程中去更新界面。但是我們采用.NET技術開發Excel的Automation有一個特殊性在於,我們可以直接在非UI線程中去調用Excel的COM對象,在正常情況下,如果Excel比較空閒,沒有任何問題,但是如果Excel此時比較忙,就會拋出COM異常,這種異常難以捕捉。這也是導致插件不穩定的一個非常重要的因素。這種情況通常出現在以下情形中:
當我們的插件在後台線程中向服務端請求了大量數據,進行了一些處理(這種情況很常見)後,在Excel的Sheet頁中將數據填充到單元格中,然後對單元格進行樣式,字體等格式化,這個過程需要與COM進行交互,而且在某些情況下比較耗時,如果在此過程中,用戶操作了Excel的單元格,比如鼠標點擊填充過程中的單元格,這樣由於後台線程通過COM對象對Excel的操作會遇到忙碌狀態,就會拋出COM異常。
在RTD 函數中,我們在某些情況下需要定時刷新單元格,比如在Excel中直播NBA比賽得分,使用實時的股票市場行情信息進行建模。在RTD 中,我們可以直接調用UpdateNotify方法,通常該方法應該在UI主線程上調用,這樣Excel就會將其放到消息隊列中,在某一時候觸發。但是在很多時候,我們獲取數據比如NBA實時比分,實時行情數據,通常是在另外一根工作線程中進行的,我們可以在工作線程中直接調用RTD的UpdateNofity方法,但是在Excel忙碌的時候就會拋出COM異常。
Excel中運行我們再工作線程中通過Excel 的Application對象來直接更新UI界面元素給了我們一個假象。原因在於這樣是很不穩定的,非Excel主線程的每一次COM調用中都需要檢查是否拋出異常,在調用過程中Excel很可能處於忙碌狀態,Excel也可能在任何情況下拒絕線程對COM調用的請求,尤其是在用戶正與Excel進行交互的時候。通常我們至少要捕獲和處理一下三種COM異常:
u const uint RPC_E_SERVERCALL_RETRYLATER = 0x8001010A;
u const uint VBA_E_IGNORE = 0x800AC472;
u const uint RPC_E_CALLREJECTED
在其他線程中直接調用Excel對象不僅會導致性能損失,而且會增加插件的復雜性和不穩定性。
正確的做法是,在工作線程中獲取Excel主線程對象的SynchronizationContext,然後將待操作的步驟Post到Excel主線程的消息隊列中等待處理。但是作為一個Addin,在一般情況下如果直接獲取SynchronizationContext對象,該對象是為空的,只有在插件加載後,手動創建一個Winform窗體或者控件才能夠獲取到主線程的SynchronizaitonContext對象。這個From窗體通常就是我們插件的登錄窗體。
比如如果要在非Excel 主線程中調用RTD函數的UpdateNotify方法,我們可以首先定義一個SynchronizationContext用來保存Excel主線程的同步上下文。
private SynchronizationContext ExcelContext;
然後在RTD啟動時獲取當前Excel主線程的上下文。
public int ServerStart(IRTDUpdateEvent CallbackObject)
{
this.ExcelContext = new SynchronizationContext();
xlRTDUpdater = CallbackObject;
}
最後工作線程中,通過傳進來的ExcelContext,然後將需要做的操作Send或者Post回Excel主線程中執行。
ExcelContext.Post(delegate(object obj)
{
xlRTDUpdater.UpdateNotify();
}, null);
所以其他非UI線程中需要操作Excel COM對象的方法經過如此封裝將需要做的操作以消息的形式封送到UI線程,這樣就可以解決之前調用COM組件可能出現的COM異常,能夠極大提高Excel插件的穩定性。
本文很多內容涉及到COM組件的相關知識,這裡只是簡單的講解了一些與Excel插件開發中可能與之相關的一些問題,介紹了如何正確的在工作線程中更新Excel UI操作的一些正確做法,希望這些知識對您有所幫助。
作者: yangecnu(yangecnu's Blog on 博客園)
出處:http://www.cnblogs.com/yangecnu/