Реализация работы с Long Poll сервером в клиенте ВКонтакте для Sailfish OS

    Введение


    К сожалению, даже сейчас, в современном мире, не всегда удаётся воспользоваться всеми благами технологии 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.
    • +10
    • 2,9k
    • 9
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

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

      0
      А что там по факту существования этой операционной системы в реале и возможности ее реального использования?
        0
        Всё зависит от потребностей. Базовые функции обеспечиваются. На части девайсов есть поддержка Android-приложений, которыми можно заменить недостающие нативные.
          0
          И где эти девайсы?
            0
            Jolla в официальной продаже закончились, так что на Авито/eBay. На 4pda есть выкорчеванный из них Alien Dalvik, можно впихнуть в прошитые на Sailfish Nexus 4, Nexus 5 и, возможно, в единственный продаваемый смартфон на этой оси Inoi R7.
            Короче, всё очень грустно.
              0
              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.
                0
                К сожалению и там все очень и очень плохо. Вот например сравнение работы камеры в родном для иксперии Андроиде и в Сейлфиш:
                www.youtube.com/watch?v=aEE91Hayv20

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

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

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

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

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое