调用 Application.Calculate 中断正在编辑的公式

Calling Application.Calculate breaks formula being edited

我正在开发一个加载项,它使用以下方法定期请求重新计算:

((Excel.Application) xlApp).Calculate()

如果用户恰好当时正在编辑公式,Excel 在我这样做时会破坏用户公式

我预计该操作会因用户 activity 而失败,但在奇怪地中断用户输入的任何公式之前不会失败。


例如,如果用户正在向单元格 =SUM(1+2+ 中键入内容,只要我 运行 上面的代码行,他们的输入就会被中断并且 Excel抱怨他们的公式不完整或相似

请注意,这甚至与用户点击 "Enter" 时的行为不同,这将导致如下对话框:

Excel 正在做一些更奇怪的事情,并试图在没有细节的情况下将用户踢出他们的公式输入。

更奇怪 - 如果用户的公式在语法上是有效的,Excel 不仅将他们踢出编辑公式,而且用结果替换公式的内容!


我已经确认我的许多用户都遇到了这个问题,他们都使用现代版本的 Excel。

我已经尝试在主 excel 拥有的应用程序和后台进程中调用 Calculate(),但两者的行为相同。我试过不同的计算方法(比如CalculateFull()),但都是一样的。我还尝试了其他互操作操作,例如 xlApp.StatusBar = "Test",它们不会像 Calculate 那样中断用户的操作。

我能做些什么来防止像这样打扰用户吗?我可以发誓这不是旧版本 Excel 中的行为。

如果它有所不同,我使用 Excel-Dna 库作为我的加载项的基础,但我纯粹使用 Microsoft.Office.Interop.Excel.Application 来完成这篇文章。


更新更奇怪:

我使用 here 描述的方法检查我是否可以将 Application.Interactive 设置为 false 然后返回 true 取得了轻微的成功 - 如果不能,用户是编辑单元格。这允许我在公式编辑器中跳过 Calculate,但奇怪的是,这并不能阻止 Excel 将用户踢出其他输入。

例如,如果用户是:

--当调用 app.Calculate() 时,用户将被踢出所有这些操作,并且检测他们是否正在编辑公式的常用方法不会检测用户何时正在执行这些操作。 --

更新回复:Application.Interactive

事实证明,设置Application.Interactive = True 会导致更多的用户中断问题,例如从对话框中窃取焦点,以及中断鼠标拖动操作(例如调整大小或移动 windows)。如果您的目标是不惹恼用户,则不推荐作为解决方案。

编辑

使用 Application.Interactive 导致的问题多于解决的问题。不推荐这种方案。


这项工作仍在进行中,但到目前为止,我不得不求助于各种 hack 来检测用户是否在 Excel 中积极地做某事,并简单地阻止自己调用“计算”如果是。

主要检查有:

  • Excel 拥有的 window 当前在前台吗?
  • 如果不是,一些用户正在使用其他程序,调用计算不会中断他们。
  • 如果是,是否是主工作簿window (Excel.Application.Hwnd)?
    • 如果不是,则用户正在某些 Excel 对话框、VBA 编辑器等中。请勿打扰。
    • 如果是这样,我们需要深入挖掘。
  • 用户的鼠标当前是否处于按下状态?
    • 如果是这样,他们肯定很忙(拖动、单击、调整大小等)。请不要打扰。
  • 用户的光标是否在某个可编辑控件中? (重命名 sheet,从下拉列表中选择一种字体,在命名范围框中键入,等等)。
    • 如果是这样,请不要打扰。 (还没有想出如何测试这个)。
  • 用户正在编辑单元格吗? (可以使用 Excel.Application.Interactive 专门对此进行测试)
    • 如果是这样,请不要打扰。

基于这有多困难,我觉得我好像在尝试用 Excel 做一些不适合它的事情。 Excel 绝对意味着自动化,并且绝对意味着最终用户使用。也许不只是同时?

还有一些事情需要解决,但这已经很有帮助了。


2 年后 - 我很高兴有人开始使用它。实际上,我最终对此做了一些重大改进,以便能够尽可能多地确定“为什么”Excel 很忙。因此,我将提出两种解决方案。

首先,简单、独立(但有限)的解决方案:

/// <summary>A variety of checks to see whether Excel is busy. This is required because
/// in recent versions of Excel, Recalculate can interrupt user activity.</summary>
private static bool IsExcelBusy(Application xlApp)
{
    try
    {    
        // The user is editing if Interactive is true and cannot be set to false
        // NOTE: Toggling App.Interactive can interrupt certain user activity
        // (e.g. renaming a sheet) so use this check sparingly.
        if (xlApp.Interactive)
        {
            xlApp.Interactive = false;
            xlApp.Interactive = true;
        }

        // Otherwise, assume Excel is not busy.
        return false;
    }
    catch (AccessViolationException)
    {
        return true;
    }
    catch (COMException)
    {
        return true;
    }
}

这是更广泛的解决方案,它现在使用本机方法(请参阅附录)来检查 Excel UI 本身的属性。免责声明:如果 Excel 将来做出任何其他重大 UI 更改,这些可能会中断。

#region IsExcelBusy
/// <summary>A variety of checks to see whether Excel is busy. This is required because
/// in recent versions of Excel, invoking Recalculate can interrupt user activity.</summary>
/// <param name="xlApp">The excel application instance to test for activity.</param>
/// <param name="reason">out - the detected reason for Excel being busy,.</param>
/// <returns>True if a recalculation should be deferred, false if it's safe to recalculate.</returns>
public static bool IsExcelBusy(Application xlApp, out string reason)
{
    reason = null;
    try
    {
        if (xlApp.ActiveWorkbook == null) return false;

        // Check whether the user's cursor in some editable Excel control
        // (like the formula bar, renaming a sheet, typing in the Named Range box, etc.)
        IntPtr excelHwnd = (IntPtr)xlApp.Hwnd;
        uint excelThreadId = NativeMethods.GetWindowThreadProcessId(excelHwnd, out uint excelProcessId);
        // Get the handle of whatever window is in the foreground (system-wide)
        IntPtr foreground = NativeMethods.GetForegroundWindow();
        // TODO: Don't check the focused control if this control's parent doesn't include the main interface?
        //       There are false positives with, e.g. controls in the VBA editor.

        // If a non-excel-owned process has focus, we cannot get the focused control
        uint foregroundThreadId = NativeMethods.GetWindowThreadProcessId(foreground, out uint foregroundProcessId);
        if (foregroundProcessId == excelProcessId)
        {
            // We need to attach the thread that owns this window to get the focused control
            uint thisThreadId = NativeMethods.GetCurrentThreadId();
            try
            {
                if (thisThreadId != foregroundThreadId)
                    NativeMethods.AttachThreadInput(foregroundThreadId, thisThreadId, true);
                IntPtr focusedControlHandle = NativeMethods.GetFocus();
                if (focusedControlHandle != IntPtr.Zero)
                {
                    // Get the class name of the control that the user is currently interacting with (if any)
                    StringBuilder classNameResult = new StringBuilder(256);
                    NativeMethods.GetClassName(focusedControlHandle, classNameResult, 256);
                    string className = classNameResult.ToString();
                    // Determine if this control is at risk of being interrupted by a recalculations
                    switch (className)
                    {
                        case "EXCEL6":
                            reason = "User is editing a cell";
                            return true;
                        case "EXCEL<":
                            reason = "User is editing in the formula bar";
                            return true;
                        case "RICHEDIT60W":
                            reason = "User is editing a ribbon control";
                            return true;
                        case "Edit":
                            reason = "User is in the named range box";
                            return true;
                        case "EXCEL=":
                            reason = "User is renaming a sheet";
                            return true;
                    }
                }
            }
            finally
            {
                if (thisThreadId != foregroundThreadId)
                    NativeMethods.AttachThreadInput(foregroundThreadId, thisThreadId, false);
            }
        }
        // TODO: Ideally, we could discover if the user left the excel application while in edit mode so that
        //       we don't interrupt an edit-in-progress just because they temporarily switched to another window.
        else
        {
            // If the Excel application does not currently have focus, there's no winAPI call to figure out
            // whether they were in the middle of editing one of those controls when they left Excel.
            // We can use the following "poor-man's" test of whether the user is in the middle of
            // editing a formula, but it doesn't work for the other 4 cases mentioned above,
            // And actually leads to problems of its own (like removing focus from other excel-owned controls)
            try
            {
                if (!xlApp.Interactive) return false;
                xlApp.Interactive = false;
                xlApp.Interactive = true;
            }
            // If we a COM exception (Exception from HRESULT: 0x800A03EC)
            // This indicates that the action was blocked because a cell was in edit mode.
            catch (COMException)
            {
                reason = "A cell is in edit mode";
                return true;
            }
        }

        // Otherwise, assume Excel is not busy.
        return false;
    }
    catch (AccessViolationException ex)
    {
        reason = $"Excel is shutting down? ({ex.Message})";
        return true;
    }
    catch (COMException ex)
    {
        if (reason == null)
            reason = $"Excel is not responding to requests ({ex.Message})";
        return true;
    }
}

附录 这是 'advanced' 方法中使用的 NativeMethods class:

using System;
using System.Runtime.InteropServices;
using System.Text;

internal static class NativeMethods
{
    /// <summary>Gets the id of the thread (and process) that owns the specified window handle.</summary>
    [DllImport("kernel32.dll", SetLastError = true)]
    public static extern uint GetCurrentThreadId();

    /// <summary>What tread (and process) owns this window?</summary>
    [DllImport("user32.dll", SetLastError = true)]
    public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);

    /// <summary>What window is currently in the foreground (has focus)?</summary>
    [DllImport("user32.dll")]
    public static extern IntPtr GetForegroundWindow();

    /// <summary>Activates the specified window.</summary>
    [DllImport("user32.dll")]
    public static extern bool SetForegroundWindow(IntPtr hWnd);

    /// <summary>Gets the handle of the control that has keyboard focus (if you are on
    /// the same thread as that control).</summary>
    [DllImport("user32.dll", CharSet = CharSet.Auto, CallingConvention = CallingConvention.Winapi)]
    public static extern IntPtr GetFocus();

    /// <summary>Associate a thread's message queue with another thread.</summary>
    [DllImport("user32.dll")]
    public static extern uint AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);

    /// <summary>Get the class name of the control with the specified handle.</summary>
    [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
    public static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount);

    #region GetWindow
    /// <summary>Used with GetWindow to get the owner of the window.</summary>
    public const uint GW_OWNER = 4;

    [DllImport("user32.dll")]
    public static extern IntPtr GetWindow(IntPtr hWnd, uint uCmd);
    #endregion GetWindow

    #region SetWindowLong
    /// <summary> Called GWLP_HWNDPARENT but this is a misnomer. It changes the OWNER,
    /// not the parent, of a window when used with SetWindowLong.</summary>
    public const int GWLP_HWNDPARENT = -8;

    /// <summary>Change a property of a window.</summary>
    public static IntPtr SetWindowLong(HandleRef hWnd, int nIndex, IntPtr dwNewLong)
    {
        return IntPtr.Size == 4 ?
            (IntPtr)SetWindowLongPtr32(hWnd, nIndex, (uint)dwNewLong) :
            SetWindowLongPtr64(hWnd, nIndex, dwNewLong);
    }

    [DllImport("user32.dll", EntryPoint = "SetWindowLong", CharSet = CharSet.Auto)]
    private static extern uint SetWindowLongPtr32(HandleRef hWnd, int nIndex, uint dwNewLong);

    // See https://www.medo64.com/2013/07/setwindowlongptr/ - code analysis complains if
    // the code-analysis tool itself is 32 bit, because it doesn't 'see' the entry point.
    [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Interoperability", "CA1400:PInvokeEntryPointsShouldExist")]
    [DllImport("user32.dll", EntryPoint = "SetWindowLongPtr", CharSet = CharSet.Auto)]
    private static extern IntPtr SetWindowLongPtr64(HandleRef hWnd, int nIndex, IntPtr dwNewLong);
    #endregion SetWindowLong

    #region SetWindowPos
    [Flags]
    public enum SWP : uint
    {
        /// <summary>If the calling thread and the thread that owns the window
        /// are attached to different input queues, the system posts the request to
        /// the thread that owns the window. This prevents the calling thread from
        /// blocking its execution while other threads process the request.</summary>
        ASYNCWINDOWPOS = 0x4000,
        /// <summary>Prevents generation of the WM_SYNCPAINT message.</summary>
        DEFERERASE = 0x2000,
        /// <summary>Draws a frame (defined in the window's class description) around the window.</summary>
        DRAWFRAME = 0x0020,
        /// <summary>Applies new frame styles set using the SetWindowLong function.
        /// Sends a WM_NCCALCSIZE message to the window, even if the window's size is
        /// not being changed. If this flag is not specified, WM_NCCALCSIZE is sent
        /// only when the window's size is being changed.</summary>
        FRAMECHANGED = 0x0020,
        /// <summary>Hides the window.</summary>
        HIDEWINDOW = 0x0080,
        /// <summary>Does not activate the window. If this flag is not set, the window is
        /// activated and moved to the top of either the topmost or non-topmost group
        /// (depending on the setting of the hWndInsertAfter parameter).</summary>
        NOACTIVATE = 0x0010,
        /// <summary>Discards the entire contents of the client area. If this flag is
        /// not specified, the valid contents of the client area are saved and copied
        /// back into the client area after the window is sized or repositioned.</summary>
        NOCOPYBITS = 0x0100,
        /// <summary>Retains the current position (ignores X and Y parameters).</summary>
        NOMOVE = 0x0002,
        /// <summary>Does not change the owner window's position in the Z order.</summary>
        NOOWNERZORDER = 0x0200,
        /// <summary>Does not redraw changes. If this flag is set, no repainting
        /// of any kind occurs. This applies to the client area, the non-client area
        /// (including the title bar and scroll bars), and any part of the parent window
        /// uncovered as a result of the window being moved. When this flag is set,
        /// the application must explicitly invalidate or redraw any parts of the window
        /// and parent window that need redrawing.</summary>
        NOREDRAW = 0x0008,
        /// <summary>Same as the NOOWNERZORDER flag.</summary>
        NOREPOSITION = 0x0200,
        /// <summary>Prevents the window from receiving the WM_WINDOWPOSCHANGING message.</summary>
        NOSENDCHANGING = 0x0400,
        /// <summary>Retains the current size (ignores the cx and cy parameters).</summary>
        NOSIZE = 0x0001,
        /// <summary>Retains the current Z order (ignores the hWndInsertAfter parameter).</summary>
        NOZORDER = 0x0004,
        /// <summary>Displays the window.</summary>
        SHOWWINDOW = 0x0040
    }

    /// <summary>Can be used to move a window around, or change its z-order</summary>
    [DllImport("user32.dll", EntryPoint = "SetWindowPos")]
    public static extern IntPtr SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter,
        int x, int y, int cx, int cy, SWP wFlags);
    #endregion SetWindowPos
}