Qt: 自定义 QTreeView(2)- 显示自定义 widget 以及 GIF

Qt: 自定义 QTreeView(2)- 显示自定义 widget 以及 GIF

本文所涉及代码:https://github.com/funway/TestQTreeView

一、自定义 Widget

由于我们的自定义 widget 是由几种内建 widget 组合而来,所以不需要在 paintEvent() 方法中进行手工绘制。parent widget 会自动调用 child widgets 的绘制方法 paintEvent()

注意,在 QLabel 中加载要缩放的图片时,最好用 QLabel.setPixmap( QIcon().pixmap(w, h) ),而不是 QLabel.setPixmap( QPixmap.scaled(w, h) )

「代码-1」

import logging, sys, random

from PySide6 import QtCore
from PySide6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget, \
    QLabel, QVBoxLayout, QHBoxLayout
from PySide6.QtGui import QIcon, QMovie, QFont

class MyLabel(QLabel):
    """docstring for MyLabel."""
    def __init__(self, arg):
        super(MyLabel, self).__init__(arg)
        self.logger = logging.getLogger(self.__class__.__name__)
        self.logger.debug('Init a %s instance' % self.__class__.__name__)

    def paintEvent(self, event):
        self.logger.debug('my label paintevent: %s', event.rect())
        
        # 调用父类方法进行绘制
        super().paintEvent(event)
        pass
    

class TaskInfoWidget(QWidget):
    """自定义 Widget。包含 icon,title, description 三个部分"""
    def __init__(self, title: str, description: str = '任务描述...', icon: QIcon = None, parent: QWidget = None):
        super(TaskInfoWidget, self).__init__(parent)
        self.logger = logging.getLogger(self.__class__.__name__)
        self.logger.debug('Init a %s instance' % self.__class__.__name__)

        # 1. 图标
        self.label_icon = MyLabel(self)
        self.label_icon.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
        self.label_icon.setFixedWidth(40)
        
        # 1.1 QLabel 加载图片
        ## 使用 QIcon 来获得缩放后的 QPixmap
        # self.label_icon.setPixmap(QIcon("/Users/funway/project/python/TransferDog/transfer_dog/ui/resources/icons/dog.png").pixmap(32, 32))
        
        ### 注意!不要像下面这样直接使用 QPixmap.scaled() 来缩放图标,scaled 方法的效果并不理想!(好像)
        ## p = QtGui.QPixmap("/Users/funway/project/python/TransferDog/transfer_dog/ui/resources/icons/dog.png")
        ## self.label_icon.setPixmap(p.scaled(32, 32, transformMode=QtCore.Qt.TransformationMode.SmoothTransformation))
        
        # 1.2 QLabel 加载 gif
        self.movie = QMovie("/Users/funway/project/python/TransferDog/transfer_dog/ui/resources/icons/loading.gif")
        self.movie.setScaledSize(QtCore.QSize(32, 32))
        self.movie.setCacheMode(QMovie.CacheMode.CacheAll)
        self.label_icon.setMovie(self.movie)
        self.movie.start()

        # 2. title
        self.label_title = QLabel(self)
        font = QFont()
        font.setPointSize(24)
        self.label_title.setFont(font)
        self.label_title.setText(title)

        # 3. description
        self.label_description = QLabel(self)
        self.label_description.setText(description)  # QLabel.setText() 是支持富文本的
        
        self.verticalLayout = QVBoxLayout()
        self.verticalLayout.addWidget(self.label_title)
        self.verticalLayout.addWidget(self.label_description)

        self.horizontalLayout = QHBoxLayout(self)
        self.horizontalLayout.addWidget(self.label_icon)
        self.horizontalLayout.addLayout(self.verticalLayout)

        self.label_icon.setStyleSheet('background-color: #{:06x}'.format(random.randint(0, 0xFFFFFF)))
        self.label_title.setStyleSheet('background-color: #{:06x}'.format(random.randint(0, 0xFFFFFF)))
        self.label_description.setStyleSheet('background-color: #{:06x}'.format(random.randint(0, 0xFFFFFF)))
        
        self.verticalLayout.setSpacing(0)
        self.verticalLayout.setContentsMargins(0, 0, 0, 0)
        self.horizontalLayout.setSpacing(0)
        self.horizontalLayout.setContentsMargins(0, 0, 0, 0)
        
        pass
    
    def paintEvent(self, event):
        """所有 QWidget 绘制的时候,都是调用 paintEvent() 方法进行绘制。

        如果继承自某个内建 widget(比如 QLabel, QPushButton 这些),那么重写该方法将会覆盖父类的绘制行为。
        
        此外!!!如果该 widget 还有 child widgets 的话,在执行完自己的 paintEvent() 之后,
        还会接着自动调用所有 child widgets 的 paintEvent()!
        (其实并不是所有,只需要调用与该 event.rect() 有交集的 child widgets 的 paintEvent() 方法。)

        对于 TaskInfoWidget,我们不需要在此手工绘制什么,只需由其 child widgets 自行绘制即可。
        Args:
            event (_type_): _description_
        """
        self.logger.debug('paint event: %s', event.rect())
        pass

def test_show_TaskInfoWidget():
    app = QApplication(sys.argv)

    main_window = QMainWindow()
    main_window.setWindowTitle('TaskInfoWidget Test')
    main_window.setCentralWidget(TaskInfoWidget('任务标题', '任务详情描述...'))
    main_window.show()
    sys.exit(app.exec())
    pass


if __name__ == '__main__':
    log_format = '%(asctime)s pid[%(process)d] %(levelname)7s %(name)s.%(funcName)s - %(message)s'
    logging.basicConfig(level=logging.DEBUG, format=log_format)
    test_show_TaskInfoWidget()

 

二、使用 QTreeView.setIndexWidget() 显示自定义 widget

「代码-2」

import logging, sys

from PySide6 import QtCore
from PySide6.QtWidgets import QApplication, QMainWindow, QTreeView, QLineEdit, QVBoxLayout, QWidget, \
    QAbstractItemView, QVBoxLayout
from PySide6.QtGui import QStandardItemModel, QStandardItem

from mywidget import TaskInfoWidget

class MainWindow(QMainWindow):
    def __init__(self):
        super(MainWindow, self).__init__()
        self.logger = logging.getLogger(self.__class__.__name__)
        self.logger.debug('Init a %s instance' % self.__class__.__name__)

        self.treeview = QTreeView()
        self.treeview.setHeaderHidden(True)
        self.treeview.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
        
        # 定义数据
        self.treemodel = QStandardItemModel()
        # 根节点
        rootItem = self.treemodel.invisibleRootItem()
        # 一级节点
        gp1 = QStandardItem('TG_Default')
        gp2 = QStandardItem('TG_Test')
        # 二级节点
        tk11 = QStandardItem('t_任务1')
        tk12 = QStandardItem('t_<span style="color:red;"><b>任务</b></span>task2')
        tk13 = QStandardItem('t_资料收集333')
        tk21 = QStandardItem('t_发送测试')
        tk22 = QStandardItem('t_collection 1')

        rootItem.appendRow(gp1)
        rootItem.appendRow(gp2)
        self.logger.info('tk11 index(未加入 itemmodel 前): %s', tk11.index())
        gp1.appendRow(tk11)
        self.logger.info('tk11 index(加入 itemmodel 后): %s', tk11.index())
        gp1.appendRow(tk12)
        gp1.appendRow(tk13)
        gp2.appendRow(tk21)
        gp2.appendRow(tk22)

        # 给 QTreeView 设置数据
        self.treeview.setModel(self.treemodel)
        self.treeview.setIndexWidget(tk11.index(), TaskInfoWidget(tk11.text()))
        self.treeview.setIndexWidget(tk12.index(), TaskInfoWidget(tk12.text()))
        self.treeview.setIndexWidget(tk13.index(), TaskInfoWidget(tk13.text()))
        self.treeview.setIndexWidget(tk21.index(), TaskInfoWidget(tk21.text()))
        self.treeview.setIndexWidget(tk22.index(), TaskInfoWidget(tk22.text()))

        # 展开所有节点
        self.treeview.expandAll()
        # 连接 信号-槽函数
        self.treeview.doubleClicked.connect(self.on_doubleclicked)

        # 搜索栏
        self.ui_search = QLineEdit()
        self.ui_search.setPlaceholderText('Search...')
        self.ui_search.textChanged.connect(self.on_search_text_changed)

        main_layout = QVBoxLayout()
        main_layout.addWidget(self.ui_search)
        main_layout.addWidget(self.treeview)
        widget = QWidget()
        widget.setLayout(main_layout)
        self.setCentralWidget(widget)

    def on_doubleclicked(self, index:QtCore.QModelIndex):
        """槽函数,响应 QTreeView 的 doubleClicked() 信号,
        该信号会向槽函数传递 QModelIndex 对象,代表被双击的节点

        Args:
            index (QtCore.QModelIndex): 被双击的节点
        """
        self.logger.debug('double clicked on [%s, %s]', index.row(), index.column())
        self.logger.debug('data(Default): %s', index.data())
        self.logger.debug('data(UserRole): %s', index.data(role=QtCore.Qt.ItemDataRole.UserRole))

    def on_search_text_changed(self, text):
        self.logger.debug('search text changed: %s', text)
        # 执行 fitler 操作 ...
        self.treeview.expandAll()
        pass

def main():
    logging.info('Start main process')
    # 生成QApplication主程序
    app = QApplication(sys.argv)

    # 生成窗口类实例
    main_window = MainWindow()
    # 设置窗口标题
    main_window.setWindowTitle('QTreeView Test')
    # 设置窗口大小
    main_window.resize(400, 500)
    # 显示窗口
    main_window.show()

    # 进入QApplication的事件循环
    sys.exit(app.exec())
    
    pass


if __name__ == '__main__':
    log_format = '%(asctime)s pid[%(process)d] %(levelname)7s %(name)s.%(funcName)s - %(message)s'
    logging.basicConfig(level=logging.DEBUG, format=log_format)
    main()

使用 setIndexWidget(index, widget) 实现起来就很简单(而且不会遇到后文要说的 GIF 不刷新的问题)。

同样类似的还有 QTreeWidget.setItemWidget(item, column, widget) (看了下 Qt 源码,它底层也是调用了 setIndexWidget)。

但是使用 setIndexWidget(index, widget) 有一个问题就是,它很难与数目动态变化,顺序动态变化的 ItemModel 兼容(比如 QSortFilterProxyModel)。因为 setIndexWidget(index, widget) 的时候,index 是针对现有排序的。但这些动态变化的ItemModel,重新排序,或者过滤之后,每个 item 的 index 就变了!除非每次变化之后,重新设置 setIndexWidget。但这样实现起来太麻烦了。

按官方的说法就是:

This function should only be used to display static content within the visible area corresponding to an item of data. If you want to display custom dynamic content or implement a custom editor widget, subclass QStyledItemDelegate instead.

 

三、使用 QStyledItemDelegate.paint() 显示自定义 widget

关键点:

1、将 widget 与 item 关联,作为 QStandardItem 的成员变量。并设置为 data(role=UserRole),方便后面在 delegate 中获取 widget。

2、使用 QWidget.render() 方法进行绘制。(注意!QWidget.render() 有个绘制坐标错误的 BUG,详见下面代码)

「代码-3」

import logging, sys

from PySide6 import QtCore
from PySide6.QtWidgets import QApplication, QMainWindow, QTreeView, QLineEdit, QVBoxLayout, QWidget, \
    QStyledItemDelegate, QAbstractItemView, QVBoxLayout
from PySide6.QtGui import QStandardItemModel, QStandardItem, QIcon

from mywidget import TaskInfoWidget

class SearchProxyModel(QtCore.QSortFilterProxyModel):
    def __init__(self):
        super().__init__()
        self.logger = logging.getLogger(self.__class__.__name__)
        self.logger.debug('Init a %s instance' % self.__class__.__name__)
        pass

    def __accept_index(self, idx:QtCore.QModelIndex) -> bool:
        """判断 idx 节点(包括子节点)是否匹配 self.filterRegularExpression。节点(包括子节点)只要有一个能匹配到,就返回 True。

        Args:
            idx (QtCore.QModelIndex): 节点的 QModelIndex 对象

        Returns:
            bool: 匹配返回 True,否则返回 False
        """

        if idx.isValid():
            text = idx.data(QtCore.Qt.ItemDataRole.DisplayRole)
            if self.filterRegularExpression().match(text).hasMatch():
                return True
            # 递归对子节点进行判断
            for row in range(idx.model().rowCount(idx)):
                if self.__accept_index(idx.model().index(row, 0, idx)):
                    return True
        return False

    def filterAcceptsRow(self, sourceRow:int, sourceParent:QtCore.QModelIndex):
        """重写父类方法,判断节点是否满足过滤条件。
        节点可以通过 sourceParent[sourceRow] 定位
        过滤条件可以从 QSortFilterProxyModel.filterRegularExpression 属性获取
        
        !!注意!!:
        filter 的行为逻辑是:待过滤节点以先进先出的队列等待过滤。先过滤所有上级节点。如果某个节点匹配,则将其所有子节点放入待过滤队列。如果上级节点不匹配,则忽略它的所有子节点。
        如果设置了 setRecursiveFilteringEnabled(True),那么当上级节点不匹配时,依然会递归它的子节点。(不管 recursiveFilteringEnabled 是 True 还是 False,当上级节点匹配时,依然会递归其子节点。)

        Args:
            sourceRow (int): 节点的在父节点的第几行
            sourceParent (QtCore.QModelIndex): 父节点

        Returns:
            _type_: 满足过滤条件返回 True,否则返回 False
        """
        idx = self.sourceModel().index(sourceRow, 0, sourceParent)
        if self.__accept_index(idx):
            self.logger.debug('匹配: %s', self.sourceModel().data(idx, role=QtCore.Qt.ItemDataRole.DisplayRole))
            return True
        else:
            self.logger.debug('不匹配: %s', self.sourceModel().data(idx, role=QtCore.Qt.ItemDataRole.DisplayRole))
            return False
        
class TaskInfoItem(QStandardItem):
    """表示在 Model 中的每一个 item 项"""
    def __init__(self, title: str, description: str = '任务描述...', icon: QIcon = None):
        super(TaskInfoItem, self).__init__(title)
        self.logger = logging.getLogger(self.__class__.__name__)
        self.logger.debug('Init a %s instance. title[%s]', self.__class__.__name__, title)

        self.setEditable(False)
        
        self.widget = TaskInfoWidget(title, description, icon)
        self.setData(self.widget, role=QtCore.Qt.ItemDataRole.UserRole)
        # TaskInfoWidget 必须先初始化。如果像下面这样调用 TaskInfoWidget,实际上是不会初始化 widget 实例的。🤷‍♂️
        # self.setData(TaskInfoWidget(title, description, icon), role=QtCore.Qt.ItemDataRole.UserRole)

        # if self.widget.label_icon.movie() is not None:
        #     # emitDataChanged 会触发 treeview 单独重绘制该 item(并非重绘整个 treeview)
        #     self.widget.label_icon.movie().frameChanged.connect(self.emitDataChanged)
        #     pass
        pass


class TaskInfoDelegate(QStyledItemDelegate):
    """docstring for TaskInfoDelegate."""
    def __init__(self, parent=None):
        super(TaskInfoDelegate, self).__init__(parent)
        
        self.logger = logging.getLogger(self.__class__.__name__)
        self.logger.debug('Init a %s instance' % self.__class__.__name__)
        pass

    def paint(self, painter, option, index):
        """如果重写 paint 方法,派生类就需要自己负责绘制所有内容,包括 文字、图标、背景色 等等。

        Args:
            painter (QtGui.QPainter): 画笔(建议在绘制前 painter.save 保存画笔状态,绘制后 painter.restore 恢复画笔状态)
            option (QtWidgets.QStyleOptionViewItem): 需要通过 initStyleOption() 方法初始化
            index (QtCore.QModelIndex): 待绘制 item 的 ModelIndex
        """

        self.logger.debug('============ 开始绘制 ==============')

        # 1. 初始化 option (QStyleOptionViewItem 类型)
        self.initStyleOption(option, index)
        self.logger.debug('初始化后 option[text]: %s', option.text)
        self.logger.debug('初始化后 option[rect]: %s', option.rect)
        
        # 2. 判断是否是一级节点。如果是,则直接调用父类 paint 并返回
        if index.parent().isValid() == False:
            self.logger.debug('这是一级节点(任务组)')
            return super().paint(painter, option, index)
    
        # 3. 绘制二级节点
        self.logger.debug('这是二级节点(任务)')
        task_widget = index.data(role=QtCore.Qt.ItemDataRole.UserRole)
        task_widget.setGeometry(option.rect)
            # 设置 task_widget 相对于其父亲 widget 的位置与矩形。
            # (在这里不设置也行,因为此时的 widget 是没有 parent 的)

        painter.save()

        # task_widget.render(painter, option.rect.topLeft())
        ## 按照 Qt 文档说明,QWidget.render(painter, targetOffset, sourceRegion, ...)
        ## 应该是先在临时画布绘制出 widget,然后截取 sourceRegion 区域,最后将该区域绘制在 painter 的 targetOffset 位置。
        ## 但是实际上 QWidget.render() 一直有个官方 BUG 未解决!(https://bugreports.qt.io/browse/QTBUG-26694)
        ## 它实际绘制时候的 targetOffset,可能会变成是窗口(top-level widget)坐标系,而不是 painter.device() 指向的父 widget 坐标系。

        ## workaround-1
        ## 提前 translate 坐标系,似乎可以修复这个问题。
        painter.translate(option.rect.topLeft())
        task_widget.render(painter, QtCore.QPoint(0, 0))

        ## workaround-2
        ## 加上目标 widget 原点相对于窗口原点的位置。
        # offset = option.widget.mapTo(option.widget.window(), QtCore.QPoint(0, 0))
        # self.logger.debug('QTreeView.viewport() 原点相对于窗口坐标原点的 offset: %s', offset)
        # task_widget.render(painter, option.rect.topLeft() + offset)

        painter.restore()

        pass

    def sizeHint(self, option, index):
        """绘制自定义 widget 时,需要重写 sizeHint 来返回自定义 widget 的大小,来占位。 """
        # 如果是一级节点,返回父类的 sizeHint
        if index.parent().isValid() == False:
            self.logger.debug('一级节点: %s', index.data())
            return super().sizeHint(option, index)
        
        # 如果是二级节点,返回自定义 widget 的 sizeHint
        self.logger.debug('二级节点: %s', index.data())
        task_widget = index.data(role=QtCore.Qt.ItemDataRole.UserRole)
        return task_widget.sizeHint()


class MainWindow(QMainWindow):
    def __init__(self):
        super(MainWindow, self).__init__()
        self.logger = logging.getLogger(self.__class__.__name__)
        self.logger.debug('Init a %s instance' % self.__class__.__name__)

        self.treeview = QTreeView()
        self.treeview.setHeaderHidden(True)
        self.treeview.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
        
        # 定义数据
        self.treemodel = QStandardItemModel()
        # 根节点
        rootItem = self.treemodel.invisibleRootItem()
        # 一级节点
        gp1 = QStandardItem('TG_Default')
        gp2 = QStandardItem('TG_Test')
        # 二级节点
        tk11 = TaskInfoItem('t_任务1')
        tk12 = TaskInfoItem('t_<span style="color:red;"><b>任务</b></span>task2')
        tk13 = TaskInfoItem('t_资料收集333')
        tk21 = TaskInfoItem('t_发送测试')
        tk22 = TaskInfoItem('t_collection 1')

        rootItem.appendRow(gp1)
        rootItem.appendRow(gp2)
        gp1.appendRow(tk11)
        gp1.appendRow(tk12)
        gp1.appendRow(tk13)
        gp2.appendRow(tk21)
        gp2.appendRow(tk22)

        # 定义 ProxyModel
        self.proxymodel = SearchProxyModel()
        self.proxymodel.setSourceModel(self.treemodel)
        self.proxymodel.setFilterCaseSensitivity(QtCore.Qt.CaseSensitivity.CaseInsensitive)

        # 给 QTreeView 设置数据
        self.treeview.setModel(self.proxymodel)

        # 给 QTreeView 设置自定义的 ItemDelegate
        delegate = TaskInfoDelegate()
        self.treeview.setItemDelegate(delegate)
        # 展开所有节点
        self.treeview.expandAll()

        # 搜索栏
        self.ui_search = QLineEdit()
        self.ui_search.setPlaceholderText('Search...')
        self.ui_search.textChanged.connect(self.on_search_text_changed)

        main_layout = QVBoxLayout()
        main_layout.addWidget(self.ui_search)
        main_layout.addWidget(self.treeview)
        widget = QWidget()
        widget.setLayout(main_layout)
        self.setCentralWidget(widget)

    def on_search_text_changed(self, text):
        self.logger.debug('search text changed: %s', text)
        self.proxymodel.setFilterRegularExpression(self.ui_search.text())
        self.treeview.expandAll()
        pass

def main():
    logging.info('Start main process')
    # 生成QApplication主程序
    app = QApplication(sys.argv)

    # 生成窗口类实例
    main_window = MainWindow()
    # 设置窗口标题
    main_window.setWindowTitle('QTreeView Test')
    # 设置窗口大小
    main_window.resize(400, 500)
    # 显示窗口
    main_window.show()

    # 进入QApplication的事件循环
    sys.exit(app.exec())
    pass


if __name__ == '__main__':
    log_format = '%(asctime)s pid[%(process)d] %(levelname)7s %(name)s.%(funcName)s - %(message)s'
    logging.basicConfig(level=logging.INFO, format=log_format)
    main()

在上面的代码中,我们在 delegate.paint() 方法中通过 widget.render() 绘制自定义 widget。如果我们的自定义 TaskInfoWidget 使用静态图片作为图标的话,上述代码运行的很完美,符合预期。但是如果在 TaskInfoWidget 中使用 QMovie 加载 GIF 作为图标的话,会发现其中的 GIF 不会自动刷新。除非手动缩放窗口,点击 widget,GIF 才会动一下。

这并不是我所预期的结果。所以我们必须找出原因,想办法解决这个问题。

在后续不断尝试的过程中,还发现了另一个问题。由于测试用的 GIF 图片帧率比较高,很容易就发现该测试程序 CPU 占用率高。所以我们还得想办法在刷新 GIF 的同时,把程序的 CPU 开销降下来。

四、为什么 QTreeView 中的 GIF 不刷新?如何解决?

要想了解 QTreeView 中自定义 widget 的 GIF 图片为什么不刷新,就应该了解下 QWidget 的重绘机制。参考我这两天总结的另一篇文章《Qt:QWidget 的绘制逻辑(源码分析)》

大概总结一下原因,就是 QMovie 的刷新,是通过向其所在的 QLabel 对象发送 update(rect) 信号,调用 QLabel 的 QWidget.update(rect) 来响应该信号。而所有 QWidget.update(rect) 其实是需要层层向上,由顶层窗口 tlw 将该 rect 区域标脏,然后由顶层窗口 tlw 响应 QCoreApplication 的全局 UpdateRequest 事件进行绘制。最后实际的绘制动作,是由 tlw 层层往下,先调用父 wiget 的 paintEvent(),再递归调用子 wiget 的 paintEvent()。

在「代码-1」中,因为我们的 QTaskInfoWidget 的父 widget 就是主窗口,亦即 tlw,所以最终的重绘动作会传递到 QTaskInfoWidget.label_icon。

在「代码-3」中,由于我们是在 delegate.paint() 方法自行绘制 QTaskInfoWidget,并没有给它设置父 widget。那么实际上它与 tlw 是无关的,它的 update 信号在 tlw.markDirty() 时就就被丢弃了。所以根本不会触发 tlw 的重绘,也就看不到 GIF 的刷新了。

那么我们该如何解决这个 GIF 不刷新的问题呢?

  1. 可以给 TaskInfoWidgt 设置父 widget 为 QTreeView,这样就能将 QMovie 的 update 信号传递到 tlw,由 tlw 层层向下自动进行重绘。
  2. 可以自行捕获 QMovie 的 update 信号,然后手动告知 QTreeView 需要更新某个区域。

实际上,我测试了三种不同的方式实现 GIF 的刷新。由于测试用的 GIF 帧率较高,导致程序的 CPU 开销也明显较大(比一般程序都大)。为此我还特意比较了不同实现方式的 CPU 开销,以「代码-2」中 QtreeView.setIndexWidget() 的实现方式作为基准点。它的 CPU 占用率大概在 15%。

4.1 给每个自定义 widget.setParent(treeview)   # acceptable 🟡

代码最复杂(有点 trick), CPU 占用率最低。

还需要自行处理 item 的 click, expand, collapse 事件。

使用这种方式,发生 GIF 重绘的时候,只会重绘 TaskInfoWidget.label_icon,并不会重绘 TaskInfoWidget 的其他两个 child widgets。所以它的 CPU 开销是最小的。

关键点:

① 在 treeview 设置 model 的时候,将 treeview 设置为所有 item widget 的父亲。widget.setParent(self.treeview.viewport())。这样,treeview 的重绘就会自动调用 child widgets 的重绘。但此时 child widget 的显示位置都是挂载在 parent widget 的原点。

② 在 delegate.paint() 方法中,通过 task_widget.setGeometry(option.rect) 设置 item widget 相对于 treeview 的正确位置。

③ 然后在 delegate.paint() 方法中,千万不要主动调用 task_widget.render() 来绘制 item widget。因为 treeview 的绘制链,在绘制完自身后(应该是由 treeview.paintEvent 调用 delegate.paint),会自动调用对 child widgets 的绘制。(如果在 paint 中主动绘制 task_widget,那么就与 child widgets 重复了。)

④ 在每次 QSortFilterProxyModel.filterAcceptsRow() 过滤 item 的时候,将被过滤掉的 item widget 设置为 invisible,留下来的 item widget 设置为 visible(可以使用 QWidget.show() 与 QWidget.hide() 方法)。这样被过滤掉的 item 就不会在 tlw 中被绘制了。

import logging, sys

from PySide6 import QtCore
from PySide6.QtWidgets import QApplication, QMainWindow, QTreeView, QLineEdit, QVBoxLayout, QWidget, \
    QStyledItemDelegate, QAbstractItemView, QVBoxLayout
from PySide6.QtGui import QStandardItemModel, QStandardItem, QIcon

from mywidget import TaskInfoWidget

class SearchProxyModel(QtCore.QSortFilterProxyModel):
    def __init__(self):
        super().__init__()
        self.logger = logging.getLogger(self.__class__.__name__)
        self.logger.debug('Init a %s instance' % self.__class__.__name__)
        pass

    def __accept_index(self, idx:QtCore.QModelIndex) -> bool:
        if idx.isValid():
            text = idx.data(QtCore.Qt.ItemDataRole.DisplayRole)
            tw = idx.data(role=QtCore.Qt.ItemDataRole.UserRole)
            if self.filterRegularExpression().match(text).hasMatch():
                if tw is not None:
                    tw.setVisible(True)
                return True
            else:
                if tw is not None:
                    tw.setVisible(False)

            # 递归对子节点进行判断
            for row in range(idx.model().rowCount(idx)):
                if self.__accept_index(idx.model().index(row, 0, idx)):
                    return True
        return False

    def filterAcceptsRow(self, sourceRow:int, sourceParent:QtCore.QModelIndex):
        idx = self.sourceModel().index(sourceRow, 0, sourceParent)
        if self.__accept_index(idx):
            self.logger.debug('匹配: %s', self.sourceModel().data(idx, role=QtCore.Qt.ItemDataRole.DisplayRole))
            return True
        else:
            self.logger.debug('不匹配: %s', self.sourceModel().data(idx, role=QtCore.Qt.ItemDataRole.DisplayRole))
            return False
        
class TaskInfoItem(QStandardItem):
    """表示在 Model 中的每一个 item 项"""
    def __init__(self, title: str, description: str = '任务描述...', icon: QIcon = None):
        super(TaskInfoItem, self).__init__(title)
        self.logger = logging.getLogger(self.__class__.__name__)
        self.logger.debug('Init a %s instance. title[%s]', self.__class__.__name__, title)

        self.setEditable(False)
        
        self.widget = TaskInfoWidget(title, description, icon)
        self.setData(self.widget, role=QtCore.Qt.ItemDataRole.UserRole)
        # TaskInfoWidget 必须先初始化。如果像下面这样调用 TaskInfoWidget,实际上是不会初始化 widget 实例的。🤷‍♂️
        # self.setData(TaskInfoWidget(title, description, icon), role=QtCore.Qt.ItemDataRole.UserRole)
        pass

class TaskInfoDelegate(QStyledItemDelegate):
    """docstring for TaskInfoDelegate."""
    def __init__(self, parent=None):
        super(TaskInfoDelegate, self).__init__(parent)
        
        self.logger = logging.getLogger(self.__class__.__name__)
        self.logger.debug('Init a %s instance' % self.__class__.__name__)
        pass

    def paint(self, painter, option, index):
        """如果重写 paint 方法,派生类就需要自己负责绘制所有内容,包括 文字、图标、背景色 等等。

        Args:
            painter (QtGui.QPainter): 画笔(建议在绘制前 painter.save 保存画笔状态,绘制后 painter.restore 恢复画笔状态)
            option (QtWidgets.QStyleOptionViewItem): 需要通过 initStyleOption() 方法初始化
            index (QtCore.QModelIndex): 待绘制 item 的 ModelIndex
        """

        self.logger.debug('============ 开始绘制 ==============')

        # 1. 初始化 option (QStyleOptionViewItem 类型)
        self.initStyleOption(option, index)
        self.logger.debug('初始化后 option[text]: %s', option.text)
        self.logger.debug('初始化后 option[rect]: %s', option.rect)
        
        # 2. 判断是否是一级节点。如果是,则直接调用父类 paint 并返回
        if index.parent().isValid() == False:
            self.logger.debug('这是一级节点(任务组)')
            return super().paint(painter, option, index)
    
        # 3. 绘制二级节点
        self.logger.debug('这是二级节点(任务)')
        task_widget = index.data(role=QtCore.Qt.ItemDataRole.UserRole)
        task_widget.setGeometry(option.rect)
        # 由于设置了 task_widget 的父 widget 是 treeview。
        # 所以必须设置 task_widget 相对于其父 widget 的位置与矩形。由 tlw > parent widget > child widget 这样的绘制链自动绘制。
        # 因此,我们也就不需要在此主动调用 task_widget.render() 进行手工绘制了。
        pass

    def sizeHint(self, option, index):
        """绘制自定义 widget 时,需要重写 sizeHint 来返回自定义 widget 的大小,来占位。 """
        # 如果是一级节点,返回父类的 sizeHint
        if index.parent().isValid() == False:
            self.logger.debug('一级节点: %s', index.data())
            return super().sizeHint(option, index)
        
        # 如果是二级节点,返回自定义 widget 的 sizeHint
        self.logger.debug('二级节点: %s', index.data())
        task_widget = index.data(role=QtCore.Qt.ItemDataRole.UserRole)
        return task_widget.sizeHint()

class MainWindow(QMainWindow):
    def __init__(self):
        super(MainWindow, self).__init__()
        self.logger = logging.getLogger(self.__class__.__name__)
        self.logger.debug('Init a %s instance' % self.__class__.__name__)

        self.treeview = QTreeView()
        self.treeview.setHeaderHidden(True)
        self.treeview.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
        
        # 定义数据
        self.treemodel = QStandardItemModel()
        # 根节点
        rootItem = self.treemodel.invisibleRootItem()
        # 一级节点
        gp1 = QStandardItem('TG_Default')
        gp2 = QStandardItem('TG_Test')
        # 二级节点
        tk11 = TaskInfoItem('t_任务1')
        tk12 = TaskInfoItem('t_<span style="color:red;"><b>任务</b></span>task2')
        tk13 = TaskInfoItem('t_资料收集333')
        tk21 = TaskInfoItem('t_发送测试')
        tk22 = TaskInfoItem('t_collection 1')

        rootItem.appendRow(gp1)
        rootItem.appendRow(gp2)
        gp1.appendRow(tk11)
        gp1.appendRow(tk12)
        gp1.appendRow(tk13)
        gp2.appendRow(tk21)
        gp2.appendRow(tk22)

        tk11.widget.setParent(self.treeview.viewport())
        tk12.widget.setParent(self.treeview.viewport())
        tk13.widget.setParent(self.treeview.viewport())
        tk21.widget.setParent(self.treeview.viewport())
        tk22.widget.setParent(self.treeview.viewport())

        # 定义 ProxyModel
        self.proxymodel = SearchProxyModel()
        self.proxymodel.setSourceModel(self.treemodel)
        self.proxymodel.setFilterCaseSensitivity(QtCore.Qt.CaseSensitivity.CaseInsensitive)

        # 给 QTreeView 设置数据
        self.treeview.setModel(self.proxymodel)

        # 给 QTreeView 设置自定义的 ItemDelegate
        delegate = TaskInfoDelegate()
        self.treeview.setItemDelegate(delegate)
        # 展开所有节点
        self.treeview.expandAll()

        # 搜索栏
        self.ui_search = QLineEdit()
        self.ui_search.setPlaceholderText('Search...')
        self.ui_search.textChanged.connect(self.on_search_text_changed)

        main_layout = QVBoxLayout()
        main_layout.addWidget(self.ui_search)
        main_layout.addWidget(self.treeview)
        widget = QWidget()
        widget.setLayout(main_layout)
        self.setCentralWidget(widget)

    def on_search_text_changed(self, text):
        self.logger.debug('search text changed: %s', text)
        self.proxymodel.setFilterRegularExpression(self.ui_search.text())
        self.treeview.expandAll()
        pass

def main():
    logging.info('Start main process')
    # 生成QApplication主程序
    app = QApplication(sys.argv)

    # 生成窗口类实例
    main_window = MainWindow()
    # 设置窗口标题
    main_window.setWindowTitle('QTreeView Test')
    # 设置窗口大小
    main_window.resize(400, 500)
    # 显示窗口
    main_window.show()

    # 进入QApplication的事件循环
    sys.exit(app.exec())
    pass

if __name__ == '__main__':
    log_format = '%(asctime)s pid[%(process)d] %(levelname)7s %(name)s.%(funcName)s - %(message)s'
    logging.basicConfig(level=logging.INFO, format=log_format)
    main()

 

4.2 connect(QMovie.frameChanged,  QStandardItem.emitDataChanged)   # acceptable 🟡

代码最简单,CPU 占用率稍高。

无需再自己实现 click, expand, collapse 事件。

关键点:

① 只需要将 QMovie 的 frameChanged 信号绑定到 item.emitDataChanged()。那么它就会自动告知 treeview 这个 item 的数据变了,需要重绘。

整个事件的传播链条大概是这样的:

QMovie.frameChanged >> QStandardItem.emitDataChanged >> QStandardItemModel.itemChanged(item) >> QTreeView.datachanged(index) >> QAbstractItemView.update(index)

② 这种方式,treeview 只会重绘 dataChanged 对应的那一项 item。但是不能再拆分了,因为 treeview 并不知道它的 item widget 还能再拆分,只会认为 item widget 就是由 delegate.paint() 绘制的。

亦即 item 中的 TaskInfoWidget 是整个重绘的(在 delegate.paint() 方法中调用 task_widget.render()),不能只重绘 GIF QLabel。所以这个方法会更耗时一些。

import logging, sys

from PySide6 import QtCore
from PySide6.QtWidgets import QApplication, QMainWindow, QTreeView, QLineEdit, QVBoxLayout, QWidget, \
    QStyledItemDelegate, QAbstractItemView, QVBoxLayout
from PySide6.QtGui import QStandardItemModel, QStandardItem, QIcon

from mywidget import TaskInfoWidget

class SearchProxyModel(QtCore.QSortFilterProxyModel):
    def __init__(self):
        super().__init__()
        self.logger = logging.getLogger(self.__class__.__name__)
        self.logger.debug('Init a %s instance' % self.__class__.__name__)
        pass

    def __accept_index(self, idx:QtCore.QModelIndex) -> bool:
        if idx.isValid():
            text = idx.data(QtCore.Qt.ItemDataRole.DisplayRole)
            if self.filterRegularExpression().match(text).hasMatch():
                return True
            # 递归对子节点进行判断
            for row in range(idx.model().rowCount(idx)):
                if self.__accept_index(idx.model().index(row, 0, idx)):
                    return True
        return False

    def filterAcceptsRow(self, sourceRow:int, sourceParent:QtCore.QModelIndex):
        idx = self.sourceModel().index(sourceRow, 0, sourceParent)
        if self.__accept_index(idx):
            self.logger.debug('匹配: %s', self.sourceModel().data(idx, role=QtCore.Qt.ItemDataRole.DisplayRole))
            return True
        else:
            self.logger.debug('不匹配: %s', self.sourceModel().data(idx, role=QtCore.Qt.ItemDataRole.DisplayRole))
            return False
        
class TaskInfoItem(QStandardItem):
    """表示在 Model 中的每一个 item 项"""
    def __init__(self, title: str, description: str = '任务描述...', icon: QIcon = None):
        super(TaskInfoItem, self).__init__(title)
        self.logger = logging.getLogger(self.__class__.__name__)
        self.logger.debug('Init a %s instance. title[%s]', self.__class__.__name__, title)

        self.setEditable(False)
        
        self.widget = TaskInfoWidget(title, description, icon)
        self.setData(self.widget, role=QtCore.Qt.ItemDataRole.UserRole)
        # TaskInfoWidget 必须先初始化。如果像下面这样调用 TaskInfoWidget,实际上是不会初始化 widget 实例的。🤷‍♂️
        # self.setData(TaskInfoWidget(title, description, icon), role=QtCore.Qt.ItemDataRole.UserRole)

        if self.widget.label_icon.movie() is not None:
            # emitDataChanged 会触发 treeview 单独重绘制该 item(并非重绘整个 treeview)
            self.widget.label_icon.movie().frameChanged.connect(self.emitDataChanged)
            pass
        pass

class TaskInfoDelegate(QStyledItemDelegate):
    """docstring for TaskInfoDelegate."""
    def __init__(self, parent=None):
        super(TaskInfoDelegate, self).__init__(parent)
        
        self.logger = logging.getLogger(self.__class__.__name__)
        self.logger.debug('Init a %s instance' % self.__class__.__name__)
        pass

    def paint(self, painter, option, index):
        self.logger.debug('============ 开始绘制 ==============')

        # 1. 初始化 option (QStyleOptionViewItem 类型)
        self.initStyleOption(option, index)
        self.logger.debug('初始化后 option[text]: %s', option.text)
        self.logger.debug('初始化后 option[rect]: %s', option.rect)
        
        # 2. 判断是否是一级节点。如果是,则直接调用父类 paint 并返回
        if index.parent().isValid() == False:
            self.logger.debug('这是一级节点(任务组)')
            return super().paint(painter, option, index)
    
        # 3. 绘制二级节点
        self.logger.debug('这是二级节点(任务)')
        task_widget = index.data(role=QtCore.Qt.ItemDataRole.UserRole)
        task_widget.setGeometry(option.rect) 
            # 设置 task_widget 相对于其父亲 widget 的位置与矩形。
            # (在这里不设置也行,因为此时的 widget 是没有 parent 的)

        painter.save()
        
        # task_widget.render(painter, option.rect.topLeft())
        ## 按照 Qt 文档说明,QWidget.render(painter, targetOffset, sourceRegion, ...)
        ## 应该是先在临时画布绘制出 widget,然后截取 sourceRegion 区域,最后将该区域绘制在 painter 的 targetOffset 位置。
        ## 但是实际上 QWidget.render() 一直有个官方 BUG 未解决!(https://bugreports.qt.io/browse/QTBUG-26694)
        ## 它实际绘制时候的 targetOffset,可能会变成是窗口(top-level widget)坐标系,而不是 painter.device() 指向的父 widget 坐标系。

        ## workaround-1
        ## 提前 translate 坐标系,似乎可以修复这个问题。
        painter.translate(option.rect.topLeft())
        task_widget.render(painter, QtCore.QPoint(0, 0), renderFlags=QWidget.RenderFlag.DrawChildren)

        ## workaround-2
        ## 加上目标 widget 原点相对于窗口原点的位置。
        # offset = option.widget.mapTo(option.widget.window(), QtCore.QPoint(0, 0))
        # self.logger.debug('QTreeView.viewport() 原点相对于窗口坐标原点的 offset: %s', offset)
        # task_widget.render(painter, option.rect.topLeft() + offset)
        
        painter.restore()
        pass

    def sizeHint(self, option, index):
        """绘制自定义 widget 时,需要重写 sizeHint 来返回自定义 widget 的大小,来占位。 """
        # 如果是一级节点,返回父类的 sizeHint
        if index.parent().isValid() == False:
            self.logger.debug('一级节点: %s', index.data())
            return super().sizeHint(option, index)
        
        # 如果是二级节点,返回自定义 widget 的 sizeHint
        self.logger.debug('二级节点: %s', index.data())
        task_widget = index.data(role=QtCore.Qt.ItemDataRole.UserRole)
        return task_widget.sizeHint()

class MainWindow(QMainWindow):
    def __init__(self):
        super(MainWindow, self).__init__()
        self.logger = logging.getLogger(self.__class__.__name__)
        self.logger.debug('Init a %s instance' % self.__class__.__name__)

        self.treeview = QTreeView()
        self.treeview.setHeaderHidden(True)
        self.treeview.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
        
        # 定义数据
        self.treemodel = QStandardItemModel()
        # 根节点
        rootItem = self.treemodel.invisibleRootItem()
        # 一级节点
        gp1 = QStandardItem('TG_Default')
        gp2 = QStandardItem('TG_Test')
        # 二级节点
        tk11 = TaskInfoItem('t_任务1')
        tk12 = TaskInfoItem('t_<span style="color:red;"><b>任务</b></span>task2')
        tk13 = TaskInfoItem('t_资料收集333')
        tk21 = TaskInfoItem('t_发送测试')
        tk22 = TaskInfoItem('t_collection 1')

        rootItem.appendRow(gp1)
        rootItem.appendRow(gp2)
        gp1.appendRow(tk11)
        gp1.appendRow(tk12)
        gp1.appendRow(tk13)
        gp2.appendRow(tk21)
        gp2.appendRow(tk22)

        # 定义 ProxyModel
        self.proxymodel = SearchProxyModel()
        self.proxymodel.setSourceModel(self.treemodel)
        self.proxymodel.setFilterCaseSensitivity(QtCore.Qt.CaseSensitivity.CaseInsensitive)

        # 给 QTreeView 设置数据
        self.treeview.setModel(self.proxymodel)

        # 给 QTreeView 设置自定义的 ItemDelegate
        delegate = TaskInfoDelegate()
        self.treeview.setItemDelegate(delegate)
        # 展开所有节点
        self.treeview.expandAll()

        # 搜索栏
        self.ui_search = QLineEdit()
        self.ui_search.setPlaceholderText('Search...')
        self.ui_search.textChanged.connect(self.on_search_text_changed)

        main_layout = QVBoxLayout()
        main_layout.addWidget(self.ui_search)
        main_layout.addWidget(self.treeview)
        widget = QWidget()
        widget.setLayout(main_layout)
        self.setCentralWidget(widget)

    def on_search_text_changed(self, text):
        self.logger.debug('search text changed: %s', text)
        self.proxymodel.setFilterRegularExpression(self.ui_search.text())
        self.treeview.expandAll()
        pass

def main():
    logging.info('Start main process')
    # 生成QApplication主程序
    app = QApplication(sys.argv)

    # 生成窗口类实例
    main_window = MainWindow()
    # 设置窗口标题
    main_window.setWindowTitle('QTreeView Test')
    # 设置窗口大小
    main_window.resize(400, 500)
    # 显示窗口
    main_window.show()

    # 进入QApplication的事件循环
    sys.exit(app.exec())
    pass


if __name__ == '__main__':
    log_format = '%(asctime)s pid[%(process)d] %(levelname)7s %(name)s.%(funcName)s - %(message)s'
    logging.basicConfig(level=logging.INFO, format=log_format)
    main()

 

4.3 connect(QMovie.frameChanged, QTreeView.viewport().update(gif_rect))  # wrong 🚫

代码稍复杂, CPU 占用率最高。

既然方法 4.2 中每次重绘都要整个 item widget,有没办法在 delegate.paint() 中只需要重绘 item widget 的 gif 区域呢?我想到了一个方法,但最后发现这其实错误的!

关键点(并不正确):

① 在 delegate.paint() 方法中,获取 gif 的绘制区域,然后通过 frameChanged 信号调用 treeview.viewport().update(gif_rect)。让 treeview 只绘制这个区域。

这里其实有三个谬误!:

❶ 对于 GIF 刷新而言,delegate.paint() 的调用频率太高了。那么每次在 paint() 中都要重新 connect 一次信号-槽,应该是很耗时的。

❷ 在我的实现中,由于 treeview.viewport.update() 是需要参数的,所以我使用了 lambda 函数将其包裹起来。那么每次 connect 的时候,其实都是新建了一个 lambda 函数啊!这既耗时,又费内存!甚至存在内存溢出的风险。

❸ 实际上,我后来才想通,无法找到比方法 4.1 这种 parent >> child 更快的绘制方式了。因为 treeview 本来就必须在 delegate.paint() 中绘制 item widget(方法 4.1 只是在 paint() 中刻意跳过了对 task_widget.render())。即使我们再手动通过 信号-槽 唤起 treeview update(gif_rect) 区域,根据 Qt 的绘制逻辑(可以参考 《Qt:QWidget 的绘制逻辑(源码分析)》),它会判断这个 gif_rect 与 treeview 的某个 item widget 重叠,那么这个 item widget 就必须重绘,它就会调用 delegate.paint() 重绘该 item。甚至在 paint 之前,还会先清除该 item widget 的区域。结果可想而知,你以为你可以只重绘 gif_rect 区域,到头来却依然要重绘整个 item widget,甚至还要做的更多(treeview.viewport().update(rect) 还要计算 rect 跟哪个 item 重叠)。

import logging, sys

from PySide6 import QtCore
from PySide6.QtWidgets import QApplication, QMainWindow, QTreeView, QLineEdit, QVBoxLayout, QWidget, \
    QStyledItemDelegate, QAbstractItemView, QVBoxLayout, QStyle
from PySide6.QtGui import QStandardItemModel, QStandardItem, QIcon

from mywidget import TaskInfoWidget

#############################
# Don't take this method!
#############################

class SearchProxyModel(QtCore.QSortFilterProxyModel):
    def __init__(self):
        super().__init__()
        self.logger = logging.getLogger(self.__class__.__name__)
        self.logger.debug('Init a %s instance' % self.__class__.__name__)
        pass

    def __accept_index(self, idx:QtCore.QModelIndex) -> bool:
        if idx.isValid():
            text = idx.data(QtCore.Qt.ItemDataRole.DisplayRole)
            if self.filterRegularExpression().match(text).hasMatch():
                return True
            # 递归对子节点进行判断
            for row in range(idx.model().rowCount(idx)):
                if self.__accept_index(idx.model().index(row, 0, idx)):
                    return True
        return False

    def filterAcceptsRow(self, sourceRow:int, sourceParent:QtCore.QModelIndex):
        idx = self.sourceModel().index(sourceRow, 0, sourceParent)
        if self.__accept_index(idx):
            self.logger.debug('匹配: %s', self.sourceModel().data(idx, role=QtCore.Qt.ItemDataRole.DisplayRole))
            return True
        else:
            self.logger.debug('不匹配: %s', self.sourceModel().data(idx, role=QtCore.Qt.ItemDataRole.DisplayRole))
            return False
        
class TaskInfoItem(QStandardItem):
    """表示在 Model 中的每一个 item 项"""
    def __init__(self, title: str, description: str = '任务描述...', icon: QIcon = None):
        super(TaskInfoItem, self).__init__(title)
        self.logger = logging.getLogger(self.__class__.__name__)
        self.logger.debug('Init a %s instance. title[%s]', self.__class__.__name__, title)

        self.setEditable(False)
        
        self.widget = TaskInfoWidget(title, description, icon)
        self.setData(self.widget, role=QtCore.Qt.ItemDataRole.UserRole)
        pass

class TaskInfoDelegate(QStyledItemDelegate):
    """docstring for TaskInfoDelegate."""
    def __init__(self, parent=None):
        super(TaskInfoDelegate, self).__init__(parent)
        
        self.logger = logging.getLogger(self.__class__.__name__)
        self.logger.debug('Init a %s instance' % self.__class__.__name__)
        pass

    def paint(self, painter, option, index):
        self.logger.debug('============ 开始绘制 ==============')

        # 1. 初始化 option (QStyleOptionViewItem 类型)
        self.initStyleOption(option, index)
        self.logger.debug('初始化后 option[text]: %s', option.text)
        self.logger.debug('初始化后 option[rect]: %s', option.rect)
        
        # 2. 判断是否是一级节点。如果是,则直接调用父类 paint 并返回
        if index.parent().isValid() == False:
            self.logger.debug('这是一级节点(任务组)')
            return super().paint(painter, option, index)
    
        # 3. 绘制二级节点
        self.logger.debug('这是二级节点(任务)')
        task_widget = index.data(role=QtCore.Qt.ItemDataRole.UserRole)
        
        icon_rect = QStyle.alignedRect(QtCore.Qt.LayoutDirection.LayoutDirectionAuto, 
                    option.displayAlignment, QtCore.QSize(32, 32), option.rect)
        
        # 不应该在 paint 里面 connect 信号-槽,这太耗 CPU 了!
        # 而且因为这里的槽函数是一个 lambda 函数,那么每次 connect 都会新建一个啊!!!又耗时,又费内存!
        # 不断的调用这个 connect 必然会导致内存暴涨!!!
        task_widget.movie.frameChanged.connect(lambda: option.widget.viewport().update(icon_rect), QtCore.Qt.ConnectionType.UniqueConnection)
        # task_widget.movie.frameChanged.connect(lambda: option.widget.viewport().update(icon_rect), QtCore.Qt.ConnectionType.SingleShotConnection)
        # if task_widget.first_paint:
        #     task_widget.movie.frameChanged.connect(lambda: option.widget.viewport().update(icon_rect), QtCore.Qt.ConnectionType.UniqueConnection)
        #     task_widget.first_paint = False

        painter.save()
        painter.translate(option.rect.topLeft())
        task_widget.render(painter, QtCore.QPoint(0, 0))
        painter.restore()
        pass

    def sizeHint(self, option, index):
        """绘制自定义 widget 时,需要重写 sizeHint 来返回自定义 widget 的大小,来占位。 """
        # 如果是一级节点,返回父类的 sizeHint
        if index.parent().isValid() == False:
            self.logger.debug('一级节点: %s', index.data())
            return super().sizeHint(option, index)
        
        # 如果是二级节点,返回自定义 widget 的 sizeHint
        self.logger.debug('二级节点: %s', index.data())
        task_widget = index.data(role=QtCore.Qt.ItemDataRole.UserRole)
        return task_widget.sizeHint()

class MainWindow(QMainWindow):
    def __init__(self):
        super(MainWindow, self).__init__()
        self.logger = logging.getLogger(self.__class__.__name__)
        self.logger.debug('Init a %s instance' % self.__class__.__name__)

        self.treeview = QTreeView()
        self.treeview.setHeaderHidden(True)
        self.treeview.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
        
        # 定义数据
        self.treemodel = QStandardItemModel()
        # 根节点
        rootItem = self.treemodel.invisibleRootItem()
        # 一级节点
        gp1 = QStandardItem('TG_Default')
        gp2 = QStandardItem('TG_Test')
        # 二级节点
        tk11 = TaskInfoItem('t_任务1')
        tk12 = TaskInfoItem('t_<span style="color:red;"><b>任务</b></span>task2')
        tk13 = TaskInfoItem('t_资料收集333')
        tk21 = TaskInfoItem('t_发送测试')
        tk22 = TaskInfoItem('t_collection 1')

        rootItem.appendRow(gp1)
        rootItem.appendRow(gp2)
        gp1.appendRow(tk11)
        gp1.appendRow(tk12)
        gp1.appendRow(tk13)
        gp2.appendRow(tk21)
        gp2.appendRow(tk22)

        # 定义 ProxyModel
        self.proxymodel = SearchProxyModel()
        self.proxymodel.setSourceModel(self.treemodel)
        self.proxymodel.setFilterCaseSensitivity(QtCore.Qt.CaseSensitivity.CaseInsensitive)

        # 给 QTreeView 设置数据
        self.treeview.setModel(self.proxymodel)

        # 给 QTreeView 设置自定义的 ItemDelegate
        delegate = TaskInfoDelegate()
        self.treeview.setItemDelegate(delegate)
        # 展开所有节点
        self.treeview.expandAll()

        # 搜索栏
        self.ui_search = QLineEdit()
        self.ui_search.setPlaceholderText('Search...')
        self.ui_search.textChanged.connect(self.on_search_text_changed)

        main_layout = QVBoxLayout()
        main_layout.addWidget(self.ui_search)
        main_layout.addWidget(self.treeview)
        widget = QWidget()
        widget.setLayout(main_layout)
        self.setCentralWidget(widget)

    def on_search_text_changed(self, text):
        self.logger.debug('search text changed: %s', text)
        self.proxymodel.setFilterRegularExpression(self.ui_search.text())
        self.treeview.expandAll()
        pass

def main():
    logging.info('Start main process')
    # 生成QApplication主程序
    app = QApplication(sys.argv)

    # 生成窗口类实例
    main_window = MainWindow()
    # 设置窗口标题
    main_window.setWindowTitle('QTreeView Test')
    # 设置窗口大小
    main_window.resize(400, 500)
    # 显示窗口
    main_window.show()

    # 进入QApplication的事件循环
    sys.exit(app.exec())
    pass

if __name__ == '__main__':
    log_format = '%(asctime)s pid[%(process)d] %(levelname)7s %(name)s.%(funcName)s - %(message)s'
    logging.basicConfig(level=logging.INFO, format=log_format)
    main()

4.4 setParent 并且共享一个 QMovie   # good ✅

@20230430,记录一下最终选择的方案,最终还是选择了 4.1 中的 setParent 方法。但做了如下调整:

  1. 对于 gif 图片,所有的自定义 widget 共享一个全局 QMovie。该全局 QMovie 你可以放在程序全局变量中,放在 MainWindow 中,也可以放在 QTreeView 中,随便。
  2. 只在 itemdelegate.paint() 方法中,对自定义 widget 调用 widget.show()。并且将可见的 widget 保存在一个 visible_widgets 列表中。
  3. 在响应 QTreeView.scrollbar 滚动事件、collapse 事件的时候,遍历 visible_widgets 列表中 widget,将不再可见的进行 widget.hide(),并踢出 visible_widgets 列表。

采用全局共享 QMovie 的方法能有效降低 CPU 开销 👍。

 

五、在 QTreeView 中显示自定义 widget 的一些坑

5.1 setParent 需要自己实现 expand, collapse 等事件

对于上文中的案例 4.1 给每个自定义 widget.setParent(treeview)。这种实现方式会导致自定义的 widget 无法自动响应 QTreeView 的 expand, collapse 等事件。

这时候就得自己实现这些事件。以 collpase 为例,需要捕获 QTreeView 的 collapsed 信号,在自定义槽函数中关闭对应 widget 的显示(QWidget.hide())。

# 连接 QTreeView 的 collapsed 信号到自定义槽函数 _tree_view_item_collapsed。
# collapsed 信号会传递一个参数 index 代表被点击的 QModelIndex
self.treeView.collapsed.connect(self._tree_view_item_collapsed)


# 自定义槽函数
def _tree_view_item_collapsed(self, idx):
    self.logger.debug('QTreeView collpase at node [%s]', idx.data())
    # 隐藏其下的任务子节点
    for row in range(idx.model().rowCount(idx)):
        child = idx.model().index(row, 0, idx)
        tk_widget = child.data(role=QtCore.Qt.ItemDataRole.UserRole)
        tk_widget.hide()
        pass
    pass

5.2 自定义 Widget 不支持部分 qss

对于上文中的案例4.2,直接在 QStyledItemDelegate.paint() 方法中使用 widget.render() 绘制自定义 widget。这时候,widget 会自动响应 QTreeView 的 expand,collapse,hover 事件。

尤其是 hover 事件,会根据 qss 文件中定义的 QTreeView::item:hover { background-color: #e7effd; } 自动改变 widget 的背景色。

但是对于 QTreeView::item:selected{ background-color: #82ec09; },自定义 widget 就无法自动调整背景色了。

其根本原因是 QLabel 不支持 :selected pseudo-state。所以要想我们的自定义 widget 能支持 selected 时候改变颜色,就只能自己在 QStyledItemDelegate.paint() 中实现了。

这里又有两种思路:

① 在 paint() 方法中,获取 qss 中定义的 :selected 配置,然后调用 widget.setStyleSheet() 添加配置。但是最后我放弃这种方式了。

因为我尝试了各种方法,都无法在 paint() 中获取针对当前 item 的 stylesheet 配置。。。而且我查了两天也还是没搞懂 QStyle 跟 QStyleSheet 它们各自的分工是怎么样。😵‍💫

(我发誓以后再也不用 Qt 写复杂的 GUI 了! 太难了,官方文档不行,网上问答又少!)

 

② 使用 qss 的 [] 属性选择器 Property Selector,联合在 paint() 方法中动态设置 widget 的属性 QWidget.setProperty()

但是需要注意!在代码中动态改变 property 属性后,还需要使用 QStyle.polish() 重新加载它的 stylesheet 内容能生效。

# 1 ####################################################################
### 在 delegate 的 paint() 方法中,添加如下代码 ###
if option.state & QStyle.State_Selected:
    self.logger.info('[%s] selected', index.data()) 
    if not task_widget.property('isSelected'):  # 这里不加一层判断的话,会陷入死循环,因为 QStyle.polish() 遇到有效的动态属性就会触发重绘事件!
        task_widget.setProperty('isSelected', True)
        task_widget.style().polish(task_widget)
    pass
else:
    task_widget.setProperty('isSelected', False)
    task_widget.style().polish(task_widget)
    pass

# 2 ####################################################################
### 在 qss 文件中添加如下配置 ###
TaskInfoWidget[isSelected="true"] { 
    background-color: #dc5f5f 
}

# 3 ####################################################################
### 重要!!!还必须重写自定义 QWidget 的 paintEvent() 方法 ###
def paintEvent(self, event):
    opt = QStyleOption()
    opt.initFrom(self)
    painter = QPainter(self)
    self.style().drawPrimitive(QStyle.PrimitiveElement.PE_Widget, opt, painter, self)

5.3 自定义 widget 自动适配 :hover, :selected, :active 的 qss 样式  # 重要 🔴

@2023.04.12,更正5.2的做法!在5.2中我们使用了动态属性来设置样式,这其实是一个复杂的“曲线救国”方法。其原因是我没有了解真正的 QTreeView 绘制 item widget 的机制。

我初以为的是:我们的自定义 item widget 可以响应 hover 事件与 :hover 样式,但因为自定义 widget 内部是 QLabel,它并不响应 :select 样式。所以采用了设置动态属性的方式进行加载样式。

后来,我又发现,其实可能是因为在自定义 widget 绘制的时候,它的 TaskInfoWidget.paintEvent() 方法中的 option 是没有 QStyle.StateFlag.State_Selected 这些状态的,所以它在自动绘制的时候,并不会去找 :selected 的样式。于是我尝试在 itemdelegate.paint() 中给 item widget 手动设置 State_Selected 以及 State_Active 状态。这样就可以不用自定义动态属性 [isSelected],直接使用 :selected 伪状态了。但是后来我又发现这种方式还有一个 BUG,就是自定义 widget 无法识别链式伪状态。即如下这种,只有一条会生效。

TaskInfoWidget:selected:active {
    background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #6ea1f1, stop: 1 #567dbc);
}
TaskInfoWidget:selected:!active {
    background: qlineargradient(x1: 0, y1: 0, x2: 0, y2: 1, stop: 0 #6b9b08, stop: 1 #577f0f);
}

直到今天看到这篇文章 What are the mechanics of the default delegate for item views in Qt?,再对照 Qt 源码,才发现。原来 QTreeView 的默认形式,并不是去让 item widget 自行绘制背景色。而是在 QStyledItemDelegate::paint()QCommonStyle::drawControl(QStyle::CE_ItemViewItem, &opt, painter, widget)drawPrimitive(PE_PanelItemViewItem, opt, p, widget),通过这个 drawPrimitive(PE_PanelItemViewItem) 来绘制每个 item 的背景色!这里的 opt 包含了当前 item widet 的尺寸,状态。然后这里的 widget 其实指的是 QTreeView 实例自己,而不是 item widget。style->drawPrimitive() 的时候,就会从 stylesheet 中加载匹配 widget 实参的样式进行绘制。当背景色绘制完后,才开始绘制 item widget 作为前景!

Qt 的 C++ 源码 qcommonstyle.cpp

所以正确的解决方式是,在 TaskInfoDelegate.paint(self, painter, option, index) 方法中,添加如下代码绘制背景色。

task_widget = index.data(role=QtCore.Qt.ItemDataRole.UserRole)
task_widget.setGeometry(option.rect)

# 绘制当前 item 的背景色
tree_widget = option.widget
tree_widget.style().drawPrimitive(QStyle.PrimitiveElement.PE_PanelItemViewItem, option, painter, tree_widget)

5.4 setParent 方式在 item 过多,treeview 出现滚动条时的重影 BUG

@2023.04.20 今天测试的时候发现一个问题。当 treeview 中的 item 过多,出现了滚动条,这时候上下滚动,或者收缩再拓展节点的时候,会出现节点重影的情况。如下图所示:

出现这个 bug 的原因很简单。因为我用的是 setParent 方式将自定义 widget 挂到 QTreeView 上。当超出 QTreeView.viewport() 可视区域的 widget,通过节点收缩节点或者滚动条滑动,显示到 viewport 中,这时候它就的状态就是  visible,并且 setGeometry() 到可视区域的座标位置了。这之后如果点击扩展节点或者滚动条滑动太快,导致该节点掉落到不可见区域!理论上此时我们应该隐藏该 widget 了。但是从上的代码中,我们没有做到这一点。就是我们没有实现 “当 widget 超出 QTreeView 可视区域时候,确保 widget invisible”。

要解决这个 bug,就必须先搞定两个问题:

① 如何判断自定义 widget 不在 QTreeView 的可视区域?

② 如何获取 QTreeView 的滚动事件?

答案是,我要把它再水一篇博客 ⇨ 《Qt: QTreeView 可视区域内节点判断与滚动事件》

搞懂了这两个问题的答案之后,解决这个 BUG 就很简单了。

a. 在 itemdelegate.paint() 方法中,设置 widget.show()

b. 在 treeview.expanded 信号关联的槽函数中,遍历所有 indexBelow() 于 indexAt(bottom) 的节点,将他们 widget.hide()

c. 在 treeview.scrollbar.valueChanged 信号关联的槽函数中,遍历所有的 above 顶部的节点,所有 below 底部的节点,将他们 widget.hide()

这样就 OK 了。对于节点数目少的情况,甚至都不需要去判断节点是否 above 顶部,below 底部。直接遍历所有节点都行。

如果节点数目太多,需要考虑效率的话。比如节点有 10000 个,而真正可视区域的节点只有 10 个。最高效的做法是保存一个数组或者字典,里面保留着在 itemdelegate.paint() 中被 widget.show() 的节点,即当前处于可视区域的节点的集合。然后每次只要遍历这个集合,把里面已经不再处于可视区域的节点 widget.hide() 掉即可。

Leave a Reply

Your email address will not be published. Required fields are marked *

TOC