批量填充QTableView时减少过度绘制

Reduce excessive painting when populating QTableView in batches

这是一个 MRE:

import sys, traceback, time 
from PyQt5 import QtWidgets, QtCore, QtGui

class MainWindow(QtWidgets.QMainWindow):
        def __init__(self):
            super().__init__()
            self.setWindowTitle('Latency in GUI and too much paint()')
            self.setGeometry(QtCore.QRect(100, 100, 1000, 800))
            self.table_view = SegmentsTableView(self)
            
            self.table_view.horizontalHeader().setVisible(False) 
            self.table_view.verticalHeader().setVisible(False) 
            self.setCentralWidget(self.table_view)
            
            self.worker_thread = QtCore.QThread()
            self.worker_thread.dead = False
            self.generate_row_batches_task = GenerateRowBatchesTask()
            self.generate_row_batches_task.moveToThread(self.worker_thread)
            # connect signals and slots
            self.worker_thread.started.connect(self.generate_row_batches_task.run)
            self.generate_row_batches_task.stopped_signal.connect(self.worker_thread.quit)
            self.generate_row_batches_task.stopped_signal.connect(self.generate_row_batches_task.deleteLater)
            self.generate_row_batches_task.stopped_signal.connect(self.task_has_stopped)
            self.worker_thread.finished.connect(self.worker_thread.deleteLater)
            self.worker_thread.finished.connect(self.thread_has_finished)
            self.generate_row_batches_task.batch_delivered_signal.connect(self.populate_table)
            
            self.n_batch = 0 
            
            self.worker_thread.start()
            
        def populate_table(self, batch):
            # doesn't seem to help
            # self.table_view.setUpdatesEnabled(False)        
            
            row_count = self.table_view.model().rowCount()
            for n_row, row in enumerate(batch):
                n_new_row = row_count + n_row
                self.table_view.model().insertRow(n_new_row)
                self.table_view.model().setItem(n_new_row, 0, QtGui.QStandardItem(row[0]))
                self.table_view.model().setItem(n_new_row, 1, QtGui.QStandardItem(row[1]))
                self.table_view.resizeRowToContents(n_new_row)
                
            self.n_batch += 1
            print(f'self.n_batch {self.n_batch}')    

            # doesn't seem to help
            # self.table_view.setUpdatesEnabled(True)
                    
            if row_count == 0:
                print('row count 0')
                self.table_view.setColumnWidth(0, 400)
                self.table_view.setColumnWidth(1, 400)
        
        def task_has_stopped(self):
            print('task has stopped')

        def thread_has_finished(self):
            print('thread has finished')
            
class GenerateRowBatchesTask(QtCore.QObject):
    stopped_signal = QtCore.pyqtSignal()
    batch_delivered_signal = QtCore.pyqtSignal(list)
    
    def run(self):
        rows = [
         ['one potatoe two potatoe', 'one potatoe two potatoe'],
         ['Sed ut perspiciatis, unde omnis iste natus error sit voluptatem accusantium doloremque',
          'Sed ut <b>perspiciatis, unde omnis <i>iste natus</b> error sit voluptatem</i> accusantium doloremque'],
         ['Nemo enim ipsam voluptatem, quia voluptas sit, aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos, qui ratione voluptatem sequi nesciunt, neque porro quisquam est, qui do lorem ipsum, quia dolor sit amet consectetur adipiscing velit, sed quia non numquam do eius modi tempora incididunt, ut labore et dolore magnam aliquam quaerat voluptatem.',
          'Nemo enim ipsam <i>voluptatem, quia voluptas sit, <b>aspernatur aut odit aut fugit, <u>sed quia</i> consequuntur</u> magni dolores eos</b>, qui ratione voluptatem sequi nesciunt, neque porro quisquam est, qui do lorem ipsum, quia dolor sit amet consectetur adipiscing velit, sed quia non numquam do eius modi tempora incididunt, ut labore et dolore magnam aliquam quaerat voluptatem.'
          ],
         ['Ut enim ad minima veniam',
          'Ut enim ad minima veniam'],
         ['Quis autem vel eum iure reprehenderit',
          'Quis autem vel eum iure reprehenderit'],
         ['At vero eos et accusamus et iusto odio dignissimos ducimus, qui blanditiis praesentium voluptatum deleniti atque corrupti, quos dolores et quas molestias excepturi sint, obcaecati cupiditate non provident, similique sunt in culpa, qui officia deserunt mollitia animi, id est laborum et dolorum fuga.',
          'At vero eos et accusamus et iusto odio dignissimos ducimus, qui blanditiis praesentium voluptatum deleniti atque corrupti, quos dolores et quas molestias excepturi sint, obcaecati cupiditate non provident, similique sunt in culpa, qui officia deserunt mollitia animi, id est laborum et dolorum fuga.'
         ]]
        print('run')
        # this multiplies the list to give one with 192 rows 
        for i in range(3):
            rows += rows
        # now deliver 400 batches of these 192 rows
        # NB these time.sleep()s have been chosen to produce just a bit of annoying latency in the GUI
        for i in range(400):
            batch = []
            for n_row, row in enumerate(rows):
                batch.append(row)
                if n_row % 20 == 0:
                    time.sleep(0.000001)
            self.batch_delivered_signal.emit(batch)
            time.sleep(0.001)
        self.stopped_signal.emit()                
        
class SegmentsTableView(QtWidgets.QTableView):
    def __init__(self, parent):
        super().__init__(parent)
        self.setItemDelegate(SegmentsTableViewDelegate(self))
        self.setModel(QtGui.QStandardItemModel())
        v_header =  self.verticalHeader()
        # 
        v_header.setMinimumSectionSize(5)
        v_header.sectionHandleDoubleClicked.disconnect()
        v_header.sectionHandleDoubleClicked.connect(self.resizeRowToContents)
        self.horizontalHeader().sectionResized.connect(self.resizeRowsToContents)        
        
    def resizeRowToContents(self, row):
        super().resizeRowToContents(row)
        self.verticalHeader().resizeSection(row, self.sizeHintForRow(row))

    def resizeRowsToContents(self):
        header = self.verticalHeader()
        for row in range(self.model().rowCount()):
            hint = self.sizeHintForRow(row)
            header.resizeSection(row, hint)    
            
class SegmentsTableViewDelegate(QtWidgets.QStyledItemDelegate):
    class EditorDocument(QtGui.QTextDocument):
        def __init__(self, parent):
            super().__init__(parent)
            self.setDocumentMargin(0)
            self.contentsChange.connect(self.contents_change)
            self.height = None
            parent.setDocument(self)
        
        def contents_change(self, position, chars_removed, chars_added):
            def resize_func():
                if self.size().height() != self.height:
                    doc_size = self.size()
                    self.parent().resize(int(doc_size.width()), int(doc_size.height()))
            QtCore.QTimer.singleShot(0, resize_func)
    
    def __init__(self, *args):
        super().__init__(*args)
        self.pm_index_to_editor_dict = {}
        self.paint_count = 0
    
    def createEditor(self, parent, option, index):
        class Editor(QtWidgets.QTextEdit):
            def resizeEvent(self, event):
                super().resizeEvent(event)
                parent.parent().resizeRowToContents(index.row())
                
        pm_index = QtCore.QPersistentModelIndex(index)
        if pm_index in self.pm_index_to_editor_dict:
            editor = self.pm_index_to_editor_dict[pm_index]
        else:
            editor = Editor(parent)
            editor.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
            editor.setFrameShape(0)
            self.pm_index_to_editor_dict[pm_index] = editor                    
        SegmentsTableViewDelegate.EditorDocument(editor)
        return editor
    
    def destroyEditor(self, editor, index):
        super().destroyEditor(editor, index)
        pm_index = QtCore.QPersistentModelIndex(index)
        del self.pm_index_to_editor_dict[pm_index]
        self.parent().resizeRowToContents(index.row())
        
    def paint(self, painter, option, index):
        self.paint_count += 1
        if self.paint_count % 100 == 0:
            # from this we see that it is always the visible rows (in the viewport) which are repainted
            print(f'self.paint_count {self.paint_count} index.row() {index.row()}')
        
        self.initStyleOption(option, index)
        painter.save()
        doc = QtGui.QTextDocument()
        doc.setDocumentMargin(0)
        doc.setDefaultFont(option.font)
        doc.setTextWidth(option.rect.width())
        doc.setHtml(option.text)
        option.text = ""
        option.widget.style().drawControl(QtWidgets.QStyle.CE_ItemViewItem, option, painter)
        painter.translate(option.rect.left(), option.rect.top())
        doc.drawContents(painter)
        painter.restore()
            
    def sizeHint(self, option, index):
        self.initStyleOption(option, index)
        pm_index = QtCore.QPersistentModelIndex(index)
        if pm_index in self.pm_index_to_editor_dict:
            doc = self.pm_index_to_editor_dict[pm_index].document()
        else:
            doc = QtGui.QTextDocument()
            doc.setTextWidth(option.rect.width())
            doc.setDefaultFont(option.font)
            doc.setDocumentMargin(0)
            doc.setHtml(option.text)
        return QtCore.QSize(int(doc.idealWidth()), int(doc.size().height()))
                
app = QtWidgets.QApplication([])
app.setFont(default_font)
main_window = MainWindow()
main_window.show()
exec_return = app.exec()
sys.exit(exec_return)

我在真实应用程序中所做的是将文档转换为 table 的行。问题是扩展到非常大的文档,可能包含 50,000 个单词,因此有许多 table 行。

使用信号从工作线程分批传送似乎是可行的方法。我发现我随后放入工作线程的 time.sleep() 的数量与 GUI 的响应能力有直接关系(最好 = 非常短但经常),并且确实完全忽略它似乎会导致崩溃,好像 GUI 被淹没了。

问题是,在我的实际应用程序中,当我尝试编辑单元格时仍然发现令人讨厌的延迟,而批次仍在交付并添加到 table。我 认为 罪魁祸首可能是项目委托的 paint 方法,该方法被调用了 000 次。

paint 即使在我没有尝试编辑时也会发生,并且当我在视口中看到的行完全不变时......但是正在绘制的行是(正如你可能希望)QTableView 视口中的可见行。

这个MRE在一定程度上说明了这一点。我正在尝试找到一种方法来减少正在进行的不必要的绘画数量。我不清楚是什么触发了绘画。在 Java FX 和 Swing 中,幕后有一些“无效”机制。

Qt 模型提供了一些有用的功能:

  • canFetchMore(parent),表示模型是否可以加载 更多 数据(对于给定的父级);
  • fetchMore(parent) 告诉模型 do 加载更多数据(但模型决定“更多”的数量);

这些函数由项目视图调用,以便当用户滚动到末尾(通常在底部)时,如果有更多数据要加载,它们可以 请求 模型) 并最终告诉模型进行提取。

考虑到以上情况,您需要做的是实现一个模型,该模型以指定的最小数据量开始,提供两种获取方法以加载更多数据,然后添加一个计时器 查看尽可能请求进一步获取,除非当前state()EditingState模型无法获取更多数据。

由于您的代码太复杂而无法回答,我创建了一个更简单的示例来解释这个概念;第二列显示从创建模型的那一刻开始,获取索引的时间:

from PyQt5 import QtCore, QtWidgets

class TestModel(QtCore.QAbstractTableModel):
    totalRowCount = 1980
    currentRowCount = 25
    fetchAmount = 25
    def __init__(self):
        super().__init__()
        self.eTimer = QtCore.QElapsedTimer()
        self.eTimer.start()
        self.times = {
            r:0 for r in range(self.currentRowCount)
        }

    def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole):
        if orientation == QtCore.Qt.Horizontal and role == QtCore.Qt.DisplayRole:
            return ('Item', 'Loading time')[section]
        return super().headerData(section, orientation, role)

    def rowCount(self, parent=None):
        return self.currentRowCount

    def columnCount(self, parent=None):
        return 2

    def canFetchMore(self, parent=None):
        return self.currentRowCount < self.totalRowCount

    def fetchMore(self, parent=None):
        maxRow = min(self.totalRowCount, self.currentRowCount + self.fetchAmount)
        self.beginInsertRows(QtCore.QModelIndex(), self.currentRowCount, maxRow - 1)
        t = self.eTimer.elapsed() * .001
        self.times.update({r:t for r in range(self.currentRowCount, maxRow)})
        self.currentRowCount += self.fetchAmount
        self.endInsertRows()

    def data(self, index, role=QtCore.Qt.DisplayRole):
        if role == QtCore.Qt.DisplayRole:
            if index.column() == 0:
                return 'Item at row {}'.format(index.row() + 1)
            return self.times[index.row()]

    def flags(self, index):
        return super().flags(index) | QtCore.Qt.ItemIsEditable


class TestTable(QtWidgets.QTableView):
    def __init__(self):
        super().__init__()
        self.resize(640, 480)
        self._model = TestModel()
        self.setModel(self._model)
        self.horizontalHeader().setSectionResizeMode(
            QtWidgets.QHeaderView.Stretch)
        self.fetchTimer = QtCore.QTimer(
            interval=1000, singleShot=True, timeout=self.fetchMore)
        self.fetchTimer.start()

    def fetchMore(self):
        if self.model().canFetchMore():
            if self.state() != self.EditingState:
                self.model().fetchMore()
            self.fetchTimer.start()
    

if __name__ == '__main__':
    app = QtWidgets.QApplication([])
    w = TestTable()
    w.show()
    app.exec_()

注意:canFetchMorefetchMore 的父参数是强制性的,就像 rowCountcolumnCount.

显然,如果您的模型需要一些时间来获取实际数据并最终插入新索引(例如,远程数据库或网络请求),您需要使用进一步的延迟计时器来实现它以“排队”获取请求。

您可以在模型中创建另一个单次定时器,然后在模型可用时推送 获取(beginInsertRowsendInsertRows)要做到这一点,甚至使用单独的线程。

作为进一步且无关的建议,请尝试使您的示例更加最小化:您的问题是关于具有多个项目的视图的一般更新,所有委托方面和项目的大小调整对此都是完全不必要的,它们只会成为我们应该关注的内容的烦人和不必要的干扰。