(翻譯)LearnVSXNow! #13- VS IDE中的菜單和命令
- 2019 年 10 月 5 日
- 筆記
幾乎所有的VSPackage都有用戶交互,用戶可以通過點擊Visual Studio中的菜單或工具欄來激活VSPackage的功能或顯示相關的介面。
在這一篇文章里,我們來看一下Visual Studio的菜單和工具欄是如何被定義、創建、顯示和使用的。不過這篇文章我只是說一下一些基本的知識,到下一篇文章我們再來看一些示例程式碼。
一些概念
我們創建的VSPackage的功能可以被別的package調用,也可以被最終用戶用,可以被最終用戶用的功能被稱作「命令(command)」,例如列印、添加文件,等等。
但是用戶如果想用我們的命令的話,我們必須提供某種方式給他們用才行。最常見的方式是創建一個菜單項,用戶可以點擊菜單來使用這些命令。另外,我們也可以讓用戶在類似控制台的地方敲入文本來調用我們的命令,例如VS的命令窗口(視圖|其他窗口|命令窗口)。
Visual Studio的菜單和工具條的功能是一樣的:當用戶點了它們,VS就會調用和它們綁定起來的命令。對於命令來說,它並不知道自己是由菜單調用的還是由工具條調用的。
- 菜單通常顯示在IDE的最頂部,並且會分組顯示菜單項;IDE的一些元素(例如tool window、document window、window frame)也會有它們的上下文菜單,當用戶在它們上面點擊右鍵的時候會顯示出來。
- 工具條通常是一堆控制項的集合,這些控制項和菜單項的功能是一樣的:都是為了執行命令。這些控制項可以是按鈕、下拉框、列表框、文本框或者分隔按鈕。
所以,在這篇文章里,不管是菜單項,還是工具條上的控制項,我一概用「菜單項」這個名字來表示它們。
靜態和動態的菜單項
菜單項可以是靜態的,也可以是動態的。靜態的意思是這些菜單項只會被實例化和初始化一次(通常在package初始化的時候),並由始自終地保留它們的狀態;動態的意思是這些菜單項在初始化之後,可以改變它們的狀態或者外觀,或者根據上下文的資訊動態的創建這些菜單項。對於靜態菜單項,一個很好的例子是用於顯示一個工具窗的菜單項;動態菜單項的例子則是「最近的文件」這個菜單項。
區分菜單和命令的概念
在傳統的Windows Forms開發中,開發人員經常把同一個事件處理方法附加到多個菜單項或工具條項上面,並分別處理這些菜單項或工具條項的狀態。例如,如果一個菜單項和一個工具條項有相同的功能,他們會把同一個事件處理方法附加到這個菜單項和工具條項上面,並且分別處理它們的enabled/disabled狀態。
但是在Visual Studio中,菜單項和命令的概念有更為清晰的區分。
命令負責判斷它自己的狀態(顯示名稱、可見性、可用性等等),並執行命令處理方法;菜單項負責顯示一個命令的外觀,並且提供一種方式供用戶觸發命令。
這意味著一個命令可以綁定到零個、一個或者多個菜單項上面。命令本身知道自己的狀態,並且會把這個狀態報告給相關的菜單項:開發人員只需要設置命令的狀態就行了,不用管到底有多少個菜單項和它有關聯。菜單項會根據命令的狀態自動調整它們的外觀。
區分命令和命令目標的概念
現在我們已經弄清楚了菜單項和命令的區別了,讓我們來看一下另外一個要搞清楚的東西:當調用一個命令的時候,命令本身也許並不知道要執行什麼程式碼邏輯。
命令只是一個邏輯上存在的實體,命令的目標(Command Target)才知道命令該如何執行。在VS IDE里,有一個命令路由模型,可以把一個對命令的請求轉到命令目標上。命令目標可以執行和這個命令相關的邏輯,也可以什麼都不做,表明自己不支援這個命令(例如這個目標不知道該如何處理這個命令)。命令目標甚至可以把命令轉給別的命令目標來處理。
現在讓我們來看一個例子。在「編輯」菜單和Visual Studio的標準工具條上,有剪切、複製和粘帖這幾個菜單項,這些菜單項甚至也可以添加到一些右鍵菜單中。這些菜單項綁定到了「剪切」、「複製」和「粘帖」這幾個命令上。其實在Visual Studio中並沒有一個單獨的對象知道如何執行這幾個命令,IDE根據當前的上下文資訊把請求轉發給相應的命令目標。例如,如果當前活動窗口是文本編輯器的話,IDE就會把命令轉發給文本編輯器;在用屬性窗的時候,命令就轉給了屬性窗;用ASPX設計器的時候,命令就轉給了ASPX設計器。所以,文本編輯器、屬性窗、ASPX設計器都是命令目標。這些命令目標自己決定是否支援轉過來的命令。
總結一下這幾個概念
現在讓我們總結一下和命令相關的概念:
概念 |
職責 |
---|---|
菜單項(Menu Item和Toolbar item) |
為命令提供介面,並根據命令的狀態來顯示介面 |
命令(Command) |
是一個邏輯實體,它本身不一定包含命令的執行邏輯。 |
命令目標(Command Target) |
命令目標知道如何執行一個命令。它可以轉發、執行或甚至拒絕一個命令。它也可以控制命令的狀態。 |
Visual Studio里的菜單項和命令處理
這一節我們來看一下VS是如何處理菜單和命令的。
命令的可見性
VS中的某些菜單和工具條會根據上下文的不同顯示或者隱藏。例如,「項目」和「調試」菜單在沒有打開項目的時候是不可見的;沒有連上團隊伺服器之前,你也看不到團隊(Team)這個菜單。
命令可以定義在如下不同的地方(或者說是邏輯上屬於這些地方):
- VS IDE。所有定義在VS IDE里的命令都是可見的。
- Package。Package可以決定是否顯示它定義的命令。另外,別忘了VS的絕大部分是由各種各樣的package組成的。
- 活動的項目(active project)。在同一時刻,VS里只會有一個活動的項目,只有屬於這個活動項目的命令才是可見的。
- 活動的編輯器(active editor)。如果同時打開了多個文件的話,同一時刻只會有一個活動的編輯器,只有屬於這個活動的編輯器的命令才是可見的,屬於其他編輯器的命令是不可見的。另外,有的設計器是支援不同的文件類型的(例如Image Editor),有可能命令對其中一種文件類型可用,但是對其他的文件類型不可用。例如我們可以為一個ico文件設置透明度,但是不可以為bmp文件設置。所以,根據文件類型來顯示不同的命令,也屬於編輯器的責任。
- 工具窗(tool window)。工具窗也有自己的命令。
可見性的上下文
你也許感覺到了,我們漏掉了一個重要的東西沒有講。我之前舉了一個例子:項目和調試菜單在沒有打開項目之前是不可見的。但是,Visual Studio是怎麼做到在項目沒有打開的情況下隱藏命令,在打開項目後又顯示命令的呢?
Visual Studio允許我們對命令的可見性做進一步的控制。IDE定義了一些上下文,命令的可見性可以和這些上下文綁定起來。這些上下文如下:
上下文名稱 |
描述 |
---|---|
NoSolution |
在VS IDE中沒有打開任何解決方案(此時解決方案瀏覽器是空的) |
SolutionExists |
VS IDE中打開了解決方案。可以是一個空的解決方案,或者是通過打開一個文件而自動創建的解決方案,又或者是含有一個或多個項目的解決方案。 |
EmptySolution |
VS IDE中打開了一個空的解決方案(該解決方案下不包含任何項) |
SolutionHasSingleProject |
VS IDE中打開了一個解決方案,並且這個解決方案只包含一個項目。 |
SolutionHasMultipleProjects |
VS IDE中打開了一個解決方案,並且這個解決方案包含多個項目。 |
SolutionBuilding |
當前解決方案或其中的任何一個項目正在生成的過程中。生成結束後,這個上下文就無效了。 |
Debugging |
VS IDE正處於調試模式:調試器被附加到一個進程。 |
DesignMode |
VS IDE處於設計模式(即不是調試模式) |
FullScreenMode |
VS IDE以全螢幕的方式運行(可以通過點擊「視圖|全螢幕」菜單來進入全螢幕模式) |
Dragging |
在VS IDE里正發生一個拖動的操作。 |
如果一個命令綁定到了多個上下文,那麼當VS IDE處於其中一個上下文的時候,這個命令就是可見的。
命令路由和上下文嵌套
VS IDE、package和package里的對象(例如編輯器和工具窗)定義了很多命令。根據當前上下文的不同,一個命令可以被不同的命令目標執行。
Visual Studio有一個良好的路由結構,規定了在一定的上下文之內的命令執行的規則。這個路由從最裡面的上下文開始,依次向最外部的上下文轉發請求,直到它轉到了全局的上下文。每一個上下文都有一個所謂的命令目標,用於執行命令。
那麼,什麼是「最裡面的」上下文,什麼又是「全局的」上下文呢?
上下文是可以嵌套的,例如我們創建了一個帶有工具窗的package,並註冊到了VS IDE中的話,我們就有了如下結構的上下文:
- 最外層的(即全局的)上下文就是VS IDE本身。
- 我們的package載入到IDE之後,package自己的上下文就是一個嵌套在VS IDE里的上下文。
- 當工具窗被創建後,工具窗的上下文又變成了嵌套在package里的上下文。
路由演算法從上下文嵌套樹的葉子節點開始,一直冒泡到樹的根節點,即全局上下文。
路由演算法
命令冒泡到的節點被稱作「活動命令上下文」。如果代表活動命令上下文的對象並不是一個命令目標,命令會繼續冒泡到上一級節點。如果活動命令上下文是一個命令目標的話,就可以處理這個命令,或者告訴IDE「我不知道如何處理這個命令」,命令就會繼續向上一層冒泡。
路由演算法定義了如下幾個級別(從葉子節點到根節點):
- 外接程式(Present Add-in)。命令首先會傳遞給已經註冊和載入的外接程式(Add-ins)。
- 上下文菜單(快捷菜單)。如果命令位於上下文菜單里,那麼屬於這個上下文菜單的命令目標對象可以處理這個命令。
- 有焦點的窗口。當前有焦點的窗口是下一個可以處理命令的對象。窗口有很多類型,可以是工具窗,也可以是文檔窗口。每種類型的窗口處理命令的方式是不同的。
- 文檔窗口(Document Window)。我們到現在還沒有講到文檔窗口是什麼,在以後的文章里我們會用一個主題來講解它。文檔窗口邏輯上由兩部分組成:用於顯示文檔的document view,和用於處理文檔資訊的document data。命令首先傳遞給document view,如果document view不支援這個命令的話,就會傳遞給document data。
- 工具窗(Tool Window)。某些工具窗會在自己內部傳遞命令,例如解決方案瀏覽器,它會在自己內部把命令從葉子節點依次傳遞到解決方案節點。
- 當前項目。如果當前項目不能處理命令,命令會轉給上一級節點,直到解決方案節點。(VS SDK允許創建子項目類型(即flavor項目),所以一個項目的上級節點不一定是解決方案節點)。
- package。每個package都應該處理它們自己定義的命令,雖然從理論上說這不是必須的,但既然package不能處理這些命令,那還定義它們幹嘛?
- 全局級別。如果命令在前面幾個級別里沒有被處理,那麼就會轉到全局級別這裡。
package的按需載入
在第五篇里,我提到過package是按需載入的,也就是說當package里的對象(例如工具條、編輯器等等)要被創建了,或者package的service要被別的地方調用了,package才會載入到記憶體里。但是package會包含菜單,如果為了顯示菜單而載入package,那麼這個按需載入的模型看起來就不是那麼回事了。那麼,如果不載入package,怎樣才能顯示相應的菜單呢?
關鍵在於package的註冊(參見第8篇里關於regpkg.exe的說明)。通過註冊package,對應的菜單就會保存到註冊表中。Visual Studio通過讀取註冊表裡的資訊來顯示菜單。當用戶點了某個菜單之後,VS就會找到對應的package,如果該package還沒載入進來,那麼就會執行下面幾步:
- 載入相關的程式集到記憶體里。
- 創建這個package類的實例(通過調用package類的默認構造函數)。這個時候我們的package還不知道關於VS的任何上下文,所以我們不能夠在package的的構造函數里放一些和上下文有關的初始化程式碼(例如試圖訪問一個VS Service)。
- 裝載(Site)package。此時我們的package裝載到VS中。
- 調用package的Initialize方法。我們可以override這個方法,放一些初始化程式碼。在這裡就可以放一些和VS上下文有關的程式碼了。另外,如果我們的package定義了菜單,也應該在這裡把菜單和對應的命令綁定起來。
用戶點了某個菜單之後,VS就找到相關的命令,並執行它。如果我們忘了把菜單和命令綁定起來,點擊菜單就會沒有任何反應——當然,雖然沒有反應,但我們的package會因此而載入進來。
另外,我提到過命令目標將負責更新命令的狀態。如果路由演算法路由到一個還沒被載入到記憶體的package的時候,VS並不會去載入這個package,而只是用這個命令的初始狀態代替。
總結
在這篇文章里我給了大家一個關於菜單、菜單項、工具條、命令和命令目標的簡要的概括。
Visual Studio把UI和它們相應的功能給分開了。在不同的上下文里,同一個命令(例如剪切、複製、粘帖)有可能執行不同的動作。
Visual Studio里定義了命令目標的概念。一個命令目標知道如何更新命令的狀態,如何執行命令。VS IDE定義了一個路由演算法,可以把命令轉發給不同級別的命令對象。
在下一篇文章里,我們來看一看用於實現今天我們提到的這些概念的VSX的類型。