因工作需要研究了下Visual Studio里创建Office外接程序的方式,对其自动生成的C#模板工程有点兴趣。给开发者提供了最简单直接的方式实现功能,并在背后隐藏了很多细节,比Com版本的Office 加载项方便了很多。
当前环境
- Visual Studio 2022: .net 桌面开发、Visual Studio Tools for Office (VSTO)
- Windows 11
- Office 2016
创建项目
- 使用模板新建项目
这里以Excel模块为例,项目名称取名为:MyExcelAddIn(这个名称后续在工程里比较重要,取名请慎重)。此时新建好的工程里只有以下文件:
- MyExcelAddIn.csproj
- Properties
- ThisAddIn.Designer.cs
- ThisAddIn.Designer.xml
- ThisAddIn.cs
后续分别解析这些文件的功能。在这里就可以直接F5生成工程,并启动Excel。Excel的加载项列表里显示已经有了MyExcelAddIn了。
并且这里F5后新生成了一个文件:MyExcelAddIn_TemporaryKey.pfx
,用于对生成的AddIn进行签名(走的是[ClickOnce](ClickOnce 参考 - Visual Studio (Windows) | Microsoft Docs
)逻辑),这里不再详述。
- 新增Office顶部的Ribbon控件选项卡
一个Offcie插件可以有很多功能,我们这里以在顶部按钮区(Ribbon)添加一个选项卡为例。在解决方案资源管理器中右键MyExcelAddIn项目,然后选择新建项,再如下图中选中功“能区(可视化设计器)。”
在此步骤中直接用了默认文件名Ribbon1
,这个名称后续在工程里比较常见,但不是很重要。在此步骤会新增以下几个文件:
Ribbon1.Designer.cs、Ribbon1.cs、Ribbon1.resx
此时新增的代码依然非常简单,一个界面和一个cs(这里和常见的C#窗口类似)。此时一个最基本的VSTO加载项工程建立好了,这时F5直接运行会打开Excel,但新的选项卡并未出现,但在设置里有它(但奇怪的是,无论是界面设计器还是在代码中使用的名称都是默认的TabAddIns
,但Excel中显示中文名称加载项,怀疑是Excel的中文语文包自动将单词翻译成了中文)。
使用工具箱在界面上新增一个按钮,并且改为大按钮,并更改相关属性。
,这时F5启动就有了选项卡。
然后按同样的方法,再加了一个按钮,并更改其它属性,然后双击两个按钮,随便弹出个MessageBox,再F5运行,功能正常。
Visual Studio 背后隐藏的代码
你以为我会继续讲开发什么功能?no,这种教程挺多的,自行去搜索。我这里只是由这个基本工程略微深入一下看看VS偷偷在背后搞了哪些事情,来,一步一步查看。
F5为什么能直接运行Office并且加载扩展?
在MyExcelAddIn.csproj
工程文件中(直接使用文本编辑器查看它吧),中间里面包含了节点OfficeApplication
,这个节点定义了插件的类型(Word|Excel|...)等,这里也是很重要的一个节点,在后面被引用。
<PropertyGroup>
<!--
OfficeApplication
Add-in host application
-->
<OfficeApplication>Excel</OfficeApplication>
</PropertyGroup>
并在最底部,导入了Office相关的生成规则文件Microsoft.VisualStudio.Tools.Office.targets
(规则文件的相关概念也是三天三夜都说不完的东西,应该没多少人自定义吧。。应该吧。。除非是某个大傻子。。),以及ProjectExtensions节点,
当然你以为我懂节点里面的规则?并不。。反正记住这里的Host以及HostItem结点,在下面的代码中有所体现。
<!-- Include additional build rules for an Office application add-in. -->
<Import Project="$(VSToolsPath)\OfficeTools\Microsoft.VisualStudio.Tools.Office.targets" Condition="'$(VSToolsPath)' != ''" />
<!-- This section defines VSTO properties that describe the host-changeable project properties. -->
<ProjectExtensions>
<VisualStudio>
<FlavorProperties GUID="{BAA0C2D2-18E2-41B9-852F-F413020CAA33}">
<ProjectProperties HostName="Excel" HostPackage="{29A7B9D7-A7F1-4328-8EF0-6B2D1A56B2C1}" OfficeVersion="15.0" VstxVersion="4.0" ApplicationType="Excel" Language="cs" TemplatesPath="" DebugInfoExeName="#Software\Microsoft\Office\16.0\Excel\InstallRoot\Path#excel.exe" DebugInfoCommandLine="/x" AddItemTemplatesGuid="{51063C3A-E220-4D12-8922-BDA915ACD783}" />
<Host Name="Excel" GeneratedCodeNamespace="MyExcelAddIn" IconIndex="0">
<HostItem Name="ThisAddIn" Code="ThisAddIn.cs" CanonicalName="AddIn" CanActivate="false" IconIndex="1" Blueprint="ThisAddIn.Designer.xml" GeneratedCode="ThisAddIn.Designer.cs" />
</Host>
</FlavorProperties>
</VisualStudio>
</ProjectExtensions>
ThisAddIn
整个模块工程我们能直观看到的代码只有这ThisAddIn和Ribbon1 两个cs文件,而里面的代码都非常简单,并且除了添加按钮处理事件,其它的完全都不用去修改。
但我们要看一看生成的文件里面包含什么内容,程序的主要入口是ThisAddIn,它继承于Microsoft.Office.Tools.AddInBase
主要实现代码在自动生成的ThisAddIn.Designer.cs中(此文件不建议修改,但后面也没有被其它操作所更新, 所以理论上你也能改),构造函数如下:
public ThisAddIn(global::Microsoft.Office.Tools.Excel.ApplicationFactory factory, global::System.IServiceProvider serviceProvider) :
base(factory, serviceProvider, "AddIn", "ThisAddIn") {
Globals.Factory = factory;
}
这里有两个参数,一个factory,一个serviceProvider。然后转调用基类构造函数。
protected AddInBase(Factory factory, IServiceProvider serviceProvider, string primaryCookie, string identifier)
{
_inner = factory.CreateAddIn(null, null, primaryCookie, identifier, this, this);
_extensionSite = _inner.DefaultExtension;
}
在这里primaryCookie就是传入的“AddIn”,而identifier就是传入的“ThisAddIn”,这里又使用入口处的 Excel.ApplicationFactory::CreateAddIn 来创建一个私有的addin类型对象_inner,有一些事件或系统对象的获取最终都指向它。
在ThisAddIn的Initialize函数中设置了Application属性(this.Application = this.GetHostItem<Microsoft.Office.Interop.Excel.Application>(typeof(Microsoft.Office.Interop.Excel.Application),
),以及设置了Globals.ThisAddIn为自己。
Globals
在ThisAddIn的构造函数中,还引入了Globals类,它提供了3个属性:
- ThisAddIn
- Factory:Microsoft.Office.Tools.Excel.ApplicationFactory
- Ribbons 继承于Microsoft.Office.Tools.Ribbon.RibbonCollectionBase
ThisAddIn有时会在开发者自己编写的代码中引用,而Factory基本只会在自动生成的代码中引用。
而Ribbons返回当前VSTO中所包含的所有Ribbons对象。可以使用var r = Globals.Ribbons.GetRibbon(typeof(Ribbon1));
或 Globals.Ribbons.Ribbon1
来引用Ribbon对象。
回到ThisAddIn.cs
模板中,提供给用户的默认只有两个函数ThisAddIn_Startup和ThisAddIn_Shutdown,并且在默认自动折叠的生成代码InternalStartup中绑定到this.Startup和this.Shutdown事件,这两个事件如同上面所说是基类的私有成员_inner的事件,在插件启动和关闭时会回调这两个函数。
ThisAddIn.Designer.xml
<hostitem:hostItem hostitem:baseType="Microsoft.Office.Tools.AddInBase" hostitem:namespace="MyExcelAddIn" hostitem:className="ThisAddIn" hostitem:identifier="ThisAddIn" hostitem:primaryCookie="AddIn" hostitem:master="true" hostitem:factoryType="Microsoft.Office.Tools.Excel.ApplicationFactory" hostitem:startupIndex="0" xmlns:hostitem="http://schemas.microsoft.com/2004/VisualStudio/Tools/Applications/HostItem.xsd">
<hostitem:hostObject hostitem:name="Application" hostitem:identifier="Application" hostitem:type="Microsoft.Office.Interop.Excel.Application" hostitem:cookie="Application" hostitem:modifier="Internal" />
<hostitem:hostControl hostitem:name="CustomTaskPanes" hostitem:identifier="CustomTaskPanes" hostitem:type="Microsoft.Office.Tools.CustomTaskPaneCollection" hostitem:primaryCookie="CustomTaskPanes" hostitem:modifier="Internal" />
<hostitem:hostControl hostitem:name="VstoSmartTags" hostitem:identifier="VstoSmartTags" hostitem:type="Microsoft.Office.Tools.SmartTagCollection" hostitem:primaryCookie="VstoSmartTags" hostitem:modifier="Internal" />
</hostitem:hostItem>
先看了ThisAddIn的代码,再来看这个文件是不是就很清晰。在模板工程创建时经过一些神秘的步骤,某些工具根据这个文件里的内部生成了ThisAddIn.Designer.cs代码。
Ribbon1.cs
接下来看看Ribbon1,它主要功能也是在自动生成的Ribbon1.Designer.cs
文件中,里面的代码不多,主要是构造函数、控件成员变量、界面初使化函数。
partial class Ribbon1 : Microsoft.Office.Tools.Ribbon.RibbonBase
{
public Ribbon1()
: base(Globals.Factory.GetRibbonFactory())
{
InitializeComponent();
}
...
这个类是继承于RibbonBase,同AddInBase一样实际上是调用了Factory的CreateOfficeRibbon作为私有成员,并且暴露出一些函数。
PS
在测试过程中,我发现我创建的多个Excel AddIn工程,即使以不一样的tab label 和name,也会合并到同一个选项卡里。
尝试玩活儿
终上,按模板的结构Word和Excel的扩展得使用统一的一个,而我想创建出同时支持Excel和Word等的通用扩展。
首先尝试直接更改代码将Excel的改成Word插件。
- 将工程文件里的Excel都换成Word(注意别将MyExcelAddIn也换掉了),
#excel.exe
这个换成#winword.exe
。 -
ThisAddIn.Designer.cs
和ThisAddIn.cs
中Excel改为Word(全字和大小写匹配)。 - Ribbon1在界面中选中最外层界面,然后在属性中将RibbonType增加一个
Microsoft.Word.Document
类型,这样生成的代码中就是this.RibbonType = "Microsoft.Excel.Workbook, Microsoft.Word.Document";
了。
然后F5启动,哒哒哒,竟然成功了,在Word中也可以显示了。
尝试直接支持多产品
上面改过之后,启动Excel,结果Excel并未加载扩展,根据调试显示未能创建ThisAddIn入口,按上文Factory的类型不一样,所以应该是找不到对应的构造函数,于是继续玩活儿。
使用git对比代码,将部分改名的代码合并回来。
- 工程文件将引用Microsoft.Office.Tools.Excel和Microsoft.Office.Interop.Excel加回来。OfficeApplication不能改。
-
ThisAddIn.cs
中同时引用Excel和Word的命名空间。 -
ThisAddIn.Designer.cs
这一步是最多的改动,值得单独弄一段。
复制构造函数将factory类型改掉,Globals.Factory及_factory的类型都改为基类Microsoft.Office.Tools.Factory
以实现兼容。
然后生成工程(这时不能F5了,否则F5还是启动Word)。单独启动Excel程序,哒哒哒,也显示我们定义的Ribbon了。
但似乎Manifest不通用。后续待研究。。