Введение
К сожалению, даже сейчас, в современном мире, не всегда удаётся воспользоваться всеми благами технологии push и порой приходится реализовывать обходные пути, например, в виде Long Poll, который позволяет эмулировать механизм push-уведомлений. В частности, такая необходимость возникла при реализации клиента ВКонтакте для Sailfish OS.
В данной статье не будут рассматриваться принципы взаимодействия с Long Poll сервером ВКонтакте — он имеет очень подробную документацию, а базовые примеры уже публиковались ранее. Вместо этого будет рассмотрена практическая реализация под конкретную платформу.
Подразумевается, что читатель знаком с разработкой под Sailfish OS не только на QML, но и на C++.
Long Poll клиент
Основным классом клиента является класс
LongPoll, осуществляющий запросы к Long Poll серверу и разбирающий его ответы.Метод
getLongPollServer, в задачу которого входит получение информации для открытия соединения с сервером, вызывается во время инициализации приложения, что позволяет сразу же получать пользовательские обновления:/** * Метод получает данные для соединения с Long Poll сервером ВКонтакте. */ void LongPoll::getLongPollServer() { QUrl url("https://api.vk.com/method/messages.getLongPollServer"); // Адрес запроса к API QUrlQuery query; query.addQueryItem("access_token", _accessToken); // Указывается Access Token query.addQueryItem("v", "5.53"); // Указывается версия используемого API url.setQuery(query); // Параметры запроса конкатенируются с адресом запроса _manager->get(QNetworkRequest(url)); // Выполняется GET-запрос к серверу ВКонтакте }
В случае успешного выполнения запроса, происходит сохранение информации о соединении с Long Poll сервером и открывается соединение с помощью метода
doLongPollRequest:/* * Метод обрабатывает результаты запроса к серверу. * @:param: reply -- указатель на ответ сервера. */ void LongPoll::finished(QNetworkReply* reply) { QJsonDocument jDoc = QJsonDocument::fromJson(reply->readAll()); // Преобразование ответа в JSON if (_server.isNull() || _server.isEmpty()) { // Проверка на наличие сохранённых данных QJsonObject jObj = jDoc.object().value("response").toObject(); _server = jObj.value("server").toString(); // Сохранение адреса сервера _key = jObj.value("key").toString(); // Сохранение ключа доступа _ts = jObj.value("ts").toInt(); // Сохранение номера последнего события doLongPollRequest(); // Открытие соединения с Long Poll сервером } else { // ... // Работа при открытом соединении // ... } reply->deleteLater(); // Удаление ответа из памяти }
В методе
doLongPollRequest Long Poll серверу передаются необходимые параметры соединения:/* * Метод создаёт соединение с Long Poll сервером. */ void LongPoll::doLongPollRequest() { QUrl url("https://" + _server); // Формирование адреса запроса QUrlQuery query; query.addQueryItem("act", "a_check"); // Параметр действия по умолчанию query.addQueryItem("key", _key); // Ключ доступа query.addQueryItem("ts", QString("%1").arg(_ts)); // Номер последнего события query.addQueryItem("wait", "25"); // Максимум 25 секунд ожидания query.addQueryItem("mode", "10"); // Получение вложений и расширенного набора событий url.setQuery(query); // Параметры запроса конкатенируются с адресом запроса _manager->get(QNetworkRequest(url)); // Выполнение GET-запроса к Long Poll серверу }
Стоит заметить, что значение поля
mode, равное 10, было получено путём сложения опции получения вложений (2) и возвращения расширенного набора событий (8).В качестве ответа на открытие соединения, сервер возвращает JSON, содержащий последние события. Ответ обрабатывается в методе
finished:/* * Метод обрабатывает результаты запроса к серверу. * @:param: reply -- указатель на ответ сервера. */ void LongPoll::finished(QNetworkReply* reply) { QJsonDocument jDoc = QJsonDocument::fromJson(reply->readAll()); // Преобразование ответа в JSON if (_server.isNull() || _server.isEmpty()) { // ... // Сохранение параметров соединения // ... } else { QJsonObject jObj = jDoc.object(); if (jObj.contains("failed")) { // Проверка на успешность запроса к серверу if (jObj.value("failed").toInt() == 1) { // Проверка типа ошибки _ts = jObj.value("ts").toInt(); // Сохранение нового номера последнего события doLongPollRequest(); // Повторный запрос к Long Poll серверу } else { _server.clear(); // Удаление адреса сервера _key.clear(); // Удаление ключа доступа _ts = 0; // Удаление номера последнего события getLongPollServer(); // Запрос новой информации для соединения } } else { // Если запрос выполнился без ошибок _ts = jObj.value("ts").toInt(); // Сохранение нового номера последнего события parseLongPollUpdates(jObj.value("updates").toArray()); // Разбор ответа от сервера doLongPollRequest(); // Повторный запрос к Long Poll серверу } } reply->deleteLater(); // Удаление ответа из памяти }
Поле
failed в ответе может принимать четыре значения, но только одно из них, равное единице, не требует повторного запроса информации для соединения с Long Poll сервером. По этой причине в код и было добавлено условие jObj.value("failed").toInt() == 1
Метод
parseLongPollUpdates представляет собой простой цикл по всем пришедшим событиям с проверкой их типа:enum LONGPOLL_EVENTS { NEW_MESSAGE = 4, // Новое сообщение INPUT_MESSAGES_READ = 6, // Входящие сообщения прочитаны OUTPUT_MESSAGES_READ = 7, // Исходящие сообщения прочитаны USER_TYPES_IN_DIALOG = 61, // Пользователь набирает текст в диалоге USER_TYPES_IN_CHAT = 62, // Пользователь набирает текст в чате UNREAD_DIALOGS_CHANGED = 80, // Изменение количества непрочитанных диалогов }; /* * Метод разбирает события, пришедшие от Long Poll сервера. * @:param: updates -- массив с новыми событиями. */ void LongPoll::parseLongPollUpdates(const QJsonArray& updates) { for (auto value : updates) { // Цикл по всем событиям QJsonArray update = value.toArray(); // Получение объекта события switch (update.at(0).toInt()) { // Проверка типа события case NEW_MESSAGE: emit gotNewMessage(update.at(1).toInt()); break; case INPUT_MESSAGES_READ: emit readMessages(update.at(1).toInt(), update.at(2).toInt(), false); break; case OUTPUT_MESSAGES_READ: emit readMessages(update.at(1).toInt(), update.at(2).toInt(), true); break; case USER_TYPES_IN_DIALOG: emit userTyping(update.at(1).toInt(), 0); break; case USER_TYPES_IN_CHAT: emit userTyping(update.at(1).toInt(), update.at(2).toInt()); break; case UNREAD_DIALOGS_CHANGED: emit unreadDialogsCounterUpdated(update.at(1).toInt()); break; default: break; } } }
Из кода видно, что для каждого нового события Long Poll клиентом посылается сигнал, который должен быть обработан другой частью приложения. Аргументом сигнала является не весь объект события, а только необходимые его части. Например, сигнал
gotNewMessage передаёт только идентификатор нового сообщения, по которому запрашивается его полное содержание:void VkSDK::_gotNewMessage(int id) { _messages->getById(id); }
В результате выполнения этой однострочной функции осуществляется запрос к серверу ВКонтакте для получения полной информации о сообщении по его идентификатору с дальнейшим созданием объекта этого сообщения. В завершение, посылается сигнал, связанный с пользовательским интерфейсом, передающий данные о новом сообщении, которые отображаются в панели уведомлений:
import QtQuick 2.0 // Подключение модуля для поддержки компонентов QML import Sailfish.Silica 1.0 // Подключение модуля для поддержки компонентов Sailfish OS import org.nemomobile.notifications 1.0 // Подключение модуля для поддержки уведомлений ApplicationWindow // Окно приложения { // ... // Инициализация интерфейса // ... Notification { // Компонент для отображения уведомлений id: commonNotification // Идентификатор для обращения category: "harbour-kat" // Категория уведомлений remoteActions: [ // Отключение каких-либо действий с уведомлением { "name": "default", "service": "nothing", "path": "nothing", "iface": "nothing", "method": "nothing" } ] } Connections { // Компонент для получения сигналов target: vksdk // Сигналы принимаются из SDK ВКонтакте onGotNewMessage: { // Обработка сигнала о новом сообщении commonNotification.summary = name // Заголовок уведомления в панели уведомлений commonNotification.previewSummary = name // Заголовок уведомления при отображении commonNotification.body = preview // Тело уведомления в панели уведомлений commonNotification.previewBody = preview // Тело уведомления при отображении commonNotification.close() // Закрываем предыдущее уведомление если есть commonNotification.publish() // Отображаем новое уведомление } } }
Интерфейс диалогов
Теперь, опираясь на принципы взаимодействия клиента с Long Poll сервером и принципы передачи полученной информации в пользовательский интерфейс, можно рассмотреть пример обновления открытого диалога.
Первое, что бросается в глаза, — это компонент
Connections:Connections { // Компонент для получения сигналов target: vksdk // Сигналы принимаются из SDK ВКонтакте onSavedPhoto: { // Обработка сигнала об окончании загрузки фотографии attachmentsList += name + ","; // Добавление имени фотографии к списку загруженных attachmentsBusy.running = false; // Прекращение отображения процесса загрузки } onUserTyping: { // Обработка сигнала о наборе собеседником сообщения var tempId = userId; // Временная переменная для идентификатора комнаты if (chatId !== 0) { // Проверка типа комнаты tempId = chatId; // Если чат, то идентификатор комнаты меняется } if (tempId === historyId) { // Сравнение полученного и текущего идентификаторов комнаты typingLabel.visible = true // Отображение уведомления о наборе сообщения } } }
Слот
onUserTyping обрабатывает событие набора собеседником сообщения путём отображения пользователю соответствующего уведомления. Здесь, на первом шаге, производится получение идентификатора комнаты (под комнатой понимается обобщённый термин для диалогов и чатов), а на втором — отображение уведомления если полученный идентификатор и идентификатор текущей комнаты совпадают.Стоит заметить, что уведомление о наборе сообщения отображается десять секунд, если за это время не приходило новое событие, вновь активирующее уведомление. Это обеспечивается с помощью компонента
Timer:Label { // Объявление компонента для уведомления id: typingLabel // Идентификатор для обращения anchors.bottom: newmessagerow.top // Расположение над полем для набора сообщения width: parent.width // Ширина эквивалентна ширине экрана horizontalAlignment: Text.AlignHCenter // Выравнивание текста уведомления по центру font.pixelSize: Theme.fontSizeExtraSmall // Маленький размер шрифта color: Theme.secondaryColor // Цвет шрифта неактивного элемента text: qsTr("typing...") // Текст для отображения visible: false // По умолчанию уведомление не отображается onVisibleChanged: if (visible) typingLabelTimer.running = true // Запуск таймера при активации Timer { // Объявление компонента таймера id: typingLabelTimer // Идентификатор для обращения interval: 10000 // Длительность таймера -- десять секунд onTriggered: typingLabel.visible = false // Скрытие уведомления по завершению работы таймера } }
Слот
onSavedPhoto отвечает за обработку окончания загрузки изображения в сообщениях, что выходит за рамки текущей статьи.Второе, что вызывает интерес — список сообщений:
SilicaListView { // Объявление компонента списка id: messagesListView // Идентификатор для отображения // Ширина списка от левого края экрана до правого: anchors.left: parent.left anchors.right: parent.right // Высота списка от верхнего края экрана до элемента уведомления о наборе сообщения: anchors.top: parent.top anchors.bottom: typingLabel.top verticalLayoutDirection: ListView.BottomToTop // Обратный порядок отображения элементов списка clip: true // Скрытие элементов списка, выходящих за указанные границы model: vksdk.messagesModel // Модель для отображения delegate: MessageItem { // Объявление компонента одного сообщения // Ширина сообщения от левого края экрана до правого: anchors.left: parent.left anchors.right: parent.right // Передача параметров для отображения сообщения: userId: fromId // Идентификатор отправителя date: datetime // Дата отправки сообщения out_: out // Исходящее ли сообщение read_: read // Прочитано ли сообщение avatarSource: avatar // Адрес изображения пользователя bodyText: body // Текст сообщения photos: photosList // Список изображений во вложении videos: videosList // Список видеозаписей во вложении audios: audiosList // Список аудиозаписей во вложении documents: documentsList // Список документов во вложении links: linksList // Список ссылок во вложении news: newsList // Список записей со стены во вложении geoTile: geoTileUrl // Адрес изображения карты геометки geoMap: geoMapUrl // Адрес для открытия геометки на карте в браузере fwdMessages: fwdMessagesList // Список пересланных сообщений Component.onCompleted: { // Обработка сигнала завершения отрисовки сообщения if (index === vksdk.messagesModel.size-1) { // Если это последнее сообщение в списке // Запрос с сервера части предыдущих сообщений: vksdk.messages.getHistory(historyId, vksdk.messagesModel.size) } } } VerticalScrollDecorator {} // Отображение полосы вертикальной прокрутки списка }
Здесь, компонент
MessageItem отвечает за отображение отдельного сообщения. Его рассмотрение выходит за рамки данной статьи.Сами сообщения берутся из модели
vksdk.messagesModel. Эта модель представляет собой список объектов Message, который может обновляться в режиме реального времени методами add, prepend, addProfile, readMessages и clear:/* * Метод очищает список сообщений. */ void MessagesModel::clear() { beginRemoveRows(QModelIndex(), 0, _messages.size()); // Начало группового удаления элементов _messages.clear(); // Удаление сообщений _profiles.clear(); // Удаление профилей собеседников endRemoveRows(); // Окончание группового удаления элементов // Сигнал об обновлении модели: QModelIndex index = createIndex(0, 0, nullptr); emit dataChanged(index, index); } /* * Метод добавляет новое сообщение в список. * @:param: message -- указатель на объект сообщения. */ void MessagesModel::add(Message* message) { // Начало добавления элементов: beginInsertRows(QModelIndex(), _messages.size(), _messages.size()); _messages.append(message); // Добавление нового сообщения endInsertRows(); // Окончание добавления элементов // Сигнал об обновлении модели: QModelIndex index = createIndex(0, 0, static_cast<void *>(0)); emit dataChanged(index, index); } /* * Метод добавляет новое сообщение в начало списка. * @:param: message -- указатель на объект сообщения. */ void MessagesModel::prepend(Message* message) { // Выход при пустом списке сообщений или несовпадении идентификаторов: if (_messages.isEmpty()) return; if (message->chat() && _messages.at(0)->chatId() != message->chatId()) return; if (!message->chat() && _messages.at(0)->userId() != message->userId()) return; beginInsertRows(QModelIndex(), 0, 0); // Начало добавления элементов _messages.insert(0, message); // Добавление сообщения endInsertRows(); // Окончание добавления элементов // Сигнал об обновлении модели: QModelIndex index = createIndex(0, _messages.size(), nullptr); emit dataChanged(index, index); } /* * Метод метод добавляет профиль пользователя в комнату. * @:param: profile -- указатель на профиль пользователя. */ void MessagesModel::addProfile(Friend* profile) { // Добавление профиля собеседника, если его ещё нет в списке if (_profiles.contains(profile->id())) return; _profiles[profile->id()] = profile; // Сигнал об обновлении модели: QModelIndex startIndex = createIndex(0, 0, nullptr); QModelIndex endIndex = createIndex(_messages.size(), 0, nullptr); emit dataChanged(startIndex, endIndex); } /* * Метод помечает сообщения прочитанными. * @:param: peerId -- идентификатор комнаты. * @:param: localId -- идентификатор первого непрочитанного сообщения. * @:param: out -- исходящее ли сообщение было прочитано. */ void MessagesModel::readMessages(qint64 peerId, qint64 localId, bool out) { // Выход при пустом списке сообщений или несовпадении идентификаторов: if (_messages.isEmpty()) return; if (_messages.at(0)->chat() && _messages.at(0)->chatId() != peerId) return; if (!_messages.at(0)->chat() && _messages.at(0)->userId() != peerId) return; foreach (Message *message, _messages) { // Цикл по всем сообщениям в списке if (message->id() <= localId && message->isOut() == out) // Проверка статуса сообщения message->setReadState(true); // Сообщение помечается прочитанным } // Сигнал об обновлении модели: QModelIndex startIndex = createIndex(0, 0, nullptr); QModelIndex endIndex = createIndex(_messages.size(), 0, nullptr); emit dataChanged(startIndex, endIndex); }
Общим для всех пяти методов является использование сигнала
dataChanged, который показывает, что в модели произошло обновление данных. Испускание данного сигнала приводит к обновлению элементов SilicaListView для отображения актуального статуса сообщений. Добавление сообщений в SilicaListView обеспечивается вызовом методов beginInsertRows и endInsertRows, отправляющих сигналы rowsAboutToBeInserted и rowsInserted соответственно. В результате, пользователь будет видеть в диалоге новые сообщения и их статус в режиме реального времени.Заключение
В данной статье было рассмотрено взаимодействие с Long Poll сервером при разработке для Sailfish OS на примере приложения ВКонтакте. Были рассмотрены некоторые особенности реализации клиента и способ обновления пользовательского интерфейса в режиме реального времени. Код приложения, описанного в данной статье, доступен на GitHub.
