首页 > 解决方案 > Excel VSTO 异步按钮 - 用户交互的奇怪行为?

问题描述

我有一个通过 VSTO 的 Excel 功能区。单击按钮时,会发生一些处理并在当前工作表上填充行。在此过程中,Excel 锁定 - 用户无法继续使用他们的程序。我的解决方法涉及如下实现异步解决方案:

 // button1 click handler
 private async void button1_Click(object sender, RibbonControlEventArgs e)
 {
     await Task.Run(new Action(func));
 }

 // simple func
 void func()
 {
    var currSheet = (Worksheet) Globals.ThisAddIn.Application.ActiveSheet;
    int rowSize = 50;
    int colSize = 50;

    for (int i = 1; i <= rowSize ; i++)
        for (int j = 1; j <= colSize ; j++)
            ((Range) activeSheet.Cells[i, j]).Value2 = "sample";
 }

这种方法的一个大问题是,当用户单击时,会弹出以下错误:

System.Runtime.InteropServices.COMException:“来自 HRESULT 的异常:0x800AC472”

但是,与键盘的交互不会触发此类事件。

我不确定如何调试这个错误,但它让我问了几个问题:

标签: c#excelmultithreadingasynchronousvsto

解决方案


可能是 2018 年了,但是底层架构没变,还是不推荐多线程。

现在,尽管如此,还是有办法的。是我所知道的关于正确操作的最佳资源......但它会预先警告你:

首先警告:这是一个高级场景,除非您确定自己知道自己在做什么,否则不应尝试使用此技术。发出此警告的原因是,虽然此处描述的技术非常简单,但也很容易出错,从而严重干扰主机应用程序。

其余的:

问题描述:您构建了一个定期调用回宿主对象模型的 Office 加载项。有时呼叫会失败,因为主机正忙于做其他事情。也许它正在重新计算工作表;或者(最常见的),也许它正在显示一个模式对话框并等待用户输入才能继续。

如果您未在加载项中创建任何后台线程,因此在创建加载项的同一线程上进行所有 OM 调用,则您的调用不会失败,直到主机才会调用它已畅通无阻。然后,将按顺序处理。这是正常情况,建议您在大多数情况下采用这种方式设计 Office 解决方案,即不创建任何新线程。

但是,如果您确实创建了额外的线程,并尝试对这些线程中的任何一个进行 OM 调用,那么如果主机被阻塞,调用就会失败。你会得到一个 COMException,通常是这样的:System.Runtime.InteropServices.COMException, Exception from HRESULT: 0x800AC472。

要解决此问题,您可以在加载项中实现 IMessageFilter,并在附加线程上注册消息过滤器。如果您这样做,并且当您在该线程上进行调用时 Excel 正忙,那么 COM 将回调您的 IMessageFilter.RetryRejectedCall 实现。这使您有机会处理失败的呼叫 - 通过重试和/或采取其他一些缓解措施,例如显示一个消息框,告诉用户如果他们希望您的操作继续,则关闭所有打开的对话框。

请注意,通常定义了 2 个 IMessageFilter 接口。一个在 System.Windows.Forms 中——你不想要那个。相反,您需要在 objidl.h 中定义的那个,您需要像这样导入它:

[StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct INTERFACEINFO
{
    [MarshalAs(UnmanagedType.IUnknown)]
    public object punk;
    public Guid iid;
    public ushort wMethod;
}

[ComImport, ComConversionLoss, InterfaceType((short)1), Guid("00000016-0000-0000-C000-000000000046")]
public interface IMessageFilter
{
    [PreserveSig, MethodImpl(MethodImplOptions.InternalCall,
        MethodCodeType = MethodCodeType.Runtime)]
    int HandleInComingCall(
        [In] uint dwCallType,
        [In] IntPtr htaskCaller,
        [In] uint dwTickCount,
        [In, MarshalAs(UnmanagedType.LPArray)] INTERFACEINFO[] lpInterfaceInfo);

    [PreserveSig, MethodImpl(MethodImplOptions.InternalCall,
        MethodCodeType = MethodCodeType.Runtime)]
    int RetryRejectedCall(
        [In] IntPtr htaskCallee,
        [In] uint dwTickCount,
        [In] uint dwRejectType);

    [PreserveSig, MethodImpl(MethodImplOptions.InternalCall,
        MethodCodeType = MethodCodeType.Runtime)]
    int MessagePending(
        [In] IntPtr htaskCallee,
        [In] uint dwTickCount,
        [In] uint dwPendingType);
}

然后,在您的 ThisAddIn 类中实现此接口。请注意,IMessageFilter 也在服务器上实现(即,在我们的示例中,在 Excel 中),并且 IMessageFilter.HandleInComingCall 调用仅在服务器上进行。其他 2 个方法将在客户端(即本例中的加载项)上调用。在应用程序进行 COM 方法调用后,我们将收到 MessagePending 调用,并且在调用返回之前出现 Windows 消息。重要的方法是 RetryRejectedCall。在下面的实现中,我们显示一个消息框,询问用户是否要重试该操作。如果他们说“是”,我们返回 1,否则返回 -1。COM 期望此调用的以下返回值:

  • -1:呼叫应该被取消。COM 然后从原始方法调用返回 RPC_E_CALL_REJECTED。
  • 值 >= 0 且 <100:将立即重试调用。
  • 值 >= 100:COM 将等待这么多毫秒,然后重试调用。
public int HandleInComingCall([In] uint dwCallType, [In] IntPtr htaskCaller, [In] uint dwTickCount,
    [In, MarshalAs(UnmanagedType.LPArray)] INTERFACEINFO[] lpInterfaceInfo)
{
    Debug("HandleInComingCall");
    return 1;
}

public int RetryRejectedCall([In] IntPtr htaskCallee, [In] uint dwTickCount, [In] uint dwRejectType)
{
    int retVal = -1;
    Debug.WriteLine("RetryRejectedCall");
    if (MessageBox.Show("retry?", "Alert", MessageBoxButtons.YesNo) == DialogResult.Yes)
    {
        retVal = 1;
    }
    return retVal;
}

public int MessagePending([In] IntPtr htaskCallee, [In] uint dwTickCount, [In] uint dwPendingType)
{
    Debug("MessagePending");
    return 1;
}

最后,使用 CoRegisterMessageFilter 向 COM 注册您的消息过滤器。消息过滤器是每个线程的,因此您必须在创建的后台线程上注册过滤器以进行 OM 调用。在下面的示例中,加载项提供了一个方法 InvokeAsyncCallToExcel,该方法将从功能区按钮调用。在这种方法中,我们创建一个新线程并确保这是一个 STA 线程。在我的示例中,线程过程 RegisterFilter 完成了注册过滤器的工作——然后它休眠 3 秒,让用户有机会做一些会阻塞的事情——例如在 Excel 中弹出一个对话框。这显然只是为了演示目的,这样您就可以看到当 Excel 在后台线程调用之前阻塞时会发生什么。CallExcel 方法在 Excel 的 OM 上进行调用。

[DllImport("ole32.dll")]
static extern int CoRegisterMessageFilter(IMessageFilter lpMessageFilter, out IMessageFilter lplpMessageFilter);

private IMessageFilter oldMessageFilter;
internal void InvokeAsyncCallToExcel()
{
    Thread t = new Thread(this.RegisterFilter);
    t.SetApartmentState(ApartmentState.STA);
    t.Start();
}

private void RegisterFilter()
{
    CoRegisterMessageFilter(this, out oldMessageFilter);
    Thread.Sleep(3000);
    CallExcel();
}

private void CallExcel()
{
    try
    {
        this.Application.ActiveCell.Value2 = DateTime.Now.ToShortTimeString();
    }
    catch (Exception ex)
    {
        Debug.WriteLine(ex.ToString());
    }
}

注意我已将返回类型从 uint 更改为 int,因为原始代码没有编译。我已经在 Word 中尝试过,它确实有效,但我没有将它包含在我的软件中,主要是因为我不确定它会以何种方式爆炸。作者没说。


推荐阅读