25 July 2014

Qt 5.3: низкий старт в мобильной кроссплатформе

Development of mobile applicationsQt
Приветствую всех, кому интересна кроссплатформенная разработка для мобильных устройств! До недавнего времени относился с огромным скепсисом к инструментариям, позволяющим создавать ненативные приложения, которые работали бы сразу на ряде платформ. Однако в один момент любопытство и желание попробовать что-то новое всё же перевесили сдерживающий рассудок. Выбор пал на Qt 5.3. Почему? Потому что Qt открыт и бесплатен для некоммерческого и коммерческого (под лицензией LGPL) использования, имеет продолжительную историю (вышел в свет в 1996 году) и достаточное количество качественных проектов (навскидку — Skype, приложения 2ГИС), реализованных с использованием данных библиотек.

Цель публикации — познакомить читателей с мобильной разработкой на последней версии Qt: показать реализацию основных элементов клиент-серверных приложений и осветить возможные «подводные камни» при работе с библиотеками Qt.
Требуемый уровень подготовки и опыта — минимальный (знание основ C++, элементарное умение конфигурации локального сервера).
Материал для удобства восприятия разбит на 4 части: «Подводные камни», История одного проекта и работа над ошибками, Основы клиент-серверной разработки, Резюме.


1. «Подводные камни»
1.1. Несовместимость разных версий библиотек.

Qt достаточно часто обновляется, что, с одной стороны, хорошо, но, с другой стороны, порой делает разработку ночным кошмаром. Происходит это потому, что новые версии не имеют совместимости со своими предшественниками (имеются в виду мажорные версии), часть функционала которых в лучшем случае устаревает, в худшем — становится более недоступной. Следствие — обилие неактуальных материалов/примеров в Сети, которые невозможно использовать в версии 5.3. Вообще, к огромному сожалению, годной информации по Qt 5.3 ничтожно мало: описывая проблему в поисковике, мы попадаем в документацию (документация качественная, но одной общей теории в большинстве случаев не хватает) или примеры для версий 4.6 или 4.8, которые, как правило, бесполезны для Qt 5.3. Официальные гайды и примеры, опять-таки, в основном описывают очень тривиальные случаи, с которыми, как правило, в действительности практически не сталкиваешься. О литературе упоминать не буду, с ней дела обстоят примерно также (актуальна для старых версий). Впрочем, эти минусы отчасти компенсирует активное сообщество (официальный форум — qt-project.org/forums).

1.2. Немного «сырая» поддержка функционала Android/iOS.

Если говорить в лоб, то на текущий момент нет реализации WebView для данных платформ. Возможно, не самый страшный недостаток, но о простой работе с геокартами, например, пока стоит забыть. Вторая проблема — невозможность получить IMEI устройства (скажем, для реализации идентификации на сервере): в старых версиях для этой цели служила библиотека QtMobility, в новых версиях её нет, полноценная замена для прежней ещё не реализована. Впрочем, даже эти недостатки реально решить, однако для этого требуется «погружение» на нативный уровень для каждой платформы (например, получение IMEI на Android — habrahabr.ru/sandbox/77966), но это, имхо, противоречит изначальной идее платформонезависимой разработки. Также отмечу присутствие некоторых незначительных багов в самой среде разработки (Qt Creator) — иногда, например, не применялись изменения в QML-файлах без перезапуска IDE.

1.3. Отслеживание ошибок.

Общаясь с другими разработчиками (Android, iOS, Web) у меня создалось впечатление, что некоторые программисты не горят желанием использовать библиотеки, где как основной язык используется C++. В действительности при разработке небольших приложений основная часть неприятных ситуаций возникала из-за специфики всё же самого Qt, нежели C++. Как правило, сообщения об ошибках не очень содержательны: выдаваемые «file not found», «undefined reference» и несколько других сообщений могут указывать на строчки кода в классе, когда проблема может заключаться, например, в отсутствии подключения модуля в *.pro-файле, неправильном вызове метода совсем в другом месте программы и так далее. Для отслеживания ошибок (чтобы не искать последние почти вслепую) очень выручают логи: функция qDebug() — в C++ коде и console.log() — в QML. Теме ошибок посвящена практически вся следующая часть публикации, к которой мы уже подошли.

2. История одного проекта и работа над ошибками
Эта часть об ошибках, которые были допущены мною при разработке небольшого клиент-серверного приложения. Здесь будет описан путь к решениям, предлагаемым в следующей части. Раздел можно пропустить без вреда для дальнейшего чтения.

Тезисно о поставленной цели: приложение, позволяющее:
— зарегистрироваться в сервисе;
— авторизоваться;
— выводить списки;
— отправлять запросы на сервер и парсить получаемые ответы.

В общем, всё предельно просто. Мне хотелось использовать знакомые по разработке в Android идиомы: разделить логику и UI, чтобы не прописывать всё в одном коде. Взглянув на UI-формы [1] в Qt, решил, что подобный «десктопный» вид для пользовательского интерфейса не подойдёт явно: даже на устройствах с большими экранами трудно попасть по некоторым элементам. Погружаться в стили UI-форм не захотел, поэтому для вёрстки и дизайна было решено использовать QML-файлы (о них подробней в следующей части).

image
1. Внешний вид UI-формы на Android-устройстве.

Чтобы соединить интерфейс, созданный в QML, с C++ бэкендом воспользовался этим примером — habrahabr.ru/post/138837. Хороший рабочий пример (по всей видимости, автор столкнулся с теми же проблемами в своё время, которые возникли и у меня при знакомстве с Qt). Однако по причине устаревания используемой в примере версии Qt (4.7.4), в дальнейшем я столкнулся с некоторыми проблемами. Первая из них — изрядные тормоза ListView при запуске на Android. Причина — QML старой версии (QtQuick 1.0).

Исправляем, меняем импорт в QML с QtQuick 1.0 на 2.2. Но не всё так просто, потому что старый бэкенд, использующий QtDeclarative/QDeclarativeView актуален только для QtQuick 1.0. Переделываем — qt-project.org/doc/qt-5/qtquick-porting-qt5.html. Вообще, как оказалось, QtQuick 1.0 не очень то и дружит с мобильными платформами — в нём были некоторые странности при, например, появлении клавиатуры во время ввода — интерфейс либо сжимался в два раза, чтобы был целиком виден одновременно с клавиатурой, либо сохранял свои размеры так, что половина экрана за клавиатурой становилась не видна. QtQuick 2.2 приятно удивил в этом отношении, потому что при открытии ввода теперь интерефйс аккуратно подтягивался вверх, чтобы не было искажений и поле ввода не терялось из вида.

Теперь вроде бы всё хорошо, ListView летает, но появляется новая неожиданность. Изначально делал приложение таким образом, чтобы для каждого окна был свой класс с привязанным к нему интерфейсом (использующим QML-файл). Когда мы открываем новое окно — создаётся экземпляр соответствующего класса, это окно отображается сверху, старое никуда не исчезает. Когда закрываем текущее окно, экземпляр класса удаляется, а предыдущее окно становится вновь видимым. Что-то вроде нескольких активити с бэкстэком в Android. И тут сюрприз — с QtQuick 2.2 так сделать уже не получается (в этом случае мы будем наблюдать просто статический серый экран в Android). Причина есть в багрепорте — bugreports.qt-project.org/browse/QTBUG-39454. Суть: мы можем использовать только одно OpenGL-окно (а QtQuick 2.2 как раз его использует) на Android. На iOS окна отображались, но появились свои косяки.

К огромному сожалению, от желаемой идиомы пришлось отказаться. Теперь используется один основной QML-файл (через QQmlApplicationEngine), а разные окна и переходы между ними осуществляются Loader’ом (о нём в следующей части). Не буду углубляться детальней, скажу лишь, что определённая часть времени потребовалась для того, чтобы также освоить парсинг JSON, HttpPost-запросы, решить косяки с отсутствием появления/нежеланием исчезать у клавиатуры, созданием навигации на манер бэкстэка в QML («ручная» обработка нажатия кнопки «Back») и ещё парой-тройкой сущностей.

3. Основы клиент-серверной разработки
Здесь будут описаны решения, к которым я пришёл после непродолжительного пробивания стен головой в Qt. Это основная часть публикации.

Итак, мы готовы приступить! Предполагается, что у вас уже установлен Qt 5.3 — qt-project.org/downloads, для компиляции на Android скачены JDK, Android SDK, NDK, apache-ant (к ним нужно будет указать путь в настройках Qt Creator), для iOS — XCODE.

3.1. Создание и подготовка проекта
Запускаем Qt Creator, нажимаем «Новый проект». Выбираем «Приложение Qt Quick»:
image

Назовём наш проект «Sample» (нельзя использовать кириллицу в наименовании проекта и пути к нему), чтобы у нас не было отличий в названиях:
image

Набор компонентов Qt Quick оставим на Qt Quick Controls 1.1:
image

Qt Quick Controls 1.1 — это набор библиотек, который содержит помимо базовых элементов Qt Quick 2.2 ещё различные функциональные элементы интерфейса (контролы). Здесь сразу оговорюсь, что использование контролов увеличивает размер приложения на мегабайта 3. В моём случае собранный apk-файл с контролами весил 11.6 Мбайт, после установки на устройство приложение занимало чуть меньше 20 Мбайт. Плюс контролы, по моему впечатлению, немного замедляют запуск приложения (впрочем, проблему можно решить лоадером или сплэшскрином). Однако их использование упрощает разработку, поэтому мы не будем от них отказываться.

Далее — выбираем платформы, на которых будем запускать приложение:
image

Поставим галочки напротив необходимых платформ: вне зависимости от установленных у вас платформ рекомендую также отметить Desktop — на ней приложение быстрее всего запускается, поэтому удобно тестировать.

Не будем останавливаться на подключении контроля версий (делается это несложно, в нашем примере для этого нет нужды), поэтому просто нажимаем «Готово»:
image

Наш проект создан! Из чего он состоит?
image

Давайте посмотрим на дерево проекта. Прежде всего, в работе нам будут нужны:
— конфигурационный *.pro-файл (Sample.pro): что-то отдалённо напоминающее файл манифеста в Android;
— исходники и заголовочные файлы (их создадим чуть позже), в которых будет бэкенд приложения;
— *.qrc-файл (qml.qrc): в нём мы будем указывать все необходимые для приложения ресурсы (QML-файлы, изображения и т.д.);
— QML-файлы для интерфейса (пока у нас есть только main.qml).

Сохраним и запустим приложение, на десктопе увидим что-то вроде следующего:
image

3.2. Создание интерфейса
3.2.1. Создаём кнопку
Открываем файл main.qml. Удаляем title, menuBar и Text. В итоге у нас должно получиться:

import QtQuick 2.2
import QtQuick.Controls 1.1

ApplicationWindow {
    visible: true
    width: 640
    height: 480
}


ApplicationWindow — наш корневой элемент, в который добавляются дочерние. Он обязательно должен присутствовать в главном QML-файле. Сразу пропишем внутри ApplicationWindow два поля: id и objectName:

ApplicationWindow {
    id: mainWindow
    objectName: "mainWindow"
    visible: true
    width: 640
    height: 480
}


id будет использоваться для обращения к ApplicationWindow внутри QML-файла, objectName нужен, чтобы найти наше основное окно из QML в C++ коде.

Параметры width и height с фиксированными значениями пусть пока вас не смущают. Интерфейс будет только изначально создаваться с такими размерами, затем растянется под размеры экрана смартфона. По этой причине на Android возникает небольшое дёрганье экрана при старте приложения, как исправить — опишу в самом конце.

Вообще QML-файлы по синтаксису чем-то напоминают гремучую смесь веб-вёрстки и javascript-логики, однако сходство лишь частичное. В QML мы можем создавать не только интерфейс, но и логику (что удобно, например, для проверки заполнения форм), что является плюсом.

Давайте укажем цвет нашего окна, для этого добавим в ApplicationWindow чуть ниже «height: 480» строчку


color: "#F0F0FF"


Задавать цвет можно hex-кодом, некоторые цвета можно прописать названием, например, «red», «transparent» и т.д. Можно также использовать созданную константу, об этом чуть позже. Теперь добавим в импорт:

import QtQuick.Controls.Styles 1.2


Стили будут нужны для кастомизации контролов. Создаём кнопку внутри ApplicationWindow. Общий код:

import QtQuick 2.2
import QtQuick.Controls 1.1
import QtQuick.Controls.Styles 1.2

ApplicationWindow {
    id: mainWindow
    objectName: "mainWindow"
    visible: true
    width: 640
    height: 480
    color: "#F0F0FF"

    Button {
        width: mainWindow.width / 2.5
        height: mainWindow.height / 10
        x: 0
        y: mainWindow.height - height
    }

}


Ширину кнопки рассчитываем относительно ширины главного окна, высоту — относительно высоты главного экрана. Здесь мы видим пример обращения к элементу по id, элементарные выражения для расчёта размеров и позиции кнопки [см. дополнение 1]. Если мы не указываем id перед параметром в выражении (height в расчёте y), то считается, что используется параметр текущего элемента, внутри которого мы находимся. Всё сохраним и запустим:
image

Кнопка появилась, её размеры просчитываются относительно размеров открытого окна (Desktop) или экрана мобильного устройства, что, на мой взгляд, решает проблемы одинакового отображения интерфейса на разных девайсах (с чем обычно геморрой на Android, если не использовать кастомные классы для создания UI).

Пришло время создать стиль для новоиспечённой кнопки! Для этого внутри Button добавляем:

style: ButtonStyle {

}


Стиль для кнопки будет состоять из двух частей: background и label. Эти части мы создадим в отдельных QML-файлах, чтобы не загромождать основной. Для этого свернём Qt Creator, зайдём в папку нашего проекта и создадим там папку «QMLs», чтобы рабочие файлы были аккуратно разложены по полочкам.

Возвращаемся в Qt Creator. Правой кнопкой щёлкаем по названию проекта в дереве (Sample) -> в контекстном меню выбираем «Добавить новый…». Затем выделяем Qt, «Файл QML (Qt Quick 2)»:
image

Имя — «ButtonBackground», путь указываем к только что созданной папке «QMLs»:
image

Сверьте префикс, контроль версий не нужен, поэтому просто жмём «Готово»:
image

Аналогичным образом создадим файл «ButtonLabel.qml». Теперь наш проект выглядит следующим образом:
image

Открываем двойным щелчком «QMLs/ButtonBackground.qml». Отредактируем его следующим образом:

import QtQuick 2.2

Rectangle {
    anchors.fill: parent
    anchors.margins: 4
    border.width: 2
    border.color: "#FFFFFF"
    radius: height / 4
    gradient: Gradient {
        GradientStop { position: 0; color: control.pressed ? "#00AAAA" : "#AAFFFF" }
        GradientStop { position: 1; color: "#00CCCC" }
    }
    // color: "#00AAAA"
    // opacity: 0.25
}


По порядку:
— anchors.fill: parent — чтобы подложка (background) кнопки полностью её заполняла, parent — обращение к родительскому элементу, внутри которого расположен текущий элемент;
— anchors.margins: 4 — устанавливаем небольшие отступы со всех сторон;
— border — настройка обводки [см. дополнение 8];
— radius: height / 4 — тут уже интересно; в данном случае служит для скругления углов (величина расыитывается относительно высоты кнопки), но если установить ширину и высоту у Rectangle одинаковыми, то достаточно большое значение radius позволяет получить круг из Rectangle;
— gradient состоит из двух точек; обратите внимание на первую точку (с нулевой позицией) — здесь мы обращаемся к состоянию кнопки (control.pressed) для определения цвета в зависимости от нажатия кнопки, используем простой условный оператор: условие? действие в случае истины: действие при значении false;
— можем вместо градиента установить цвет, также, например, можно использовать прозрачность (opacity: от 0 до 1).

Сохраняем «ButtonBackground.qml», возвращаемся к «main.qml». Добавляем в импорт нашу созданную папку «QMLs»:

import "QMLs"


Теперь мы имеем доступ ко всем QML-файлам в этой папке. Возвращаемся к стилю кнопки и прописываем только что созданный background. В итоге должно получиться:

import QtQuick 2.2
import QtQuick.Controls 1.1
import QtQuick.Controls.Styles 1.2
import "QMLs"

ApplicationWindow {
    id: mainWindow
    objectName: "mainWindow"
    visible: true
    width: 640
    height: 480
    color: "#F0F0FF"

    Button {
        width: mainWindow.width / 2.5
        height: mainWindow.height / 10
        x: 0
        y: mainWindow.height - height
        style: ButtonStyle {
            background: ButtonBackground {}
        }
    }

}


Всё сохраняем, запускаем:
image

Кнопка стала немного ярче, но ей не хватает подписи. Исправим это. Открываем «ButtonLabel.qml», редактируем следующим образом:

import QtQuick 2.2

Text {
    anchors.fill: parent
    horizontalAlignment: Text.AlignHCenter
    verticalAlignment: Text.AlignVCenter
    font.bold: true
    font.pixelSize: (mainWindow.width>mainWindow.height) ? parent.height/1.75 : parent.height / 2.5
    color: "#005050"
    // text: "Тест"
}


Подпись полностью заполняет тело кнопки, выравнивается по центру, имеет полужирное начертание. Размер подписи рассчитывается относительно текущей ориентации экрана: логично, что при большей ширине она будет альбомной, а высоте — портретной [см. дополнение 2]. Здесь устанавливать текст кнопки мы не будем, это неудобно.

Снова открываем «main.qml». Добавляем только что созданный «ButtonLabel» в label стиля, здесь же сразу указываем текст кнопки [см. дополнение 3]. Получится следующим образом:

import QtQuick 2.2
import QtQuick.Controls 1.1
import QtQuick.Controls.Styles 1.2
import "QMLs"

ApplicationWindow {
    id: mainWindow
    objectName: "mainWindow"
    visible: true
    width: 640
    height: 480
    color: "#F0F0FF"

    Button {
        width: mainWindow.width / 2.5
        height: mainWindow.height / 10
        x: 0
        y: mainWindow.height - height
        style: ButtonStyle {
            background: ButtonBackground {}
            label: ButtonLabel { text: "Тест" }
        }
    }

}


Всё сохраним и запустим:
image

Итак, у нас есть кликабельная кнопка, давайте добавим для неё обработчик нажатия, который будет выводить в лог сообщение. Для этого внутри кнопки добавляем строку:

onClicked: console.log("Button 'Тест' clicked")


В общем итоге:

import QtQuick 2.2
import QtQuick.Controls 1.1
import QtQuick.Controls.Styles 1.2
import "QMLs"

ApplicationWindow {
    id: mainWindow
    objectName: "mainWindow"
    visible: true
    width: 640
    height: 480
    color: "#F0F0FF"

    Button {
        width: mainWindow.width / 2.5
        height: mainWindow.height / 10
        x: 0
        y: mainWindow.height - height
        style: ButtonStyle {
            background: ButtonBackground {}
            label: ButtonLabel { text: "Тест" }
        }
        onClicked: console.log("Button 'Тест' clicked")
    }

}


Запускаем, проверяем. При нажатии на кнопку на панели «Вывод приложения» (в самом низу окна Qt Creator) появляется новая строчка: «qml: Button 'Тест' clicked»:
image

На этом мы пока что завершим работу с нашей кнопкой.

3.2.2. Создаём поле для ввода текста
Открываем «main.qml». Ниже «Button» добавим:

TextField {
        id: textField
        width: parent.width
        height: parent.height / 10
        horizontalAlignment: Text.AlignHCenter
        placeholderText: "ваше имя"
        validator: RegExpValidator {
            regExp: /[а-яА-Яa-zA-Z]{16}/
        }
        style: TextFieldStyle {
            background: Rectangle {color: "white"}
            textColor: "#00AAAA"
            placeholderTextColor: "#00EEEE"
            font: font.capitalization = Font.Capitalize, font.bold = true, font.pixelSize = mainWindow.height / 25
        }
        Keys.onPressed: {
            if (event.key == Qt.Key_Enter || event.key == Qt.Key_Return)
            {
                Qt.inputMethod.hide()
                event.accepted = true
            }
        }
    }


Задаём размеры, id будет нужно для считывания текста из поля. Ставим выравнивание по центру, указываем плэйсхолдер.

Также добавим простое регулярное выражение для этого ввода (символы от ‘а’ до ‘я’ и от ‘a’ до ‘z’ в любом регистре, всего не больше 16 символов).

Стили выносить в отдельные файлы не будем. На подложку (background) ставим белый прямоугольник, устанавливаем цвета для текста ввода и плэйсхолдера. У шрифта настраиваем пропись слов с заглавной буквы (font.capitalization = Font.Capitalize), полужирное начертание и размер (здесь мы не имеем доступа к родительскому элементу, поэтому рассчитываем размер относительно высоты экрана).

Для «TextField» можно также добавить поле inputMethodHints: со значением, например, Qt.ImhPreferNumbers. В зависимости от значения будут отображаться разные клавиатуры для ввода (в случае Qt.ImhPreferNumbers появится клавиатура для ввода номеров [см. дополнение 6]). В данном случае это поле добавлять не будем. Есть также и другие полезные параметры, например, echoMode (его можно установить на TextInput.Password для ввода пароля).

Отдельно следует упомянуть о выражении внутри Keys.onPressed. Здесь мы отслеживаем нажатие кнопки завершения ввода, чтобы при нажатии на неё закрыть клавиатуру на Android-устройствах. Итак, наш «main.qml» окончательно имеет вид:

import QtQuick 2.2
import QtQuick.Controls 1.1
import QtQuick.Controls.Styles 1.2
import "QMLs"

ApplicationWindow {
    id: mainWindow
    objectName: "mainWindow"
    visible: true
    width: 640
    height: 480
    color: "#F0F0FF"

    Button {
        width: mainWindow.width / 2.5
        height: mainWindow.height / 10
        x: 0
        y: mainWindow.height - height
        style: ButtonStyle {
            background: ButtonBackground {}
            label: ButtonLabel { text: "Тест" }
        }
        onClicked: console.log("Button 'Тест' clicked")
    }

    TextField {
        id: textField
        width: parent.width
        height: parent.height / 10
        horizontalAlignment: Text.AlignHCenter
        placeholderText: "ваше имя"
        validator: RegExpValidator {
            regExp: /[а-яА-Яa-zA-Z]{16}/
        }
        style: TextFieldStyle {
            background: Rectangle {color: "white"}
            textColor: "#00AAAA"
            placeholderTextColor: "#00EEEE"
            font: font.capitalization = Font.Capitalize, font.bold = true, font.pixelSize = mainWindow.height / 25
        }
        Keys.onPressed: {
            if (event.key == Qt.Key_Enter || event.key == Qt.Key_Return)                {
                Qt.inputMethod.hide()
                event.accepted = true
            }
        }
    }

}


На Android-планшете и iPhone-симуляторе текущее приложение выглядит так:
image

image

Чтобы достичь логической завершённости, давайте также добавим подпись, в которой будет отображаться приветствие с нашем именем при нажатии на кнопку. После «TextField» в «main.qml» добавляем:

Text {
        id: text
        y: textField.height
        width: parent.width
        height: parent.height / 10
        font.pixelSize: height / 2
        horizontalAlignment: Text.AlignHCenter
        verticalAlignment: Text.AlignVCenter
        wrapMode: Text.WordWrap
}


Располагаться подпись будет сразу под полем для ввода текста (y: textField.height), для неё мы также установим перенос по словам. Опять-таки, весь «Text» можно вынести в отдельный QML-файл для удобства, в данном случае мы делать этого не будем.

Осталось прописать логику нажатия кнопки. Мы можем прописать все желаемые действия в поле onClicked кнопки, но в этот раз давайте лучше для этого создадим функцию. Вот так будет выглядеть «main.qml» с добавленной функцией для кнопки:

import QtQuick 2.2
import QtQuick.Controls 1.1
import QtQuick.Controls.Styles 1.2
import "QMLs"

ApplicationWindow {
    id: mainWindow
    objectName: "mainWindow"
    visible: true
    width: 640
    height: 480
    color: "#F0F0FF"

    Button {
        function hello() {
            if (textField.text != "") {
                text.text = "Привет, <b>" + textField.text.toUpperCase() + "</b>!"
            }
        }
        width: mainWindow.width / 2.5
        height: mainWindow.height / 10
        x: 0
        y: mainWindow.height - height
        style: ButtonStyle {
            background: ButtonBackground {}
            label: ButtonLabel { text: "Тест" }
        }
        onClicked: hello()
    }

    TextField {
        id: textField
        width: parent.width
        height: parent.height / 10
        horizontalAlignment: Text.AlignHCenter
        placeholderText: "ваше имя"
        validator: RegExpValidator {
            regExp: /[а-яА-Яa-zA-Z]{16}/
        }
        style: TextFieldStyle {
            background: Rectangle {color: "white"}
            textColor: "#00AAAA"
            placeholderTextColor: "#00EEEE"
            font: font.capitalization = Font.Capitalize, font.bold = true, font.pixelSize = mainWindow.height / 25
        }
        Keys.onPressed: {
            if (event.key == Qt.Key_Enter || event.key == Qt.Key_Return) {
                Qt.inputMethod.hide()
                event.accepted = true
            }
        }
    }

    Text {
        id: text
        y: textField.height
        width: parent.width
        height: parent.height / 10
        font.pixelSize: height / 2
        horizontalAlignment: Text.AlignHCenter
        verticalAlignment: Text.AlignVCenter
        wrapMode: Text.WordWrap
    }
}


Как видите, текст можно форматировать (в данном случае тэгами ) Сохраним, запустим и проверим:
image

3.2.3. Работаем с ListView
В этом примере мы сделаем сразу два списка, чтобы показать больше возможностей ListView. Добавляем в «main.qml» ниже «Text»:

ListView {
        id: lvList
        objectName: "lvList"
        y: text.y + text.height
        width: parent.width
        height: parent.height * 0.3
        clip: true
        spacing: 8
        model: ListModel {
            ListElement { name: "Элемент 0" }
            ListElement { name: "Элемент 1" }
            ListElement { name: "Элемент 2" }
        }
        delegate: Rectangle {
            width: lvList.width
            height: lvList.height / 3
            color: "#00AAAA"
            Text {
                text: name
            }
            MouseArea {
                anchors.fill: parent
                onClicked: console.log("ListView el#" + index + " clicked!")
            }
        }
    }


Задаём идентификаторы и размеры ListView. Сам по себе ListView прозрачен, мы не будем делать для него подложку.

Параметр clip установлен на true: это нужно сделать, т.к. в противном случае при прокрутке списка элементы, которые становятся видимыми в списке, вылезают за границы ListView.

Параметр spacing задаёт отступы между элементами списка.

Далее, model — по сути наша модель данных. Заполняется элементом «ListModel», который содержит несколько «ListElement». «ListElement» — собственно элемент нашего списка, внутри мы можем прописать желаемые пары «имя-значение», в данном случае будет только одна пара.

Параметр delegate определяет то, как будет выглядеть каждый элемент списка. У нас это будет просто прямоугольник с текстом внутри. Для значения параметра text указываем ранее прописанный нами в модели параметр name.

«MouseArea» — это область, в которой мы можем отслеживать нажатие кнопки мыши или прикосновение к экрану. Заполняем её по размеру делегата. В случае нажатия выводим в лог индекс элемента. Отмечу, что у «MouseArea» есть также полезные поля onPressed
и onReleased.

Вообще модель, прописанная в QML, нам в дальнейшем будет не нужна, т.к. заполнением займётся бэкенд. Однако, удобно делать такие заглушки, чтобы настроить внешний вид списка. Также желательно для удобства выносить оформление делегата в отдельный QML-файл. Ну, и ещё добавлю, что у ListView очень много разных параметров, которые можно попробовать подкрутить. Но здесь мы не будем больше ничего усложнять. Давайте посмотрим, что получилось. Сохраняем, запускаем:
image

Попробуем понажимать на элементы списка и проверить лог, у нас должны появляться строчки вроде «qml: ListView el#0 clicked!».

Переходим ко второму списку. Добавляем ниже первого списка:

ListView {
        id: lvPager
        y: lvList.y + lvList.height
        width: parent.width
        height: parent.height * 0.2
        clip: true
        model: ListModel {
            ListElement { map_url: "http://maps.googleapis.com/maps/api/staticmap?size=640x320&scalse=2&sensor=false&center=Moscow" }
            ListElement { map_url: "http://maps.googleapis.com/maps/api/staticmap?size=640x320&scalse=2&sensor=false&center=London" }
            ListElement { map_url: "http://maps.googleapis.com/maps/api/staticmap?size=640x320&scalse=2&sensor=false&center=Rio" }
        }
        delegate: Image {
            width: lvPager.width
            height: lvPager.height
            source: map_url
            fillMode: Image.PreserveAspectFit
        }
        orientation: ListView.Horizontal
        snapMode: ListView.SnapOneItem
    }


В качестве моделей используем ссылки на возвращаемые по запросу изображения карт. Делегатом будет непосредственно изображение, источником которого (source) станет указанная в модели ссылка. Как источник можно также указать и локальный файл. Для этого нужно:
— добавить изображение в папку проекта;
— в Qt Creator в дереве проекта правой кнопкой щёлкнуть по «qml.qrc» -> «Открыть в редакторе»;
— в открывшемся окне «Добавить» -> «Добавить файлы» -> выбрать добавленное изображение;
— в «Image» QML-файла указать как source путь к изображению следующим образом: «qrc:/полное_имя_файла».

Для «Image» также задаём fillMode, чтобы сама картинка подстраивалась под нужные размеры ширины/высоты с сохранением пропорций.

Параметры orientation (установленный на горизонтальную ориентацию) и snapMode: ListView.SnapOneItem позволяют нам в совокупности добиться эффекта, чтобы ListView стал чем-то вроде ViewPager в Android.

Почти готово. Теперь в дереве проекта открываем файл «Sample.pro» и в строчку «QT += qml quick widgets» добавляем «network», иначе подключение к сети не будет работать:
image

На всякий случай выкладываю листинг всего кода «main.qml»:

import QtQuick 2.2
import QtQuick.Controls 1.1
import QtQuick.Controls.Styles 1.2
import "QMLs"

ApplicationWindow {
    id: mainWindow
    objectName: "mainWindow"
    visible: true
    width: 640
    height: 480
    color: "#F0F0FF"

    Button {
        function hello() {
            if (textField.text != "") {
                text.text = "Привет, <b>" + textField.text.toUpperCase() + "</b>!"
            }
        }
        width: mainWindow.width / 2.5
        height: mainWindow.height / 10
        x: 0
        y: mainWindow.height - height
        style: ButtonStyle {
            background: ButtonBackground {}
            label: ButtonLabel { text: "Тест" }
        }
        onClicked: hello()
    }

    TextField {
        id: textField
        width: parent.width
        height: parent.height / 10
        horizontalAlignment: Text.AlignHCenter
        placeholderText: "ваше имя"
        validator: RegExpValidator {
            regExp: /[а-яА-Яa-zA-Z]{16}/
        }
        style: TextFieldStyle {
            background: Rectangle {color: "white"}
            textColor: "#00AAAA"
            placeholderTextColor: "#00EEEE"
            font: font.capitalization = Font.Capitalize, font.bold = true, font.pixelSize = mainWindow.height / 25
        }
        Keys.onPressed: {
            if (event.key == Qt.Key_Enter || event.key == Qt.Key_Return) {
                Qt.inputMethod.hide()
                event.accepted = true
            }
        }
    }

    Text {
        id: text
        y: textField.height
        width: parent.width
        height: parent.height / 10
        font.pixelSize: height / 2
        horizontalAlignment: Text.AlignHCenter
        verticalAlignment: Text.AlignVCenter
        wrapMode: Text.WordWrap
    }

    ListView {
        id: lvList
        objectName: "lvList"
        y: text.y + text.height
        width: parent.width
        height: parent.height * 0.3
        clip: true
        spacing: 8
        model: ListModel {
            ListElement { name: "Элемент 0" }
            ListElement { name: "Элемент 1" }
            ListElement { name: "Элемент 2" }
        }
        delegate: Rectangle {
            width: lvList.width
            height: lvList.height / 3
            color: "#00AAAA"
            Text {
                text: name
            }
            MouseArea {
                anchors.fill: parent
                onClicked: console.log("ListView el#" + index + " clicked!")
            }
        }
    }

    ListView {
        id: lvPager
        y: lvList.y + lvList.height
        width: parent.width
        height: parent.height * 0.2
        clip: true
        model: ListModel {
            ListElement { map_url: "http://maps.googleapis.com/maps/api/staticmap?size=640x320&scalse=2&sensor=false&center=Moscow" }
            ListElement { map_url: "http://maps.googleapis.com/maps/api/staticmap?size=640x320&scalse=2&sensor=false&center=London" }
            ListElement { map_url: "http://maps.googleapis.com/maps/api/staticmap?size=640x320&scalse=2&sensor=false&center=Rio" }
        }
        delegate: Image {
            width: lvPager.width
            height: lvPager.height
            source: map_url
            fillMode: Image.PreserveAspectFit
        }
        orientation: ListView.Horizontal
        snapMode: ListView.SnapOneItem
    }

}


Можем запустить и посмотреть, что получилось:
image

На мой взгляд, работа с ListView в QML очень простая и удобная, есть много возможностей для кастомизации. С помощью ListView можно также реализовать, например, Spinner’ы (всплывающие списки), играясь с видимостью (поле visible) списка.

ВАЖНОЕ ПРИМЕЧАНИЕ: почему-то Хабр заменяет &center в «map_url» на символ цента. Учтите, что в ссылке запроса дожно быть & center=… только без пробела.

3.2.4. Работа с Loader и немного анимации
«Loader» — это такая сущность, которая позволяет реализовать мультиоконность в QML. В своей сути «Loader» — просто некоторый контейнер, который имеет как источник QML-файл (в теории можно даже расположить этот файл где-нибудь на внешнем сервере). Здесь очень важно, если мы обращаемся из QML или C++ кода к какому-нибудь элементу внутри Loader’а, нужно чтобы контейнер, содержащий этот элемент, был в данный момент загружен в Loader’е, иначе приложение вылетит. Логично, что мы не можем обращаться к несуществующему в данный момент элементу и уж тем более просить Qt изменить какие-нибудь его свойства. Можно решить проблему, сохраняя boolean-флаг об активности конкретного контейнера или в опасный момент (например, когда идёт подключение к серверу) запрещать переключение содержимого лоадера (можно ставить enabled: false на кнопки навигации).

Итак, создадим в папке «QMLs» нашего проекта два QML-файла. Назовём их «Loader1.qml» и «Loader2.qml». «Loader1.qml»:

import QtQuick 2.2

Rectangle {
    anchors.fill: parent
    color: "green"
    opacity: 0.2
}


Просто зелёный прямоугольник. Теперь «Loader2.qml»:

import QtQuick 2.2

Rectangle {
    id: rect
    anchors.fill: parent
    focus: true
    color: "blue"
    opacity: 0.2
    SequentialAnimation {
        running: true
        loops: SequentialAnimation.Infinite
        PropertyAnimation { target: rect; property: "opacity"; to: 0; duration: 500 }
        PropertyAnimation { target: rect; property: "opacity"; to: 0.2; duration: 500 }
    }
    Keys.onPressed: {
        if (event.key == Qt.Key_Back || event.key == Qt.Key_Escape) {
            loader.source = "qrc:/QMLs/Loader1.qml"
            event.accepted = true
        }
    }
}


Обязательно указываем focus: true. В противном случае не будет срабатывать секция Keys.onPressed.

«SequentialAnimation» — последовательный набор анимаций. Указываем параметр running: true, чтобы анимация сразу работала, loops (число повторов) ставим на бесконечность.

Внутри у «SequentialAnimation» две «PropertyAnimation». Они способны изменять какой-нибудь параметр (property) у целевого элемента (target) до нужного значения (to) за определённое время (duration).

Отмечу, что после остановки анимации (если установим running: false), нужно «вручную» возвращать все изменённые параметры цели на изначальные.

Осталось создать сам Loader внутри «main.qml»:

Loader {
        id: loader
        y: lvPager.y + lvPager.height
        width: parent.width
        height: parent.height * 0.2
        focus: true
        source: "qrc:/QMLs/Loader1.qml"
}


Обязательно указываем focus: true, чтобы внутренний элемент (source) отслеживал нажатие кнопок.

Небольшой нюанс: после работы с текстовым полем теряется фокус у лоадера. Чтобы это исправить, изменим Keys.onPressed у текстового поля следующим образом:

Keys.onPressed: {
            if (event.key == Qt.Key_Enter || event.key == Qt.Key_Return || event.key == Qt.Key_Back)              {
                Qt.inputMethod.hide()
                loader.forceActiveFocus()
                event.accepted = true
            }
}


И добавим кнопку в «main.qml», которая будет изменять содержимое лоадера:

Button {
        width: mainWindow.width / 4.5
        height: mainWindow.height / 10
        x: mainWindow.width / 2
        y: mainWindow.height - height
        style: ButtonStyle {
            background: ButtonBackground {}
            label: ButtonLabel { text: "Loader" }
        }
        onClicked: loader.setSource("qrc:/QMLs/Loader2.qml")
}


Окончательный листинг «main.qml»:

import QtQuick 2.2
import QtQuick.Controls 1.1
import QtQuick.Controls.Styles 1.2
import "QMLs"

ApplicationWindow {
    id: mainWindow
    objectName: "mainWindow"
    visible: true
    width: 640
    height: 480
    color: "#F0F0FF"

    Button {
        function hello() {
            if (textField.text != "") {
                text.text = "Привет, <b>" + textField.text.toUpperCase() + "</b>!"
            }
        }
        width: mainWindow.width / 2.5
        height: mainWindow.height / 10
        x: 0
        y: mainWindow.height - height
        style: ButtonStyle {
            background: ButtonBackground {}
            label: ButtonLabel { text: "Тест" }
        }
        onClicked: hello()
    }

    TextField {
        id: textField
        width: parent.width
        height: parent.height / 10
        horizontalAlignment: Text.AlignHCenter
        placeholderText: "ваше имя"
        validator: RegExpValidator {
            regExp: /[а-яА-Яa-zA-Z]{16}/
        }
        style: TextFieldStyle {
            background: Rectangle {color: "white"}
            textColor: "#00AAAA"
            placeholderTextColor: "#00EEEE"
            font: font.capitalization = Font.Capitalize, font.bold = true, font.pixelSize = mainWindow.height / 25
        }
        Keys.onPressed: {
            if (event.key == Qt.Key_Enter || event.key == Qt.Key_Return || event.key == Qt.Key_Back) {
                Qt.inputMethod.hide()
                loader.forceActiveFocus()
                event.accepted = true
            }
        }
    }

    Text {
        id: text
        y: textField.height
        width: parent.width
        height: parent.height / 10
        font.pixelSize: height / 2
        horizontalAlignment: Text.AlignHCenter
        verticalAlignment: Text.AlignVCenter
        wrapMode: Text.WordWrap
    }

    ListView {
        id: lvList
        objectName: "lvList"
        y: text.y + text.height
        width: parent.width
        height: parent.height * 0.3
        clip: true
        spacing: 8
        model: ListModel {
            ListElement { name: "Элемент 0" }
            ListElement { name: "Элемент 1" }
            ListElement { name: "Элемент 2" }
        }
        delegate: Rectangle {
            width: lvList.width
            height: lvList.height / 3
            color: "#00AAAA"
            Text {
                text: name
            }
            MouseArea {
                anchors.fill: parent
                onClicked: console.log("ListView el#" + index + " clicked!")
            }
        }
    }

    ListView {
        id: lvPager
        y: lvList.y + lvList.height
        width: parent.width
        height: parent.height * 0.2
        clip: true
        model: ListModel {
            ListElement { map_url: "http://maps.googleapis.com/maps/api/staticmap?size=640x320&scalse=2&sensor=false&center=Moscow" }
            ListElement { map_url: "http://maps.googleapis.com/maps/api/staticmap?size=640x320&scalse=2&sensor=false&center=London" }
            ListElement { map_url: "http://maps.googleapis.com/maps/api/staticmap?size=640x320&scalse=2&sensor=false&center=Rio" }
        }
        delegate: Image {
            width: lvPager.width
            height: lvPager.height
            source: map_url
            fillMode: Image.PreserveAspectFit
        }
        orientation: ListView.Horizontal
        snapMode: ListView.SnapOneItem
    }

    Loader {
        id: loader
        y: lvPager.y + lvPager.height
        width: parent.width
        height: parent.height * 0.2
        focus: true
        source: "qrc:/QMLs/Loader1.qml"
    }

    Button {
        width: mainWindow.width / 4.5
        height: mainWindow.height / 10
        x: mainWindow.width / 2
        y: mainWindow.height - height
        style: ButtonStyle {
            background: ButtonBackground {}
            label: ButtonLabel { text: "Loader" }
        }
        onClicked: loader.setSource("qrc:/QMLs/Loader2.qml")
    }

}


Не забываем про автозамену правильного url запроса (& center) на символ цента, писал об этом выше. Всё сохраняем, запускаем:
image

image

При нажатии на кнопку «Loader» содержимое лоадера меняется. На Android-устройстве мы также можем нажать кнопку «Back», чтобы вернуть прежнее содержимое лоадера.

На этом мы завершаем обзор работы с UI в QML и переходим к разработке C++ бэкенда.


3.3. Разработка бэкенда
В этой главе мы разберёмся, как связать элементы из QML-файла с C++ бэкендом, как обращаться к функциям и объектам в QML из C++ и наоборот, как делать GET/POST-запросы к серверу и парсить JSON-ответ.

Все действия будем выполнять с локальным сервером-заглушкой, описывать установку/конфигурацию/запуск оного здесь не буду, по теме очень много материалов в Сети.

В папке с сайтами локального сервера создадим новую папку «qt_api», внутри неё создаём файл «test.php». Содержимое файла:

<?
echo json_encode(json_decode(
'{
  "done" : {
    "boolean" : true,
    "number" : 123,
    "list" : [
      "field1",
      "field2",
      "field3",
      "field4",
      "field5"
    ]
  }
}'
));
?>


Простенький JSON, будем делать запрос к «test.php» (http://localhost/qt_api/test.php) с отправкой полей и парсить выдаваемый ответ.

Теперь создаём новый C++ класс в Qt Creator: правой кнопкой по «Sample» в дереве -> «Добавить новый…» -> «C++» / «Класс C++». Прописываем имя класса «Backend», как базовый класс выбираем «QQuickItem» [см. дополнение 4], остальное не трогаем:
image

Жмём «Продолжить», а в следующем окне «Готово». В проекте открываем заголовочный файл «backend.h». Изменяем следующим образом:

#ifndef BACKEND_H
#define BACKEND_H

#include <QQmlApplicationEngine>
#include <QQuickItem>
#include <QtQuick>
#include <QNetworkAccessManager>

class Backend : public QQuickItem {

    Q_OBJECT

public:
    explicit Backend(QQuickItem *parent = 0);

    Q_INVOKABLE void makeRequest(int id);

private:
    QQmlApplicationEngine engine;
    QObject * mainWindow;
    QObject * lvList;
    QObject * btnRequest;
    QNetworkAccessManager * namRequest;

    static const QString color_example;

signals:

private slots:
    void slotRequestFinished(QNetworkReply*);

};

#endif // BACKEND_H


Сначала подключаем все необходимые для работы библиотеки.

Объявляем конструктор. Можно объявить и деструктор (он будет без ключевого слова explicit). Мне, однако, использовать его ещё не приходилось. Вообще в Qt работа с жизненными циклами объектов имеет некоторые особенности. Напрмер, нам не нужно самостоятельно удалять объекты интерфейса (более того, получим ошибку, если попытаемся это сделать), созданные в куче, потому что Qt сам удаляет все дочерние объекты основного элемента интерфейса.

Теперь объявляем функцию makeRequest. Чтобы иметь возможность вызывать её внутри QML-файла, добавляем макрос Q_INVOKABLE. На вход функция будет принимать некоторый id (просто заглушка).

Объявляем закрытыми (private) необходимые объекты. QQmlApplicationEngine загружает основной QML-файл. Три QObject будут нужны для доступа к соответствующим элементам в QML [см. дополнение 5]. QNetworkAccessManager используется для выполнения запросов к серверу (что-то вроде DefaultHttpClient в Android). Также создаём константу цвета, которую потом будем использовать в QML.

Теперь о сигналах и слотах. Есть много объяснений сущности в сети, постараюсь объяснить по-своему. Сигналы/слоты — интересная фича в Qt, отчасти напоминающая, например, колбэки. В заголовочном файле мы можем описать сигнал следующим образом:

void mySignal();


Описанный сигнал можно вызывать из кода:

emit mySignal();


Главная суть в том, что сигналы и слоты объектов можно связывать (функция connect()) между собой. Так, чтобы, например, при вызове сигнала одного объекта у другого выполнялась какая-нибудь слот-функция. В нашем случае сигнал создавать не будем, потому что он уже есть у объекта namRequest. А вот слот нам будет нужен (slotRequestFinished(QNetworkReply*)) — это что-то вроде колбэка для QNetworkAccessManager: как только придёт ответ от сервера, у namRequest вызовется сигнал finished(QNetworkReply*), его мы свяжем с нашим слотом, чтобы обработать полученный ответ (QNetworkReply).

Открываем теперь «backend.cpp», изменяем:

#include "backend.h"

const QString Backend::color_example = "#000000";

Backend::Backend(QQuickItem *parent) : QQuickItem(parent) {
    engine.rootContext()->setContextProperty("color_example", color_example);

    engine.load(QUrl(QStringLiteral("qrc:///main.qml")));

    mainWindow = engine.rootObjects().value(0);
    lvList = mainWindow->findChild<QObject*>("lvList");
    btnRequest = mainWindow->findChild<QObject*>("btnRequest");

    engine.rootContext()->setContextProperty("backend", this);

    namRequest = new QNetworkAccessManager(this);
    connect(namRequest, SIGNAL(finished(QNetworkReply*)), this, SLOT(slotRequestFinished(QNetworkReply*)));
}

void Backend::makeRequest(int id) {
    btnRequest->setProperty("enabled", "false");
    // btnRequest->property("enabled");

    QString prepareRequest("http://localhost/qt_api/test");

    // HttpGet
    prepareRequest.append("?id=");
    prepareRequest.append(QString::number(id));
    qDebug(prepareRequest.toUtf8());
    QNetworkRequest request(QUrl(prepareRequest.toUtf8()));
    namRequest->get(request);

    // HttpPost
    /*QNetworkRequest request(QUrl(prepareRequest.toUtf8()));
    request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
    QString params("id=");
    params.append(QString::number(id));
    qDebug(params.toUtf8());
    namRequest->post(request, QByteArray(params.toUtf8()));*/
}

void Backend::slotRequestFinished(QNetworkReply * reply) {
    if (reply->error() != QNetworkReply::NoError) {
        qDebug(reply->errorString().toUtf8());
    } else {
        QJsonDocument jsonDoc = QJsonDocument::fromJson(reply->readAll());
        QJsonObject jsonObj;
        QJsonValue jsonVal;
        QJsonArray jsonArr;

        jsonObj = jsonDoc.object();
        jsonVal = jsonObj.value("done");
        if (!jsonVal.isNull() && jsonVal.isObject()) {
            jsonObj = jsonVal.toObject();
            jsonVal = jsonObj.value("number");
            if (!jsonVal.isNull() && jsonVal.isDouble()) {
                qDebug(QString::number(jsonVal.toDouble(), 'f', 3).toUtf8());
            }
        }

        if (jsonDoc.object().value("done").toObject().value("boolean").toBool()) {
            qDebug("json true");
        } else {
            qDebug("json false");
        }

        jsonArr = jsonDoc.object().value("done").toObject().value("list").toArray();
        QMetaObject::invokeMethod(lvList, "clear");
        for (int i=0; i<jsonArr.size(); i++) {
            QVariantMap map;
            map.insert("name", jsonArr.at(i).toString());
            QMetaObject::invokeMethod(lvList, "append", Q_ARG(QVariant, QVariant::fromValue(map)));
        }
    }

    btnRequest->setProperty("enabled", "true");

    reply->deleteLater();
}


Очень много всего, понимаю. По порядку: присваиваем занчение константе цвета.

В конструкторе класса прописываем путь к корневому QML-файлу для QQmlApplicationEngine.

Находим корневой экран QML:

mainWindow = engine.rootObjects().value(0);


Все дочерние объекты корневого экрана находятся по их objectName, прописанных в QML. Пример для списка lvList:

lvList = mainWindow->findChild<QObject*>("lvList");


Теперь мы связываем слово «backend» с самим классом Backend. Таким образом мы сможем обращаться к нашему классу (ко всем его публичным Q_INVOKABLE-функциям) из QML:

engine.rootContext()->setContextProperty("backend", this);


В самом начале конструктора мы связывали константу цвета со словом «color_example» в QML:

engine.rootContext()->setContextProperty("color_example", color_example);


Осталось создать объект namRequest:

namRequest = new QNetworkAccessManager(this);


И связать его сигнал finished(QNetworkReply*) с нашим слотом slotRequestFinished(QNetworkReply*):

connect(namRequest, SIGNAL(finished(QNetworkReply*)), this, SLOT(slotRequestFinished(QNetworkReply*)));


Функция connect в данном случае имеет 4 параметра:
— объект, сигнал которого слушаем;
— непосредственно сигнал этого объекта;
— объект, который будет реагировать на услышанный сигнал (наш класс);
— слот объекта, который сработает при вызове сигнала.

Переходим к функции makeRequest. Первым делом отключаем кнопку, чтобы во время запроса на неё нельзя было нажать снова:

btnRequest->setProperty("enabled", "false");


Похожим образом можно также считывать любые поля элемента из QML (следующая закоментированная строчка). Пишем адрес запроса в QString:

QString prepareRequest(«http://localhost/qt_api/test");


Чтобы сделать GET-запрос с параметрами, просто добавляем их в строку запроса:

prepareRequest.append("?id=");
prepareRequest.append(QString::number(id));


Обратите внимание на преобразование int-переменной (которая идёт на вход в функции makeRequest) к QString. Для этого используется функция QString::number().

На всякий случай выводим в лог весь запрос (QString преобразуем к QByteArray):

qDebug(prepareRequest.toUtf8());


Далее формируем QNetworkRequest из строки запроса и просим QNetworkAccessManager выполнить GET-запрос:

QNetworkRequest request(QUrl(prepareRequest.toUtf8()));
namRequest->get(request);


POST-запрос выполняется похожим образом, оставил его закомментированным. Похоже, это единственный способ сделать POST-запрос, чтобы сайт, написанный на CodeIgniter’е, не выдавал «Disallowed Characters Error», с этим было много возни.

Переходим к нашему слоту, который запустится, как только придёт ответ с сервера.

Сначала проверяем, успешно ли прошёл запрос, выводим в лог сообщение об ошибке, если таковая возникнет:

if (reply->error() != QNetworkReply::NoError) {
    qDebug(reply->errorString().toUtf8());
}


Если ошибок нет, то будем заполнять список. Создаём QJsonDocument и основные объекты для работы с JSON-ом. Чтобы получить QJsonDocument из ответа используем функцию QJsonDocument::fromJson(reply->readAll()), которой на вход подаём полученный ответ сервера: reply->readAll(). Отмечу, что если мы один раз считаем ответ из QNetworkReply, второй раз его считать будет уже нельзя, учтите это (поэтому ответ желательно сохранять в какой-нибудь переменной).

Теперь сначала получаем JSON-объект из QJsonDocument:

jsonObj = jsonDoc.object();


Затем получаем QJsonValue из поля «done» JSON-объекта:

jsonVal = jsonObj.value("done");


QJsonValue — это значение поля JSON-объекта, точная сущность которого нам неизвестна (может быть объектом, массивом, строкой и т.д.). Затем мы явно проверяем на пустоту наш QJsonValue и является ли он объектом. Если условие истинно, то мы преобразовываем QJsonValue в JSON-объект и получаем новое QJsonValue (поле «number») из этого объекта. Делаем аналогичную проверку, но только теперь нам нужно узнать, является ли QJsonValue значением типа double. В случае истинности выводим в лог поле «number» с форматированием (3 знака после точки).
Следующий блок кода проверяет значение поля «boolean» внутри объекта «done» и в зависимости от его значения выводит в лог сообщение.

Небольшой нюанс работы с JSON-ами в Qt: если мы будем пытаться получить несуществующий JSON-объект или его поле, то никакой ошибки не произойдёт (в случае нативной разработки на Android, например, выскочит JSONException). Вместо этого несуществующее значение заполнится значением по умолчанию (например, нулём), учтите это.

Ещё отмечу, что Qt очень «строг» к соответствию типов в JSON-объекте, преобразовать самостоятельно, предположим, число из JSON’а к строке не сможет.

Теперь немного детальней рассмотрим заполнение списка в QML JSON-массивом, полученным в ответе от сервера.

Сначала мы создаём из JSON-массива «list» объект класса QJsonArray:

jsonArr = jsonDoc.object().value("done").toObject().value("list").toArray();


Затем вызываем функцию «clear» у объекта «lvList» (функцию «clear» внутри ListView в QML-файле создадим позже):

QMetaObject::invokeMethod(lvList, "clear");


Затем в цикле заполняем список «lvList» элементами JSON-массива — сначала создаём карту ключ-значение, затем вставляем в неё значение текущего элемента массива, используя ключ «name» (помните, мы указывали этот ключ в делегате ListView?):

QVariantMap map;
map.insert("name", jsonArr.at(i).toString());


И в конце вызываем функцию «append» у «lvList» (её мы тоже создадим совсем скоро):

QMetaObject::invokeMethod(lvList, "append", Q_ARG(QVariant, QVariant::fromValue(map)));


Синтаксис функции QMetaObject::invokeMethod() следующий:
— первый параметр — объект, у которого будем вызывать функцию;
— второй параметр — функция, которую нужно вызвать у объекта «lvList»;
— третий — параметры, которые будут идти на вход функции «append», в нашем случае это карта «map».

После обработки запроса вновь делаем кнопку активной:

btnRequest->setProperty("enabled", «true");


И удаляем обработанный ответ:

reply->deleteLater();


Почти всё. Осталось чуточку поменять файл «main.cpp»:

#include <QApplication>
#include "backend.h"

int main(int argc, char *argv[])
{
    QApplication app(argc, argv);

    new Backend();

    return app.exec();
}


При старте приложения будет создаваться экземпляр класса «Backend», внутри которого вся прописанная нами логика.

Теперь добавим функции «clear», «append» в первый ListView («lvList») в файле «main.qml»:

function clear() {
    lvList.model.clear()
}
function append(newElement) {
    lvList.model.append(newElement)
}


В C++ бэкенде на вход функции «append» мы подавали заполненную карту (этот объект мы видим в аргументе функции), карта присоединиться к модели списка при вызове этой функции.

Также закомментируем элементы модели в первом списке:

model: ListModel {
            //ListElement { name: "Элемент 0" }
            //ListElement { name: "Элемент 1" }
            //ListElement { name: "Элемент 2" }
}


Осталось добавить кнопку, которая бы вызывала функцию makeRequest() класса Backend:

Button {
        objectName: "btnRequest"
        property int _id: 3
        width: mainWindow.width / 4.5
        height: mainWindow.height / 10
        x: mainWindow.width - width
        y: mainWindow.height - height
        style: ButtonStyle {
            background: ButtonBackground { border.color: color_example }
            label: ButtonLabel { text: "Request" }
        }
        onClicked: backend.makeRequest(_id)
}


У кнопки есть имя «btnRequest», которое нужно, чтобы мы нашли её в C++ коде, поле

property int _id: 3


— это что-то вроде инициализации переменной в самом QML-файле: ключевое слово «property», тип, имя переменной и значение. Обращаться к переменной можно так: id_элемента.имя_переменной.

Остановимся здесь ещё на двух моментах. Во-первых, мы переопределяем цвет рамки у кнопки:

background: ButtonBackground { border.color: color_example }


Помните, как мы связывали значение цвета с именем «color_example» в классе «Backend»? Во-вторых, обратите внимание на вызов функции по нажатию кнопки:

onClicked: backend.makeRequest(_id)


Сначала пишем ключевое слово, которое определили в бэкенде для доступа к его функциям, затем пишем непосредственно имя функции и указываем параметр, идущий на вход (в данном случае property «_id», которое объявляли чуть выше).

Полный листинг «main.qml»:

import QtQuick 2.2
import QtQuick.Controls 1.1
import QtQuick.Controls.Styles 1.2
import "QMLs"

ApplicationWindow {
    id: mainWindow
    objectName: "mainWindow"
    visible: true
    width: 640
    height: 480
    color: "#F0F0FF"

    Button {
        function hello() {
            if (textField.text != "") {
                text.text = "Привет, <b>" + textField.text.toUpperCase() + "</b>!"
            }
        }
        width: mainWindow.width / 2.5
        height: mainWindow.height / 10
        x: 0
        y: mainWindow.height - height
        style: ButtonStyle {
            background: ButtonBackground {}
            label: ButtonLabel { text: "Тест" }
        }
        onClicked: hello()
    }

    TextField {
        id: textField
        width: parent.width
        height: parent.height / 10
        horizontalAlignment: Text.AlignHCenter
        placeholderText: "ваше имя"
        validator: RegExpValidator {
            regExp: /[а-яА-Яa-zA-Z]{16}/
        }
        style: TextFieldStyle {
            background: Rectangle {color: "white"}
            textColor: "#00AAAA"
            placeholderTextColor: "#00EEEE"
            font: font.capitalization = Font.Capitalize, font.bold = true, font.pixelSize = mainWindow.height / 25
        }
        Keys.onPressed: {
            if (event.key == Qt.Key_Enter || event.key == Qt.Key_Return || event.key == Qt.Key_Back) {
                Qt.inputMethod.hide()
                loader.forceActiveFocus()
                event.accepted = true
            }
        }
    }

    Text {
        id: text
        y: textField.height
        width: parent.width
        height: parent.height / 10
        font.pixelSize: height / 2
        horizontalAlignment: Text.AlignHCenter
        verticalAlignment: Text.AlignVCenter
        wrapMode: Text.WordWrap
    }

    ListView {
        id: lvList
        objectName: "lvList"
        y: text.y + text.height
        width: parent.width
        height: parent.height * 0.3
        clip: true
        spacing: 8
        model: ListModel {
            //ListElement { name: "Элемент 0" }
            //ListElement { name: "Элемент 1" }
            //ListElement { name: "Элемент 2" }
        }
        delegate: Rectangle {
            width: lvList.width
            height: lvList.height / 3
            color: "#00AAAA"
            Text {
                text: name
            }
            MouseArea {
                anchors.fill: parent
                onClicked: console.log("ListView el#" + index + " clicked!")
            }
        }
        function clear() {
            lvList.model.clear()
        }
        function append(newElement) {
            lvList.model.append(newElement)
        }
    }

    ListView {
        id: lvPager
        y: lvList.y + lvList.height
        width: parent.width
        height: parent.height * 0.2
        clip: true
        model: ListModel {
            ListElement { map_url: "http://maps.googleapis.com/maps/api/staticmap?size=640x320&scalse=2&sensor=false&center=Moscow" }
            ListElement { map_url: "http://maps.googleapis.com/maps/api/staticmap?size=640x320&scalse=2&sensor=false&center=London" }
            ListElement { map_url: "http://maps.googleapis.com/maps/api/staticmap?size=640x320&scalse=2&sensor=false&center=Rio" }
        }
        delegate: Image {
            width: lvPager.width
            height: lvPager.height
            source: map_url
            fillMode: Image.PreserveAspectFit
        }
        orientation: ListView.Horizontal
        snapMode: ListView.SnapOneItem
    }

    Loader {
        id: loader
        y: lvPager.y + lvPager.height
        width: parent.width
        height: parent.height * 0.2
        focus: true
        source: "qrc:/QMLs/Loader1.qml"
    }

    Button {
        width: mainWindow.width / 4.5
        height: mainWindow.height / 10
        x: mainWindow.width / 2
        y: mainWindow.height - height
        style: ButtonStyle {
            background: ButtonBackground {}
            label: ButtonLabel { text: "Loader" }
        }
        onClicked: loader.setSource("qrc:/QMLs/Loader2.qml")
    }

    Button {
        objectName: "btnRequest"
        property int _id: 3
        width: mainWindow.width / 4.5
        height: mainWindow.height / 10
        x: mainWindow.width - width
        y: mainWindow.height - height
        style: ButtonStyle {
            background: ButtonBackground { border.color: color_example }
            label: ButtonLabel { text: "Request" }
        }
        onClicked: backend.makeRequest(_id)
    }

}


Всё сохраняем, запускаем. При нажатии на «Request» (на iPhone размеры текста не рассчитал немного) заполняется список:
image

В логах видим:

localhost/qt_api/test?id=3
123.000
json true


Всё верно. Вот такая сборная солянка получилось для демонстрации :). Прикрепляю также сам проект на всякий случай (https://www.dropbox.com/s/to9kk0l71d6ma4h/Sample.zip).

И в качестве небольшого приложения пример кода сохранения параметров, использования даты и создания уникального идентификатора посредством Qt:

// Создаём объект QSettings, указываем имя файла, в котором будут содержаться данные
    QSettings settings("settings.ini", QSettings::IniFormat);
    // Создаём строку, которую запишем в файл настроек
    QString stringToSave;
    // Строка будет содержать текущую дату, поэтому создаём объект QDate
    QDate date = QDate::currentDate();
    // Присваиваем значение текущей даты строке для записи
    stringToSave = date.toString("ddd-dd-MM-yyyy");
    // Создаём уникальный id посредством класса QUuid
    QUuid uniq_id = QUuid::createUuid();
    // Добавляем полученный id к строке
    stringToSave.append(" id=");
    stringToSave.append(uniq_id.toByteArray());
    // Записываем значение в файл настроек
    settings.setValue("value1", stringToSave);
    settings.sync();
    // Считываем записнное значение и выводим в лог
    qDebug(settings.value("value1").toString().toUtf8());


Это в принципе всё, что я хотел рассказать в этой публикации. Остался только один момент, о котором говорил в самом начале — как избежать скачков изображения на Android при запуске приложения. Самое простое решение — запускать приложение в полноэкранном режиме, а в ApplicationWindow сразу прописывать размеры экрана устройства [см. дополнение 7]. Делается в два шага — в самом начале конструктора класса «Backend» создаём поля для QML со значениями ширины и высоты экрана:

engine.rootContext()->setContextProperty("screen_width", this->width());
engine.rootContext()->setContextProperty("screen_height", this->height());


А непосредственно в QML-файле указываем как ширину и высоту у «ApplicationWindow» только что созданные поля:

ApplicationWindow {
    . . .
    width: screen_width
    height: screen_height
    color: "#F0F0FF"
    . . .


Вот теперь точно всё :). Надеюсь, получилось не слишком сумбурно. В случае Android осталось только подписать приложение (эта функция, кстати, уже встроена в Qt, так что не нужно возиться с командной строкой, как обычно), создать иконки и можно публиковать. Все эти настройки для деплоя находятся на вкладке «Проекты» -> «Android» -> «Запуск».


4. Резюме
Что ж, безусловно не стоит говорить о том, что ни один кроссплатформенный интсрументарий не сравнится с нативом. Однако Qt, если закрыть глаза на некоторые небольшие недостатки, очень здорово показал себя в кроссплатформенной разработке. На мой взгляд, это оптимальное решение, когда нужно разработать несложное клиент-серверное приложение с тривиальными функциями на все мобильные платформы. Огромным плюс — удобная разработка адаптивного интерефейса.

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

Что касается недостатков, о которых упоминал в самом начале, то тут тоже не стоит переживать — Qt продолжает работу над своим инструментарием, и скоро мы получим полноценный QtWebEngine (который, надеюсь, решит проблему WebView на мобильных устройствах), поддержку BLE и многое другое — qt-project.org/wiki/New-Features-in-Qt-5.4.

За время работы в Qt я всё же успел полюбить этот инструментарий, даже несмотря на некоторые возникшие сначала трудности. Здорово, если теперь Qt понравится и вам.

Спасибо за внимание, искренне надеюсь, что материал был полезен для вас!

Дополнения и примечания
1. Использовать ширину и высоту главного экрана для расчёта координат расположения элемента UI нерационально, т.к. для этого существуют параметры «anchors». Таким образом можно удобно задавать, например, положение одного элемента относительно другого:
anchors.bottom: some_element_id.top

2. При необходимости задания разных параметров элемента в зависимости от текущей ориентации экрана устройства, во-первых, расчёт параметров желательно выносить в отдельные property, во-вторых, определить ориентацию экрана можно без сравнения ширины/высоты корневого UI-элемента, т.к. для этой цели существует Screen.primaryOrientation (для работы нужно подключить QtQuick.Window 2.0).

3. Текст кнопки логичнее указывать внутри самой кнопки (поле «text:»), а не её стиля. Это не критично, но делает код более лаконичным.

4. Для создания C++ класса бэкенда лучше использовать класс, производный от QObject. Но в данном случае выбор QQuickItem также не критичен.

5. Для обращения из C++ к QML в большинстве случаев лучше использовать сигналы (для разделения UI-логики от бэкенда и удобного поддерживания кода). Пример реализации:
— в заголовочном файле объявить сигнал:
public: signals: void testSignal(); 

— где-нибудь в коде бэкенда посылать сигнал:
emit testSignal(); 

— в QML создать блок «Connections» внутри «ApplicationWindow», чтобы отслеживать сигнал из C++:
Connections{ target: backend; onTestSignal: console.log("emited!") } 

«target» — ContextProperty, установленное из бэкенда, «onTestSignal:» — действие в случае emit'а сигнала «testSignal()».

6. Для ввода именно телефонных номеров следует использовать для поля «inputMethodHints» значение «Qt.ImhDialableCharactersOnly», т.к. «Qt.ImhPreferNumbers» служит для отображения клавиатуры ввода чисел.

7. Для правильного запуска UI без «скачков» изображения в Android при старте ещё проще использовать в самом QML (присваивать ширине и высоте ApplicationWindow) Screen.desktopAvailableWidth/Screen.desktopAvailableHeight (не забудьте добавить в импорт QtQuick.Window 2.0).

8. Однотипные параметры можно удобно группировать:
border {
        width: 2
        color: "#FFFFFF"
}

Tags:кроссплатформенная разработкаклиент-сервер
Hubs: Development of mobile applications Qt
+30
35.7k 248
Comments 37
Top of the last 24 hours