Гибридное приложение PyQt + React

Давным-давно я ничего не писал на PyQt… Так уж сложилось исторически, что последнее время PyQt я использую только для каких-то набросков, после чего перехожу на чистый Qt. С помощью python я могу быстро и криво набросать идею, для реализации которой использую родной Qt, который выправляет кривизну рук сам. Таким образом и случилось однажды, что открыв в очередной раз sublime text и накидав очередную заготовку, я узнал о том, что QWebKit (как и когда-то Phonon!) был выпилен из Qt и теперь всем предлагается пользоваться неким QtWebEngine.
Попробовал и оказался в восторге! К моему немалому удивлению, оказалось, что QtWebEngine сразу, безо всяких танцев с бубнами и прочего шаманства умеет выполнять javascript! Причём любой, хоть из файла html, хоть из локального файла js, хоть из cdn. Сходу получив такой инструмент я уже не мог остановиться до тех пор, пока не написал работающее приложение. А написав его, понял, что хочу не просто javascript, но React! И вот оно случилось…
Итак, что я написал и о чём будет эта статья? Я написал парсер старого доброго цитатника рунета. Весь интерфейс отрисовывается с помощью React, в качестве бандлера выступает Parcel. Всё это хозяйство запускается в centralWidget
QMainWindow
, а непосредственно само получение и парсинг цитат происходит с помощью библиотек requests
и re
соответственно. По сути у нас получается клиент-серверное приложение, где html-js-css выполняет роль клиента, а Qt/PyQt — сервера.
Сразу скажу, что статья по использованию QWebKit и React на чистом Qt будет! А пока мы начнём с python.
Для работы нам потребуется терминал. Поскольку я писал всё это хозяйство под Windows, использоваться будет cmd. Когда QtWebEngine только появился он требовал MSVC, как сейчас не знаю…
Содержание
Клиентская часть. Пишем простое React-приложение
Открываем терминал и создаём директорию командой mkdir pyqt-react-bash
, переходим в неё и создаём новый проект npm init
. Как вы понимаете, node должен быть установлен в системе. Отвечайте на вопросы npm как хотите, можно просто всё пропустить. Если вы так сделали, то в директории pyqt-react-bash
(ну или как вы там её назвали) появится файл package.json
такого содержания:
1 2 3 4 5 6 7 8 9 10 11 |
{ "name": "pyqt-react-bash", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "author": "", "license": "ISC" } |
Дальше необходимо поставить две тонны библиотек (о веб, ты ой!). В моём случае — parcel
нужно установить глобально, то есть npm install -g parcel-bundler
, остальные с флагом --save-dev
. Последовательно или сразу пишем в терминале
npm install react --save-dev
npm install react-dom --save-dev
npm install react-addons-css-transition-group --save-dev
npm install babel-preset-env --save-dev
npm install babel-preset-react --save-dev
npm install babel-preset-stage-0 --save-dev
npm install babel-preset-stage-1 --save-dev
npm install babel-plugin-react-html-attrs --save-dev
npm install babel-plugin-transform-class-properties --save-dev
Если вы хоть раз писали на react, то знаете зачем оно всё нужно. Для остальных просто скажу, что эти все вещи понадобятся нам для того, чтобы во-первых писать на react, во-вторых использовать стильный, модный и молодёжный es6. Есть ещё в третьих и так далее, но это не так важно. У меня нет цели познакомить вас с «прекрасным» миром веба.
Теперь нужно создать ещё две директории — scr
для исходников (там будут храниться файлы js и jsx) и static
, где будет лежать вся статика — html, js, css (да-да, похоже на Flask или Django!).
Переходим в директорию static
и создаём файл index.html
с таким содержанием:
1 2 3 4 5 6 7 8 9 10 |
<!doctype html> <html lang="ru"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> </head> <body> <div id="app"></div> <script src="index.js"></script> </body> |
Тут тоже ничего нового — обычный html. div id="app"
— точка входа в приложение. Теперь нам необходимо создать index.js
и App.jsx
. В первом мы подключим приложение react к нашей html странице, а во втором будем собственно его и писать.
Переходим в директорию src
и создаём App.jsx
такого содержания:
1 2 3 4 5 6 7 8 9 10 |
import React, { Component } from 'react'; export default class App extends Component { render() { return ( <h1>Привет, PyQt + React!</h1> ) } } |
Это заглушка нашего будущего приложения. Сейчас всё, что она будет делать — это выводить заголовок «Привет, PyQt + React!». Далее, в этой же директории создаём файл index.js
с таким содержимым:
1 2 3 4 5 6 7 8 9 |
import React from 'react'; import ReactDOM from 'react-dom'; import App from './App.jsx'; window.onload = () => { ReactDOM.render( <App />, document.getElementById('app') )}; |
Это стандартная рутинная операция. Мы подключаем наше реакт-приложение к html-странице к тегу div
с id="app"
. Этот файл можно закрыть, мы больше не будем его трогать.
Возвращаемся в корневую директорию нашего проекта, открываем файл package.json
и в секции scripts
после "test": "echo \"Error: no test specified\" && exit 1"
ставим запятую и на следующую строчку добавляем такую команду: "start": "parcel build ./src/index.js -d static --no-source-maps"
.
Теперь наконец-то можно выполнить в консоли npm start
и собрать проект. Если всё прошло удачно (а по-другому быть не должно), то в директории src
появится файл index.js
. Открывать его смысла особенного нет, так как он не очень читаемый. Теперь можно открыть index.html
из папки static
с помощью любого браузера и посмотреть на радостное «Привет, PyQt + React!»
Фууу-х! Рутинная часть почти закончена. Как вы могли заметить, parcel используется только как сборщик, никаких функций тестирования не предусмотрено, что не очень хорошо. Я не буду использовать сервер для отладки, так как это не сильно поможет при работе с Qt/PyQt, но вот одна полезная идея есть — открываем файл package.json
и в секцию scripts
добавляем такую команду: "watch": "parcel watch ./src/index.js -d static"
. Да, не забывайте про запятую! Теперь можно будет выполнить в консоли npm run watch
и отслеживать изменения. В будущем это нам может понадобиться.
Перед тем, как перейти к серверной части, пара моментов. Я использую Sublime Text 3 и Visual Studio Code с разными линтерами. Для того, чтобы писать на стильном, модном и молодёжном es6 и не получать тонны предупреждений рекомендуется сделать следующие вещи (Для VS Code):
- В корневой директории проекта создайте файл
.babelrc
следующего содержания (все плагины должны быть установлены)123456{"presets": ["env", "stage-0", "stage-1", "react"],"plugins": ["react-html-attrs", "transform-class-properties"]} - В корневой директории проекта создайте файл
.jshintrc
следующего содержания123{"esversion" : 6}
И да пребудет с вами ES6!
Серверная часть. Пишем простое PyQt-приложение
Тут вообще ничего сложного. В корневой директории нашего проекта создаём файл app.py
следующего содержания
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
#! /usr/bin/python # -*- coding: utf-8 -*- import re import os import sys import random import requests import webbrowser from PyQt5.QtCore import * from PyQt5.QtGui import * from PyQt5.QtWidgets import * from PyQt5.QtWebEngine import * from PyQt5.QtWebEngineWidgets import * from PyQt5.QtWebChannel import * class MainWindow(QMainWindow): def __init__(self): super(MainWindow, self).__init__() def main(): application = QApplication(sys.argv) window = MainWindow() window.setWindowTitle("PyQt-React-Bash") window.showMaximized() sys.exit(application.exec()) if __name__ == "__main__": main() |
За исключением импортов это самое тривиальное приложение PyQt. Можно его запустить и посмотреть на пустое окно. Сто́ит пробежаться по импортам:
- с помощью
re
мы будем парсить ответ bash.im random
будем использовать для того, чтобы взять случайную цитату (от 1 до 10) со страницы случайных цитат bash.im- получать страницу будем с помощью
requests
- дадим пользователю возможность перейти на сайт bash.im с помощью
webbrowser
Особо обратить внимание нужно на from PyQt5.QtWebEngine import *
, from PyQt5.QtWebEngineWidgets import *
и from PyQt5.QtWebChannel import *
. Это и есть наши новые QtWebEngine
и QtWebEngineWidgets
(так последнее время принято в Qt — что-то и Widgets), а также загадочный QtWebChannel
, с которым мы познакомимся поближе уже совсем скоро.
Соединяем всё вместе!
Пришло время слить два наших приложения в одно! Для начала просто подключим к серверу клиент. Делается это в четыре строчки в методе __init__
1 2 3 4 5 |
''' Create webbrowser frame ''' self.browser = QWebEngineView() # web engine view self.setCentralWidget(self.browser) # centralize self.browser.load(QUrl.fromLocalFile(os.getcwd() + "/static/index.html")) # open .html located in static dir self.browser.show() # show |
Главный тут — QWebView
, который просто открывает статическую страницу. Если вдруг после запуска новой версии вас не приветствует надпись «Привет, PyQt + React!», просто выполните в консоли npm start
и перезапуститесь. Ура, с нами программа Франкенштейн на PyQt, которая запускает некий QWebView
, в котором выполняется react-приложение, предварительно откомпилированное в понятный браузеру javascript… Впереди нас ждёт самое интересное — наладить взаимодействие между клиентским окружением (js/react) и сервером (pyqt/python).
Налаживаем связи
Установка связи между PyQt и React сложнее, чем между PyQt и JavaScript, однако общих моментов достаточно. Сначала докрутим сервер. Тут нам на помощь приходит тот самый загадочный QtWebChannel
. В тот же метод __init__
добавляем ещё четыре строчки:
1 2 3 4 5 6 7 |
''' Create webchannel to communicate between pyqt and js ''' self.web = WebClass() # must be instance of QObject! self.channel = QWebChannel(self) self.channel.registerObject("server", self.web) # register object. check the static/qtconnector.js file self.browser.page().setWebChannel(self.channel) # set channel to page class WebClass(QObject): pass |
Здесь нужно немного пояснить. Сначала мы объявляем некий экземпляр класса WebClass
(имя может быть любым), который обязательно должен быть потомком QObject
! Создание класса WebClass
будет показано ниже, пока же мы просто оставим заглушку. Затем мы создаём канал, который будет связывать серверный код, который в большинстве своём будет реализован в классе WebClass
с клиентским окружением. В третьей строчке мы связываем (точнее даже будет сказать «регистрируем»!) наш экземпляр класса WebClass
с произвольным именем в глобальном пространстве имён window
. Строго говоря, window
это не пространство имён, а глобальный объект javascript, но для нас это не принципиально. Таким образом мы сможем получить доступ к серверному классу WebClass
как к объекту javascript window.server
(или просто server
). Имя "server"
также может быть любым. Наконец мы устанавливаем канал на нашу страницу. Мы соединили PyQt с JS!
На самом деле, если попробовать запустить программу, то ничего не случится, более того (скажу по секрету!), ничего мы пока не соединили. Мы написали только серверную часть связки, пришло время писать клиентскую.
Но сначала небольшое отступление. В документации Qt говорится, что WebClass
использует некий файл qwebchannel.js
, но вот файла этого нигде нет. По крайней мере, когда я писал это приложение (под PyQt 5.9.1) у меня его не было. Гуглением он был нарыт. Если у вас его тоже нигде нет, то можете скачать его тут или скопировать текст под спойлером.
Файл qwebchannel.js
(строго с таким названием!) необходимо положить в директорию static
. Также может потребоваться jquery (во всяком случае в моей версии jquery была нужна, хотя в документации к Qt 5.12 про неё не пишут). Jquery можно подключать как локально, так и через cdn. Я выбрал локальный вариант.
Раз мы всё равно будем редактировать клиент, то сразу позаботимся и о внешнем виде приложения. Идея простая — одна кнопка, типа fab в центре экрана для получения цитат и кнопка-гамбургер для управления боковым меню. В боковом меню две возможности — переход на сайт bash.im и выход из приложения. Я использовал библиотеку drawer для создания бокового меню (как на сайте). Также мне захотелось прикрутить анимационные эффекты и я нашёл вот такое решение (спасибо Gabriele Romanato!).
И последнее, но не менее важное — debug! С этим, честно говоря, всё не так чтобы здорово… Несмотря на то, что QtWebEngine
базируется на Chromium, я не нашёл информации как именно вызвать инструменты разработчика в QtWebEngine
. Пришлось использовать firebug, который иногда сам немного лагает… Про инструменты, которые позволяют дебажить React, я умолчу. Вроде как есть способы запускать React Developer Tools как standalone приложение, но мне оно не понадобилось.
Таким образом, после всех добавлений и украшательств, файл index.html
превратился в такой:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 |
<!doctype html> <html lang="ru"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <!-- drawer.css --> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/drawer/3.2.2/css/drawer.min.css"> <link rel="stylesheet" type="text/css" href="style.css"> </head> <body id="dr" class="drawer drawer--left"> <script src="jquery.js"></script> <script type="text/javascript" src="https://getfirebug.com/firebug-lite.js"></script> <!-- Dev only --> <script src="qwebchannel.js"></script> <!-- for drawer.js --> <script src="https://cdnjs.cloudflare.com/ajax/libs/iScroll/5.2.0/iscroll.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/drawer/3.2.2/js/drawer.min.js"></script> <script type="text/javascript"> $(document).ready(function() { $('#dr').drawer(); }); /* by gabrieleromanato */ (function() { var FX = { easing: { linear: function(progress) { return progress; }, quadratic: function(progress) { return Math.pow(progress, 2); }, swing: function(progress) { return 0.5 - Math.cos(progress * Math.PI) / 2; }, circ: function(progress) { return 1 - Math.sin(Math.acos(progress)); }, back: function(progress, x) { return Math.pow(progress, 2) * ((x + 1) * progress - x); }, bounce: function(progress) { for (var a = 0, b = 1, result; 1; a += b, b /= 2) { if (progress >= (7 - 4 * a) / 11) { return -Math.pow((11 - 6 * a - 11 * progress) / 4, 2) + Math.pow(b, 2); } } }, elastic: function(progress, x) { return Math.pow(2, 10 * (progress - 1)) * Math.cos(20 * Math.PI * x / 3 * progress); } }, animate: function(options) { var start = new Date; var id = setInterval(function() { var timePassed = new Date - start; var progress = timePassed / options.duration; if (progress > 1) { progress = 1; } options.progress = progress; var delta = options.delta(progress); options.step(delta); if (progress == 1) { clearInterval(id); options.complete(); } }, options.delay || 10); }, fadeOut: function(element, options) { var to = 1; this.animate({ duration: options.duration, delta: function(progress) { progress = this.progress; return FX.easing.swing(progress); }, complete: options.complete, step: function(delta) { element.style.opacity = to - delta; } }); }, fadeIn: function(element, options) { var to = 0; this.animate({ duration: options.duration, delta: function(progress) { progress = this.progress; return FX.easing.swing(progress); }, complete: options.complete, step: function(delta) { element.style.opacity = to + delta; } }); } }; window.FX = FX; })() </script> <div id="app"></div> <script src="index.js"></script> </body> |
Тут очень много всего, поэтому сто́ит остановиться поподробней на некоторых моментах:
- Добавлены файлы стилей —
https://cdnjs.cloudflare.com/ajax/libs/drawer/3.2.2/css/drawer.min.css
— для drawer (да-да, они будут получены через cdn!) иstyle.css
— локальный файл для стиля страницы, в котором мы дадим стили кнопке получения цитат. - Тег
body
обзавёлся id и классом. Это также необходимо для использования библиотеки drawer. - Сразу после тега
body
самым первым скриптом обязательно должен быть jquery! В нашем случае он локальный. - Строго dev only добавился скрипт, который подгрузит firebug к нашей странице. Для того, чтобы вызвать его, необходимо нажать F12. Я не уверен, что этот скрипт должен быть там, где он есть, возможно его надо опустить в самый низ, перед локальными скриптами.
- Скрипт
qwebchannel.js
должен идти строго перед скриптом, в котором мы будем связывать сервер с клиентом, в нашем случае перед приложением React. Скрипт строго обязателен! - Скрипты для библиотеки drawer (jquery был загружен выше)
- В локальном скрипте, который написан прям в html-файле стандартная рутина — инициализация drawer и решение Gabriele Romanato по анимации. Можно было отделить их и написать всё в отдельном файле, но я оставил это специально для того, чтобы показать как
QtWebEngine
обрабатывает всевозможные скрипты — cdn, локальные и местные. - Строго после всего это должна быть точка входа в реакт-приложение и сам скрипт приложения.
Осталось ещё немного рутины — нам нужно написать файл стилей. Он прост, поэтому не требует комментариев:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
main { margin-left: 5%; margin-top: 5%; } .fab { display: inline-block; position: fixed; left: 50%; bottom: 5px; outline: none; -webkit-user-select: none; user-select: none; cursor: pointer; width: 60px; height: 60px; /* line-height: 60px;*/ text-align: center; font-size: 32px; z-index: 999; background: #eee; color: #336699; border-radius: 50%; box-shadow: 0 0 5px rgba(0, 0, 0, 0.15); transition: 0.2s opacity ease-in-out; -webkit-transition: 0.2s opacity ease-in-out; border: 0; overflow: hidden; } .fab:after { content: ''; display: block; position: absolute; left: 50%; top: 50%; width: 120px; height: 120px; margin-left: -60px; margin-top: -60px; background: #aaa; border-radius: 100%; opacity: .6; transform: scale(0); } @keyframes ripple { 0% { transform: scale(0); } 20% { transform: scale(1); } 100% { opacity: 0; transform: scale(1); } } .fab:not(:active):after { animation: ripple 2s ease-out; } .fab:after { visibility: hidden; } .fab:focus:after { visibility: visible; } .fab.fab-dark { color: #fff; background: #444; } .fab.fab-dark:after { background: #2b2b2b; } |
Его также необходимо положить в директорию static
. Таким образом, в папке static
будет четыре наших файла — style.css
, index.html
, qwebchannel.js
и jquery.js
. Пятым будет index.js
, который будет создавать для нас parcel.
Если сейчас запустить наше приложение (не важно, пересобирали ли мы react приложение) и нажать F12, то мы увидим окно firebug и предупреждение в консоли о том, что jquery что-то там не смогла сделать… Мы не увидим ни нашего дизайна, ни кнопок, ни анимации. Более того, если мы постараемся из консоли получить доступ к window.server
, то мы тоже получим undefined
.
Пришло время это исправить! Открываем файл App.jsx
и наконец-то начинаем соединять сервер с клиентом. Для этого нам необходимо написать метод componentDidMount()
. Если вы знакомы с React, то вы понимаете почему именно здесь нужно писать связь. Реализация метода проста:
1 2 3 4 5 6 7 8 |
componentDidMount() { return ( new QWebChannel(qt.webChannelTransport, (channel) => { var server = channel.objects.server; window.server = server; })); } |
В принципе, должно быть понятно, что именно тут происходит. Важные моменты — qwebchannel.js
должен быть загружен в коде до того, как начнёт отрабатывать приложение React, иначе получите ошибку Uncaught ReferenceError: qt is not defined
; в вызове new QWebChannel
первый параметр должен быть qt.webChannelTransport
, если же вы пишете на QML, то navigator.qtWebChannelTransport
Теперь можно выполнить npm start
, запустить наше приложение, открыть firebug, и наконец-то в консоли увидеть, что server
— это некий объект. Да, теперь мы точно соединили сервер с клиентом!
Заканчиваем с внешним видом!
Пришло время уже́ заставить наше приложение отрисовываться так, как нам хочется! Для этого нужно изменить метод render()
. Вместо унылого «Привет PyQt + React!» мы напишем следующее:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
render() { /* You must use arrow function to call methods. Otherwise it fires error! */ return ( <div> <header role="banner"> <button type="button" class="drawer-toggle drawer-hamburger"> <span class="sr-only">toggle navigation</span> <span class="drawer-hamburger-icon"></span> </button> <nav class="drawer-nav" role="navigation"> <ul class="drawer-menu"> <li><a style={{ fontSize: 25 }} class="drawer-brand" href="#">BASHER</a></li> <li><a style={{ fontSize: 20 }} class="drawer-menu-item" href="#" onClick={() => console.log("away")}>ПЕРЕЙТИ НА BASH.IM</a></li> <li><a style={{ fontSize: 20 }} class="drawer-menu-item" href="#" onClick={() => console.log("out")}>ВЫХОД</a></li> </ul> </nav> </header> <main role="main"> <div id="fade" style={{ fontSize: 25, paddingLeft: 100, paddingRight: 100 }} dangerouslySetInnerHTML={{ __html: "here's gonna be quote" }}></div> <button class="fab fab-dark" onClick={() => console.log("getRandomQuote")}>+</button> </main> </div> ) } |
Эта разметка не претендует на какое-то откровение, большая часть просто взята со страницы библиотеки drawer. Есть, однако, вещь, которая сто́ит того, чтобы на неё обратить внимание. Почему-то (может быть это только у меня) мы обязаны использовать конструкции типа onClick={() => console.log("away")}
. Попытка не использовать стрелочные функции привела меня к тому, что ничего не работало.
В данный момент у нас есть три заглушки для методов, которые будут вызывать серверный код. У нас есть клик по ссылке «ПЕРЕЙТИ НА BASH.IM», который откроет нам сайт в браузере по умолчанию (вне текущего приложения!), клик по ссылке «ВЫХОД», который закроет приложение PyQt, и клик по кнопке «+», который вызовет серверный код для получения случайной цитаты. Кстати говоря, мы могли бы получать цитату и из React-кода, к примеру с помощью axios, но в данный момент нам это не нужно.
Теперь можно скомпилировать react-приложение, запустить app.py
и увидеть наш минималистичный дизайн. Пусть вас не смущает, если при загрузке страницы всё дёргается — это связано с неспешной загрузкой firebug, когда мы её уберём такого не будет. Открыв консоль и потыкав в кнопки, можно увидеть, что react-приложение работает должным образом. Значит, пришло время реализовывать наши методы.
Реализуем методы
Начнём с простого — выход из приложения. Всё, что нам нужно на сервере — три строчки кода:
1 2 3 |
@pyqtSlot() def close(self): sys.exit() |
Метод должен быть реализован в классе WebClass
и мы обязаны использовать декоратор @pyqtSlot()
. Именно поэтому WebClass
должен быть наследником QObject
. Мы не передаём в наш код никаких параметров с клиента, поэтому декоратор идёт с пустыми скобками. Если бы нам нужно было передать какие-либо параметры, то мы должны были бы указать тип, например так @pyqtSlot(int)
.
Теперь наша задача на клиенте просто вызвать серверный метод. Так как наш WebClass
для клиента является объектом window.server
, то мы можем просто взять его и вызвать! Изменим наш react-код:
1 2 3 4 |
<li><a style={{ fontSize: 20 }} class="drawer-menu-item" href="#" onClick={() => server.close()}>ВЫХОД</a></li> |
Компилируем, запускаем, выполняем! Профит! Мы только что осуществили взаимодействие между React и PyQt! На клиенте мы вызвали серверный код! Если бы мы захотели передать серверу какие-то параметры, то просто написали бы как-то так server.method(arg)
, а на сервере наш декоратор должен был бы знать тип передаваемого значения arg
, например как-то так — pyqtSlot(str)
. Поскольку по сути мы передаём пустое значение ()
, то и декоратор тоже пуст.
Логично предположить, что и второй наш метод, который требует открытия браузера можно и нужно реализовать аналогично! Можно сразу изменить код на клиенте:
1 2 3 4 |
<li><a style={{ fontSize: 20 }} class="drawer-menu-item" href="#" onClick={() => server.away()}>ПЕРЕЙТИ НА BASH.IM</a></li> |
Теперь наша задача сводится к тому, чтобы на сервере реализовать метод away()
, который не принимает никаких аргументов и по сути имеет тип возвращаемого значения void
, то есть ничего не возвращает. Реализация достаточно банальна:
1 2 3 |
@pyqtSlot() def away(self): webbrowser.open("https://bash.im") |
Компилируем, запускаем, выполняем! Ну вы поняли…
Третья задача интересней и сложней. Мы должны получить от клиента команду, выполнить код и отдать клиенту данные! То есть наш метод будет что-то возвращать. Забавно, но код, который предстоит нам написать для React и для PyQt удивительно похож! Я думаю, если вы знаете что-то о React (а иначе что вы тут делаете??), то вы знаете и понимаете, что такое state
. Мы будем манипулировать этим самым состоянием для обновления компонента. Для начала давайте напишем constructor:
1 2 3 4 5 6 7 |
constructor() { super(); this.state = { quote: "" }; this.update.bind(this); } |
Мы говорим, что наш компонент будет принимать некое состояние this.state.quote
, которое по умолчанию пусто. Кроме того, нам бы хотелось красивые эффекты, при смене цитаты. Я выбрал классику — FadeIn/FadeOut. Давайте сразу напишем метод, который будет вызывать эффект FadeIn при смене цитаты.
1 2 3 4 5 6 7 |
update() { FX.fadeIn(document.getElementById('fade'), { duration: 1000, complete: () => { console.log('Complete in') } }); } |
Выше в конструкторе мы связываем метод — this.update.bind(this)
— стандартная рутина для React. Сам метод update()
просто находит элемент с id fade
и применяет к нему эффект fadeIn
за 1 секунду. Теперь нам осталось только изменить ту часть контейнера, где будут отображаться цитаты:
1 2 3 4 |
<div id="fade" style={{ fontSize: 25, paddingLeft: 100, paddingRight: 100 }} dangerouslySetInnerHTML={{ __html: this.state.quote }}> </div> |
Да, мы используем dangerouslySetInnerHTML={{ __html: this.state.quote }}
, так как знаем, что с сайта мы будем получать html-код, который и будем вставлять в наш div. Теперь нам осталось только разобраться с кликом по кнопке. По сути своей — это просто вызов серверного метода. Мы снова не передаём в сервер никаких параметров, мы уже писали такое дважды. Напишем в третий раз:
1 |
<button class="fab fab-dark" onClick={() => server.getQuote()}>+</button> |
Опять же неважно как именно мы обзовём метод сервера — хоть getQuote()
, хоть qwerty123()
, главное, чтобы мы реализовали метод с точно таким же названием на сервере в классе WebClass
! Хорошо, реализуем:
1 2 3 4 |
@pyqtSlot() def getQuote(self): self.quote = re.findall('<div class="text">.*</div>', requests.get("http://bash.im/random").text)[random.randint(0, 10)] |
Если особо не вдаваться в детали реализации этого метода, то делает он следующее — получает страницу со случайными цитатами, парсит её с помощью простого регулярного выражения, а затем случайным образом выбирает одну из них. Славно, мы получили нашу self.quote
и теперь нам её надо вернуть клиенту. Возникает разумный вопрос — «а как?»
Оказывается тут нам на помощь приходит макрос Q_PROPERTY
, а точнее его питоновская реализация. Перепишем наш WebClass
так:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class WebClass(QObject): quote_changed = pyqtSignal(str) def __init__(self): super(WebClass, self).__init__() self._quote = "" @pyqtProperty(str, notify=quote_changed) def quote(self): return self._quote @quote.setter def quote(self, new_value): self._quote = new_value self.quote_changed.emit(new_value) # дальше все остальные методы, включая getQuote() |
Сначала мы создаём сигнал quote_changed
(имя может быть любым) с параметром str
— это тип нашей цитаты. Затем мы создаём свойство self.quote
. Обязательно используем декоратор @pyqtProperty(str)
с типом! Второй аргумент необязателен, более того, насколько я понял, в python он вообще не используется, но notify=quote_changed
позволяет указать, какой сигнал будет вызываться при изменении данного свойства. Это может быть полезно, если у вас тонна разных свойств и сигналов. Сеттер для self.quote
прост, но в нём обязательно нужно вызвать self.quote_changed.emit(new_value)
, чтобы отработал сигнал quote_changed
. И, наконец, последнее — в методе self.getQuote()
мы обязательно связываем полученный результат со свойством self.quote
!
На самом деле, так положено делать (вроде как), но можно считерить 🙂 Удалим весь код из предыдущей вставки и наш метод getQuote()
и напишем так
1 2 3 4 5 6 7 8 |
class WebClass(QObject): quote_changed = pyqtSignal(str) @pyqtSlot() def getQuote(self): self.quote_changed.emit(re.findall('<div class="text">.*</div>', requests.get("http://bash.im/random").text)[random.randint(0, 10)]) # дальше все остальные методы |
То есть мы сразу вызываем сигнал, в который передаём значение. Это позволяет сильно сократить код. Впрочем, если мы прямо сейчас запустим наше приложение, то ничего смешного с баша мы не получим. Дело в том, что нам надо связать сигнал со слотом. И делать это мы будем… на клиенте!
1 2 3 4 5 6 7 8 9 10 |
componentDidMount() { return ( new QWebChannel(qt.webChannelTransport, (channel) => { var server = channel.objects.server; window.server = server; server.quote_changed.connect((value) => (this.setState({ quote: value }), this.update())); })); } |
Во-о-о-т! Мы добавили connect
для связи со слотом, вот только сделали мы это в реакт-приложении. Слотом у нас выступает стрелочная функция, которая меняет состояние this.state.quote
и вызывает анимационные эффекты. Остальное берёт на себя React! Компилируем, запускаем, выполняем!
Ложка юзабилити
По сути наше приложение написано. Остались мелкие штрихи. Сначала откроем файл index.html
и удалим скрипт для firebug. Это значительно ускорит загрузку страницы и практически полностью уберёт скакание элементов (почему-то иногда элементы всё равно могут это делать). Теперь перейдём к серверу. Раз мы реализовали выход из приложения с помощью react, то неплохо было бы запускать его в полноэкранном режиме. Достигается это одной строчкой кода. В метод __init__
класса MainWindow
в самый низ добавим
1 |
self.setWindowFlags(Qt.FramelessWindowHint) |
и получим желаемое. Ещё хотелось бы не кликать мышкой по кнопке, а задать какую-нибудь комбинацию клавиш для получения новых цитат. Хотя, зачем комбинацию? F5 мне вполне подойдёт! И кстати, я ещё не показал как напрямую вызвать код javascript с сервера. Так давайте сделаем это (код должен быть в классе MainWindow
):
1 2 3 4 5 6 |
def getRandom(self): self.browser.page().runJavaScript("FX.fadeOut(document.getElementById('fade'), {duration: 500, complete: function() {console.log('Complete');});", lambda x: self.web.getQuote()) def keyPressEvent(self, event): event.key() == Qt.Key_F5 and self.getRandom() |
Метод keyPressEvent
просто вызывает метод getRandom
при нажатии клавиши F5. А вот сам getRandom
куда интереснее! Мы обращаемся напрямую к странице и просто выполняем произвольный код javascript. В нашем случае мы «тушим» текущую цитату перед получением новой. Примечательно, что метод runJavaScript
вторым параметром принимает callback, то есть мы можем сразу после вызова javascript вызвать ещё что-то, например нашу getQuote()
. Внешние эффекты (которые от вызова javascript) не столь заметны в нашем случае, зато мы теперь получаем цитаты по нажатию клавиши F5.
С точки зрения удобства было бы ещё неплохо открывать/закрывать боковое меню по нажатию клавиши Esc (ну к примеру). Поскольку Qt не очень дружит с этой клавишей (так уж повелось), для корректной работы надо написать свой eventFilter. Пишем:
1 2 3 4 5 6 |
def eventFilter(self, obj, event): if event.type() == QEvent.KeyPress: if event.key() == Qt.Key_Escape: self.browser.page().runJavaScript("$('.drawer').drawer('toggle');", lambda x: None) return super(MainWindow, self).eventFilter(obj, event) |
и устанавливаем (в самый конец метода __init__
)
1 |
qApp.installEventFilter(self) |
Мы снова вызываем код javascript из python, но используем jquery (просто обучения ради). Запустив приложение, можно убедиться, что всё работает так, как хочется. В общем-то и всё!
Заключение
Qt делает фантастические вещи! Во всяком случае выпил полумёртвого WebKit с заменой на QtWebEngine это очень круто. Впрочем, как обычно бывает, ложка дёгтя в бочке мёда есть. В заключении я постараюсь рассказать о плюсах и минусах такого гибридного приложения. Начну с минусов:
- Медленная загрузка даже статической страницы — забавно, но pyqt справляется лучше (значительно лучше!), чем родной Qt. Не знаю почему, но в чистом Qt страница очень не торопится отрисоваться.
- Ограниченность самой технологии — по крайней мере на момент написания моего первого приложения и активного гугления требовался компилятор MSVC 64 bit. То есть огромный пласт разработчиков с MinGW, с 32 bit компиляторами просто в пролёте. Надеюсь, что это уже не актуально или станет не актуальным со временем…
- Трудность дебага. Особенно если писать на React. С чистым javascript несколько проще.
- Скомпилированное (точнее замороженное с помощью cx_freeze) приложение получается не сильно меньше приложения electron, которое весит далеко за 100 Мб… Хотелось бы меньше
- Если сервер будет выполнять тяжёлые операции, то интерфейс приложения замирает (что логично). Решение элементарно — тяжеловесные функции, функции, которые получают что-то из интернета и проч. должны выполняться в отдельных потоках. Это не является таким уж минусом, просто об этом нужно знать и помнить. В нашем случая я пренебрёг этим правилом, так как для нас не критично застывание интерфейса в ожидании новой цитаты…
Открытым также остаётся вопрос по потреблению памяти. Общеизвестна прожорливость electron, который в свою очередь базируется на том же самом Chromium. Я ничего не могу сказать по PyQt/Qt — не проводил исследований. По субъективным ощущениям гибриды PyQt/Qt потребляют память не так активно, как electron. Плюсы:
- Возможность использовать любую веб-технологию для построения абсолютно любого интерфейса. QML нервно курит в сторонке.
- Возможность создавать гибридные интерфейсы — к примеру QSplitter, в одной части которого QWebEngineView с приложением на JS/React, а в другом QTableView, связанный с QSqlTableModel
- Возможность использовать сильные стороны «серверного» языка. Будь то тяжеленные вычисления (C++) или вообще что угодно (python)…
- Возможность быстро адаптировать любой сайт/веб-продукт для десктопа.
- Возможность раздельного написания (одни люди пишут интерфейс, другие логику).
В отличие от приложений electron, в Qt/PyQt проще взаимодействие между интерфейсной и «серверной» частями, проще взаимодействие с файловой системой (ИМХО, конечно). В зависимости от конкретной ситуации можно использовать только сильные стороны языка (например С++ и тяжёлые вычисления, но JS для разбора JSON или python и разбор JSON, но chart.js для построения графиков). Одна только возможность расширять javascript-движки для игр смотрится крайне заманчивой! Надеюсь, что технология будет востребована и будет развиваться, желательно семимильными шагами, а я прощаюсь!
Следующая статья будет посвящена созданию гибридного приложения на Qt и Javascript (не React!).
Предыдущая статья: ЧМ-2018. Япония или Сенегал?
Следущая статья: Как правильно почистить зубы, будучи третьеклассником
Сообщить об опечатке
Текст, который будет отправлен нашим редакторам: