协慌网

登录 贡献 社区

如何正确清理 Excel 互操作对象?

我在 C#( ApplicationClass )中使用 Excel 互操作,并将以下代码放在我的 finally 子句中:

while (System.Runtime.InteropServices.Marshal.ReleaseComObject(excelSheet) != 0) { }
excelSheet = null;
GC.Collect();
GC.WaitForPendingFinalizers();

尽管这种工作有效,但是即使我关闭 Excel, Excel.exe进程仍在后台。仅在我的应用程序手动关闭后才释放它。

我在做什么错,还是有其他方法可以确保正确处理互操作对象?

答案

Excel 不会退出,因为您的应用程序仍在保留对 COM 对象的引用。

我猜您正在调用 COM 对象的至少一个成员,而没有将其分配给变量。

对我来说,这是excelApp.Worksheets对象,我直接使用它而不将其分配给变量:

Worksheet sheet = excelApp.Worksheets.Open(...);
...
Marshal.ReleaseComObject(sheet);

我不知道 C#在内部为Worksheets COM 对象创建了一个包装,而该包装没有被我的代码释放(因为我当时不知道),这就是为什么未卸载 Excel 的原因。

我在此页面上找到了解决问题的方法, 该方法对于在 C#中使用 COM 对象也有一个很好的规则:

切勿对 COM 对象使用两个点。


因此,有了这些知识,完成上述操作的正确方法是:

Worksheets sheets = excelApp.Worksheets; // <-- The important part
Worksheet sheet = sheets.Open(...);
...
Marshal.ReleaseComObject(sheets);
Marshal.ReleaseComObject(sheet);

事后更新:

我希望每个读者都仔细阅读 Hans Passant 的答案,因为它解释了我和许多其他开发人员所陷入的陷阱。多年前我写这个答案时,我不知道调试器对垃圾收集器的影响,并得出了错误的结论。为了历史起见,我的答案保持不变,但请阅读此链接, 不要采用 “两个点” 的方式: 了解. NET 中的垃圾收集使用 IDisposable 清理 Excel Interop 对象

实际上,您可以干净地释放 Excel Application 对象,但是您一定要小心。

关于绝对为您访问的每个 COM 对象维护一个命名引用,然后通过Marshal.FinalReleaseComObject()显式释放它的建议在理论上是正确的,但不幸的是,在实践中很难管理。如果一个人滑到任何地方并使用 “两个点”,或者通过for each循环或任何其他类似类型的命令通过一个单元来迭代单元,那么您将拥有未引用的 COM 对象并可能会死机。在这种情况下,将无法在代码中找到原因。您将不得不仔细检查所有代码,并希望找到原因,这对于大型项目而言几乎是不可能的。

好消息是,您实际上不必维护对所使用的每个 COM 对象的命名变量引用。而是,调用GC.Collect() ,然后GC.WaitForPendingFinalizers()释放所有您不持有引用的(通常是次要的)对象,然后显式释放您确实拥有命名变量引用的对象。

您还应该以相反的顺序释放命名的引用:首先是范围对象,然后是工作表,工作簿,最后是 Excel Application 对象。

例如,假设你有一个名为 Range 对象变量xlRng ,命名工作表变量xlSheet ,命名工作簿变量xlBook并命名为 Excel 应用程序变量xlApp ,那么你的清理代码可能看起来像下面这样:

// Cleanup
GC.Collect();
GC.WaitForPendingFinalizers();

Marshal.FinalReleaseComObject(xlRng);
Marshal.FinalReleaseComObject(xlSheet);

xlBook.Close(Type.Missing, Type.Missing, Type.Missing);
Marshal.FinalReleaseComObject(xlBook);

xlApp.Quit();
Marshal.FinalReleaseComObject(xlApp);

在大多数代码示例中,您会看到从. NET 清理 COM 对象的方式, GC.WaitForPendingFinalizers()调用GC.Collect()GC.WaitForPendingFinalizers()方式如下:

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
GC.WaitForPendingFinalizers();

但是,除非您使用的是 Visual Studio Tools for Office(VSTO),否则它不是必需的,因为 Visual Studio Tools for Office(VSTO)使用的终结器会使整个对象图在终结队列中得到提升。 在下一次垃圾回收之前,不会释放此类对象。但是,如果您不使用 VSTO,则应该只能调用一次GC.Collect()GC.WaitForPendingFinalizers()

我知道显式调用GC.Collect()是不行的(当然,两次进行听起来很痛苦),但是说实话,这是没有办法的。通过正常操作,您将生成隐藏的对象,这些对象没有引用,因此只能通过调用GC.Collect()来通过其他任何方式释放。

这是一个复杂的主题,但实际上仅此而已。一旦为清理程序建立了此模板,就可以正常编写代码,而无需包装器等。:-)

我在这里有一个教程:

使用 VB.Net/COM Interop 自动化 Office 程序

它是为 VB.NET 编写的,但不要因此而推迟,其原理与使用 C#时完全相同。

前言:我的答案包含两种解决方案,因此阅读时请务必小心,不要错过任何内容。

对于如何使 Excel 实例卸载,有不同的方法和建议,例如:

  • 使用 Marshal.FinalReleaseComObject()显式释放每个 com 对象(不要忘记隐式创建的 com 对象)。要释放每个创建的 com 对象,可以使用此处提到的 2 点规则:
    如何正确清理 Excel 互操作对象?

  • 调用 GC.Collect()和 GC.WaitForPendingFinalizers()可使 CLR 释放未使用的 COM 对象 *(实际上,它可以工作,有关详细信息,请参见我的第二个解决方案)

  • 检查 com-server-application 是否显示一个等待用户回答的消息框(尽管我不确定它是否可以阻止 Excel 关闭,但我听说过几次)

  • 将 WM_CLOSE 消息发送到 Excel 主窗口

  • 在单独的 AppDomain 中执行与 Excel 一起使用的功能。有人认为,卸载 AppDomain 后,Excel 实例将关闭。

  • 杀死所有在我们的 excel 互操作代码启动后实例化的 excel 实例。

但!有时,所有这些选项都无济于事或不合适!

例如,昨天我发现在我的其中一个函数(与 excel 一起使用)中,Excel 在函数结束后一直运行。我尝试了一切!我彻底检查了整个功能 10 次,并为所有内容添加了 Marshal.FinalReleaseComObject()!我也有 GC.Collect()和 GC.WaitForPendingFinalizers()。我检查了隐藏的消息框。我试图将 WM_CLOSE 消息发送到 Excel 主窗口。我在一个单独的 AppDomain 中执行了我的功能,并卸载了该域。没有任何帮助!关闭所有 excel 实例的选项是不合适的,因为如果用户在我的函数执行期间手动启动另一个 Excel 实例(也可以使用 Excel),则该实例也将被我的函数关闭。我打赌用户不会满意!因此,说实话,这是一个 la 脚的选择(没有冒犯的人)。所以我花了几个小时才找到一个好的解决方案 (以我的拙见): 通过其主窗口的 hWnd 杀死 excel 进程 (这是第一个解决方案)。

这是简单的代码:

[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);

/// <summary> Tries to find and kill process by hWnd to the main window of the process.</summary>
/// <param name="hWnd">Handle to the main window of the process.</param>
/// <returns>True if process was found and killed. False if process was not found by hWnd or if it could not be killed.</returns>
public static bool TryKillProcessByMainWindowHwnd(int hWnd)
{
    uint processID;
    GetWindowThreadProcessId((IntPtr)hWnd, out processID);
    if(processID == 0) return false;
    try
    {
        Process.GetProcessById((int)processID).Kill();
    }
    catch (ArgumentException)
    {
        return false;
    }
    catch (Win32Exception)
    {
        return false;
    }
    catch (NotSupportedException)
    {
        return false;
    }
    catch (InvalidOperationException)
    {
        return false;
    }
    return true;
}

/// <summary> Finds and kills process by hWnd to the main window of the process.</summary>
/// <param name="hWnd">Handle to the main window of the process.</param>
/// <exception cref="ArgumentException">
/// Thrown when process is not found by the hWnd parameter (the process is not running). 
/// The identifier of the process might be expired.
/// </exception>
/// <exception cref="Win32Exception">See Process.Kill() exceptions documentation.</exception>
/// <exception cref="NotSupportedException">See Process.Kill() exceptions documentation.</exception>
/// <exception cref="InvalidOperationException">See Process.Kill() exceptions documentation.</exception>
public static void KillProcessByMainWindowHwnd(int hWnd)
{
    uint processID;
    GetWindowThreadProcessId((IntPtr)hWnd, out processID);
    if (processID == 0)
        throw new ArgumentException("Process has not been found by the given main window handle.", "hWnd");
    Process.GetProcessById((int)processID).Kill();
}

如您所见,根据 Try-Parse 模式,我提供了两种方法(我认为这里很合适):如果无法杀死 Process(例如,该进程不再存在),则一种方法不会引发异常。 ,如果 Process 没有被杀死,则另一个方法将引发异常。此代码中唯一的弱项是安全权限。从理论上讲,用户可能没有杀死进程的权限,但是在所有情况下,有 99.99%的用户都具有这种权限。我还用来宾帐户对其进行了测试 - 效果很好。

因此,使用 Excel 的代码如下所示:

int hWnd = xl.Application.Hwnd;
// ...
// here we try to close Excel as usual, with xl.Quit(),
// Marshal.FinalReleaseComObject(xl) and so on
// ...
TryKillProcessByMainWindowHwnd(hWnd);

瞧! Excel 已终止! :)

好的,让我们回到第二个解决方案,就像我在文章开头所说的那样。 第二种解决方案是调用 GC.Collect()和 GC.WaitForPendingFinalizers()。是的,它们确实有效,但是您在这里需要小心!
许多人说(我说过),调用 GC.Collect()并没有帮助。但这无济于事的原因是,如果仍然有对 COM 对象的引用! GC.Collect()无效的最常见原因之一是在 Debug 模式下运行项目。在调试模式下,不再真正引用的对象将在方法结束之前不会被垃圾回收。
因此,如果您尝试使用 GC.Collect()和 GC.WaitForPendingFinalizers()并没有帮助,请尝试执行以下操作:

1)尝试以发布模式运行项目,并检查 Excel 是否正确关闭

2)将使用 Excel 的方法包装在单独的方法中。因此,而不是像这样:

void GenerateWorkbook(...)
{
  ApplicationClass xl;
  Workbook xlWB;
  try
  {
    xl = ...
    xlWB = xl.Workbooks.Add(...);
    ...
  }
  finally
  {
    ...
    Marshal.ReleaseComObject(xlWB)
    ...
    GC.Collect();
    GC.WaitForPendingFinalizers();
  }
}

你写:

void GenerateWorkbook(...)
{
  try
  {
    GenerateWorkbookInternal(...);
  }
  finally
  {
    GC.Collect();
    GC.WaitForPendingFinalizers();
  }
}

private void GenerateWorkbookInternal(...)
{
  ApplicationClass xl;
  Workbook xlWB;
  try
  {
    xl = ...
    xlWB = xl.Workbooks.Add(...);
    ...
  }
  finally
  {
    ...
    Marshal.ReleaseComObject(xlWB)
    ...
  }
}

现在,Excel 将关闭 =)