Язык программирования Python

Фоновые задачи с помощью PyQt на Python

Все библиотеки для разработки приложений работают с главным циклом, который обрабатывает такие события, как отображение окна на экране, его перемещение, изменение размера, реакция на нажатие кнопки. Словом, любое взаимодействие с интерфейсом.

Некоторые из этих событий могут быть связаны с функцией, которую мы предоставляем.

Например, метод button1_pressed(), который вызывается этой библиотекой, когда пользователь нажимает на элемент управления button1.

При работе с Qt способ реагирования на эти события обычно заключается в подключении сигнала к слоту.

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

Это приводит к тому, что процессор занят выполнением нашей задачи и не может заниматься основным циклом приложения.

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

Перейдем к конкретному примеру.

Следующий код дублирует окно с меткой (QLabel) и кнопкой (QPushButton), которая при нажатии загружает файл, используя стандартный модуль urllib.request.

#!/usr/bin/env python # -*- coding: utf-8 -*- from urllib.request import urlopen from PyQt5.QtWidgets import QApplication, QMainWindow, QLabel, QPushButton class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Пример загрузки файла") self.resize(400, 300) self.label = QLabel("Нажмите кнопку, чтобы начать загрузку.", self) self.label.setGeometry(20, 20, 200, 25) self.button = QPushButton("Начать скачивание", self) self.button.move(20, 60) self.button.pressed.connect(self.downloadFile) def downloadFile(self): self.label.setText("Загрузка файла...") # Отключение кнопки во время загрузки файла. self.button.setEnabled(False) url = "https://www.python.org/ftp/python/3.7.2/python-3.7.2.exe" filename = "python-3.7.2.exe" # Открываем URL. with urlopen(url) as r: with open(filename, "wb") as f: # Чтение удаленного файла и запись локального файла. f.write(r.read()) self.label.setText("Файл загружен!") # Сброс кнопки. self.button.setEnabled(True) if __name__ == "__main__": app = QApplication([]) window = MainWindow() window.show() app.exec_()

Вы заметите, что во время загрузки, которая в моем случае длится около пяти секунд, интерфейс полностью замирает.

Мы уже упоминали причину такого поведения: в данном конкретном коде строка, блокирующая выполнение, – это строка 32, где вызывается метод r.read(), который читает содержимое удаленного файла.

Разберем три решения этой же проблемы с их сильными и слабыми сторонами.

Первое решение: потоки

Это решение предполагает запуск нового потока для выполнения нашей трудоемкой задачи.

Поскольку основной цикл Qt выполняется в основном потоке программы, а наша операция выполняется во вторичном потоке, интерфейс остается активным, пока файл загружается в фоновом режиме.

Для этого мы используем класс QThread, который предоставляет кроссплатформенный API для создания потоков.

#!/usr/bin/env python # -*- coding: utf-8 -*- from urllib.request import urlopen from PyQt5.QtCore import QThread from PyQt5.QtWidgets import QApplication, QMainWindow, QLabel, QPushButton class Downloader(QThread): def __init__(self, url, filename): super().__init__() self._url = url self._filename = filename def run(self): # Открываем URL. with urlopen(self._url) as r: with open(self._filename, "wb") as f: # Чтение содержимого и запись его в новый файл. f.write(r.read()) class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Пример загрузки файла") self.resize(400, 300) self.label = QLabel("Нажмите кнопку , чтобы начать загрузку.", self) self.label.setGeometry(20, 20, 200, 25) self.button = QPushButton("Начать скачивание", self) self.button.move(20, 60) self.button.pressed.connect(self.initDownload) def initDownload(self): self.label.setText("Загрузка файла...") # Отключение кнопки на время загрузки файла. self.button.setEnabled(False) # Выполнение загрузки в новом потоке. self.downloader = Downloader( "https://www.python.org/ftp/python/3.7.2/python-3.7.2.exe", "python-3.7.2.exe" ) # Qt вызовет метод `downloadFinished()`, когда поток завершится. self.downloader.finished.connect(self.downloadFinished) self.downloader.start() def downloadFinished(self): self.label.setText("Файл загружен!") # Сброс кнопки. self.button.setEnabled(True) # Удаление потока после его использования. del self.downloader if __name__ == "__main__": app = QApplication([]) window = MainWindow() window.show() app.exec_()

Ядром кода является класс Downloader, который наследуется от QThread и повторно реализует метод run() (строка 17), содержимое которого будет выполняться в новом потоке, когда мы создадим экземпляр и вызовем метод start() (строки 43 и 50).

В строке 49 мы соединяем сигнал finished, который отдает Qt, когда поток завершает выполнение, с нашим методом downloadFinished().

Хотя в примере речь идет о загрузке файла, этот метод позволяет переместить любую ресурсоемкую задачу в новый поток: просто поместите его внутрь метода run().

С другой стороны, многопоточное программирование должно выполняться с особой осторожностью.

Только метод run() выполняется в новом потоке, в то время как все остальные (включая сам Downloader.init()) выполняются в основном.

Кроме того, важно быть осторожным, чтобы не разделять объекты, к которым могут одновременно обращаться два или более потоков.

Второе решение: processEvents()

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

Функция, которая позаботится об этом, – QCoreApplication.processEvents().

В предыдущих вариантах кода функция, которая выполняет тяжелую работу и блокирует выполнение на несколько секунд, была r.read().

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

К счастью для нас, read() опционально принимает в качестве аргумента количество байт данных, которые мы хотим прочитать.

#!/usr/bin/env python # -*- coding: utf-8 -*- from urllib.request import urlopen from PyQt5.QtCore import QCoreApplication from PyQt5.QtWidgets import QApplication, QMainWindow, QLabel, QPushButton class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Пример загрузки файла") self.resize(400, 300) self.label = QLabel("Нажмите кнопку , чтобы начать загрузку.", self) self.label.setGeometry(20, 20, 200, 25) self.button = QPushButton("Начать скачивание", self) self.button.move(20, 60) self.button.pressed.connect(self.downloadFile) def downloadFile(self): self.label.setText("Загрузка файла...") # Отключение кнопки на время загрузки файла. self.button.setEnabled(False) # Открываем URL. url = "https://www.python.org/ftp/python/3.7.2/python-3.7.2.exe" filename = "python-3.7.2.exe" with urlopen(url) as r: with open(filename, "ab") as f: while True: # Qt обрабатывает ваши события, чтобы сохранить отзывчивость окна. QCoreApplication.processEvents() # Чтение части загружаемого файла. chunk = r.read(128) # Если результат равен `None`, это означает, что данные еще не были загружены. Мы просто продолжаем ждать. if chunk is None: continue # Если результатом является пустой экземпляр `bytes`, это означает, что файл готов. elif chunk == b"": break # Запись загруженной части в локальный файл. f.write(chunk) self.label.setText("Файл загружен!") # Сброс кнопки. self.button.setEnabled(True) if __name__ == "__main__": app = QApplication([]) window = MainWindow() window.show() app.exec_()

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

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

Однако r.read(128) все равно является вызовом, который блокирует выполнение кода даже на очень короткое время, практически незаметное.

Если скорость интернет-соединения слишком низкая, даже извлечение этого небольшого количества байтов может привести к зависанию пользовательского интерфейса.

Третье решение: Twisted

Модуль qt5reactor позволяет объединить основные циклы Twisted и Qt в одном приложении, предоставляя нам доступ ко всему арсеналу асинхронных функций, предоставляемых сетевой библиотекой.

Для этого третьего решения мы также будем использовать библиотеку treq (похожую на Requests, но построенную на базе Twisted) для доступа к URL файла и загрузки содержимого.

Мы устанавливаем эти два инструмента просто с помощью pip:

pip install qt5reactor treq

Теперь код выглядит следующим образом:

#!/usr/bin/env python # -*- coding: utf-8 -*- from PyQt5.QtCore import QCoreApplication from PyQt5.QtWidgets import QApplication, QMainWindow, QLabel, QPushButton from twisted.internet.defer import inlineCallbacks class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Пример загрузки файла") self.resize(400, 300) self.label = QLabel("Нажмите кнопку , чтобы начать загрузку.", self) self.label.setGeometry(20, 20, 200, 25) self.button = QPushButton("Начать скачивание", self) self.button.move(20, 60) self.button.pressed.connect(self.initDownload) def initDownload(self): self.label.setText("Загрузка файла...") # Отключение кнопки на время загрузки файла. self.button.setEnabled(False) url = "https://www.python.org/ftp/python/3.7.2/python-3.7.2.exe" # Метод `requestSucceeded()` будет вызван, когда соединение с URL будет успешно установлено. treq.get(url).addCallback(self.requestSucceeded) @inlineCallbacks def requestSucceeded(self, response): # Мы получаем содержимое удаленного файла. Обратите внимание, что эта операция не блокирует выполнение. content = yield response.content() # Записываем его в локальный файл. with open("python-3.7.2.exe", "wb") as f: f.write(content) self.label.setText("Файл загружен!") # Сброс кнопки. self.button.setEnabled(True) def closeEvent(self, event): QCoreApplication.instance().quit() if __name__ == "__main__": app = QApplication([]) import qt5reactor qt5reactor.install() window = MainWindow() window.show() from twisted.internet import reactor import treq import os import certifi # Требуется для соединений HTTPS. os.environ["SSL_CERT_FILE"] = certifi.where() reactor.run()

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

HTTP-запросы библиотеки treq основаны на логике отложенных запросов, которые похожи на сигналы Qt при вызове определенного события.

Здесь нам также не придется иметь дело с проблемами совместного использования объектов между потоками, поскольку Twisted всегда работает в главном потоке.

Те, кто немного разбирается в Twisted, найдут это решение весьма удачным.

И это действительно так: Qt и Twisted очень хорошо подходят друг другу благодаря своей структуре, философии и даже соглашениям об именах.

Заключение

Мы рассмотрели все три решения – какое из них лучше всего подходит для вашей задачи?

Приведем основные моменты каждого из них.

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

Если ваше приложение использует много HTTP-запросов или обращается к любым другим ресурсам в Интернете (а также к локальным файлам) и вы разбираетесь в Twisted (или нет, но вам было бы интересно побаловаться с ним), вы будете рады решению с qt5reactor и treq.

Наконец, если ваш код позволяет это сделать, простое добавление вызова QCoreApplication.processEvents() в нужном месте сделает ваш интерфейс отзывчивым к взаимодействию с пользователем.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *