简介:桌面应用开发中,GUI框架的选择与数据持久化设计直接决定工具类软件的用户体验。PySide6是Qt官方提供的Python绑定,凭借LGPL许可证、丰富的控件生态与对PyInstaller的良好支持,成为构建轻量级离线工具的主流方案。在刷题和备考场景中,用户往往受限于商业App题库封闭、强制联网、统计维度粗糙等问题,需要一款可自定义题库的本地应用。技术实现上,通过openpyxl解析Excel题库,设计容错的格式归一化逻辑以兼容多选与判断题;使用SQLite持久化答题会话记录,并以快照机制保障题库变更后历史数据依然可读;最后利用PyInstaller完成exe打包,解决资源路径与体积优化等典型坑点。这套组合不仅适用于刷题软件,也适配调查问卷、学习测验等需要本地数据采集与统计的桌面工具场景。文章围绕真实需求展开,完整呈现从需求梳理到可分发exe的工程实践路径。 你有没有过这种经历:马上要考试了,手里攒了一堆从课件、资料里整理出来的重点题,但市面上的刷题工具一个比一个不顺手——题库锁死不能导入,必须联网才能用,统计功能约等于没有。我自己就撞上过这种尴尬,最后决定不再纠结,直接用PySide6写了个单机版刷题复习软件:能导入自定义Excel题库,自动统计已答、正确、错误、未答情况,还能保存每一次的答题记录。这个项目从需求梳理到开发,再到打包成exe,踩了不少坑,也攒下不少经验,这篇就把完整过程都记录下来。
如果你正在备考、需要给学生出题练习,或者刚接触PySide6想做点真正能用的桌面程序,这篇文章应该能给你一套可以直接参考的解决方案,包括Excel题库格式设计、答题状态跟踪、SQLite历史记录、PyInstaller打包排坑这些环节,我都会把关键细节摊开讲。
1. 先说说为什么要造这个轮子:刷题软件的市场痛点
1.1 市面上刷题工具的三大硬伤
我一开始也老老实实下载过几个热门刷题App,用了一圈下来,发现它们的问题非常集中。
第一是题库封闭。绝大多数刷题App自带题库,看起来题目很多,但你真正需要复习的内容往往来自自己的笔记、老师的重点整理、教材配套练习。你想把这些内容导进去,要么不支持,要么需要手动一道一道录入,几百道题录完基本也就没复习动力了。
第二是必须联网。很多刷题工具的核心功能都挂在云端,没网络连题目都加载不出来。但实际复习场景里,地铁上、图书馆角落、自习室信号差的地方才是刷题高频场景,断网直接白给。
第三是统计维度太粗。大多数软件只给你一个正确率百分比,但复习时其实更关心的是:哪些题做过了,哪些题还没碰,哪些题反复错。这个“已答数、未答数、错误数”的细粒度信息,对安排复习节奏非常关键,市面上却很少做得清晰。
1.2 我的需求清单和功能边界
所以在动手之前,我给这个软件划了一条很明确的需求边界,避免做到一半失控:
- 单机离线运行,绿色exe,双击就能用,不装数据库、不配环境。
- 题库通过Excel导入,用户自己维护题目内容,格式要足够简单。
- 自动统计四种状态:已答、正确、错误、未答,界面上一眼能看明白。
- 保存历次答题记录,方便回看和分析。
- 不给它加用户系统、云同步、社区分享这类功能。单机工具的定位就是纯粹、快速、数据在我自己手里。
这个功能列表看着简单,但真正做起来,Excel解析、状态跟踪、记录持久化、打包分发,每一块都有不少细节坑。下面逐个展开说。
2. 技术选型:PySide6 + openpyxl + SQLite这套组合是怎么定下来的
2.1 PySide6为什么比PyQt6和Tkinter更适合这个项目
GUI框架的选择,我其实比较过一轮。PyQt6确实功能强大,API和PySide6几乎一样,但它的GPL许可证对个人项目还好,如果以后想分享给别人或者商用,就必须认真考虑许可证问题。PySide6是Qt官方出品的Python绑定,使用的是LGPL许可证,对个人工具项目友好得多,这是它胜出的关键原因。
Tkinter也考虑过,它内置在Python标准库里,打包体积小,但界面控件太老旧,做表格展示、滚动列表这些交互效果很费劲,要实现一个稍微好看点的答题界面得自己拼半天。PySide6拥有成熟的QTableWidget、QStackedWidget、QListView等控件,开发效率高,做出来的界面效果也现代。
还有一个现实原因是PySide6的生态足够成熟,PyInstaller对它有现成的hook支持,打包成exe的路径上有大量前人踩坑记录可以借鉴。这一点对要交付exe文件的项目来说,比框架本身的新特性更重要。
对比表格大概是这样的感受:
| 方案 | 许可证 | 界面能力 | 开发效率 | 打包体积 | 适合场景 |
|---|---|---|---|---|---|
| PySide6 | LGPL | 强 | 高 | 中等 | 桌面工具、中小型软件 |
| PyQt6 | GPL/商业授权 | 强 | 高 | 中等 | 有商业授权预算的团队 |
| Tkinter | Python内置 | 弱 | 中 | 小 | 极简工具、内部脚本 |
| Electron | MIT | 强 | 高 | 巨大 | 不在乎体积和内存的重交互应用 |
Electron理论上也能做,但打包出来动辄几百MB,启动还慢,给用户一个刷题工具搞得像装了个IDE,不合适。
2.2 题库解析用openpyxl而不是pandas
读Excel这个环节,我一开始想用pandas,毕竟read_excel一行就能出DataFrame。但后来仔细一想,在这个项目里pandas有几个问题:
- 引入pandas会让打包体积多出三四十MB,而且启动时import pandas比较慢,对单机工具来说代价太大了。
- 我需要做的Excel操作其实非常基础,无非是遍历行、读取单元格、处理表头,这些openpyxl完全能胜任。
- openpyxl只依赖et_xmlfile等少量库,打包后体积非常友好。
openpyxl唯一的短板是处理超大Excel时性能一般,但题库撑死几千道题,完全在它舒适区内。所以最终选型就是openpyxl,实测下来读一个几百道题的Excel文件只需要零点几秒,体验很好。
2.3 答题记录为什么放SQLite而不是JSON
答题记录最初考虑过用JSON文件保存,简单直接。但JSON文件的数据量大了以后会有问题:追加记录要整体读出来改完再写回去,历史记录多的时候文件会越来越臃肿,而且如果中途程序崩溃,JSON文件可能损坏。SQLite完美解决这些问题。
SQLite是Python内置的sqlite3模块,不需要额外安装依赖,数据库就是一个单文件,用户备份、迁移都非常方便。查询历史记录可以按时间倒序、按错误率排序,想怎么聚合都行。
所以这套技术栈就是:PySide6做界面,openpyxl解析题库,sqlite3存答题记录,PyInstaller打包exe。每个组件都负责自己最擅长的部分,不搞花活。
3. Excel题库格式与导入校验:项目里最容易翻车的部分
Excel题库格式是整个软件的地基。这个格式设计得不好,后面所有功能都会做得别扭。我第一版就是随便定义了几列,结果用户反馈格式太严格、老导入失败,后来重新设计才稳定下来。
3.1 题库模板的列结构
题库Excel最终采用单sheet结构,第一行是表头,固定以下几列:
| 列名 | 是否必填 | 说明 |
|---|---|---|
| 题型 | 是 | 单选、多选、判断 |
| 题干 | 是 | 题目内容 |
| 选项A | 单选必填 | 判断题可留空 |
| 选项B | 单选必填 | 判断题可留空 |
| 选项C | 单选必填 | 判断题可留空 |
| 选项D | 单选必填 | 判断题可留空 |
| 正确答案 | 是 | 单选填A/B/C/D,多选填ABD,判断填“对”或“错” |
| 解析 | 否 | 判分后展示,帮助记忆 |
| 知识点 | 否 | 备用,后续按知识点维度统计 |
为什么要把“序号”列去掉?因为我发现用户手动维护Excel时,一旦中间删掉几行,序号就会断,容易出现心理负担。所以序号由程序导入时自动生成,不依赖Excel内容。
判断题的处理方式是通过判断“题型”列来决定的。判断为“判断题”时,程序自动忽略选项A到D的内容,在界面上只显示“正确”和“错误”两个按钮。这样用户不需要在Excel里专门为判断题造出两个假选项。
3.2 导入容错:那些让人头大的用户输入
这一块是最容易翻车的,因为手动维护Excel时,用户输入永远比你想象的更随意。我在调试阶段收集到这些真实情况:
- 表头带了不可见空格或者UTF-8 BOM,导致列名匹配失败。
- 题干里出现了换行符,展示时整个排版乱掉。
- 正确答案写成“A、B”而不是“AB”,或者写成“a b”,甚至有人填“A和B”。
- 判断题答案有人填“对”,有人填“正确”,有人填“T”,有人填“√”。
- “多选题”有人写成“不定项”,题型列无法精确匹配。
应对方案是在导入层做统一归一化。
def normalize_answer(s): if not s: return "" s = str(s).strip().upper() # 去掉常见分隔符:顿号、逗号、空格、和字 for ch in ["、", ",", ",", " ", "和"]: s = s.replace(ch, "") return s def normalize_question_type(s): if not s: return "" s = str(s).strip() if s in ("单选", "单选题", "single"): return "单选" if s in ("多选", "多选题", "multiple"): return "多选" if s in ("判断", "判断题", "bool"): return "判断" return s所有答题时对比用户答案和正确答案,都先经过normalize_answer再比较。判断题的答案则单独处理:
def normalize_judge_answer(s): s = str(s).strip() if s in ("对", "正确", "T", "√", "是", "1"): return "对" if s in ("错", "错误", "F", "×", "否", "0"): return "错" return s这样不管用户怎么填,导入时都能转成统一格式,答题判分逻辑就不用到处兼容了。
导入之后的校验反馈也很重要。我在界面上会给三个级别的提示:成功导入多少道题、跳过多少空行、发现多少疑似格式错误的行,并且把错误行号列出来。这样用户能快速定位Excel里哪一行写错了,而不是面对一个笼统的“导入失败”。
3.3 “首次运行时自动生成示例题库”这个小功能
这个功能是测试过程中被一个朋友点醒的。他说:你让我用这个软件,总得告诉我Excel到底长什么样吧。手动看说明文档太麻烦了。
所以我加了一个逻辑:程序启动时,如果检测到题库数据目录下没有设置任何题库文件,就在界面上提示“未导入题库”,并提供一个按钮【生成示例题库】。点击后会在指定目录自动生成一个示例Excel,包含单选、多选、判断各若干道,列格式和实际要求完全一致。用户拿到这个文件,改内容保留格式,再导入就能跑通全流程。
这个功能看似不起眼,但对降低上手门槛帮助极大。很多用户根本不会去读README,给一个能直接改的模板文件比写十页说明都管用。
4. 答题主流程与状态统计的实现思路
4.1 数据结构设计:Question与AnswerRecord
界面是流于表面的东西,真正重要的是数据模型。我用dataclass定义了两个核心类:
from dataclasses import dataclass, field @dataclass class Question: qid: int qtype: str # 单选 / 多选 / 判断 content: str # 题干 options: dict # {'A': 'xxx', 'B': 'xxx', 'C': 'xxx', 'D': 'xxx'} answer: str # 标准化后的正确答案:'A' / 'AB' / '对' explanation: str = "" knowledge: str = "" @dataclass class AnswerRecord: selected: str = "" # 用户本次选择的选项 is_correct: bool = False answered: bool = False # 是否作过答拿一个列表保存所有Question,再拿一个等长的AnswerRecord列表保存答题状态,一一对应。所有的统计逻辑都基于这两个列表,界面刷新只是把统计结果填到Label上。
为什么不直接在Question里加一个user_answer字段?因为Question描述的是题目本身,AnswerRecord描述的是用户这次会话中的作答行为,两者职责混在一起会让后续扩展错题本、历史记录等功能时变得混乱。把数据和行为分离,是我在重构第二版时最大的体会。
4.2 界面布局与做题交互
答题主界面用QStackedWidget来实现每道题的切换。QStackedWidget相当于一个页面容器,每一道题对应一个QWidget页面,切换题目时只需要setCurrentIndex,非常方便。
每一道题页面的结构是:题干Label + 选项区域 + “上一题/下一题”按钮。单选和判断题用QRadioButton组,多选题用QCheckBox组,这样用户的操作习惯是自然的。
有个很重要的细节:切换题目时必须重新渲染当前选项的选中状态。也就是说,用户在第1题选了A,跳到第2题再跳回第1题,A还是要处于选中状态,这个状态就保存在AnswerRecord.selected里。如果不做这个回显,用户会以为自己的答案丢了,体验非常差。
4.3 判分逻辑和四种统计指标的更新时机
判分逻辑围绕一个核心问题:什么时候把一道题标记为“已答”。我的产品决策是:只要用户在当前题目上做过一次选择,就标记为answered=True,并立即判分。因为刷题复习的核心场景是“做一道、对一道、立刻吸收”,而不是模拟考试交卷后才出分。
四种统计指标的精确含义如下:
- 已答数:answered=True 的题目数量。
- 正确数:is_correct=True 的题目数量。
- 错误数:answered=True 且 is_correct=False 的题目数量。
- 未答数:answered=False 的题目数量。
所以“已答数”在数值上等于“正确数 + 错误数”,“未答数”等于“总题数 - 已答数”。这四个数字在底部状态栏常驻显示,每次切换题目或者做出选择时都重新计算一次,保证用户任何时候看到的都是实时进度。
判分函数本身要兼容三种题型:
def check_answer(qtype, user_answer, correct_answer): user_answer = normalize_answer(user_answer) correct_answer = normalize_answer(correct_answer) if qtype == "判断": user_answer = normalize_judge_answer(user_answer) correct_answer = normalize_judge_answer(correct_answer) return user_answer == correct_answer这里的小陷阱是:多选题如果用户少选了一个选项,比如正确答案是ABD,用户只选了AB,应该判错。我的做法是严格相等才判对。对刷题复习场景来说,少选就等于没有完全掌握,不该放水。
关于“是不是选完就立刻显示正确答案”这个问题,我也纠结过。最后采用的方案是:点击“下一题”或者“上一题”时,如果当前题已经作答,就弹出一小块答案解析区域,显示正确答案和解析内容。这样能避免用户还没思考就先看到答案的“偷看”问题,答题时更专注,切题之后又能立刻获得反馈。
4.4 做题结果页与错题回看
从最后一题点“完成答题”,会跳转到结果页。结果页用一个大号字体卡片展示四个统计数字,并配一个简单的进度条来显示正确率。
进度条是QProgressBar,取值规则是:如果已答数为0,进度显示0;否则显示正确数除以已答数的百分比。不把未答题计入正确率分母,因为未答题还没有得分概念,强行算进去会得到偏低的正确率。
结果页还放了一个“错题回顾”按钮。点击后跳转到一个只包含错题的列表,同样支持浏览和查看解析。这个功能对于考前的最后一轮复习非常实用,相当于自动生成了一份错题集,省得自己手动整理。
但这里还有一个问题:结果页的错题回顾只保留在当前会话里,软件关闭后数据就没了。如果需要跨会话沉淀错题,就得靠下一章的历史记录功能。
5. 历史记录功能:SQLite表设计与“快照”这个关键决策
历史记录是这个项目里最容易被低估的功能。很多人觉得就是把数据存起来,但实际上存什么、怎么存、存多久,每一步都有坑。
5.1 sessions与answer_logs两张表的用处
历史记录需要回答两个问题:一次答题总体情况如何?每一道题我当时的作答是什么?所以我设计了两张表。
sessions表记录每一次答题会话的汇总:
CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, started_at TEXT NOT NULL, finished_at TEXT NOT NULL, total INTEGER NOT NULL, answered INTEGER NOT NULL, correct INTEGER NOT NULL, wrong INTEGER NOT NULL, unanswered INTEGER NOT NULL, accuracy REAL NOT NULL );answer_logs表记录每一次会话中每一道题的作答明细:
CREATE TABLE IF NOT EXISTS answer_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER NOT NULL, qidx INTEGER NOT NULL, qtype TEXT NOT NULL, question_content TEXT NOT NULL, options_snapshot TEXT NOT NULL, correct_answer TEXT NOT NULL, user_answer TEXT, is_correct INTEGER NOT NULL, explanation TEXT, knowledge TEXT, FOREIGN KEY (session_id) REFERENCES sessions(id) );答题结束时,把整个会话的数据一次性写入这两张表。这种“结束后统一写入”的方式操作简单,避免每答一道题就写一次库导致界面卡顿。
5.2 为什么历史记录要存题目标题快照而不是题目ID
这是我做这个项目时最重要的一次设计修正。第一版为了省空间,answer_logs只存qid和user_answer,回看历史时再去Excel题库里查题目内容。结果发现一个致命问题:题库文件是会被修改的。用户今天导入的题库,过两天可能补充了答案解析,甚至删掉了某些题。这时候打开昨天的历史记录,题目内容对不上号,甚至直接找不到,历史记录就变得毫无意义。
解决方案就是“快照”。在答题结束时,把题干、选项、正确答案、解析这一系列内容原文写进answer_logs表。这样即使Excel题库后续改得面目全非,历史记录依然能完整还原当时的做题场景。代价是每条记录要多占一点存储空间,但SQLite对这类数据的处理能力绰绰有余,完全没有必要为了省这点空间而牺牲可靠性。
options_snapshot字段存的是选项字典的JSON序列化字符串:
import json options_snapshot = json.dumps(question.options, ensure_ascii=False)读回来时再json.loads,就能还原成dict。
5.3 历史记录界面的交互细节
历史记录界面用QTableWidget展示,表头包括:时间、总题数、已答、正确、错误、未答、正确率。所有会话按时间倒序排列。双击任意一行,弹出一个详情对话框,里面用QListWidget列出该会话每一道题的题干、你的答案、正确答案、是否正确。
实际测试中发现一个体验问题:一次做500道题,answer_logs里就会插入500条明细,历史详情界面的QListWidget一次性加载500个条目时,滚动会有轻微卡顿。解决方法是分页加载,每次只加载50条,滚动到底部时再追加下一批。具体实现可以用QListWidget的滚动条滑块位置判断是否接近底部:
def on_scroll(self, value): scrollbar = self.list_widget.verticalScrollBar() if value >= scrollbar.maximum() - 100: self.load_more_records()这样既保证了流畅,又避免了一次性创建大量控件导致的启动延迟。
另外,我还加了一个“导出当前会话为txt”的功能。很多用户喜欢把错题打印出来,对着纸质版复习。虽然Qt提供了QTextDocument导出PDF的能力,但我这里选择了最简单的txt格式,每一题的题干、选项、正确答案、解析都用纯文本格式写清楚,兼容所有打印场景。
6. 打包exe:PyInstaller实战与排坑记录
开发阶段用python main.py跑得好好的,并不代表打包成exe后就一定能跑。PySide6项目的打包,我前前后后踩了不下五个坑,这里把关键问题都整理出来。
6.1 打包命令与spec文件的关键配置
基础打包命令如下:
pip install pyinstaller pyinstaller --noconfirm --clean --windowed --onefile --name "刷题复习软件" --icon=app.ico main.py参数解释:
--windowed:不显示黑色控制台窗口。--onefile:打包成单个exe文件,分发方便。--icon=app.ico:设置exe图标。--name:指定输出文件名。
不过单纯用命令行参数控制不了所有细节,所以我会在第一次打包后,进入生成的刷题复习软件.spec文件里微调再重新构建。spec文件是PyInstaller的构建配置,比命令行参数更灵活。
6.2 三个必踩的坑:资源路径、缺插件、体积过大
资源路径问题是所有打包新手都会撞的。开发时相对路径指向项目目录,但exe用--onefile方式运行时会先把所有文件解压到一个临时目录,程序和资源文件的相对位置关系全变了。解决办法是定义一个resource_path函数,在打包环境下用sys._MEIPASS取临时目录,在开发环境下用当前工作目录:
import sys import os def resource_path(relative_path): if hasattr(sys, '_MEIPASS'): return os.path.join(sys._MEIPASS, relative_path) return os.path.join(os.path.abspath('.'), relative_path)所有读取图标、Excel模板、配置文件的地方,都通过resource_path来定位。
缺插件的问题更隐蔽。PySide6程序打包后可能出现在某些电脑上启动时没有窗口样式或者直接崩溃,原因多半是Qt的平台插件没有被打包进来。PyInstaller的hook一般能处理,但为了保险,我会在spec文件里手动把PySide6的plugins目录包含进来:
a = Analysis( ['main.py'], ... datas=[('C:/Python311/Lib/site-packages/PySide6/plugins', 'PySide6/plugins')], ... )体积过大是PySide6打包的宿命,但可以优化。基础的PySide6程序打包出来约150MB,其中很大一部分来自无辜的QtWebEngine、QtMultimedia等模块。如果程序里根本用不到这些,可以在spec文件中通过排除模块减少体积。比较有效的方式是用PyInstaller的excludes参数:
excludes=['PySide6.QtWebEngineCore', 'PySide6.QtWebEngineWidgets', 'PySide6.QtMultimedia'],实测可以把体积压掉不少。
6.3 杀毒软件误报与onedir方案
--onefile模式打包的exe在分发时更容易被某些杀毒软件报毒,这是PyInstaller单文件模式的已知问题。因为单文件exe运行时会自解压并动态加载代码,这个行为模式和部分恶意软件有一定相似性,杀软容易误判。
如果遇到严重的误报问题,一个可行的替代方案是改用--onedir模式打包。--onedir模式会生成一个文件夹,里面包含exe和依赖文件,虽然分发时要打包整个目录,但误报率会明显降低,启动速度也比--onefile快。
由于我的分发场景比较简单,最终采用了--onefile。但我要特别提示:单文件模式启动时额外多了一步解压,所以启动速度会比源码运行慢1到2秒,这在可接受范围内。
6.4 打包后的实际验证情况
打包完的exe,我在三台不同的电脑上做了验证:一台Win10 x64新机器,一台Win10老笔记本,一台Win11新电脑。测试结果比较理想,新机器上双击到界面出现大约3秒,老笔记本约5秒,题库导入和答题流程都正常。
需要额外注意的是运行时依赖的VC运行库。某些精简版系统缺VC++运行库,可能导致exe启动报错“找不到VCRUNTIME140.dll”。我的做法是在说明文档里附上“安装微软VC++ 2015-2022运行库”的提示,实测大部分用户的电脑都已经有这些运行库,只有少数精简系统需要手动补装。
7. 后续迭代方向:从“能用”到“好用”
一个软件做到能跑只是开始,要真正做到好用,还需要在很多细节上持续迭代。基于我这段时间的使用体会,列几个我认为最有价值的扩展方向。
7.1 错题重刷与知识点维度出题
当前版本的错题回顾功能只在本次会话内有效。如果把answer_logs表里的错题按知识点聚合,就能实现“按知识点刷错题”的功能。比如用户发现自己“导数”这个知识点错误率奇高,就可以只刷这个知识点的错题,这是最有针对性的复习方式。
具体做法是在选题范围时加一个筛选项:从历史记录中提取所有is_correct=0的题,按knowledge字段分组展示,选择某个知识点后,把该知识点下的错题重新组成一个临时的Question列表,复用现有答题流程即可。
7.2 导出答题记录与学习趋势
历史记录虽然能在软件里看,但没有趋势图还是不够直观。可以把每次会话的正确率按时间顺序画成折线图,这样用户能直观地看到自己的进步曲线。PySide6里可以用QPainter自己画,也可以引入pyqtgraph,但为了控制打包体积,我倾向用QPainter手绘一个简单的折线图,完全够用。
同时可以加一个“导出全部历史记录为Excel”的按钮,把每天刷题量和正确率导成表格,方便自己用Excel做进一步分析。
7.3 我个人最想做的考试模拟模式
现在的答题流程是实时判分、随时看答案解析,本质上偏向“知识巩固”。如果要模拟真实考试场景,用户需要的是:整卷计时、交卷前不显示答案、交卷后统一判分。这种“考试模式”和现有的“练习模式”在数据结构和界面层都能复用,只需要在设置里加一个模式开关,调整判分时机和答案展示逻辑即可。
从代码架构上看,因为AnswerRecord已经完整保存了用户的作答状态,切换到考试模式只需要把“选择即判分”改成“交卷时统一判分”,把“立即显示解析”改成“交卷后显示解析”,改动成本并不高,但产品价值上是一大步。
这个项目做下来,我最深的体会是:工具类软件的价值不在于功能堆得多高,而在于把核心场景打磨得足够顺手。自定义Excel题库导入、四种状态自动统计、历史记录持久化,这三个点看起来都很朴素,但组合在一起,真正解决了“自己的题、自己的进度、自己的记录”这个刷题复习中最实际的需求。如果你也正在被类似的桌面工具需求困扰,希望这篇记录能给你一些参考,少走几步弯路。
本文还有配套的精品资源,点击获取