从零做一个 C 盘大文件夹清理器
—— 写给 Python 小白的完整实战教程
写在前面:这篇教程是怎么组织的
这篇文章不是"给你一份代码让你抄",而是把当时写代码时脑子里想的东西摊开给你看:
为什么这么设计、先写哪一块、哪里踩了坑、坑是怎么被找出来的、修完怎么验证。
每一章都是同一个节奏:
这一步要解决的问题 → 我的思路 → 真实代码(带注释) → 代码逻辑讲解 → 验证结果所有代码都取自真实跑起来的文件,不是示意用的伪代码。你照着做,能得到和我一样的结果。
阅读建议:如果你是完全的小白,按顺序读;如果你只想抄代码,跳到每章的"完整代码"小节。
第 0 章 先想清楚要做什么
0.1 原始需求
拿到手的需求是这样的:
我需要一个删除、移动本地电脑 C 盘大文件的软件:
- 扫描 C 盘下所有非系统文件夹,列出目录,标明属于哪个软件、多大、能不能删
- 可以选择文件夹
- 可以全选、点选
- 提供删除和移动两个选项
0.2 把人话翻译成技术问题
这一步特别重要。需求是给人的,代码是给机器的,中间必须翻译一遍。我把上面四句话拆成了六个技术问题:
| 需求里的话 | 背后真正的技术问题 |
|---|---|
| “扫描非系统文件夹” | 哪些目录算"系统目录"?怎么界定?判错了会怎样? |
| “有多大” | 递归遍历几十万个文件,怎么算得快?会不会卡死界面? |
| “属于哪个软件” | 目录名叫Doubao好认,叫{8A3F-xxxx}怎么办? |
| “能不能删” | 凭什么判断?判错了用户系统就废了 |
| “选择 / 全选” | 图形界面的表格怎么做?勾选状态存哪儿? |
| “删除和移动” | 直接删太危险,能不能后悔?移动后软件找不到文件怎么办? |
0.3 定下三条设计原则
动手前我先给自己立了规矩,后面所有代码都围绕这三条:
原则一:扫描阶段绝对只读。
不管分析出什么结论,扫描过程一个字节都不许改。这样就算程序写错了,最坏的结果也只是"显示不对",不会毁掉数据。
原则二:拿不准就往保守方向判。
"不确定能不能删"的时候,宁可标成"谨慎"让用户自己决定,也不能标成"可清理"骗用户去删。这个软件的价值在于让人敢用,而不在于删得多狠。
原则三:任何破坏性操作都要能后悔。
删除默认走回收站;移动时在原位置留一个"目录联接"(后面会细讲),让软件以为文件还在原地。
这三条原则不是空话。后面你会看到,第三章的
dir_size里我只累加st_size从不调用任何删除;第四章的classify里凡是没匹配上明确规则的,一律返回"谨慎";第五章的move_dir采用"先复制 → 校验 → 再删源",中途失败数据还在。
第 1 章 环境准备:先确认你能跑起来
1.1 为什么选 Python 3.9 而不是最新版
写 GUI 需要tkinter(Python 自带的图形界面库)。不是每个 Python 都带它。
我一开始用的是受管环境的 Python 3.13,结果:
>>>importtkinter ModuleNotFoundError:No module named'tkinter'所以第一步永远是先确认环境,别写完一千行才发现跑不了。
打开命令行,执行:
python-c"import sys, tkinter; print(sys.version); print('tk', tkinter.TkVersion)"在我这台机器上,能用的解释器是系统自带的 Python 3.9.13:
3.9.13 (tags/v3.9.13:6de2ca5, May 17 2022, 16:36:42) [MSC v.1929 64 bit (AMD64)] tk 8.6记下这个路径,后面所有命令都要用它:
C:\Users\shijie\AppData\Local\Programs\Python\Python39\python.exe小白提示:
ModuleNotFoundError是新手最常见的报错之一,意思是"你要用的这个模块,我找不到"。90% 的情况是装错了解释器,或者根本没装。
1.2 零第三方依赖的决定
我决定不装任何第三方库(不用psutil、不用send2trash)。理由是:
- 需要的功能都能用标准库 + Windows 自带接口实现
- 零依赖 = 拷给别人就能跑,不用管环境
- 逼自己理解底层原理,而不是调黑盒
最终用到的"武器"只有这些:
importos# 遍历目录、路径处理importqueue# 线程之间传消息importthreading# 后台线程importctypes# 调用 Windows 系统接口(关键)importtkinter# 图形界面ctypes是这里唯一看起来陌生的东西。它的作用一句话说清:
让 Python 直接调用 Windows 系统自带的函数。
比如"把文件删到回收站"这个能力,Python 标准库没有,但 Windows 系统有(SHFileOperationW),那就用ctypes去调它。
1.3 项目目录结构
c_disk_cleaner/ ├── main.py 入口程序:启动界面 / 命令行模式 ├── scanner.py 扫描引擎:枚举目录 + 计算体积 ├── analyzer.py 分析引擎:识别归属软件 + 判定风险等级 ├── operations.py 操作模块:删除、移动、创建目录联接 ├── ui.py 图形界面 ├── selftest.py 自检脚本(只读,用来验证判定逻辑) └── 启动.bat 双击即可运行为什么要分成这么多文件?
一个 3000 行的文件你改一行都心惊胆战。拆开之后:
- 想改"识别逻辑",只看
analyzer.py - 想改"扫描速度",只看
scanner.py - 两个文件之间通过明确的函数签名交流,互不干扰
这就是所谓的"分而治之",也是这个项目能反复改而不崩的原因。
第 2 章 整体架构:先画蓝图,再写代码
2.1 数据是怎么流动的
这是整个项目最重要的一张图。看懂它,后面所有代码都是它的展开:
用户点"开始扫描" │ ▼ ┌───────────────┐ │ 扫描线程 │ scanner.py │ (后台运行) │ 枚举目录 → 计算体积 └───────┬───────┘ │ 往队列里塞消息 ("item", "size", "done") ▼ ┌─────────────────────────────────────┐ │ 队列 queue.Queue │ ← 线程之间的唯一通道 └───────────────┬─────────────────────┘ │ 主线程定时来取 ▼ ┌───────────────┐ │ 主线程(界面) │ ui.py │ 取消息 → 更新表格│ └───────┬───────┘ │ 每条目录信息都要问一次 ▼ ┌───────────────┐ │ 分析引擎 │ analyzer.py │ 归属? 风险? │ └───────────────┘ 用户勾选 → 点删除/移动 → operations.py 执行2.2 为什么必须用"队列"而不是直接调用
这是新手写 GUI 最容易栽的跟头,我专门讲。
错误做法(后台线程直接改界面):
defscan_thread():forpathinpaths:size=calc(path)tree.insert(...)# 后台线程直接操作界面控件问题在哪:tkinter这个库不是线程安全的。多个不同线程同时改一个界面控件,就像两个人同时在同一张纸上写字——结果不可预料,轻则显示错乱,重则程序直接崩溃。
正确做法:后台线程只管干活,把结果放进队列;主线程(界面所在的线程)定时来队列里取,取出来自己更新界面。
# 后台线程:只放消息,绝不动界面self.q.put(("size",path,size,count,done,total))# 主线程:定时取消息,自己更新def_poll(self):whileTrue:try:msg=self.q.get_nowait()self._handle(msg)# 安全:这里是主线程exceptqueue.Empty:break记住这条铁律:后台线程只往队列发消息,绝不碰任何界面控件。
第八章我会讲我第一次违反这条铁律时,程序是怎么死的——那是个非常隐蔽、查了很久才找到的 BUG。
2.3 模块之间的接口(先约定好,再各写各的)
就像盖楼前先定好门窗尺寸。我在动手前定好了这些函数的"长相":
# scanner.py 对外提供list_candidates(labels)->[路径1,路径2,...]dir_size(path,stop_event)->(总字节数,文件数,是否中断,采样exe)find_cache_dirs(root,...)->[(缓存目录,大小),...]# analyzer.py 对外提供annotate(path,installed,...)->(软件名,置信度,风险等级,类别,说明)# operations.py 对外提供send_to_recycle_bin(path)->(是否成功,说明)move_dir(src,dest_root,...)->(是否成功,目标路径,说明)好处是:ui.py写的时候完全不用关心"体积是怎么算出来的",它只要知道"调用dir_size能拿到一个数字"就够了。
第 3 章 扫描引擎 scanner.py:先解决"扫哪里"和"有多大"
3.1 第一个问题:哪些目录绝对不能碰
这是安全性问题,必须最先解决。
思路很直接:写两张黑名单。
# 绝对不能碰的系统目录(小写匹配,路径中任意一层出现就跳过)SYSTEM_BLOCK=("windows","winnt","system32","syswow64","winsxs","drivers","system volume information","$recycle.bin","recovery","efi","boot","config.msi","msocache","windows.old","$windows.~ws","$windows.~bt","perflogs",)# 明确排除的顶层路径(小写)ROOT_BLOCK=(r"c:\windows",r"c:\programdata\microsoft",r"c:\programdata\package cache",r"c:\program files\windows",r"c:\program files (x86)\windows",r"c:\program files\common files",r"c:\program files (x86)\common files",r"c:\users\default",r"c:\users\defaultuser0",r"c:\users\public",r"c:\users\all users",)然后用这两个名单做检查:
defis_system_path(path):"""判断路径是否属于系统保护区域"""low=path.lower().rstrip("\\")# 1) 先看是不是整个路径被明确排除forblkinROOT_BLOCK:iflow==blkorlow.startswith(blk+"\\"):returnTrue# 2) 再看路径里有没有出现系统目录名parts=[pforpinlow.replace("/","\\").split("\\")ifp]forpinparts:ifpinSYSTEM_BLOCK:returnTruereturnFalse代码逻辑讲解:
- 为什么要
.lower()?Windows 路径不区分大小写,C:\Windows和c:\windows是同一个地方。统一转小写再比,省得漏。 - 为什么要
.rstrip("\\")?C:\Windows\结尾带个反斜杠,不去掉就和名单里的c:\windows对不上。 - 为什么要
replace("/", "\\")?有时候传入的路径是C:/Windows(正斜杠),不统一的话所有规则都会失配。这是个真实的坑,我在第四章又被它咬了一次。 - 为什么分两级检查?顶层路径要精确匹配(不能因为
C:\ProgramData\Foo就排除整个 ProgramData),而系统目录名要在任意层级都生效(C:\Foo\Windows\Bar也要排除)。
3.2 第二个问题:扫描哪些范围
不是整个 C 盘无脑扫,而是给用户几个"入口",让他自己勾选:
# 扫描入口: (显示名, 路径模板, 模式)# 模式 children -> 把该目录下的一层子目录作为候选# 模式 self -> 把该目录本身作为候选# 模式 dotfile -> 取该目录下以 . 开头的隐藏目录(开发类缓存)SCAN_ROOTS=[("AppData\\Local",r"%LOCALAPPDATA%","children"),("AppData\\Roaming",r"%APPDATA%","children"),("AppData\\LocalLow",r"%USERPROFILE%\AppData\LocalLow","children"),("用户文件夹(文档/下载/桌面等)",r"%USERPROFILE%","userdirs"),("开发工具缓存目录",r"%USERPROFILE%","dotfile"),("ProgramData",r"C:\ProgramData","children"),("Program Files",r"C:\Program Files","children"),("Program Files (x86)",r"C:\Program Files (x86)","children"),("C 盘根目录",r"C:\\","children"),]为什么用%LOCALAPPDATA%这种写法?
因为不能把路径写死。C:\Users\shijie\AppData\Local是我这台机器的路径,换台电脑用户名就变了。用环境变量,程序会自动展开成当前用户正确的路径:
defexpand(path_tpl):"""展开环境变量并规范化路径"""returnos.path.normpath(os.path.expandvars(path_tpl))为什么分成不同模式?
children:AppData\Local下面有几百个软件目录,每个软件目录是"可独立处理"的单位 → 把它的一层子目录列为候选userdirs:用户目录下只有"文档/下载/桌面"这几个值得看,其它是系统隐藏目录 → 单独列名单dotfile:C:\Users\shijie下以点开头的.gradle、.m2、.nuget是开发工具缓存,往往几个 GB → 专门扫
枚举候选目录的函数:
deflist_candidates(selected_labels):""" 根据用户在界面上勾选的扫描范围, 返回候选目录路径列表。 返回的每一项都是"可作为一个整体被删除/移动"的文件夹。 """out=[]seen=set()defadd(path):ifnotpath:returnp=os.path.normpath(path)key=p.lower()ifkeyinseen:# 去重:同一目录可能被多个入口扫到returnifis_system_path(p):# 系统目录直接跳过returnifnotos.path.isdir(p):returnseen.add(key)out.append(p)templates={label:(tpl,mode)forlabel,tpl,modeinSCAN_ROOTS}forlabelinselected_labels:iflabelnotintemplates:continuetpl,mode=templates[label]root=expand(tpl)ifmode=="self":add(root)continueifnotos.path.isdir(root):continueifmode=="userdirs":fornameinUSER_DIRS:add(os.path.join(root,name))continueifmode=="dotfile":# 扫以 . 开头的隐藏目录:.gradle / .m2 / .nuget 这类try:withos.scandir(root)asit:forentryinit:try:ifentry.name.startswith(".")andentry.is_dir(follow_symlinks=False):add(entry.path)except(OSError,ValueError):continueexcept(OSError,ValueError):passcontinue# children 模式:取一层子目录try:withos.scandir(root)asit:forentryinit:try:ifentry.is_dir(follow_symlinks=False)andnotis_reparse(entry.path):add(entry.path)except(OSError,ValueError):continueexcept(OSError,ValueError):continuereturnout代码逻辑讲解:
seen集合用来去重。因为"用户文件夹"和"开发工具缓存目录"都以%USERPROFILE%为根,可能扫到同一个东西。- 满屏的
try...except (OSError, ValueError): continue是不是很啰嗦?这是必须的。遍历 C 盘一定会遇到没权限的目录、正在被占用的文件、损坏的链接。不捕获异常的话,扫到一半程序就崩了。宁可跳过几个目录,也不能让整个扫描挂掉。 follow_symlinks=False是让is_dir()不跟随链接,否则可能绕回自己形成死循环。
3.3 第三个问题:目录有多大
3.3.1 为什么不用递归函数
最直觉的写法是递归:
defdir_size(path):total=0forentryinos.scandir(path):ifentry.is_dir():total+=dir_size(entry.path)# 递归调用自己else:total+=entry.stat().st_sizereturntotal这个写法有个致命问题:Python 的递归深度上限大约 1000 层。软件的缓存目录嵌套深起来很吓人,一层套一层,很容易超过 1000 层,然后:
RecursionError: maximum recursion depth exceeded解决办法:用"栈"把递归改成循环。
stack=[path]# 用列表当"待办清单"whilestack:# 只要清单不空就继续cur=stack.pop()# 取出一个来处理forentryinos.scandir(cur):ifentry.is_dir():stack.append(entry.path)# 子目录塞回清单,而不是递归调用else:total+=entry.stat().st_size这个技巧叫"用显式栈消除递归",在处理目录树、JSON 嵌套这类问题时非常常用,值得记牢。
3.3.2 完整的 dir_size
defdir_size(path,stop_event=None,slice_check=None):""" 递归计算目录体积。 返回 (总字节数, 文件数, 是否提前中断, 采样到的 exe 路径) 顺手采样一个 exe, 后面的软件识别会读它的版本信息来判断归属, 这样在遍历同一个目录树时几乎没有额外开销。 """total=0count=0aborted=Falsesample_exe=""sample_depth=99# 超过 240 字符的路径需要 \\?\ 前缀才能被 Windows API 正常打开stack=[(long_path(path)iflen(path)>240elsepath,0)]tick=0whilestack:ifstop_eventisnotNoneandstop_event.is_set():aborted=Truebreakcurrent,depth=stack.pop()try:withos.scandir(current)asit:forentryinit:tick+=1if(tick&0x3FF)==0:# 每 1024 项检查一次ifstop_eventisnotNoneandstop_event.is_set():aborted=Truebreakifslice_checkisnotNoneandslice_check():aborted=Truebreaktry:# 直接用 scandir 已经拿到的文件属性判断类型:# 比 is_dir() + GetFileAttributesW 少一次系统调用,# 全盘扫几十万个条目时这点开销很可观st=entry.stat(follow_symlinks=False)attrs=getattr(st,"st_file_attributes",0)ifattrs&FILE_ATTRIBUTE_DIRECTORY:# 跳过 junction/符号链接, 否则会重复统计同一批文件ifattrs&FILE_ATTRIBUTE_REPARSE_POINT:continuestack.append((entry.path,depth+1))else:total+=st.st_size count+=1# 采样: 越浅层的 exe 越可能是这个软件的主程序if(depth<sample_depthandsample_depthandentry.name.lower().endswith(".exe")andst.st_size>50*1024):sample_exe=entry.path sample_depth=depthexcept(OSError,ValueError):continueexcept(OSError,ValueError):continueifaborted:breakreturntotal,count,aborted,sample_exe逐段讲解:
(1)stop_event—— 让扫描能被叫停
如果不用这个,用户点了"停止",程序要等全部扫完才响应,体验极差。做法是每隔一段时间检查一下"有没有人让我停":
if(tick&0x3FF)==0:# 每 1024 项检查一次ifstop_event.is_set():# 有人按了停止aborted=Truebreak
tick & 0x3FF是位运算技巧,0x3FF= 1023,所以tick是 1024 的倍数时结果为 0。等价于tick % 1024 == 0,但位运算更快。为什么不是每处理一项都检查?因为检查本身也有开销,几百万次检查会拖慢扫描。
(2) 一次stat()拿两样信息
st=entry.stat(follow_symlinks=False)attrs=getattr(st,"st_file_attributes",0)ifattrs&FILE_ATTRIBUTE_DIRECTORY:# 是不是目录ifattrs&FILE_ATTRIBUTE_REPARSE_POINT:# 是不是链接continue我最初写的是entry.is_dir()判断目录 +GetFileAttributesW()判断链接,等于每个文件查询两次系统。改成一次stat()从属性位里读两样信息后,扫描快了一大截。
这就是"性能优化"的真相:不是什么高深算法,就是少做重复的事。几十万个文件,每个省一次系统调用,加起来就是几秒。
(3) 为什么跳过 junction(目录联接)
这是个真实的坑。C:\Users\shijie\AppData\Local\Application Data其实是个"链接",指向C:\Users\shijie\AppData\Roaming。如果你跟着链接走进去统计,同一批文件会被算两遍,体积直接翻倍。
判断方法:
FILE_ATTRIBUTE_DIRECTORY=0x10FILE_ATTRIBUTE_REPARSE_POINT=0x400defis_reparse(path):""" 判断是否为目录联接(junction)/符号链接。 这类目录指向别处, 跟随统计会造成体积重复计算, 必须跳过。 """try:attrs=ctypes.windll.kernel32.GetFileAttributesW(str(path))exceptException:returnFalsereturnattrs!=-1andbool(attrs&FILE_ATTRIBUTE_REPARSE_POINT)(4) 顺手采样一个 exe
遍历的时候如果遇到.exe,就记下它的路径。第四章会用它读取"这个软件叫什么名字"。在遍历时顺手做,等于零成本——反正已经打开这个目录了。
3.4 第四个问题:路径超过 260 字符怎么办
Windows 有个历史遗留限制:路径最长 260 字符。软件的缓存目录嵌套深了很容易超。突破办法是加\\?\前缀:
deflong_path(path):"""把路径转成可突破 260 字符限制的 \\?\ 形式"""p=os.path.abspath(path)ifp.startswith("\\\\?\\"):returnpifp.startswith("\\\\"):# 网络路径是另一种前缀return"\\\\?\\UNC\\"+p[2:]return"\\\\?\\"+p注意:Python 字符串里
\\表示一个反斜杠。"\\\\?\\"实际是\\?\。
3.5 第五个问题:太慢了,怎么提速
第一版串行扫描 239 个目录用了4.05 秒,全盘 453 个目录要十几秒。能不能快一点?
分析瓶颈:遍历目录树主要是等磁盘 I/O(等硬盘返回数据),CPU 其实闲着。这种"等 I/O"的任务,多线程效果特别好。
设计:
- 目录枚举保持单线程(保证列表顺序稳定,用户看到的顺序不跳来跳去)
- 只把最耗时的"算体积"并行化
defparallel_sizes(paths,stop_event,on_result,workers=4,quick=False):""" 并行计算一批目录的体积, 每算完一个就回调 on_result(path, size, count, sample, done, total)。 这里是唯一的体积计算调度入口, 扫描线程和"下钻子目录"都走它, 避免两处各写一份线程池逻辑、行为不一致。 关键: 停止时不能等已提交的任务跑完(ThreadPoolExecutor 的 with 语句 会 wait=True), 否则用户点了"停止"界面要干等几分钟。 """total=len(paths)done=0ifnotpaths:return0n_workers=min(max(1,int(workers)),total)defemit(path,triple):size,count,sample=triple on_result(path,size,count,sample,done_local[0],total)try:fromconcurrent.futuresimportThreadPoolExecutor,as_completed pool=ThreadPoolExecutor(max_workers=n_workers)done_local=[0]try:futures=