QMovie не запускается, если запущен из потока

Коллеги -разработчики! У меня вопрос по Qt и многопоточности.

=== КРАТКАЯ ВЕРСИЯ ===========================================

Можно ли с Qt делать то, что я хочу? То есть (1) показать загрузчик; (2) скачать гифку в фоновом режиме; (3) показывать загруженный gif в главном окне после его загрузки?

=== ДЛИННАЯ ВЕРСИЯ ============================================

У меня есть идея, что когда я нажимаю кнопку, она:

  1. показывает загрузчик;
  2. активирует поток, загружающий гифку из сети;
  3. заменяет гифку по умолчанию, скрытую в главном окне, на загруженную и показывает ее
  4. скрывает загрузчик;

Проблема, с которой я столкнулся, заключается в том, что когда загруженный gif отображается, он "заморожен" или отображается только первый кадр. В остальном все в порядке. Однако после того, как загрузчик скрыт, необходимо, чтобы гифка была анимирована.

Он упоминается здесь, что

Таким образом, цикл событий Qt отвечает за выполнение вашего кода в ответ на различные вещи, которые происходят в вашей программе, но пока он выполняет ваш код, он не может делать ничего другого.

Я считаю, что в этом суть проблемы. Также предлагается, чтобы

Рекомендуется использовать QThread с Qt, а не поток Python, но поток Python будет работать нормально, если вам никогда не понадобится связываться с основным потоком из вашей функции.

Поскольку мой поток заменил содержимое gif по умолчанию, я считаю, что он отвечает:(

Пожалуйста, найдите мой код ниже:)

import sys
from time import sleep

from PyQt5.QtCore import QThread
from PyQt5.QtGui import QMovie
from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QLabel


class ChangeGif(QThread):
    """
    A worker that:
    1 - waits for 1 second;
    2 - changes the content of the default gif;
    3 - shows the default gif with new contents and hide the loader.
    """

    def __init__(self, all_widgets):
        QThread.__init__(self)
        self.all = all_widgets

    def run(self):
        sleep(1)
        self.new_gif_file = QMovie("files/new.gif")  # This is the "right" gif that freezes :(
        self.new_gif_file.start()
        self.all.gif_label.setMovie(self.new_gif_file)
        self.all.gif_label.show()
        self.all.loader_label.hide()


class MainWindow(QWidget):
    """
    Main window that shows a button. If you push the button, the following happens:
    1 - a loader is shown;
    2 - a thread in background is started;
    3 - the thread changes the contents of the default gif;
    4 - when the gif is replaced, the loader disappears and the default gif with the new content is shown
    """
    def __init__(self):
        super(MainWindow, self).__init__()

        # BUTTON
        self.button = QPushButton("Push me", self)
        self.button.setFixedSize(100, 50)
        self.button.clicked.connect(lambda: self.change_gif())

        # DEFAULT GIF
        self.gif_file = QMovie("files/default.gif")  # This is the "wrong" gif
        self.gif_file.start()
        self.gif_label = QLabel(self)
        self.gif_label.setMovie(self.gif_file)
        self.gif_label.move(self.button.width(), 0)
        self.gif_label.hide()

        # LOADER
        self.loader_file = QMovie("files/loader.gif")
        self.loader_file.start()
        self.loader_label = QLabel(self)
        self.loader_label.setMovie(self.loader_file)
        self.loader_label.move(self.button.width(), 0)
        self.loader_label.hide()

        # WINDOW SETTINGS
        self.setFixedSize(500, 500)
        self.show()

    def change_gif(self):
        self.loader_label.show()
        self.worker = ChangeGif(self)
        self.worker.start()


app = QApplication(sys.argv)
window = MainWindow()
app.exec_()

2 ответа

Решение

Помимо того факта, что к элементам пользовательского интерфейса нельзя обращаться или создавать их вне основного потока Qt, это справедливо также для элементов пользовательского интерфейса, которые используют объекты, созданные в других потоках.

В вашем конкретном случае это не только означает, что вы не можете установить фильм в отдельном потоке, но и не можете создать там QMovie.

В следующем примере я открываю локальный файл и использую сигнал для отправки данных в основной поток. Оттуда я создаю QBuffer для хранения данных в устройстве ввода-вывода, которое может использоваться QMovie. Обратите внимание, что и буфер, и фильм должны иметь постоянную ссылку, иначе они будут собраны мусором, как только функция вернется.

from PyQt5.QtCore import QThread, QByteArray, QBuffer

class ChangeGif(QThread):
    dataLoaded = pyqtSignal(QByteArray)
    def __init__(self, all_widgets):
        QThread.__init__(self)
        self.all = all_widgets

    def run(self):
        sleep(1)
        f = QFile('new.gif')
        f.open(f.ReadOnly)
        self.dataLoaded.emit(f.readAll())
        f.close()


class MainWindow(QWidget):
    # ...
    def change_gif(self):
        self.loader_label.show()
        self.worker = ChangeGif(self)
        self.worker.dataLoaded.connect(self.applyNewGif)
        self.worker.start()

    def applyNewGif(self, data):
        # a persistent reference must be kept for both the buffer and the movie, 
        # otherwise they will be garbage collected, causing the program to 
        # freeze or crash
        self.buffer = QBuffer()
        self.buffer.setData(data)
        self.newGif = QMovie()
        self.newGif.setCacheMode(self.newGif.CacheAll)
        self.newGif.setDevice(self.buffer)
        self.gif_label.setMovie(self.newGif)
        self.newGif.start()
        self.gif_label.show()
        self.loader_label.hide()

Обратите внимание, что приведенный выше пример предназначен только для целей объяснения, поскольку процесс загрузки может быть выполнен с использованием модулей QtNetwork, которые работают асинхронно и предоставляют простые сигналы и слоты для загрузки удаленных данных:

from PyQt5.QtCore import QUrl
from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest

class MainWindow(QWidget):
    def __init__(self):
        # ...
        self.downloader = QNetworkAccessManager()

    def change_gif(self):
        self.loader_label.show()
        url = QUrl('https://path.to/animation.gif')
        self.device = self.downloader.get(QNetworkRequest(url))
        self.device.finished.connect(self.applyNewGif)

    def applyNewGif(self):
        self.loader_label.hide()
        self.newGif = QMovie()
        self.newGif.setDevice(self.device)
        self.gif_label.setMovie(self.newGif)
        self.newGif.start()
        self.gif_label.show()

Основное правило работы с Qt заключается в том, что только один основной поток отвечает за управление виджетами пользовательского интерфейса. Его часто называют GUI thread. Вы никогда не должны пытаться получить доступ к виджетам из других потоков. Например, таймеры Qt не будут запускаться из другого потока, а Qt выведет предупреждение в консоли времени выполнения. В вашем случае - если вы поместите все манипуляции с QMovie в поток графического интерфейса, скорее всего, все будет работать так, как ожидалось.

Что делать? Используйте сигналы и слоты - они также предназначены для работы между потоками.

Что должен делать ваш код:

  1. Показать загрузчик из основного потока.
  2. Активируйте ветку, загружающую гифку из Интернета.
  3. После того, как загрузка будет готова - подать сигнал и зафиксировать его в основной GUI thread'. Remember to use Qt::QueuedConnection` при соединении сигнала и слота, хотя в некоторых случаях он будет использоваться автоматически.
  4. В слоте приема замените гифку по умолчанию в главном окне на загруженную, покажите ее и скройте загрузчик.

Вам придется использовать какой-то механизм синхронизации, чтобы избежать скачков данных. Мьютекса будет достаточно. Или вы можете передать данные в качестве параметра сигнального слота, но в случае гифки он, вероятно, будет слишком большим.

Другие вопросы по тегам