PyQt5合并QFileSystemModel目录树的实现与优化问询
将两个QFileSystemModel拼接为一个模型后统一展示,相关内容已完成多次迭代更新。
为构造最小可复现示例,在C盘创建测试文件夹MyFolder,文件夹内包含若干子文件夹与文件,目录结构如下:
导入必要依赖后,以下基于PyQt5库编写的Python代码可正常运行,代码修改自StackOverflow问题《How to display parent directory in tree view?》:
# 代理模型用于实现指定目录作为根节点展示,该段逻辑从参考资料复制未做修改 class ProxyModel(QSortFilterProxyModel): def __init__(self, parent=None): super().__init__(parent) self._root_path = "" def filterAcceptsRow(self, source_row, source_parent): source_model = self.sourceModel() if self._root_path and isinstance(source_model, QFileSystemModel): root_index = source_model.index(self._root_path).parent() if root_index == source_parent: index = source_model.index(source_row, 0, source_parent) return index.data(QFileSystemModel.FilePathRole) == self._root_path return True @property def root_path(self): return self._root_path @root_path.setter def root_path(self, p): self._root_path = p self.invalidateFilter() class MainWindow(QMainWindow): def __init__(self, parent=None): super().__init__(parent) self.create_treeview() self.setCentralWidget(self.treeView_1) # 后续效果对比会修改该行 def create_treeview(self): self.treeView_1 = QTreeView() self.dirModel_1 = QFileSystemModel() self.dirModel_1.setRootPath(QDir.rootPath()) path_1 = 'C:/MyFolder/SubFolder1' # 修改路径即可切换展示的目录 root_index_1 = self.dirModel_1.index(path_1).parent() self.proxy_1 = ProxyModel(self.dirModel_1) self.proxy_1.setSourceModel(self.dirModel_1) self.proxy_1.root_path = path_1 self.treeView_1.setModel(self.proxy_1) proxy_root_index_1 = self.proxy_1.mapFromSource(root_index_1) self.treeView_1.setRootIndex(proxy_root_index_1) self.treeView_2 = QTreeView() self.dirModel_2 = QFileSystemModel() self.dirModel_2.setRootPath(QDir.rootPath()) path_2 = 'C:/MyFolder' root_index_2 = self.dirModel_2.index(path_2).parent() self.proxy_2 = ProxyModel(self.dirModel_2) self.proxy_2.setSourceModel(self.dirModel_2) self.proxy_2.root_path = path_2 self.treeView_2.setModel(self.proxy_2) proxy_root_index_2 = self.proxy_2.mapFromSource(root_index_2) self.treeView_2.setRootIndex(proxy_root_index_2) if __name__ == "__main__": import sys app = QApplication(sys.argv) w = MainWindow() w.show() sys.exit(app.exec_())
不同参数下的运行效果:
- 执行
self.setCentralWidget(self.treeView_1)时,运行效果如下:
- 修改为
self.setCentralWidget(self.treeView_2)时,运行效果如下:
需要将两个目录树拼接后共同展示,程序启动后用户可看到如下效果:
两个目录的展示顺序无要求,仅需保证MyFolder与SubFolder1作为完全独立的顶级项展示即可(即使二者实际存在父子目录关系)。所有展示内容为静态加载,无需监听文件夹或文件的实时变更,仅需在程序启动运行时读取一次现有目录与文件结构即可。
经过多日研究尝试,目前已取得重大进展,参考musicamante给出的使用QTreeWidget的思路,实现逻辑为遍历各个模型的内容,逐步将所有条目迁移至新建的QTreeWidget中。为避免界面卡顿,采用懒加载方案:仅当用户展开QTreeWidget节点需要查看更多内容时,才调用QFileSystemModel的fetchMore方法加载对应子项。
以下代码可正常运行,已接近预期效果:
import os from PyQt5.QtCore import* from PyQt5.QtWidgets import* from PyQt5 import QtTest class To_Display_Folder(QSortFilterProxyModel): def __init__(self, disables=False, parent=None): super().__init__(parent) self._disables = bool(disables) self._root_path = "" def filterAcceptsRow(self, source_row, source_parent): source_model = self.sourceModel() if self._root_path and isinstance(source_model, QFileSystemModel): root_index = source_model.index(self._root_path).parent() if root_index == source_parent: index = source_model.index(source_row, 0, source_parent) return index.data(QFileSystemModel.FilePathRole) == self._root_path return True @property def root_path(self): return self._root_path @root_path.setter def root_path(self, p): self._root_path = p self.invalidateFilter() def matchIndex(self, index): return (self.sourceModel().isDir(index) or super().filterAcceptsRow(index.row(), index.parent())) def flags(self, index): flags = super().flags(index) if (self._disables and not self.matchIndex(self.mapToSource(index))): flags &= ~Qt.ItemIsEnabled return flags class Widget_Item_from_Proxy(QTreeWidgetItem): def __init__(self, index_in_dirModel, parent = None): super().__init__(parent) self.setText(0, index_in_dirModel.data(QFileSystemModel.FileNameRole)) self.setText(1, index_in_dirModel.data(QFileSystemModel.FilePathRole)) if os.path.isfile(index_in_dirModel.data(QFileSystemModel.FilePathRole)): self.setIcon(0,QApplication.style().standardIcon(QStyle.SP_FileIcon)) else: self.setIcon(0,QApplication.style().standardIcon(QStyle.SP_DirIcon)) class MainWindow(QMainWindow): def __init__(self, parent=None): super().__init__(parent) self.treeWidget = QTreeWidget() self.treeWidget.itemExpanded.connect(self.upon_expansion) self.treeWidget.itemClicked.connect(self.tree_click) # 配置需要在树中展示的目录 self.add_path_to_tree_widget('C:/MyFolder') self.add_path_to_tree_widget('C:/Users/r2d2w/OneDrive/Desktop') self.add_path_to_tree_widget('C:/') self.setCentralWidget(self.treeWidget) def add_path_to_tree_widget(self,path): dirModel = QFileSystemModel() dirModel.setRootPath(QDir.rootPath()) dirModel.directoryLoaded.connect(lambda: self.once_loaded(path, dirModel)) def once_loaded(self, path, dirModel): if dirModel.canFetchMore(dirModel.index(path)): dirModel.fetchMore(dirModel.index(path)) return root_index = dirModel.index(path).parent() proxy = To_Display_Folder(disables = False, parent = dirModel) proxy.setSourceModel(dirModel) proxy.root_path = path proxy_root_index = proxy.mapFromSource(root_index) origin_in_proxy = proxy.index(0,0,parent = proxy_root_index) root_item = Widget_Item_from_Proxy( proxy.mapToSource(origin_in_proxy)) self.treeWidget.addTopLevelItem(root_item) for row in range(0, proxy.rowCount(origin_in_proxy)): proxy_index = proxy.index(row,0,parent = origin_in_proxy) child = Widget_Item_from_Proxy( proxy.mapToSource(proxy_index), parent = self.treeWidget.topLevelItem(self.treeWidget.topLevelItemCount()-1)) dirModel.directoryLoaded.disconnect() @pyqtSlot(QTreeWidgetItem) def upon_expansion(self, treeitem): for i in range(0, treeitem.childCount()): if os.path.isdir(treeitem.child(i).text(1)): self.add_child_path_to_tree_widget(treeitem.child(i)) def add_child_path_to_tree_widget(self,subfolder_item): subfolder_path = subfolder_item.text(1) dirModel = QFileSystemModel() dirModel.setRootPath(QDir.rootPath()) dirModel.directoryLoaded.connect(lambda: self.child_once_loaded(subfolder_item, subfolder_path,dirModel)) def child_once_loaded(self, subfolder_item, subfolder_path, dirModel): if dirModel.canFetchMore(dirModel.index(subfolder_path)): dirModel.fetchMore(dirModel.index(subfolder_path)) return root_index = dirModel.index(subfolder_path).parent() proxy = To_Display_Folder(disables = False, parent = dirModel) proxy.setSourceModel(dirModel) proxy.root_path = subfolder_path proxy_root_index = proxy.mapFromSource(root_index) origin_in_proxy = proxy.index(0,0,parent = proxy_root_index) root_item = Widget_Item_from_Proxy( proxy.mapToSource(origin_in_proxy)) folder_item = subfolder_item.parent() itemIndex = folder_item.indexOfChild(subfolder_item) folder_item.removeChild(subfolder_item) folder_item.insertChild(itemIndex, root_item) for row in range(0, proxy.rowCount(origin_in_proxy)): proxy_index = proxy.index(row,0,parent = origin_in_proxy) child = Widget_Item_from_Proxy( proxy.mapToSource(proxy_index), parent = root_item) dirModel.directoryLoaded.disconnect() @pyqtSlot(QTreeWidgetItem) def tree_click(self, item): print(item.text(0)) print(item.text(1)) if __name__ == "__main__": import sys app = QApplication(sys.argv) w = MainWindow() w.show() sys.exit(app.exec_())
- 部分场景下(尤其是代码中存在
self.add_path_to_tree_widget('C:/')语句时),首次运行程序无法加载展示所有目录,关闭窗口重启后即可恢复正常。该问题成因是QFileSystemModel没有足够时间完成指定文件夹的遍历,需要找到程序化方式修复该加载时序问题。 - 代码中
add_path_to_tree_widget与add_child_path_to_tree_widget、once_loaded与child_once_loaded逻辑高度相似,需要重构精简这些重复函数,提升代码简洁度。
问题1解决方案:修复加载时序问题
现有代码的时序bug核心是两点:一是每次加载路径都新建QFileSystemModel实例,重复初始化带来异步加载状态不可控;二是directoryLoaded信号触发后,单次fetchMore无法保证拉完大目录(比如C盘根目录)的所有子项,直接return等待下一次信号很容易因为信号断开或者事件循环调度问题导致加载中断。
修复方案:
- 全局复用一个
QFileSystemModel实例,避免重复初始化的开销和状态混乱。 - 增加加载状态标记,避免同一路径重复触发加载。
- 拉取子项时增加循环校验,配合短时间的事件循环处理,确保当前路径所有子项都拉取完成后再生成节点,不要依赖多次信号触发。
核心修复代码片段:
# 在MainWindow __init__中初始化全局复用的模型 self.fs_model = QFileSystemModel() self.fs_model.setRootPath(QDir.rootPath()) self._loading = set() # 记录正在加载的路径,避免重复加载 def _wait_path_loaded(self, path): idx = self.fs_model.index(path) # 循环拉取直到没有更多子项 while self.fs_model.canFetchMore(idx): self.fs_model.fetchMore(idx) # 短时间处理事件循环,给模型加载留时间,不阻塞UI QApplication.processEvents(QEventLoop.ExcludeUserInputEvents, 30)
问题2解决方案:重构重复逻辑
两套加载逻辑的唯一差异是节点挂载的目标位置:顶级路径挂载到QTreeWidget根层,子路径挂载到对应的父QTreeWidgetItem下。完全可以抽成通用函数,通过传入父容器参数统一处理,不需要维护两套重复代码。
重构思路:
- 抽离通用加载函数
_load_path,接收path和parent两个参数,parent支持传入QTreeWidget(顶级节点场景)或QTreeWidgetItem(子节点场景)。 - 文件夹节点默认加一个空的占位子项,标记为未加载状态,展开时判断标记触发加载,加载完成后替换占位项即可,不需要单独写子节点加载的特殊逻辑。
- 节点生成、代理过滤、子项遍历的逻辑全部放到通用函数里,根据父容器类型选择调用
addTopLevelItem还是addChild。
重构后的核心结构片段:
def add_top_level_path(self, path): # 顶级路径直接传treeWidget作为父容器 self._load_path(path, self.treeWidget) def _on_item_expanded(self, item): # 判断是否是未加载的文件夹节点,是则触发加载 if item.data(0, Qt.UserRole+1) == "not_loaded": self._load_path(item.text(1), item) def _load_path(self, path, parent): if path in self._loading: return self._loading.add(path) self._wait_path_loaded(path) # 统一走代理过滤、节点生成逻辑 # ... 省略原有代理、节点生成代码 if isinstance(parent, QTreeWidget): parent.addTopLevelItem(root_item) else: # 替换原有占位节点 idx = parent.parent().indexOfChild(parent) parent.parent().removeChild(parent) parent.parent().insertChild(idx, root_item) self._loading.discard(path)
内容的提问来源于stack exchange,提问作者温泽海

