【问题标题】:Excel VBA List Files Grouped by FolderExcel VBA 列出按文件夹分组的文件
【发布时间】:2019-10-17 02:22:13
【问题描述】:

以下宏在按文件夹对文件进行分组方面做得很好,但是,当它在包含数万个文件的目录(如“我的图片”)上运行时非常慢。有什么办法可以加快速度吗?

Option Explicit
Sub cmdList()
Dim objShell    As Object
Dim objFolder   As Object
Dim sPath       As String
Dim fOut        As Variant
Dim r           As Integer
Dim listRng     As Range
Dim cell        As Range
Dim i           As Integer
Dim j           As Integer

Set objShell = CreateObject("Shell.Application")
Set objFolder = objShell.BrowseForFolder(0, "Select Folder", 0, 17)
If objFolder Is Nothing Then Exit Sub
Application.ScreenUpdating = False
sPath = objFolder.self.Path
Set objFolder = Nothing: Set objShell = Nothing

r = 6: Range(r & ":" & Rows.Count).Delete
Cells(r - 1, 1) = sPath

fOut = Split(CreateObject("WScript.Shell").exec("cmd /c dir """ & sPath & """ /a:-h-s /b /s").StdOut.ReadAll, vbNewLine)

Cells(r, 1).Resize(UBound(fOut), 1) = WorksheetFunction.Transpose(fOut)

Set listRng = Cells(r, 1).CurrentRegion
listRng.Sort Key1:=Cells(r, 1), Order1:=xlAscending, Header:=xlYes

For i = 1 To listRng.Count
    For j = i + 1 To listRng.Count
        If InStr(listRng.Cells(j), listRng.Cells(i)) Then
            With listRng.Cells(j)
                .Rows.Group
                .IndentLevel = .Rows.OutlineLevel - 1
            End With
        Else
            Exit For
        End If
    Next j
Next i
Application.ScreenUpdating = True
End Sub

我希望实现的输出是这样的:

1级....

2级...

3级...

【问题讨论】:

  • 减速发生在哪个阶段?
  • 看起来你有一个嵌套循环,在相同的范围内。也许您可以重写条件以不必这样做两次。和/或将范围分配给一个数组,并且只在需要的地方触摸行,应该会有所帮助。
  • 减速肯定是在嵌套循环中,罗恩。不过,我不确定如何实施 DarXyde 的建议。
  • 循环 10K 行两次是 100M 的迭代。循环的确切概念是什么?分组的行数是多少?如果它们是 2 并且它们彼此相邻,这可以仅通过 1 个循环来实现,因此线性复杂度是完全可以的。
  • 这就是我在这里的原因,Vityata。不知道如何实现....是否有我应该使用的“初学者;s VBA”线程???

标签: excel vba directory grouping


【解决方案1】:

EDIT:更新答案以将分组更改为above,并更正错误缩进和分组行的条件。

所以这是一个有趣的问题。除了实际的解决方案之外,我还有一些其他提示,我通常会在我的代码中包含这些提示,我也会提到这些提示。我的解决方案也非常快。当我解析 C:\Program Files\ 目录树(18,017 个文件)时,它在 5 秒内运行。

  1. 将变量声明为尽可能接近首次使用它们的位置。这样可以更轻松地确定变量类型和定义,还有助于对代码进行功能分组。
  2. 然后可以将这些逻辑组在功能上隔离为单独的功能和子。这将使您的代码的主要逻辑更容易在一个快速视图中掌握,而不是要求读者(可能在几个月内YOU)重新阅读大型逻辑部分并将其消化为了理解它。

在我的示例代码中,我从三个快速函数开始,它们可以准确地告诉您发生了什么:

Dim rootFolder As String
rootFolder = SelectFolder

Dim pathArray As Variant
pathArray = GetAllFiles(rootFolder)

Dim folderGroups As Object
Set folderGroups = BuildFolderDictionary(pathArray)

第一个功能很简单,并且紧跟您选择根文件夹的方法:

Private Function SelectFolder() As String
    '--- returns the user-selected folder as a string
    Dim objShell As Object
    Dim objFolder As Object
    Set objShell = CreateObject("Shell.Application")
    Set objFolder = objShell.BrowseForFolder(0, "Select Folder", 0, 17)
    If Not objFolder Is Nothing Then
        SelectFolder = objFolder.self.path
    End If
End Function

下一个函数 (GetAllFiles) 也使用您的方法,但不是将结果直接放入工作表中,而是将结果保存在基于内存的数组中(在此答案的底部,我将整个模块包含在用于稍后复制/粘贴的单个代码块):

Private Function GetAllFiles(ByVal rootPath As String, _
                             Optional onlyFolders As Boolean = False) As Variant
    '--- returns a sorted array of all filepaths in the given directory path
    Dim dirOptions As String
    If onlyFolders Then
        dirOptions = """ /a:d-h-s /b /s"
    Else
        dirOptions = """ /a:-h-s /b /s"
    End If
    Dim fOut() As String
    fOut = Split(CreateObject("WScript.Shell").exec("cmd /c dir """ & _
                                                    rootPath & _
                                                    dirOptions).StdOut.ReadAll, _
                 vbNewLine)
    QuickSort fOut, LBound(fOut), UBound(fOut)

    '--- the pathArray skips the first position from the fOut array
    '    because it's always blank, but add the root folder as the first entry
    Dim pathArray As Variant
    ReDim pathArray(1 To UBound(fOut) + 1, 1 To 1)
    pathArray(1, 1) = rootPath
    Dim i As Long
    For i = 2 To UBound(fOut) + 1
        pathArray(i, 1) = fOut(i - 1)
    Next i
    GetAllFiles = pathArray
End Function

基于内存的数组比使用CellsRanges 直接在工作表外工作要快得多。

下一个函数 (BuildFolderDictionary) 使用路径数组并用于构建文件夹层次结构中唯一文件夹的列表 (Dictionary)。在此过程中,它还会创建子文件夹包含的行的“跨度”。这将在以后非常有用。请记住,我们在内存中执行所有这些操作,因此速度很快。

Private Function BuildFolderDictionary(ByRef paths As Variant) As Object
    Dim folders As Object
    Set folders = CreateObject("Scripting.Dictionary")

    '--- scan all paths and create a dictionary of each folder and subfolder
    '    noting which items (rows) map into each dictionary
    Dim i As Long
    For i = LBound(paths) To UBound(paths)
        Dim folder As String
        Dim pos1 As Long
        If Not IsEmpty(paths(i, 1)) Then
            pos1 = InStrRev(paths(i, 1), "\")   'find the last folder separator
            folder = Left$(paths(i, 1), pos1)
            If Not folders.Exists(folder) Then
                '--- new (sub)folder, create a new entry
                folders.Add folder, CStr(i) & ":" & CStr(i)
            Else
                '--- extisting (sub)folder, add to the row range
                Dim rows As String
                rows = folders(folder)
                rows = Left$(rows, InStr(1, rows, ":"))
                rows = rows & CStr(i)
                folders(folder) = rows
            End If
        End If
    Next i

    '--- final fixup: the root folder group should always encompass all
    '    the entries (runs from the second row to the end)...
    '    and we'll also determine the indent level using the first entry
    '    as the baseline (level 1).  stored as "rows,level" e.g. "2:7,1"
    Dim rootSlashes As Long
    rootSlashes = Len(root) - Len(Replace(root, "\", "")) - 1
    folders(root) = "2:" & UBound(paths) & ",1"

    Dim slashes As Long
    folder = folders.Keys
    For i = 1 To UBound(folder)
        slashes = Len(folder(i)) - Len(Replace(folder(i), "\", ""))
        folders(folder(i)) = folders(folder(i)) & "," & _
                                     CStr(slashes - rootSlashes)
    Next i

    For Each folder In folders
        Debug.Print folder & " - " & folders(folder)
    Next folder

    Set BuildFolderDictionary = folders
End Function

最后两部分是将内存数组(文件路径)复制到工作表...

    Const START_ROW As Long = 6
    Dim pathRange As Range
    Set pathRange = Sheet1.Range("A" & START_ROW).Resize(UBound(pathArray) + 1, 1)
    pathRange = pathArray

然后应用行的缩进和分组。我们正在使用我们创建的文件夹组字典,其中已经为我们很好地定义了所有子文件夹行...

    Const MAX_GROUP_LEVEL As Long = 8
    Dim rowGroup As Variant
    Dim level As Long
    Dim folderData As Variant
    Dim theseRows As String
    For Each rowGroup In folderGroups
        folderData = Split(folderGroups(rowGroup), ",")
        theseRows = folderData(0)
        level = folderData(1)
        With pathRange.rows(theseRows)
            .IndentLevel = level
            If level < MAX_GROUP_LEVEL Then
                .Group
            End If
        End With
    Next rowGroup

(我在测试过程中遇到了一个问题,当程序错误的组级别超过 8。所以我在逻辑中设置了一个限制以防止错误。)

所以现在,整个模块在一个块中:

Option Explicit

Public Sub ShowFilePaths()
    Dim rootFolder As String
    rootFolder = SelectFolder
    If rootFolder = vbNullString Then Exit Sub

    '--- quick fixup if needed
    rootFolder = rootFolder & IIf(Right$(rootFolder, 1) = "\", vbNullString, "\")

    Dim pathArray As Variant
    pathArray = GetAllFiles(rootFolder)

    Dim folderGroups As Object
    Set folderGroups = BuildFolderDictionary(rootFolder, pathArray)

    '--- when debugging, this block just clears the worksheet to make it
    '    easier to rerun and test the code
    On Error Resume Next
    With Sheet1
        .UsedRange.ClearOutline
        .UsedRange.Clear
        .Outline.SummaryRow = xlAbove
    End With
    Err.Clear
    On Error GoTo 0

    '--- copy the array to the worksheet
    Const START_ROW As Long = 6
    Dim pathRange As Range
    Set pathRange = Sheet1.Range("A" & START_ROW).Resize(UBound(pathArray), 1)
    pathRange = pathArray

    '------ now apply the indention levels to each line on the sheet
    '       and group the same rows
    Const MAX_GROUP_LEVEL As Long = 8
    Dim rowGroup As Variant
    Dim level As Long
    Dim folderData As Variant
    Dim theseRows As String
    For Each rowGroup In folderGroups
        folderData = Split(folderGroups(rowGroup), ",")
        theseRows = folderData(0)
        level = folderData(1)
        With pathRange.rows(theseRows)
            .IndentLevel = level
            If level < MAX_GROUP_LEVEL Then
                .Group
            End If
        End With
    Next rowGroup
End Sub

Private Function SelectFolder() As String
    '--- returns the user-selected folder as a string
    Dim objShell As Object
    Dim objFolder As Object
    Set objShell = CreateObject("Shell.Application")
    Set objFolder = objShell.BrowseForFolder(0, "Select Folder", 0, 17)
    If Not objFolder Is Nothing Then
        SelectFolder = objFolder.self.Path
    End If
End Function

Private Function GetAllFiles(ByVal rootPath As String, _
                             Optional onlyFolders As Boolean = False) As Variant
    '--- returns a sorted array of all filepaths in the given directory path
    Dim dirOptions As String
    If onlyFolders Then
        dirOptions = """ /a:d-h-s /b /s"
    Else
        dirOptions = """ /a:-h-s /b /s"
    End If
    Dim fOut() As String
    fOut = Split(CreateObject("WScript.Shell").exec("cmd /c dir """ & _
                                                    rootPath & _
                                                    dirOptions).StdOut.ReadAll, _
                 vbNewLine)
    QuickSort fOut, LBound(fOut), UBound(fOut)

    '--- the pathArray skips the first position from the fOut array
    '    because it's always blank, but add the root folder as the first entry
    Dim pathArray As Variant
    ReDim pathArray(1 To UBound(fOut) + 1, 1 To 1)
    pathArray(1, 1) = rootPath
    Dim i As Long
    For i = 2 To UBound(fOut) + 1
        pathArray(i, 1) = fOut(i - 1)
    Next i
    GetAllFiles = pathArray
End Function

Private Function BuildFolderDictionary(ByVal root As String, _
                                       ByRef paths As Variant) As Object
    Dim folders As Object
    Set folders = CreateObject("Scripting.Dictionary")

    '--- scan all paths and create a dictionary of each folder and subfolder
    '    noting which items (rows) map into each dictionary
    Dim folder As Variant
    Dim i As Long
    For i = LBound(paths) To UBound(paths)
        Dim pos1 As Long
        If Not IsEmpty(paths(i, 1)) Then
            pos1 = InStrRev(paths(i, 1), "\")   'find the last folder separator
            folder = Left$(paths(i, 1), pos1)
            If Not folders.Exists(folder) Then
                '--- new (sub)folder, create a new entry
                folders.Add folder, CStr(i) & ":" & CStr(i)
            Else
                '--- extisting (sub)folder, add to the row range
                Dim rows As String
                rows = folders(folder)
                rows = Left$(rows, InStr(1, rows, ":"))
                rows = rows & CStr(i)
                folders(folder) = rows
            End If
        End If
    Next i

    '--- final fixup: the root folder group should always encompass all
    '    the entries (runs from the second row to the end)...
    '    and we'll also determine the indent level using the first entry
    '    as the baseline (level 1).  stored as "rows,level" e.g. "2:7,1"
    Dim rootSlashes As Long
    rootSlashes = Len(root) - Len(Replace(root, "\", "")) - 1
    folders(root) = "2:" & UBound(paths) & ",1"

    Dim slashes As Long
    folder = folders.Keys
    For i = 1 To UBound(folder)
        slashes = Len(folder(i)) - Len(Replace(folder(i), "\", ""))
        folders(folder(i)) = folders(folder(i)) & "," & _
                                     CStr(slashes - rootSlashes)
    Next i

    For Each folder In folders
        Debug.Print folder & " - " & folders(folder)
    Next folder

    Set BuildFolderDictionary = folders
End Function

Private Sub QuickSort(ByRef Field() As String, ByVal LB As Long, ByVal UB As Long)
    '--- from https://stackoverflow.com/a/152333/4717755
    Dim P1 As Long, P2 As Long, Ref As String, TEMP As String

    P1 = LB
    P2 = UB
    Ref = Field((P1 + P2) / 2)

    Do
        Do While (Field(P1) < Ref)
            P1 = P1 + 1
        Loop

        Do While (Field(P2) > Ref)
            P2 = P2 - 1
        Loop

        If P1 <= P2 Then
            TEMP = Field(P1)
            Field(P1) = Field(P2)
            Field(P2) = TEMP

            P1 = P1 + 1
            P2 = P2 - 1
        End If
    Loop Until (P1 > P2)

    If LB < P2 Then Call QuickSort(Field, LB, P2)
    If P1 < UB Then Call QuickSort(Field, P1, UB)
End Sub

【讨论】:

  • PeterT,在“Private Function BuildFolderDictionary(ByRef paths As Variant) As Dictionary”我收到“编译错误:未定义用户定义类型”?我认为我需要实现后期绑定,因为我不希望最终用户必须启用“Microsoft Scripting Runtime”库。
  • 没关系。我已经更新了答案以显示使用后期绑定实现的Dictionary
  • 你对速度的看法是对的,PeterT!快得惊人。但是,输出列表中的最后一组不包括最后一个文件夹中包含的文件夹/文件。缩进级别看起来不错,但最后一个文件夹的内容与第一级分组。
  • 我不确定这是您的文件夹结构还是代码中的工件。当我针对驱动器上的几个不同文件夹层次结构运行它时,我没有看到这个问题。您可以尝试注释掉 QuickSort 调用,看看这是否有任何区别,但除此之外,如果它在代码中,我需要更多信息来帮助解决问题。
  • 实际上,您必须将QuickSort 保留在逻辑中才能正确进行分组。所以我仍然不确定代码是否有问题。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多