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

805

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

while (System.Runtime.InteropServices.Marshal.ReleaseComObject(excelSheet) != 0) { }
excelSheet = null;
GC.Collect();
GC.WaitForPendingFinalizers();
尽管这样做是有效的,但即使在关闭Excel后,Excel.exe进程仍然在后台运行。只有手动关闭应用程序后,它才会释放。我做错了什么,或者有没有其他方法确保interop对象被正确处理?

3
你是想在不关闭应用程序的情况下关闭Excel.exe吗?我不确定我完全理解你的问题。 - Bryant
4
我正在尝试确保未受控制的互操作对象能够得到正确的处理和释放。这样即使用户已经完成了从应用程序创建的Excel电子表格,也不会出现Excel进程挂起的情况。 - HAdes
4
如果你能尝试通过生成XML Excel文件来完成它,否则请考虑使用VSTO托管/非托管内存管理:http://jake.ginnivan.net/vsto-com-interop - Jeremy Thompson
这个能很好地转换成Excel吗? - Paul C
但是我仍然有两个建议针对上面的代码:1)你应该使用Marshal.FinalReleaseComObject(excelSheet)而不是使用这个while循环2)当“excelSheet”是一个局部变量时,行“excelSheet = null;”是不需要的 - jreichert
2
请参考微软的支持文章,其中他们专门提供了解决此问题的方案:http://support.microsoft.com/kb/317109/ - Arjan
43个回答

13

常见的开发者,你们的解决方案都不适用于我,因此我决定实现一个新的技巧

首先,让我们明确“我们的目标是什么?”=>“任务管理器中不再看到Excel对象”。

好吧。让我们开始摧毁它,但要考虑不破坏其他并行运行的Excel实例。

所以,获取当前进程列表并获取EXCEL进程的PID,然后一旦完成工作,我们在进程列表中有了一个新的客人,具有唯一的PID,请找到并仅销毁那个进程。

<请记住,在您的Excel工作期间任何新的Excel进程都将被检测到并销毁> <更好的解决方案是捕获新创建的Excel对象的PID并仅销毁该对象>

Process[] prs = Process.GetProcesses();
List<int> excelPID = new List<int>();
foreach (Process p in prs)
   if (p.ProcessName == "EXCEL")
       excelPID.Add(p.Id);

.... // your job 

prs = Process.GetProcesses();
foreach (Process p in prs)
   if (p.ProcessName == "EXCEL" && !excelPID.Contains(p.Id))
       p.Kill();
这解决了我的问题,希望也能解决你的问题。

2
这有点笨重的方法,类似于我也正在使用的方式:(但需要改变。使用这种方法的问题是经常在打开Excel时,在运行此程序的机器上,左侧栏中会出现警报,显示类似"Excel意外关闭"之类的信息:不太优秀。我认为本主题中较早的建议更值得采纳。 - whytheq
因为这只是一个丑陋的hack而不是解决方案,所以被踩了。 - Anton Shepelev

12

我一直按照VVS的回答中所提供的建议来操作。然而,为了保持这个答案与最新的选项同步,我认为所有未来的项目都将使用“NetOffice”库。

NetOffice是Office PIAs的完全替代品,完全不受版本限制。它是一组托管COM封装器,可以处理在.NET中使用Microsoft Office时常常导致头痛的清理工作。

其中一些关键特点包括:

  • 大部分独立于版本(并且版本相关功能已经有文档说明)
  • 无依赖性
  • 无PIA
  • 无需注册
  • 无需VSTO

我与该项目没有任何关联; 我只是真正欣赏减少了问题的数量。


3
这应该被标记为答案。NetOffice抽象掉了所有这些复杂性。 - C. Augusto Proiete
2
我已经使用NetOffice相当长的时间来编写Excel插件,它运行得非常完美。唯一需要考虑的是,如果你不显式地处理已使用的对象,它会在你退出应用程序时进行处理(因为它无论如何都会跟踪它们)。所以,对于NetOffice来说,经验法则就是始终对每个Excel对象(如单元格、范围或工作表等)使用"using"模式。 - Stas Ivanov

10

此外造成Excel没有关闭的原因之一,即使你在读取和创建对象时都直接引用了每个对象,是由于“For”循环导致的。

For Each objWorkBook As WorkBook in objWorkBooks 'local ref, created from ExcelApp.WorkBooks to avoid the double-dot
   objWorkBook.Close 'or whatever
   FinalReleaseComObject(objWorkBook)
   objWorkBook = Nothing
Next 

'The above does not work, and this is the workaround:

For intCounter As Integer = 1 To mobjExcel_WorkBooks.Count
   Dim objTempWorkBook As Workbook = mobjExcel_WorkBooks.Item(intCounter)
   objTempWorkBook.Saved = True
   objTempWorkBook.Close(False, Type.Missing, Type.Missing)
   FinalReleaseComObject(objTempWorkBook)
   objTempWorkBook = Nothing
Next

另一个原因是,在释放先前的值之前重复使用引用。 - Grimfort
谢谢。我也在使用“for each”,你的解决方案对我有用。 - Chad Braun-Duin
+1 谢谢。另外,注意如果您有一个 Excel 范围的对象,并且您想在对象的生命周期内更改范围,我发现我必须在重新分配之前释放 Com 对象,这使得代码有点不整洁! - AjV Jsy

9

这里的被接受的答案是正确的,但需要注意的是不仅需要避免“两个点”的引用,还需要避免通过索引检索到的对象。您也不需要等到程序完成后才清理这些对象,最好创建函数,在您完成使用它们时立即清理它们。以下是我创建的一个函数,它分配了一个名为xlStyleHeader的样式对象的一些属性:

public Excel.Style xlStyleHeader = null;

private void CreateHeaderStyle()
{
    Excel.Styles xlStyles = null;
    Excel.Font xlFont = null;
    Excel.Interior xlInterior = null;
    Excel.Borders xlBorders = null;
    Excel.Border xlBorderBottom = null;

    try
    {
        xlStyles = xlWorkbook.Styles;
        xlStyleHeader = xlStyles.Add("Header", Type.Missing);

        // Text Format
        xlStyleHeader.NumberFormat = "@";

        // Bold
        xlFont = xlStyleHeader.Font;
        xlFont.Bold = true;

        // Light Gray Cell Color
        xlInterior = xlStyleHeader.Interior;
        xlInterior.Color = 12632256;

        // Medium Bottom border
        xlBorders = xlStyleHeader.Borders;
        xlBorderBottom = xlBorders[Excel.XlBordersIndex.xlEdgeBottom];
        xlBorderBottom.Weight = Excel.XlBorderWeight.xlMedium;
    }
    catch (Exception ex)
    {
        throw ex;
    }
    finally
    {
        Release(xlBorderBottom);
        Release(xlBorders);
        Release(xlInterior);
        Release(xlFont);
        Release(xlStyles);
    }
}

private void Release(object obj)
{
    // Errors are ignored per Microsoft's suggestion for this type of function:
    // http://support.microsoft.com/default.aspx/kb/317109
    try
    {
        System.Runtime.InteropServices.Marshal.ReleaseComObject(obj);
    }
    catch { } 
}

请注意,我必须将 xlBorders [Excel.XlBordersIndex.xlEdgeBottom]设置为一个变量以便于清除(不是因为两个点引用了无需释放的枚举,而是因为我所指的对象实际上是需要释放的边框对象)。
这种情况在标准应用程序中并不是必需的,因为它们非常擅长自我清理。但是在ASP.NET应用程序中,如果你漏掉了其中一个,即使你多次调用垃圾收集器,Excel仍然会在你的服务器上运行。
编写此代码时需要非常注意细节,并在监视任务管理器时执行许多测试执行,但这样做可以避免在代码页面中拼命搜索找到您错过的一项。这在循环中特别重要,因为您需要释放每个对象的EACH INSTANCE,即使在每次循环时使用相同的变量名。

在发行版中,检查是否为空(Joe的回答)。这样就可以避免不必要的空异常。我测试了很多方法,这是唯一有效地释放Excel的方法。 - Gerhard Powell

9

在尝试了以下操作后:

  1. 以相反的顺序释放COM对象
  2. 在结尾处两次添加GC.Collect()GC.WaitForPendingFinalizers()
  3. 不超过两个点
  4. 关闭工作簿并退出应用程序
  5. 以发布模式运行

对我有效的最终解决方案是移动一个组。

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

我们将添加到函数末尾的内容封装成一个包装器,如下所示:
private void FunctionWrapper(string sourcePath, string targetPath)
{
    try
    {
        FunctionThatCallsExcel(sourcePath, targetPath);
    }
    finally
    {
        GC.Collect();
        GC.WaitForPendingFinalizers();
    }
}

我发现我不需要将ComObjects置空,只需要调用Quit() - Close()和FinalReleaseComObject。我简直不敢相信这就是我需要做的一切才能让它正常工作。太好了! - Carol

8

我完全按照这个做了...但是有时候还是会出现1/1000的问题,谁知道为什么。看来得拿出“锤子”了...

在Excel应用程序类被实例化后,我立刻获取了刚创建的Excel进程。

excel = new Microsoft.Office.Interop.Excel.Application();
var process = Process.GetProcessesByName("EXCEL").OrderByDescending(p => p.StartTime).First();

完成所有上述的 COM 清理后,我会确保该进程不再运行。如果它仍在运行,就结束它!

if (!process.HasExited)
   process.Kill();

7

¨°º¤ø„¸ 熟练使用Excel并解决问题 ¸„ø¤º°¨

public class MyExcelInteropClass
{
    Excel.Application xlApp;
    Excel.Workbook xlBook;

    public void dothingswithExcel() 
    {
        try { /* Do stuff manipulating cells sheets and workbooks ... */ }
        catch {}
        finally {KillExcelProcess(xlApp);}
    }

    static void KillExcelProcess(Excel.Application xlApp)
    {
        if (xlApp != null)
        {
            int excelProcessId = 0;
            GetWindowThreadProcessId(xlApp.Hwnd, out excelProcessId);
            Process p = Process.GetProcessById(excelProcessId);
            p.Kill();
            xlApp = null;
        }
    }

    [DllImport("user32.dll")]
    static extern int GetWindowThreadProcessId(int hWnd, out int lpdwProcessId);
}

6
“不要在COM对象中使用两个点”是一个很好的经验法则,以避免COM引用泄漏,但Excel PIA可能会以比表面上更多的方式导致泄漏。
其中一种方式是订阅任何Excel对象模型的COM对象公开的事件。
例如,订阅Application类的WorkbookOpen事件。
关于COM事件的一些理论
COM类通过回调接口公开一组事件。为了订阅事件,客户端代码可以简单地注册实现回调接口的对象,COM类将响应特定事件并调用其方法。由于回调接口是COM接口,因此实现对象有责任对其接收到的任何COM对象(作为参数)的引用计数进行减少,以用于任何事件处理程序。
Excel PIA如何公开COM事件
Excel PIA将Excel Application类的COM事件公开为常规的.NET事件。每当客户端代码订阅.NET事件(重点在于'a')时,PIA将创建实现回调接口的类的实例,并向Excel注册它。
因此,针对不同的.NET代码订阅请求,会有多个回调对象向Excel注册。每个事件订阅对应一个回调对象。
事件处理的回调接口意味着PIA必须订阅每个.NET事件订阅请求的所有接口事件,不能挑选感兴趣的。当接收到事件回调时,回调对象检查关联的.NET事件处理程序是否对当前事件感兴趣,然后调用处理程序或者静默忽略回调。
对COM实例引用计数的影响
所有这些回调对象不会减少它们接收到的任何COM对象(作为参数)的引用计数,即使是那些被静默忽略的回调方法。它们完全依赖于CLR垃圾回收器来释放COM对象。
由于GC运行是非确定性的,这可能会导致Excel进程持续时间比预期更长,并创建“内存泄漏”的印象。
解决方案
目前唯一的解决方案是避免使用PIA的COM类事件提供程序,编写自己的事件提供程序以确定性地释放COM对象。
对于Application类,可以通过实现AppEvents接口并使用IConnectionPointContainer interface将其注册到Excel中来完成。Application类(以及所有使用回调机制公开事件的COM对象)都实现了IConnectionPointContainer接口。

6

请注意,Excel非常敏感于您正在运行的文化环境。

在调用Excel函数之前,您可能需要将文化环境设置为EN-US。这不适用于所有函数,但适用于其中一些。

    CultureInfo en_US = new System.Globalization.CultureInfo("en-US"); 
    System.Threading.Thread.CurrentThread.CurrentCulture = en_US;
    string filePathLocal = _applicationObject.ActiveWorkbook.Path;
    System.Threading.Thread.CurrentThread.CurrentCulture = orgCulture;

即使您正在使用VSTO,也适用于此操作。
详情请参阅:http://support.microsoft.com/default.aspx?scid=kb;en-us;Q320369

5

一篇关于释放COM对象的好文章是2.5 释放COM对象(MSDN)。

我推荐的方法是,如果Excel.Interop引用不是局部变量,则将其设置为null,然后调用GC.Collect()GC.WaitForPendingFinalizers()两次。局部作用域的Interop变量将自动处理。

这样就不需要为每个COM对象保留一个命名引用了。

下面是一篇文章中的例子:

public class Test {

    // These instance variables must be nulled or Excel will not quit
    private Excel.Application xl;
    private Excel.Workbook book;

    public void DoSomething()
    {
        xl = new Excel.Application();
        xl.Visible = true;
        book = xl.Workbooks.Add(Type.Missing);

        // These variables are locally scoped, so we need not worry about them.
        // Notice I don't care about using two dots.
        Excel.Range rng = book.Worksheets[1].UsedRange;
    }

    public void CleanUp()
    {
        book = null;
        xl.Quit();
        xl = null;

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

以下文字摘自文章:

在几乎所有情况下,将RCW引用设为null并强制进行垃圾回收即可进行清理。如果您还调用GC.WaitForPendingFinalizers,则垃圾回收将尽可能地确定性。也就是说,在第二次调用WaitForPendingFinalizers后返回的时候,您就可以相当确定对象已经被清理了。作为替代方案,您可以使用Marshal.ReleaseComObject。但需要注意的是,您几乎不太可能需要使用此方法。


在我的情况下,即使是本地变量也需要在 finally {} 块中设置为 null,以防 try{} 块中抛出异常。 - emanresu

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接