הרצת משימות ברקע עם Qt בפייתון
בכתיבת תוכניות גרפיות עם Qt ב Python נוכל לשים לב שפעולות ארוכות ״תוקעות״ את התוכנית. לדוגמה נדמיין תוכנית המציגה תיבת חיפוש לקבצים, כפתור ורשימה ובלחיצה על הכפתור התוכנית מחפשת קבצים שמתאימים לשם הקובץ שבתיבה וממלאת את הרשימה בשמות הקבצים שנמצאו.
אם נריץ את התוכנית תוך שימוש ב Thread אחד, בלחיצה על הכפתור פייתון יתחיל לחפש קבצים ולא יהיה זמין לטפל באירועים אחרים הקשורים לממשק המשתמש - למשל הגדלה או הקטנה של החלון, או אפילו שינויי עיצוב קטנים כשסמן העכבר עובר על הכפתור. התנהגות כזאת תיתן למשתמשים הרגשה שהתוכנית ״תקועה״, למרות שההיפך הוא הנכון והתוכנית עובדת מאוד קשה. יותר מזה, רק אחרי שיימצאו כל הקבצים ולולאת החיפוש תסתיים נראה שינוי בממשק המשתמש ולכן ברגע אחד יתווספו לרשימה כל הקבצים שמצאנו.
חווית משתמש טובה יותר תאפשר למשתמשים להמשיך לעבוד עם הממשק ותוסיף את התוצאות שהיא מוצאת אחת-אחת בזמן בו התוכנית מוצאת אותן. בארכיטקטורה של Qt יש שתי דרכים מרכזיות להגיע לתוצאה זו:
אפשר לכתוב את לולאת החיפוש עם ״הפסקות״, כך שכל פעם שמוצאים קובץ שמתאים למילת החיפוש נעצור לנשום ונבקש מ Qt לטפל באירועים גרפיים שאולי הצטברו. זה ייתן חווית משתמש טובה יותר ויפתור את הבעיה בתוכנית חיפוש הקבצים, אבל לא תמיד אפשרי במקרה הכללי כי לא תמיד יש מקום טוב לעצירות כאלה.
פיתרון יותר כללי יהיה לפתוח Thread נוסף ולבצע את הלולאה (במקרה שלנו לולאת חיפוש) מתוך ה Thread הנוסף.
התקשורת בין תהליכון ראשי ב Qt לבין תהליכון נוסף שרץ ברקע מתבצעת במנגנון הסיגנלים הרגיל של Qt - התהליכון הראשי שולח סיגנל ותהליכון הרקע קורא את הסיגנל הזה ומתחיל לעבוד. כל פעם שתהליכון החיפוש מזהה תוצאה חדשה הוא ישלח סיגנל והתהליכון הראשי יוסיף פריט נוסף לתיבת התוצאות.
קוד? ברור, ומספיק קובץ אחד:
from PySide6.QtCore import *
from PySide6.QtGui import *
from PySide6.QtWidgets import *
from pathlib import Path
from time import sleep
class Finder(QObject):
path_found = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
def search(self, text):
results = Path('.').rglob(text)
for result in results:
self.path_found.emit(str(result.absolute()))
sleep(0.01)
class Ui(QWidget):
start_search = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.results = QListWidget()
self.searchbox = QLineEdit()
self.search_button = QPushButton("Search")
self.finder = Finder()
self.finder_thread = QThread(self)
self.finder.moveToThread(self.finder_thread)
self.finder_thread.start()
self.main_layout = QVBoxLayout(self)
self.top_layout = QHBoxLayout()
self.main_layout.addLayout(self.top_layout)
self.top_layout.addWidget(self.searchbox)
self.top_layout.addWidget(self.search_button)
self.main_layout.addWidget(self.results)
self.search_button.clicked.connect(self.search)
self.start_search.connect(self.finder.search)
self.finder.path_found.connect(self.results.addItem)
def search(self):
self.results.clear()
self.start_search.emit(self.searchbox.text())
def closeEvent(self, event):
self.finder_thread.exit(0)
app = QApplication()
w = Ui()
w.show()
app.exec()
אז מה היה לנו כאן?
האוביקט Finder אחראי על חיפוש הקבצים. הוא יורש מ QObject כי ככה Qt דורש. הפונקציה
moveToThread
שלו שולחת אותו לעבוד ב Thread אחר.ה Thread אגב הוא Thread של Qt ולא של פייתון. נוצר באמצעות
QThread()
.ה Widget הראשי מגדיר סיגנל כדי לאותת ל Finder שצריך להתחיל את החיפוש, וה Finder מגדיר סיגנל כדי לאותת ל Widget הראשי שהוא מצא קובץ.
השורות שמחברות את הסיגנלים לקודי הטיפול מופעלות ב init של ה Widget והן:
self.start_search.connect(self.finder.search)
self.finder.path_found.connect(self.results.addItem)
כל השאר זה קסם אוטומטי של Qt - כשהסיגנל start_search
נשלח ה Finder מתחיל לחפש ברקע, וכל תוצאה שהוא מצא נשלחת דרך סיגנל לפונקציה addItem
של תיבת התוצאות.
בסגירה אנחנו צריכים לסגור את תהליכון הרקע ובשביל זה הגדרתי פונקציית טיפול באירוע סגירה:
def closeEvent(self, event):
self.finder_thread.exit(0)
פיתרון כזה יכול לתת מענה טוב לכל תוכנית גרפית שצריכה לבצע עבודה ברקע. נכון אין להם עדיין תמיכה טובה ב async/await, אבל אני מקווה שבעתיד גם לזה יימצא פיתרון.