Работа с данными ВК / подсказка к 5 задачке

5

Необходимо прочувствовать идею асинхронности и сделать отзывчивый интерфейс с аватарками

Сейчас, когда листаешь страницу происходит небольшое подвисание интерфейса.

Связано это с тем что, при клике на кнопку, на сервер ВК отправляется запрос и ожидается ответ, и хотя это занимает не сильно много времени какие-то сотни миллисекунд, интерфейс в это время становится недоступным.

Чтобы блокировки интерфейса не происходило мы можем воспользоваться так называемым асинхронным подходом к выполнению кода. При таком подходе мы операцию по отправке запроса выносим в отдельный исполняемый поток и блокировка интерфейса пропадает

Делается это так. Сначала надо кусок кода ответственный за запрос вынести в отдельную функцию. Вот так:

class UI():
    @staticmethod # обязательно помечаем функцию как staticmethod
    def get_request(offset_from_start): # функця с параметром offset_from_start
        proxies = {
            "http": "http://172.27.100.5:4444",
            "https": "http://172.27.100.5:4444",
        }
        
        r = requests.get("https://api.vk.com/method/groups.getMembers", {
            "access_token": "203a983a203a983a203a983a58204daa522203a203a983a405dff18fea57bccc1c68d62",
            "v": "5.130",
            "group_id": "golos_irnitu",
            "fields": "photo_max_orig",
            "offset": self.offset_from_start, # тут вместо self.offset_from_start используем offset_from_start
            "count": 10,
            "lang": "ru",
        }, proxies=proxies)
        
        data = r.json()
        pprint(data)
         
        return data # функцию должна вернуть данные которые получит от ВК
    
    def get_members(self):
        """ отсюда убираем
        r = requests.get("https://api.vk.com/method/groups.getMembers", {
            "access_token": "203a983a203a983a203a983a58204daa522203a203a983a405dff18fea57bccc1c68d62",
            "v": "5.130",
            "group_id": "golos_irnitu",
            "fields": "photo_max_orig",
            "offset": self.offset_from_start,
            "count": 10,
            "lang": "ru",
        })
        
        data = r.json()
        pprint(data)
        """
    
        # дальше все так же
        items = data['response']['items']
        
        # ...
        

теперь код, который ответственен за наполнение формы (т.е. создание лейблов тоже вынесем в отдельную функцию с параметром data

class UI():
    @staticmethod
    def get_request(offset_from_start):
         # ...
        
        return data
    
    # добавил новую функцию с копипастой кода по заполнению лейблов
    def fill_labels(self, data):
        items = data['response']['items']
        
        for label in self.labels:
            label.destroy()
        
        self.labels = []
        
        y = 10
        for r in items:
            first_name = r['first_name']
            last_name = r['last_name']
            label = Label(text=f"{first_name} {last_name}")
            label.place(x=10, y=y)
            y += 30
            
            self.labels.append(label)
            
        self.label_count['text'] = f"{self.offset_from_start} / {data['response']['count']}"
        
    def get_members(self):
       """ А ТУТ ВСЕ УБИРАЕМ
       items = data['response']['items']
        
        for label in self.labels:
            label.destroy()
        
        self.labels = []
        
        y = 10
        for r in items:
            first_name = r['first_name']
            last_name = r['last_name']
            label = Label(text=f"{first_name} {last_name}")
            label.place(x=10, y=y)
            y += 30
            
            self.labels.append(label)
            
        self.label_count['text'] = f"{self.offset_from_start} / {data['response']['count']}"
        """

Теперь самое интересное)

У нас есть функция get_request которая делает запрос, и функция fill_labels которая результат запроса выведет на форму, можем попробовать их связать сначала синхронно:

class UI():
    @staticmethod
    def get_request(offset_from_start):
        # ...
        return data
    
    def fill_labels(self, data):
        # ...
        self.label_count['text'] = f"{self.offset_from_start} / {data['response']['count']}"
    
    def get_members(self):
       # связали
       data = self.get_request(self.offset_from_start)
       self.fill_labels(data)
       
   # ...

оно даже будет работать так же, с небольшими подвисаниями:

а теперь вызовем эти функции асинхронно:

# …
import multiprocessing # добавим пакет для многопоточности

class UI():
    @staticmethod
    def get_request(offset_from_start):
        # ...
    
    def fill_labels(self, data):
        # ...

    def get_members(self):
        # создаем пул потоков
        # эта фиговина позволяет управлять потоками
        # и вызывать функции асинхронно
        pool = multiprocessing.Pool()
        
        # запускаем асинхронное выполнение
        pool.apply_async(
            self.get_request,  # первый параметр -- это функция которая должна выполниться в отдельном потоке
            (self.offset_from_start, ), # это параметры которые надо передать функции
            callback=self.fill_labels # а это функция которой асинхронная функция передаст выполнение по завершению
        )
        
    def __init__(self, gui):  
        # ...

    def on_button_click(self):
         # ...


# НУ И ТУТ ОБЯЗАТЕЛЬНО НАДО НАПИСАТЬ ТАКУЮ КОНСТРУКЦИЮ
# без нее асинхронный вызов не сработает
if __name__== '__main__' :
    gui = Tk()
    UI(gui)
    gui.mainloop()

тестим:

ура! теперь ничего не блокируется и можно тыкать даже по несколько раз =)

Раз уж такое дело добавим еще отображение аватарок профилей.

class UI():
    @staticmethod
    def get_request(offset_from_start):
        # ...
    
    def fill_labels(self, data):
        # ...
        
        y = 10
        for r in items:
            first_name = r['first_name']
            last_name = r['last_name']
            label = Label(text=f"{first_name} {last_name}")
            label.place(x=40, y=y) # сдвинем вправ
            # y += 30 ЭТО в КОНЕЦ УТАЩИМ
            self.labels.append(label)
            
            # ДОБАВЛЯЕМ ОТОБРАЖЕНИЕ КАРТИНОК, по стандарту
            # создаем лейбл под картинку, с границей
            lblImage = Label(borderwidth=1, relief="solid")
            lblImage.place(x=10, y=y, width=20, height=20)
            
            image_data = requests.get(r['photo_max_orig'], stream=True).raw # стягиваем картинку из интернета
            # собираем данные в картинку
            image = Image.open(image_data)
            image.thumbnail([20, 20], Image.ANTIALIAS) # уменьшаем чтобы влезло в лейбл
            
            lblImage.image = ImageTk.PhotoImage(image)
            lblImage.configure(image=lblImage.image)
            
            y += 30 # утащили вниз
            
        self.label_count['text'] = f"{self.offset_from_start} / {data['response']['count']}"

как мы видим работает весьма тормознуто

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

Стало быть, нам надо процедуру стягивания картинки и отображения ее на формы опять разбить на две функции. Первая – тянет данные из интернета и по завершению передает результат второй.

Создаем эти функции:

class UI():
    @staticmethod
    def get_request(offset_from_start):
        # ...
    
     # новая функция для стягивания картинки
     # так как ее будем вызывать асинхронно то надо добавлять @staticmethod
     # ну и на вход у нее один параметр -- url картинки
    @staticmethod
    def fetch_image(image_url):
        proxies = {
            "http": "http://172.27.100.5:4444",
            "https": "http://172.27.100.5:4444",
        }
        
        image_data = requests.get(image_url, stream=True, proxies=proxies).raw # стягиваем картинку из интернета
        # собираем данные в картинку
        image = Image.open(image_data)
        image.thumbnail([20, 20], Image.ANTIALIAS) # уменьшаем чтобы влезло в лейбл
        
        return image
    
    # еще одна функция в которую будет передаваться результат
    # на входе картинка и лейбл в который надо картинку положить
    # важно чтобы параметр image, то есть в который должны попасть данные из интернета, был первым
    def set_label_image(self, image, lblImage):
        lblImage.image = ImageTk.PhotoImage(image)
        lblImage.configure(image=lblImage.image)
        
    def fill_labels(self, data):
        # ...
        
        y = 10
        for r in items:
            # ...
            
            lblImage = Label(borderwidth=1, relief="solid")
            lblImage.place(x=10, y=y, width=20, height=20)
            
            # вызываем наши две функции последовательно
            image = self.fetch_image(r['photo_max_orig'])
            self.set_label_image(lblImage, image)
            
            y += 30
            
        self.label_count['text'] = f"{self.offset_from_start} / {data['response']['count']}"

можно проверить, по идее должно работать так же:

теперь сделаем вызов асинхронно

# ...
# добавляем импорт, он нам потребуется чтобы связать функцию fetch_image
# которая возвращает один параметр, с функций set_label_image, которая ожидает два
from functools import partial 

class UI():
    @staticmethod
    def get_request(offset_from_start):
        # ...
    
    @staticmethod
    def fetch_image(image_url):
        # ...

    def set_label_image(self, image, lblImage):
        # ...

    def fill_labels(self, data):
        # ...
        
        # создаем пул потоков, он должен быть общий для всех картинок, 
        # чтобы запросы шли параллельно 
        pool = multiprocessing.Pool()
        y = 10
        for r in items:
            # ... 
            
            # создание лейблов под картинки не трогаем
            lblImage = Label(borderwidth=1, relief="solid")
            lblImage.place(x=10, y=y, width=20, height=20)
            
            # делаем асинхронный вызов
            pool.apply_async(
                self.fetch_image, # вызываем стягивание картинки
                (r['photo_max_orig'], ), # передаем ссылку на картинку
                # ну а тут самое хитрое: partial(self.set_label_image, lblImage=lblImage) позволяет нам
                # сделать из функции с двумя аргументами -- функцию с одним аргументов
                # а в качестве аргумента автоматом lblImage подставляет тот  lblImage
                # который мы создали выше, это достаточно сложная концепция, которая называется "Каррирование"
                callback=partial(self.set_label_image, lblImage=lblImage)
            )
            
            y += 30
            
        self.label_count['text'] = f"{self.offset_from_start} / {data['response']['count']}"

ну что ж, тестируем:

Красота!

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

Поэтому сделаем один общий пул и будем его использовать везде

class UI():
    @staticmethod
    def get_request(offset_from_start):
        # ...
    
    @staticmethod
    def fetch_image(image_url):
        # ...
    
    def set_label_image(self, image, lblImage):
        # ...
    
    def fill_labels(self, data):
        # ...
        
        # pool = multiprocessing.Pool()   этот убрали
        y = 10
        for r in items:
            # ...
            
            self.pool.apply_async( # тут self добавили
                self.fetch_image,
                (r['photo_max_orig'], ),
                callback=partial(self.set_label_image, lblImage=lblImage)
            )
            
            y += 30
            
        self.label_count['text'] = f"{self.offset_from_start} / {data['response']['count']}"
        
    
    def get_members(self):
        # pool = multiprocessing.Pool() этот убрали
        self.pool.apply_async( # тут self добавили
            self.get_request, 
            (self.offset_from_start, ),
            callback=self.fill_labels
            
    def __init__(self, gui):  
        gui.geometry("400x360")
        
        self.pool = multiprocessing.Pool() # ДОБАВИЛ ОБЩИЙ ПУЛ
        
        # ...
   
   # ...

Как видим интерфейс стал еще отзывчиваее, совсем не подвисает и картинки грузятся параллельно =)