Введение


К сожалению, даже сейчас, в современном мире, не всегда удаётся воспользоваться всеми благами технологии 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.

Комментарии (9)


  1. m0nym
    13.06.2018 08:10

    А что там по факту существования этой операционной системы в реале и возможности ее реального использования?


    1. osanwe Автор
      13.06.2018 08:43

      Всё зависит от потребностей. Базовые функции обеспечиваются. На части девайсов есть поддержка Android-приложений, которыми можно заменить недостающие нативные.


      1. m0nym
        13.06.2018 08:47

        И где эти девайсы?


        1. Andrusha
          13.06.2018 12:17

          Jolla в официальной продаже закончились, так что на Авито/eBay. На 4pda есть выкорчеванный из них Alien Dalvik, можно впихнуть в прошитые на Sailfish Nexus 4, Nexus 5 и, возможно, в единственный продаваемый смартфон на этой оси Inoi R7.
          Короче, всё очень грустно.


        1. chuvilin
          13.06.2018 13:20

          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.


          1. deseven
            13.06.2018 16:47

            К сожалению и там все очень и очень плохо. Вот например сравнение работы камеры в родном для иксперии Андроиде и в Сейлфиш:
            www.youtube.com/watch?v=aEE91Hayv20

            Я в свое время был большим фанатом n9, потом с замиранием сердца следил за развитием Jolla, предзаказал их телефон, проходил с ним год. Я готов со многим мириться, но даже базовый функционал устарел на годы, невозможность сделать фото нормального качества в 2018 году тоже к этому относится.


            1. chuvilin
              13.06.2018 16:52

              Там — это где?

              Качество камеры Xperia X — это не проблемы операционной системы Sailfish.
              Судить об ОС только по камере, по крайней мере, странно, по-моему.

              И вообще, зачем это обсуждение здесь?
              Статья явно не об устройствах на SFOS, а о разработке приложений.


              1. deseven
                13.06.2018 18:28

                Камера у Xperia X хорошая, она очень плохо работает в Sailfish.

                Вы сами зачем-то решили упомянуть Sailfish X, а теперь упрекаете меня в том, что я это обсуждаю? Ну ок, всего хорошего.


                1. chuvilin
                  13.06.2018 18:48

                  Камера Xperia X работает одинаково плохо на всех сторонник прошивках, включающих AOSP из-за проприетарности драйвера, который используется в оригинальных прошивках.
                  Ещё раз, это не проблема ОС.