C# Excel 循环遍历单元格时互操作性变慢

C# Excel Interop Slow when looping through cells

我试图从 C# 中的 Excel 文档中提取所有文本数据,但遇到了性能问题。在下面的代码中,我打开工作簿,遍历所有工作表,并遍历使用范围内的所有单元格,同时从每个单元格中提取文本。问题是,这需要 14 秒才能执行。

public class ExcelFile
{
    public string Path = @"C:\test.xlsx";
    private Excel.Application xl = new Excel.Application();
    private Excel.Workbook WB;
    public string FullText;
    private Excel.Range rng;
    private Dictionary<string, string> Variables;
    public ExcelFile()
    {
        WB = xl.Workbooks.Open(Path);
        xl.Visible = true;
        foreach (Excel.Worksheet CurrentWS in WB.Worksheets)
        {
            rng = CurrentWS.UsedRange;
            for (int i = 1; i < rng.Count; i++)
            { FullText += rng.Cells[i].Value; }
        }
        WB.Close(false);
        xl.Quit();
    }
}

而在 VBA 中,我会做这样的事情,大约需要 1 秒:

Sub run()
    Dim strText As String
    For Each ws In ActiveWorkbook.Sheets
        For Each c In ws.UsedRange
            strText = strText & c.Text
        Next c
    Next ws
End Sub

或者,甚至更快(不到 1 秒):

Sub RunFast()
    Dim strText As String
    Dim varCells As Variant
    For Each ws In ActiveWorkbook.Sheets
        varCells = ws.UsedRange
        For i = 1 To UBound(varCells, 1)
            For j = 1 To UBound(varCells, 2)
                strText = strText & CStr(varCells(i, j))
            Next j
        Next i
    Next ws
End Sub

也许在 C# 的 for 循环中发生了一些我不知道的事情?是否可以将范围加载到数组类型对象中(如我上一个示例中所示)以允许仅对值而不是单元格对象进行迭代?

可以加快速度的一件事是在前一个字符串上使用 StringBuilder 而不是 +=。字符串在 C# 中是不可变的,因此您在创建最终字符串的过程中创建了大量额外的字符串。

此外,您可以改进对行、列位置的循环而不是对索引进行循环的性能。

这是使用 StringBuilder 和行、列位置循环更改的代码:

public class ExcelFile
{
    public string Path = @"C:\test.xlsx";
    private Excel.Application xl = new Excel.Application();
    private Excel.Workbook WB;
    public string FullText;
    private Excel.Range rng;
    private Dictionary<string, string> Variables;
    public ExcelFile()
    {
        StringBuilder sb = new StringBuilder();
        WB = xl.Workbooks.Open(Path);
        xl.Visible = true;

        foreach (Excel.Worksheet CurrentWS in WB.Worksheets)
        {
            rng = CurrentWS.UsedRange;
            for (int i = 1; i <= rng.Rows.Count; i++)
            {
                for (int j = 1; j <=  rng.Columns.Count; j++)
                {
                    sb.append(rng.Cells[i, j].Value); 
                }
            }
        }
        FullText = sb.ToString();
        WB.Close(false);
        xl.Quit();
    }
}

我用这个功能。循环仅用于从索引 0 开始转换为数组,主要工作在 object[,] tmp = range.Value.

中完成
public object[,] GetTable(int row, int col, int width, int height)
{
    object[,] arr = new object[height, width];

    Range c1 = (Range)Worksheet.Cells[row + 1, col + 1];
    Range c2 = (Range)Worksheet.Cells[row + height, col + width];
    Range range = Worksheet.get_Range(c1, c2);

    object[,] tmp = range.Value;

    for (int i = 0; i < height; ++i)
    {
        for (int j = 0; j < width; ++j)
        {
            arr[i, j] = tmp[i + tmp.GetLowerBound(0), j + tmp.GetLowerBound(1)];
        }
    }                 

    return arr;
}

我很同情你pwwolff。遍历 Excel 个单元格可能会很昂贵。 Antonio 和 Max 都是正确的,但 John Wu 的回答很好地总结了这一点。使用字符串生成器可能会加快速度,并且从使用范围恕我直言制作对象数组的速度与您将要使用互操作的速度差不多。我知道还有其他第三方库可能表现更好。如果使用互操作的文件很大,循环遍历每个单元格将花费不可接受的时间。

在下面的测试中,我使用了一个带有单个 sheet 的工作簿,其中 sheet 有 11 列和 100 行使用范围数据。使用对象数组实现,这需要一秒钟多一点的时间。 735 行大约需要 40 秒。

我在一个带有多行文本框的表单上放置了 3 个按钮。第一个按钮使用您发布的代码。第二个按钮将范围从循环中取出。第三个按钮使用对象数组方法。每一个都比另一个有显着的性能改进。我在表单上使用了一个文本框来输出数据,你可以直接使用字符串,但如果你必须有一个大字符串,使用字符串生成器会更好。

同样,如果文件很大,您可能需要考虑另一种实现方式。希望这有帮助。

private void button1_Click(object sender, EventArgs e) {
  Stopwatch sw = new Stopwatch();
  MessageBox.Show("Start DoExcel...");
  sw.Start();
  DoExcel();
  sw.Stop();
  MessageBox.Show("End DoExcel...Took: " + sw.Elapsed.Seconds + " seconds and " + sw.Elapsed.Milliseconds + " Milliseconds");
 }

private void button2_Click(object sender, EventArgs e) {
  MessageBox.Show("Start DoExcel2...");
  Stopwatch sw = new Stopwatch();
  sw.Start();
  DoExcel2();
  sw.Stop();
  MessageBox.Show("End DoExcel2...Took: " + sw.Elapsed.Seconds + " seconds and " + sw.Elapsed.Milliseconds + " Milliseconds");
}

private void button3_Click(object sender, EventArgs e) {
  MessageBox.Show("Start DoExcel3...");
  Stopwatch sw = new Stopwatch();
  sw.Start();
  DoExcel3();
  sw.Stop();
  MessageBox.Show("End DoExcel3...Took: " + sw.Elapsed.Seconds + " seconds and " + sw.Elapsed.Milliseconds + " Milliseconds");
}

// object[,] array implementation
private void DoExcel3() {
  textBox1.Text = "";
  string Path = @"D:\Test\Book1 - Copy.xls";
  Excel.Application xl = new Excel.Application();
  Excel.Workbook WB;
  Excel.Range rng;

  WB = xl.Workbooks.Open(Path);
  xl.Visible = true;
  int totalRows = 0;
  int totalCols = 0;
  foreach (Excel.Worksheet CurrentWS in WB.Worksheets) {
    rng = CurrentWS.UsedRange;
    totalCols = rng.Columns.Count;
    totalRows = rng.Rows.Count;
    object[,] objectArray = (object[,])rng.Cells.Value;
    for (int row = 1; row < totalRows; row++) {
      for (int col = 1; col < totalCols; col++) {
        if (objectArray[row, col] != null)
          textBox1.Text += objectArray[row,col].ToString();
      }
      textBox1.Text += Environment.NewLine;
    }
  }
  WB.Close(false);
  xl.Quit();
  Marshal.ReleaseComObject(WB);
  Marshal.ReleaseComObject(xl);
}

// Range taken out of loops
private void DoExcel2() {
  textBox1.Text = "";
  string Path = @"D:\Test\Book1 - Copy.xls";
  Excel.Application xl = new Excel.Application();
  Excel.Workbook WB;
  Excel.Range rng;

  WB = xl.Workbooks.Open(Path);
  xl.Visible = true;
  int totalRows = 0;
  int totalCols = 0;
  foreach (Excel.Worksheet CurrentWS in WB.Worksheets) {
    rng = CurrentWS.UsedRange;
    totalCols = rng.Columns.Count;
    totalRows = rng.Rows.Count;
    for (int row = 1; row < totalRows; row++) {
      for (int col = 1; col < totalCols; col++) {
        textBox1.Text += rng.Rows[row].Cells[col].Value;
      }
      textBox1.Text += Environment.NewLine;
    }
  }
  WB.Close(false);
  xl.Quit();
  Marshal.ReleaseComObject(WB);
  Marshal.ReleaseComObject(xl);
}

// original posted code
private void DoExcel() {
  textBox1.Text = "";
  string Path = @"D:\Test\Book1 - Copy.xls";
  Excel.Application xl = new Excel.Application();
  Excel.Workbook WB;
  Excel.Range rng;

  WB = xl.Workbooks.Open(Path);
  xl.Visible = true;
  foreach (Excel.Worksheet CurrentWS in WB.Worksheets) {
    rng = CurrentWS.UsedRange;
    for (int i = 1; i < rng.Count; i++) {
      textBox1.Text += rng.Cells[i].Value;
    }
  }
  WB.Close(false);
  xl.Quit();
  Marshal.ReleaseComObject(WB);
  Marshal.ReleaseComObject(xl);
}

Excel 和 C# 运行 完全不同的环境。 C# 运行s 在 .NET 框架中使用托管内存,而 Excel 是本机 C++ 应用程序,运行s 在非托管内存中。在这两者之间转换数据(称为 "marshaling" 的过程)在性能方面非常昂贵。

调整您的代码无济于事。与编组过程相比,for 循环、字符串构造等都快得惊人。获得更好性能的唯一方法是减少必须跨越进程间边界的行程次数。逐个单元格地提取数据永远不会让您获得想要的性能。

这里有几个选项:

  1. 在 VBA 中编写一个子程序或函数来执行您想要的所有操作,然后通过互操作调用该子程序或函数。 Walkthrough

  2. 使用互操作将工作表保存到 CSV 格式的临时文件中,然后使用 C# 打开该文件。您将需要遍历并解析文件以将其转化为有用的数据结构,但此循环会进行得更快。

  3. 使用 interop 将单元格区域保存到剪贴板,然后使用 C# 直接读取剪贴板。