edisonx 發表於 2012-11-2 22:57

深入列舉(搜尋)資料夾 / 檔案 - part 1

本帖最後由 rictirse 於 2012-11-5 23:23 編輯

這問題大概只存在於中小型專案以上,雖用 AutoIt 完成,實際費時不少,但這問題本身就必須花很多時間,所以大概不會有人繼續在意效能上的問題。
首先必須先定義這問題之「列舉」與「深入列舉」 (恕小弟避開了 DFS / BFS 之定義說明 )。


「列舉」指的是,給定一個目錄 (假設 C:\),會列舉出 C:\ 底下所有指標之檔案(或資料夾),
但注意的是,如果 C:\ 底下還有其它資料夾,如 C:\Folder1、C:\Folder2,便不再進去找其他檔案。
「深入列舉」和上面說的「列舉」,只差在會深入裡面的 C:\Folder1、C:\Folder2 做深度搜尋。

這篇文章主要分三大部份探討。

1. 列舉目前之硬碟 / 磁碟槽 : 主要摘自 help 範例加以變化

2. 如何列舉某個目錄下之目錄 / 檔案: 主要摘自 help 範例加以變化

3. 如何深入列舉某個目錄下之目錄 / 檔案: 沒學過遞迴 (recursive) 可能不知該如何下手,本文將予以範例

4. 建立比 _FileToArray 方便使用之副函式 : 為了日後開發方便,包裝成較易呼叫、使用之函式。

5. 進階議題: 由於筆者時間有限,本打算寫一份完整的 ScanFolder.au3,但開發時間估計不少 (因估計可以包的函式大概十項左右),在此紀錄一些進階議題,對於進階議題有興趣的可以先自行實作,若做不出來,小弟有時間的話會試試看。


因本文實在太長,part 2 部份是有機會、有空的話,會把自己寫的,易於維護、利於開發的 header 補足放上。

小弟學淺,對本篇有經驗、意見或補充,歡迎指教,在此先行感激。


1. 列舉目前之硬碟 / 磁碟槽


最舉目前之硬碟 / 磁碟槽只有一個指令:DriveGetDrive ( "type" ),
其中 "type" 包含了 "ALL", "CDROM", "REMOVABLE", "FIXED", "NETWORK", "RAMDISK","UNKNOWN"。

以下範例是列舉目前電腦看得到之磁碟槽 (摘自 help)。
Local $aArray = DriveGetDrive("ALL")
If @error Then
    ; An error occurred when retrieving the drives.
    MsgBox(4096, "DriveGetDrive", "It appears an error occurred.")
Else
    For $i = 1 To $aArray
      ; Show all the drives found and convert the drive letter to uppercase.
      MsgBox(4096, "DriveGetDrive", "Drive " & $i & "/" & $aArray & ":" & @CRLF & StringUpper($aArray[$i]))
    Next
EndIf
注意 "type" 非常重要,蠻多人直接使用 "ALL" 去取得磁碟槽,但如果要做「深入列舉」+ 「寫入」時,
事實上 "CDROM" 是可以直接略過不記的,

若需要用的可能只有 "REMOVABLE"、 "FIXED",其它的都不想進行輸出/處理,該怎辦?

基本上目前 AutoIt 還沒辦法做到這地步,必須做一點技巧性修改。

第一種方法是,先做 DriverGetDriver("ALL"),再對每個取得之 driver 做 DriveGetType,判斷是何種 type。
Local $aArray = DriveGetDrive("ALL")
If @error Then
      MsgBox(0, "Error", "DriveGetDrive Error")
Else
      For $i = 1 To $aArray
                ; MsgBox(0, "", StringFormat("Drive : ""%s""\ Type : %s", StringUpper($aArray[$i]), DriveGetType($aArray[$i])))
                $Type = DriveGetType($aArray[$i])
                If $Type="Fixed" Then
                        MsgBox(0, "", StringFormat("Get Fixed : %s", $aArray[$i]))
                ElseIf $Type = "Removable" Then
                        MsgBox(0, "", StringFormat("Get Removable : %s", $aArray[$i]))
                EndIf
      Next
EndIf
第二種方法是,分兩次列舉,也就是先做 DriveGetDrive("REMOVEABLE"),再做 DriveGetDrive("FIXED")。
Dim $Type = [ "FIXED", "REMOVABLE"]
For $i = 0 To UBound($Type)-1

      Local $aArray = DriveGetDrive($Type[$i])
      MsgBox(0, "", "Driver Type : " & $Type[$i])

      If @error Then
            MsgBox(4096, "DriveGetDrive", "It appears an error occurred.")
      Else
            For $j = 1 To $aArray
                   MsgBox(4096, "DriveGetDrive", "Drive " & $j & "/" & $aArray & ":" & @CRLF & StringUpper($aArray[$j]))
            Next
      EndIf
Next
個人較偏向方法 2,理由是,一定會先舉出 "FIXED",再取出 "REMOVABLE",且要增加 / 減少 Type 時,直接從 $Type 做修改即可。



2. 如何列舉某個目錄下之所有資料夾/檔案

這裡講的是,不會往子資料夾裡繼續找,要往子資料夾裡繼續找在下個 section 會提。

要列舉某個目錄下之所有資料夾 / 檔案並不困難,其中 AutoIt 之 File.au3 已經有提供一份函式:

_FileListToArray($sPath [, $sFilter = "*" [, $iFlag = 0]])

$sPath 是欲列舉之路徑,$sFilter 是欲列舉之檔名型態,可用 * ? 替之;
關鍵在於 $iFlag,0 代表同時傳回檔案與資料夾,1代表只傳回檔案,2代表只傳回資料夾。
要注意的是 Return Value,正常成功時,ret 是傳回數量,ret...ret 是內容;
失敗的話會傳回0,想知道原因必須去看 @error,@error=4 代表沒檔案,@error=1 代表找不到資料夾

一份 demo code 如下 (摘自 help)
#include <File.au3>
#include <Array.au3>

Local $FileList = _FileListToArray(@DesktopDir)
If @error = 1 Then
    MsgBox(0, "", "No Folders Found.")
    Exit
EndIf
If @error = 4 Then
    MsgBox(0, "", "No Files Found.")
    Exit
EndIf
_ArrayDisplay($FileList, "$FileList")
但事實上打開 File.au3 ,查看 _FileListToArray,也只是把FileFindFirstFile/ FileFindNextFile 這兩個函式封裝起來,( 這兩個函式推斷應該是接間調用 Win32 API : FindFirstFile 與 FindNextFile而來 ),換句話說,如果列舉之 Filter 為 *.* 時,其實自己直接用 FileFindFirstFile / FileFindNextFile 去做效能還比較高!

假設要列舉 D:\Code Document 底下所有檔案 \ 資料夾,原始碼如下 (修自 Help)
; Shows the filenames of all files in the current directory.
Local $Filter = "D:\Code Document\*.*" ; 注意,不可以只寫 D:\Code Document!
Local $search = FileFindFirstFile($Filter)
Local $Output, $i = 1

; Check if the search was successful
If $search = -1 Then
    MsgBox(0, "Error", "No files/directories matched the search pattern")
    Exit
EndIf

While 1
    Local $file = FileFindNextFile($search)
    If @error Then ExitLoop
    $Output = $Output & StringFormat("%02d : %s\n", $i, $file)
    $i+=1
    if Mod($i , 30) = 0 Then
            MsgBox(0, $Filter, $Output)
            $Output = "";
    EndIf
WEnd
MsgBox(0, $Filter, $Output)
FileClose($search)
有興趣可把 $Filter 改成自己電腦有的部份跑跑看。

另外,有些 coder 在判斷「是不是檔案」的時候,是判斷「有沒有副檔名」,
這方法蠻糟的,因 C:\Tmp ,代表的意義可能是 C 槽底下的 Tmp 資料夾,
也可能是 C 槽底下的 Tmp 檔案 (沒有副檔名的檔案)

關鍵只在於必須用到另一個副函式:FileGetAttrib ( "filename" ),有興趣可先看 help,
以下 code 為一副函式,單純判斷是否為資料夾

; -----------------------------------------------------
; function : IsFolder
; param   : $File , file or path
; return   : if $File doesn't exist, return -1
;               : if $File is a folder, return 1
;               : if $File is not a folder, return 0
; -----------------------------------------------------

Func IsFolder(const $File)
      $Attrib = FileGetAttrib($File)
      If @error=1 Then ; File Error
                return -1
      ElseIfStringInStr($Attrib, "D") Then ; Folder
                return 1
      Else
                return 0
      EndIf
EndFunc

這個範例大概沒什麼人有興趣,一般搜尋大多是用 *.txt / *.doc / *.jpg ... 等等,
若 Filter 放的是 "*.*" 沒什麼稀奇的,若只想找出 *.rmvb 也沒什麼好稀奇的,
但若是想找出某些特定附檔名(如所有影音檔),如*.rmvb / *.mp3 / *.mp4 ... 怎麼做?

很遺憾,目前沒辦法只列舉特定 filter,作法不外乎兩種

(1) 先將 filter 設成 *.*, 再依序判斷副檔名是否為 rmvb / mp3 / mp4。
(2) filter 設三次,*.rmvb / *.mp3 / *.mp4,依序進去做列舉,所以要列舉三次。

這兩種方法哪種效率較高?若沒做深層列舉時,方法 (1) 會較高;若做深層列舉時,答案是不一定,和資料量、資料夾深度有關,但分析有點複雜,這裡就不再提。

3. 如何深入列舉某個目錄下之目錄 / 檔案

深層列舉其實也很簡單,但用到了 recursive 概念,它的概念大概是這樣。
FuncScanFolder( $Path )
   ; 先對 $Path 做列舉
   ; 當用 FileFindNextFile / FileFindFirstFile 取得之 $file 為檔案時,做輸出或處理。
   ; 當用 FileFindNextFile / FileFindFirstFile 取得之 $file 為路徑時,再做 ScanfFolder($Path & "\" & $file)
EndFunc
去除防呆裝置,完整之程式碼如下
; ----------------------------------------------
Dim $Filter = "D:" ; 確認 $Filter 必定存在,最後不要加\ ,這裡不防呆
Dim $FileCount
Dim $FolderCount
Dim $Begin, $Diff

; ScanFolder 1
$FileCount = 0
$FolderCount = 0
$Begin = TimerInit()
ScanFolder1($Filter)
$Diff   = TimerDiff($Begin)
MsgBox(0, "ScanFolder 1",_
    StringFormat("FileCount : %d\nFolderCount : %d\n %lf ms", $FileCount, $FolderCount, $Diff))

; ----------------------------------------------
;深入列舉 Folder- 1
Func ScanFolder1(const $Path)
      Local $search = FileFindFirstFile($Path & "\*.*")
      Local $FullPath
;      If $search = -1 Then Return; 取消防呆

      while 1
                Local $file =FileFindNextFile($search)
                If @error Then ExitLoop
                $FullPath = $Path & "\" & $file

                ;ToolTip($FullPath, 0, 0) ; (A) 這裡可以做任何事

                If StringInStr(FileGetAttrib ($FullPath), "D") Then ; 找到的是資料夾
                        ScanFolder1($FullPath) ; 繼續搜尋資料夾
                        $FolderCount+=1
                Else
                        $FileCount+=1
                EndIf
      WEnd
      FileClose($search)
EndFunc

上面註解 (A) 部份是做輸出,可再做一些額外之判斷進行處理,由於這裡有拿來做測時,所以不做任何事,就空跑做列舉。
下面是筆者環境執行之結果


FileCount : 167444
FolderCount : 11409
Elaspe : 6799.538774 ms

那用 _FileToArray 可以做到深度列舉嗎?答案是可以的。假設函式名稱為 ScanFolder($Path),步驟如下
首先,先對 $Path 做 _FileToArray,只列舉出檔案 (出來的檔案直接做處理);
接著再做一次 _FileToArray,只列舉出資料夾,再對每個資料夾做 ScanFolder($Path) 動作,原始碼如下參考。
#include <File.au3>
; ----------------------------------------------
Dim $Filter = "D:" ; 確認 $Filter 必定存在,最後不要加\ ,這裡不防呆
Dim $FileCount
Dim $FolderCount
Dim $Begin, $Diff

; ScanFolder 2
$FileCount = 0
$FolderCount = 0
$Begin = TimerInit()
ScanFolder2($Filter)
$Diff   = TimerDiff($Begin)
MsgBox(0, "ScanFolder 2",_
    StringFormat("FileCount : %d\nFolderCount : %d\n %lf ms", $FileCount, $FolderCount, $Diff))

; ----------------------------------------------
;深入列舉 Folder - 2
Func ScanFolder2(const $Path)

      Local $FileList , $FolderList, $FullPath

      ; 先只列舉檔案
      $FileList = _FileListToArray($Path, "*", 1) ; Only File List
      If $FileList <> 0 Then ; 有找到檔案
                For $i = 1 To $FileList
                        ; ToolTip($FileList[$i], 0, 0) ; (A) 這裡可以做任何事
                Next
                $FileCount+=$FileList
      EndIf

      ; 再列舉資料夾
      $FolderList = _FileListToArray($Path, "*", 2) ; Only Folder List
      If $FolderList <> 0 Then ; 有找到資料夾
                For $i = 1 To $FolderList
                        $FullPath = $Path & "\" & $FolderList[$i]
                        ;ToolTip($FullPath, 0, 0); (A) 對資料夾做任何事
                        ScanFolder2($FullPath) ; 再深入掃描
                Next
                $FolderCount+=$FolderList
      EndIf
EndFunc
結果如下


FileCount : 167444
FolderCount : 11409
Elaspe : 5416.371854 ms

對這結果應不需太過驚,哪種方法速度較快必須視資料夾的深度與結果而定,結果沒有一定。
至於其它寫法這裡就不放上。

4. 建立比 _FileToArray 方便使用之副函式

一般 _FileToArray 是將取得的結果丟到陣列裡面去,然後再由 Coder 去取得陣列裡之內容進行處理。
問題便出在這樣寫下來多了一層陣列的迴圈,維護也不是件容易的事,於是改用 Call 方式進行。


以下為 _XFileFolder.au3,建立後放入 C:\Program Files\AutoIt3\Include 裡
(懂得 #include 原理可不用強制放入,不懂的話還是直接放進去吧...)
; ================================================
;
;   檔      名 : _XFileFolder.au3
;   作      者 : EdisonX / Edison.Shih
;   修 改 日期 : 20120102
;   版      本 : 1.0
;
; ================================================

#include-once

const $SCAN_FOLDER = 1
const $SCAN_FILES      = 2
; ================================================
;函式名稱 : _ScanfFolderSimple
; 函式說明 : 單層掃描資料夾/檔案
;參數 1    :const $sPath Filter, 掃描資料夾路徑 < 必填 , 最後不可有反斜線>
;參數 2   :   const $sFilter, <必填>
;參數 3   :   const $Func , 針對每個掃到之資料夾/檔案之處理函式 < 必填 >
;參數 4 :   const $scan_flag , <選填>
;                   $SCAN_FOLDER 代表只掃資料夾 ,
;                   $SCAN_FILES 代表只掃檔案,
;                   相加代表全都掃
; 傳回值 :
; ================================================
Func_ScanFolderSimple(const $sPath, const $sFilter , const $Func , _
                                                const$scan_flag=3)

      Local $FolderFlag , $FileFlag
      Local $FullPath , $search, $File

      If $scan_flag == 3 OR $scan_flag == 1 Then $FolderFlag=1 ; 有要找資料夾
      If $scan_flag == 2 OR $scan_flag == 1 Then $FileFlag=1      ; 有要找檔案

      $search = FileFindFirstFile($sPath & "\" & $sFilter)
      If $search = -1 Then Return   ; 找不到東西

      while 1
                $File = FileFindNextFile($search)
                If @error Then ExitLoop

                $FullPath = $sPath & "\" & $File
                If StringInStr(FileGetAttrib ($FullPath), "D") And $FolderFlag Then ; 找到的是資料夾
                        Call($Func, $FullPath)
                EndIf

                If StringInStr(FileGetAttrib ($FullPath), "D")=0 And $FileFlag Then ; 找到的是檔案
                        Call($Func, $FullPath)
                EndIf
      WEnd
      FileClose($search)
EndFunc
這份 header 裡使用的 function 原理在上面都有提過,包成這樣有什麼好處?我們直接寫個 demo 吧...
< 記得把這份 au3 放到 C:\Program Files\AutoIt3\Include >
#include <_XFileFolder.au3>

_ScanFolderSimple("D:","*.*", "ProcessFile", $SCAN_FILES)

Func ProcessFile(const $sFile)
      ToolTip($sFile, 10, 10)
      Sleep(300)
EndFunc
這樣是否簡潔多了?


5. 進階議題

上面的 _ScanFolderSimple,只有找單層而已,沒有再做深入列舉。要改也不難,有興趣可自行實作。

另外針對這問題,筆者所知至少還有三種設計模式,關鍵考量都是在於三點:好不好呼叫、方不方便維護、效率高不高。

由於筆者認為使用 AU 主要目的、最大優點應是將「開發時間」縮到最小,故給了一份 _ScanFolderSimple 架構參考,

這份架構會了之後,可以再寫一份 _ScanFolderDeep,做深入掃描,它的缺點是用了間接之函式呼叫,效率會差一點;

還有一方研究是將深入掃描之遞迴拿掉 (效率將提昇),這種研究對初學者較吃力,這裡就不附上與探討了。


本文敘述至此 (其實筆者也編了三天的文章了),常用的掃描議題應已敘述,其餘可加點巧思,加以改進。若對本文所有疑惑或建議,歡迎提出,共同討論。

以上。by EdisonX
頁: [1]
查看完整版本: 深入列舉(搜尋)資料夾 / 檔案 - part 1