Введение
К сожалению, даже сейчас, в современном мире, не всегда удаётся воспользоваться всеми благами технологии 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.
m0nym
А что там по факту существования этой операционной системы в реале и возможности ее реального использования?
osanwe Автор
Всё зависит от потребностей. Базовые функции обеспечиваются. На части девайсов есть поддержка Android-приложений, которыми можно заменить недостающие нативные.
m0nym
И где эти девайсы?
Andrusha
Jolla в официальной продаже закончились, так что на Авито/eBay. На 4pda есть выкорчеванный из них Alien Dalvik, можно впихнуть в прошитые на Sailfish Nexus 4, Nexus 5 и, возможно, в единственный продаваемый смартфон на этой оси Inoi R7.
Короче, всё очень грустно.
chuvilin
Jolla продаёт Sailfish X для Sony Xperia X: jolla.com/sailfishx
(вариант без поддержки Android доступен бесплатно)
Устройства с портированной Sailfish OS: wiki.merproject.org/wiki/Adaptations/libhybris
И новые устройства в этом году: blog.jolla.com/wrapping-up-mwc18
Плюс, планшеты от INOI: T8 и T10.
deseven
К сожалению и там все очень и очень плохо. Вот например сравнение работы камеры в родном для иксперии Андроиде и в Сейлфиш:
www.youtube.com/watch?v=aEE91Hayv20
Я в свое время был большим фанатом n9, потом с замиранием сердца следил за развитием Jolla, предзаказал их телефон, проходил с ним год. Я готов со многим мириться, но даже базовый функционал устарел на годы, невозможность сделать фото нормального качества в 2018 году тоже к этому относится.
chuvilin
Там — это где?
Качество камеры Xperia X — это не проблемы операционной системы Sailfish.
Судить об ОС только по камере, по крайней мере, странно, по-моему.
И вообще, зачем это обсуждение здесь?
Статья явно не об устройствах на SFOS, а о разработке приложений.
deseven
Камера у Xperia X хорошая, она очень плохо работает в Sailfish.
Вы сами зачем-то решили упомянуть Sailfish X, а теперь упрекаете меня в том, что я это обсуждаю? Ну ок, всего хорошего.
chuvilin
Камера Xperia X работает одинаково плохо на всех сторонник прошивках, включающих AOSP из-за проприетарности драйвера, который используется в оригинальных прошивках.
Ещё раз, это не проблема ОС.