我们知道,Excel中有很多内置的函数,比如求和,求平均,字符串操作函数,金融函数等等。在有些时候,结合业务要求,这些函数可能不能满足我们的需求,比如我想要一个函数能够从WebService上获取某只股票的最新价;我想要一个函数能够获取当前的天气情况,这些需求我们可以通过编写Excel自定义函数(User Define Function ,UDF )来实现,这样,在Excel中直接调用我们的自定义函数即可满足特定的业务需求,一般地,因为这种自定义函数的粒度相对较小,所以我们可以根据业务需求编写很多基础的自定义函数,然后以这些自定义函数为基础,编写各种复杂的分析报表。

    编写UDF的方式有很多种,比如直接在VBA种编写自定义函数;如果您熟悉C++,可以将自定义函数编写到XLL中,不熟悉也可以使用ExcelDNA这个开源的库使用.NET技术也可以将您的代码编译为XLL;如果熟悉.NET,使用C#编写自定义函数类库,然后将类库注册成Com组件也可在Excel中调用。下面就这几种方式简要介绍,并给出其优缺点。

1. 使用C# 类库注册的方式实现Excel自定义函数


    我自己对.NET 较熟悉,所以首先介绍这种在.NET中即可进行Excel自定义函数开发的模式,这种方法相对简单。在开始之前,还是回到我们之前对YY插件的规划,我们的YY插件有天气,财经,地图等功能,现在我们假设需要一个天气自定义函数,通过该函数能够获取某个城市某一天的天气情况,比如说气温。

    首先我们需要创建一个简单的C#类库,如下图,其名为YYWeatherUDF。

Create Class Liberary for UDF

    然后,我们创建一个所有自定义函数的基类UDFBase.cs,在该类中,我们放一些基本的注册Com组件所需要的一些操作以及屏蔽一些Object的对象的方法使其不要出现在Excel的UDF函数中来。有一点需要注意的是,在注册及取消注册为Com组件的时候,为避免Excel找不到mscoree.dll,需要往注册表中写入其全部路径,下面的代码即为实现这一功能。

public abstract class UDFBase
{
    /// <summary>
    /// 解决在某些机器的Excel提示找不到mscoree.dll的问题
    /// 这里在注册表中将该dll的路径注册进去,当使用regasm注册该类库为com组件
    /// 时会调用该方法
    /// </summary>
    /// <param name="type"></param>
    [ComRegisterFunctionAttribute]
    public static void RegisterFunction(Type type)
    {
        Registry.ClassesRoot.CreateSubKey(
            GetSubKeyName(type, "Programmable"));
        RegistryKey key = Registry.ClassesRoot.OpenSubKey(
            GetSubKeyName(type, "InprocServer32"), true);
        key.SetValue("", System.Environment.SystemDirectory + @"\mscoree.dll",
            RegistryValueKind.String);
    }

    [ComUnregisterFunctionAttribute]
    public static void UnregisterFunction(Type type)
    {
        Registry.ClassesRoot.DeleteSubKey(
            GetSubKeyName(type, "Programmable"), false);
    }

    private static string GetSubKeyName(Type type, string subKeyName)
    {
        return string.Format("CLSID\\{{{0}}}\\{1}", type.GUID.ToString().ToUpper(), subKeyName);
    }
}

    将工程的AssemblyInfo.cs 中的[assembly: ComVisible(true)]。还有一点需要注意的是,Object类还有四个公共方法,如果直接继承的话,这四个方法还是会出现在我们的UDF函数中,要避免Object对象的其中三个公共方法出现在UDF列表中,可以重写对三个方法设置ComVisible自定义属性。但是第四个GetType方法不允许重写,所以还是会出现在UDF中。要解决这一问题,我们不能使用类了,要使用接口,然后将接口暴露为Interface,具体做法可以参见http://stackoverflow.com/questions/2817942/how-to-hide-gettype-method-from-comhttp://stackoverflow.com/questions/1592440/excel-2007-udf-how-to-add-function-description-argument-help 这里为了简洁和取舍,暂采用抽象类的方式处理。

/// <summary>
///  将Object类的四个公共方法隐藏
///  否则将会出现在Excel的UDF函数中
/// </summary>
/// <returns></returns>
[ComVisible(false)]
public override string ToString()
{
    return base.ToString();
}

[ComVisible(false)]
public override bool Equals(object obj)
{
    return base.Equals(obj);
}

[ComVisible(false)]
public override int GetHashCode()
{
    return base.GetHashCode();
}

    以上这些代码是一个在C# 创建一个UDF类的基本代码,我们可以将该代码保存为基类。

    现在我们要编写我们自己的实际的UDF函数了,新建一个名为WeatherFunc的类,继承自上面的UDFBase抽象类,并在类的属性上添加一些自定义属性,这些属性在后面注册为Com组件的时候需要用到。ClassInterface类的构造函数中参数ClassInterfaceType.AutoDual表示两边都传递,这就回是的Object类的所有公共方法都会显示出来,如果采用实现接口的方式,将该熟悉设置为None即可解决多余方法出现在UDF列表中的问题,这里不细讲,继续。

[Guid("5268ABE2-9B09-439d-BE97-2EA60E103EF6")]
[ClassInterface(ClassInterfaceType.AutoDual)]
[ComVisible(true)]
public class WeatherFunc : UDFBase
{
    public WeatherFunc()
    { }
}

    其中Guid可以使用Visual Studio自带的工具生成,如果我们不指定,则Visual Studio在将该类注册为Com组件时,调用regasm工具的时候会自动生成一个Guid,在开发过程中,一般我们会手动指定一个Guid,这样在调试的时候我们可以根据Guid在注册表中去查找该项,看是否正常注册为了Com组件。

    然后我们在WeatherFunc中定义三个public方法,分别为YY_Weather_Condition ,YY_Weather_Temperture和YY_Weather_WindSpeed 这三个函数分别用来获取天气描述,气温和风速。 这里的天气情况使用了Yahoo Weather API,您可以参考http://developer.yahoo.com/weather/,API请求的格式中w代表城市编号,u代表单位,以上海为例,其请求的url为http://weather.yahooapis.com/forecastrss?w=2151849&u=c,他的返回格式是一个xml文件。这里我创建了一个Weather类对该接口进行了封装,由于这里重点讲解UDF,故不做展开。

/// <summary>
/// 根据城市和日期,返回当天的天气描述
/// </summary>
/// <param name="city">城市</param>
/// <param name="day">日期</param>
/// <returns></returns>
public String YY_Weather_Condition(String city, DateTime day)
{
    Weather weather = new Weather(city, Weather.TemperatureUnits.Celcius);
    return weather.Condition.Text;
}

/// <summary>
/// 根据城市和日期,返回当天的气温状况
/// </summary>
/// <param name="city">城市</param>
/// <param name="day">日期</param>
/// <returns></returns>
public double YY_Weather_Temperature(String city, DateTime day)
{
    Weather weather = new Weather(city, Weather.TemperatureUnits.Celcius);
    return weather.Condition.Temperature;
}

/// <summary>
/// 根据城市和日期,返回当天的风速
/// </summary>
/// <param name="city">城市</param>
/// <param name="day">城市</param>
/// <returns></returns>
public double YY_Weather_WindSpeed(String city, DateTime day)
{
    Weather weather = new Weather(city, Weather.TemperatureUnits.Celcius);
    return weather.Wind.Speed;
}

    这样我们的三个UDF函数已经写好了,然后右击工程项目,在属性设置-〉生成 注册为Com组件。

Set the dll Register for Com Interop

    这里注册为Com组件时,Visual Studio其实是去调用regasm 将类库注册为Com组件,并在注册表里面写入一些信息。在发布到客户端上部署的时候,我们可以直接导入该类库的相关注册表信息,或者直接调用regasm在客户的机器上写入这些信息,在后面讲到Excel插件安装部署的时候,会说到这些。

    然后我们直接进行编译。因为注册Com组件涉及对注册表进行操作,在XP以上系统中,需要管理员权限,如果当前Visual Studio不是以管理员身份运行。则会出现下面错误:

Register dll failed due to the privilege

保存退出,然后右键选择以管理员权限打开VS,然后重新编译。

    完成之后,我们根据之前的GUID 5268ABE2-9B09-439d-BE97-2EA60E103EF6 到注册表中去查找,可以看到,编译并注册为Com组件后会到注册表中写入一些信息,这些情况在安装Office等软件的时候,最后一步的时候,一般可以看到正在注册组件,然后后面是不停闪动GUID,应该是在做相同的工作。我机器上截图如下。

Excel UDF Register Key

现在我们打开Excel软件,在开发工具-〉加载项中,将我们的自定义函数加载进来。

Excel Addin Manager For Addin

现在,在公式->插入函数中可以看到我们编写的自定义函数。

Insert UDF

    可以看到我们之前编写的两个UDF函数,那个GetType是因为我们继承自抽象类的缘故,其中三个已经被重写对Com不可见,GetType方法不能被重写,没有被移除,前面介绍了采用暴露接口的方式可以解决这一问题,需要将我们所有的UDF函数以接口的形式定义,然后在类中实现。这里就不多讲了。

    点击相应的函数,即可弹出参数选择框:

Insert UDF into Excel

    自此,采用.NET编写自定义函数介绍完了,他的好处是编写方便,如果您熟悉.NET的话,相当于就是编写了一个特殊的类库。当然也有一些缺点,比如说,在Excel中输入函数的时候,没有AutoComplete支持,并且在界面上不能显示参数的解释和函数的解释,Com的注册和注销都需要对注册表进行读写操作,在安装部署的时候可能需要一些注册表读写权限以及需要某些杀毒软件的放行,一般地,因为我们的插件再安装的时候是一定需要道注册表中注册的,所以一般的UDF函数的注册对注册表的读写时是和插件安装一同进行的;UDF函数使用.NET 编写容易反编译;仅支持Excel 2003及以上版本。不过由于这种方式简单,所以仍然是很多开发UDF函数的可选方案。

2. VBA 的方式编写自定义函数


    相信使用Excel比较多的人对VBA比较熟悉,VBA其实就是Excel里面的脚本,通过VBA也可以编写自定义函数,存放到某个Sheet页或者作为一个独立的Excel脚本文件比如xla

文件存放。一般的,我们会将我们的自定义函数存在单独的Excel脚本.xla文件中,然后在系统加载的时候,将该脚本文件加载进来。然后就可以直接使用里面编写的UDF函数了。

    这里我们打开一个Excel文件,然后按Alt+F11打开VBA编辑器。

Open VBA

然后可以看到VBA 的IDE界面了,这时候,我们对着工程文件点击,新添加一个模块:

Create a module in VBA

    在该模块中写两个简单的函数。这一回,我们要编写查找天气最高温度,最低温度的两个函数。命名为YY_Weather_TemperatureHigh,YY_Weather_ TemperatureLow。这里先简单写个假数字。

VBA UDF

    完成后,保存为.xla文件。然后回到Excel界面,现在我们在单元格中输入我们的自定义函数的时候,当我们输入YY的时候,就有AutoComplete提示了,Excel 2003以上版本会为我们列出所有匹配以YY开头的内置函数和自定义函数了。

ExcelNDA Intellisence

    Excel函数只能提示界面上,按上,下键可以选择,按Tab键确认。输入YY_Weather_TemperatureHigh的时候,单元格会返回30,输入YY_Weather_TemperatureLow,单元格返回20,这两个数字是我们硬编码进去的。这就是在VBA中编写UDF函数的方式,简单吧。如果逻辑比较简单的话,您完全可以使用VBA语言来实现您的逻辑,比如说在VBA里面去查询数据库,去访问WebService等等。

    如果您对VBA不熟悉的话,也没关系,VBA中也可以调用C# 类库中编写的方法。我们可以使用VBA来编写UDF函数的签名,然后在VBA方法体内调用C# 里面的方法。现在我们将前面的两个取最高,最低气温的函数换成我们之前写好的通过Weather类获取气温的代码。

    要在VBA中调用C#中的方法,首先我们需要在第二篇文章中的SharedAddin程序的基础上进行,我们添加FunctionHelper类,并让其继承自StandardOleMarshalObject对象,然后提供一些对Weather函数进行包装的方法。代码如下:

public class FunctionHelper:StandardOleMarshalObject
{
    public object GetWeather_TemperatureHigh(string city,DateTime day)
    {
        Weather weather = new Weather(city, Weather.TemperatureUnits.Celcius);
        return weather.Forecast.Days[0].High;
    }

    public object GetWeather_TemperatureLow(string city, DateTime day)
    {
        Weather weather = new Weather(city, Weather.TemperatureUnits.Celcius);
        return weather.Forecast.Days[0].Low;
    }
}

    然后,在Connect类的OnConnection中将ComAddin实例对象的Object的属性设置为FunctionHelper实例对象:

public void OnConnection(object application, Extensibility.ext_ConnectMode connectMode, object addInInst, ref System.Array custom)
{
    applicationObject = application as Application;
    addInInstance = addInInst as COMAddIn;
    addInInstance.Object = new FunctionHelper();

    if (applicationObject.Version == "11.0")
    {
        if (menuDesigner == null)
        {
            menuDesigner = new MenuDesigner(applicationObject);
        }
        menuDesigner.AddMenus();
        menuDesigner.AddToolBars();
    }
}

    然后在VBA中,我们根据ProgId查找我们的插件。

Public Function GetCOMAddIn(Optional addInName As String) As COMAddIn
    Dim YYAddIn As COMAddIn
    If addInName = "" Then
        addInName = "YYSharedAddin"
    End If
    Dim addInItem As COMAddIn
    For Each addInItem In Application.COMAddIns
        If addInItem.Description = addInName Then
            Set YYAddIn = addInItem
            Exit For
        End If
    Next addInItem
    Set GetCOMAddIn = YYAddIn
End Function

    这里我们之前的插件叫YYSharedAddin,然后改写我们的之前写过的两个函数:

'获取名为city的城市的当天的最高气温
Function YY_Weather_TemperatureHigh(city As String, day As Date)
    Dim YYAddIn As COMAddIn
    Dim dataQuery As Object
    Set YYAddIn = GetCOMAddIn("YYSharedAddin")
    Set dataQuery = YYAddIn.Object
    YY_Weather_TemperatureHigh = dataQuery.GetWeather_TemperatureHigh(city, day)
End Function
'获取名为city的城市的当天的最低气温
Function YY_Weather_TemperatureLow(city As String, day As Date)
    Dim YYAddIn As COMAddIn
    Dim dataQuery As Object
    Set YYAddIn = GetCOMAddIn("YYSharedAddin")
    Set dataQuery = YYAddIn.Object
    YY_Weather_TemperatureLow = dataQuery.GetWeather_TemperatureLow(city, day)
End Function

    可以看到YYAddin.Object对象在我们的Connect类的OnConnect的时候已经赋值为了FunctionUtility的实例类,所以后面我们在VBA里可以直接调用里面的C# 方法了。

    将上面的VBA保存为Excel .xla格式的宏文件。然后在VBA中和Visual Studio中C#函数方法体内设置断点,在Sheet页中输入我们的自定义函数,然后可以看到VBA中的断点被命中,注意在VBA IDE中,逐步运行调试的快捷键是F8,当调试我们的dataQuery.GetWeather_TemperatureHigh这一句时,会跳到Visual Studio中的C#函数内部,在VS中逐步调试的快捷键是F11,有意思吧。

F8 Debug VBA

    F11 Debug C#

    一般地,我们会将自定义UDF的签名放到Excel 宏文件中以.xla文件保存,然后在方法体内调用C#方法。以YY插件项目为例,当程序运行的时候,在Connect类的OnConnect方法中我们可以使用以下方法加载.xla宏脚本文件到Excel中。

string basePath = Directory.GetParent(Assembly.GetExecutingAssembly().Location).FullName;
LoadUDFs(basePath + "\\YYFunc.xla", true);
public bool LoadUDFs(string progID, bool load)
{
    bool loaded = false;
    try
    {
        if (File.Exists(progID))
        {
            AddIn udfAddIn = applicationObject.AddIns.Add(progID, true);
            udfAddIn.Installed = load;
            loaded = udfAddIn.Installed;
        }
    }
    catch { loaded = false; }

    return loaded;
}

    其中progID传入我们脚本文件名加路径,load为true。

    一般地,采用VBA的方式来编写UDF函数简单快捷,而且比较灵活方便调试,对于一些复杂的逻辑,可以将方法体的实现逻辑放置到C#代码中,在Excel 03以上版本,还有Excel智能提示的支持,在注册的时候,不需要对注册表进行读写操作。但是这种方式也有一些缺点:

  • VBA脚本安全性比较差,虽然可以对我们的脚本设置密码,但这种密码相当容易被破解,破解的手段并不需要太复杂的枚举或者遍历逻辑,在前一篇文章中我们可以看到,破解的过程只需要将原先的脚本文件的保护密码设置为另外一个密码覆盖之前的密码即可,然后再次打开用这个密码打开,然后取消密码保护即可。
  • VBA脚本是一种解释型的脚本语言,运行的时候是逐条语句边解释边执行的,这些和采用之前的Com组件方式的编译为二进制和后面直接编译为xll的方式相比,运行效率较低。

3. XLL方式编写自定义函数


    XLL是Excel自97版本就支持的一种外接二进制插件,其运行速度较前面的两种方式快,并且有更多强大的功能,但是XLL通常使用C或者C++编写,对于开发者的要求较高。关于XLL开发在第一篇文章的参考资料中有一本Financial Applications using Excel Add-in Development in C / C++ 讲的比较深入,有兴趣的同学可以看看。

    幸运的是,有一个名为ExcelDNA的开源库使得我们使用.NET语言即可编写XLL程序。使用ExcelDNA很简单,官网上的帮助文档也很详细,这里简要介绍一下如何使用ExcelDNA来编写XLL自定义函数。当然一开始我们需要到官网上下载安装包:

    先建一个C# 类库,然后引用下载文档中的ExcelDna.Integration.dll,创建两个公共的静态的方法,这里为了完善我们的歪歪天气函数,我们创建两个函数YY_Weather_Condition,YY_Weather_Sunrise分别用来获取对天气的描述信息和当天的日出时间。

public class YYWeather
{
    [ExcelFunction(Description = "获取指定城市的最新的天气信息")]
    public static string YY_Weather_Condition(string city, DateTime day)
    {
        Weather weather = new Weather(city, Weather.TemperatureUnits.Celcius);
        return weather.Condition.Text;
    }

    [ExcelFunction(Description = "获取指定城市的日出时间")]
    public static DateTime YY_Weather_Sunrise(string city, DateTime day)
    {
        Weather weather = new Weather(city, Weather.TemperatureUnits.Celcius);
        return weather.Astronomy.Sunrise;
    }
}

    然后在项目中添加一个名为YYWeather.dna的文本文件。

<DnaLibrary Name="YY Weather Fucntion" RuntimeVersion="v4.0">
  <ExternalLibrary Path="YYWeatherUDFExcelDNA.dll" />
</DnaLibrary>

    然后将安装包内的ExcelDna.xll文件拷贝到项目内,并将其命名为YYWeather.xll,然后将其包含在项目中,设置其生成属性,保证其在编译时会拷贝到生成目录下。

Excel DNA Pack Directory

    点击生成的YYWeather.xll文件,然后先建一个Sheet页,在打开的Excel Sheet页中输入YY,可以看到,我们之前建立的两个自定义函数给予了提示。

ExcelNDA Intellisence

    选择天气状况函数,输入城市和日期,可以看到返回了正确的天气状况信息,多云天气

ExcelDNA Sh Weather

    另外在插入函数的时候,可以看到,对函数和参数有提示:

ExcelDNA Insert Function

    我们选择查询日出函数,输入城市和时间之后,可以看到能够返回正确的结果。

ExcelDNA Sh Sunrise

    另外,在使用ExcelDNA时,比如上面的例子中,我们可以看到一堆的xll, dna, dll文件,不方便发布和部署。ExcelNDA提供了一个打包工具,可以将这些文件打包成一个xll文件,使用下载包内的默认打包程序ExcelDnaPack.exe即可实现这一功能,将ExcelDNAPack.exe拷贝到生成目录,并在cmd下将当前目录切换到生成目录下,命令行下运行程序,并将YYWeather.dna作为参数传递进行:

Excel DNA Pack

    程序会生成YYWeather-packed.xll,这个即为生成好的xll文件,他会将dll中的内容打包到xll中去,分发部署的时候,只需要将这个文件发布到用户的机器上即可。

    一般地,在实际的开发环境中,以歪歪插件为例,和加载xla宏脚本文件一样,在Connect的Onconnect方法中调用之前的LoadUDFs传入该xll的完整路径和文件名即可使用里面的自定义函数了。

string basePath = Directory.GetParent(Assembly.GetExecutingAssembly().Location).FullName;
LoadUDFs(basePath + "\\YYWeather-packed.xll", true);

    ExcelDNA功能强大,这里只是简要介绍了如何使用.NET语言编写UDF函数,ExcelDNA支持VBA语言,F#语言,还可以实现Ribbon菜单,RTD函数,异步UDF函数等,这些后面会介绍。

    借助ExcelDNA编写xll的UDF有很多优点,比如说,运行速度快;Excel 2003以上版本支持函数的AutoComplete;支持函数和函数参数的注释;不用对注册表进行读写;使用.NET 借助ExcelDNA开发门槛相对较低等优点,在大多数情况下是比较理想的解决方案。

    另外,ExcelDNA的函数提示,仅支持1000个UDF,就是说超过1000个,当您在Excel中输入的时候,下拉提示框中,超出的部分函数可能不会出现。这可能是ExcelDNA的开发者考虑到过多的提示项对增加内存消耗的原因。解决方法很简单,您只需要下载ExcelDNA的源代码,搜索1000,然后改为您想要的数字,然后在有些地方添加一些代码。编译一下即可。

    最后,使用上面的方式创建好了UDF之后,我们来创建一个简单的天气预报报表,我录制了一个小动画,如下图,您应该可以体会到Excel自定义函数的强大用处:

Weather Report

4. 结语


    Excel 中的自定义函数极大地扩充了Excel的应用领域,他也是Excel插件和业务逻辑的一个极好的接入点,通过Excel UDF函数,您可以在此基础上创建各种灵活的分析报表,构建各种分析模型。本文简要介绍了常见的三种编写Excel UDF函数的方法,他们是采用.NET 托管代码注册为Com组件的方式,纯VBA脚本和VBA调用.NET 类库方式,以及借助Excel结合.NET 编写xll的方式,三种方式各有优缺点,现简单总结如下:

方式

优点

缺点

.NET Automation Add-Ins

1. 简单,有.NET基础即可。

2. 不需要借助任何第三方类库即可实现。

3. 运行速度较快

4. 安全性较高

1. Excel 2003版本不支持AutoComplete

2. 在插入函数界面上,不支持函数及参数注释

3. 需要对注册表进行读写操作。

VBA UDF

1. 简单灵活易调试,有VBA基础即可编写,其实现既可以全部使用VBA,也可以在VBA中调用其它第三方类库。

2. 拥有Excel 函数的智能提示的支持。

1. 安全性较差,非常容易被破解。

2. 插入函数界面上,不支持函数体及参数注释

3. 运行效率低下,和编译型语言相比,VBA本质上是解释型语言,边解释边执行,效率慢。

XLL

1. 运行速度快,由于事先编译为了二进制代码,直接加在到内存中执行,较VBA方式快。

2. 安全性较高,一般采用C或者C++ 编写,代码发布后为二进制,反编译难度大。

3. 拥有Excel函数智能提示支持。

4. 插入函数界面支持函数体和函数参数注释。

1.采用纯C或者C++开发难度较大。采用ExcelDNA支持.NET 编写XLL大大降低开发难度。但是这是在第三方工具的支持下进行的。

    下文将会介绍Excel中的另外一类比较重要的函数,Real Time Data 即RTD函数,这一类型的函数可以实现诸多强大的功能,比如在Excel中实现股票行情信息的实时刷新,实现Excel 异步自定义函数功能等等,敬请期待。