Pull to refresh

Разработка для Sailfish OS: архитектура FLUX в QML на примере приложения для запоминания литературных терминов

Reading time 8 min
Views 7.3K
Всем доброго времени суток! В данной статье хотелось бы рассказать, как мы разработали своё первое приложение для платформы Sailfish OS (о разработке под которую уже был ряд статей).



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

Поскольку словарь представляет собой список терминов, для каждого из которых дано определение, было решено выделить три типа вопросов для тестов. В первом типе пользователю показывается сам термин и четыре варианта определений терминов из которых он должен выбрать одно верное. Второй тип вопросов эквивалентен первому, с той лишь разницей, что пользователю уже показано одно определение и четыре термина. Пользователь должен выбрать термин, соответствующий данному определению. Наконец, последний тип вопросов — с открытым ответом. Пользователю показывается определение и ему необходимо самому написать слово, соответствующее данному определению.

Кроме описанного выше процесса обучения, необходимо было реализовать просмотр списка терминов, которым мы будем учить, чтобы пользователь мог знать, что же все-таки с него будут спрашивать. Так же было запланировано отслеживание и сохранение прогресса в изучении, с целью показать пользователю, каких успехов он достиг в изучении слов.

Особенности приложения


Собственно сам словарь был любезно предоставлен нам сотрудниками кафедры иностранных языков нашего ВУЗа (ЯрГУ им. П. Г. Демидова). Он был в обычном текстовом виде, поэтому для удобства использования мы перенесли его в xml формат. Получился xml документ состоящий из элементов вида:

<term>
    <name>
      <text>Epenalepsis</text>
    </name>
    <synonym>
      <text>Polysyndeton</text>
      <transcription>[ˌpɒlɪˈsɪndɪtən]</transcription>
    </synonym>
    <description>Use of several conjunctions</description>
    <context>He thought, and thought, and thought…</context>
</term>

Загружается такой словарь очень легко — с помощью стандартного компонента XmlListModel.

В качестве архитектуры приложения была выбрана продвигаемая корпорацией Facebook архитектура «Flux». Про саму архитектуру было уже много написано статей. Довольно интересные и понятные переводы доступны на Хабре: тут и тут. Так же при разработке мы руководствовались статьей об использовании Flux при написании QML приложений. Рекомендуем статью всем кто пишет приложения на QML (не обязательно даже мобильные). Описывать все эти моменты здесь излишне, поскольку вся информация доступна по приведённым выше ссылкам и описана она там очень хорошо. Поэтому напишем лишь, как архитектура Flux использовалась в нашем приложении.

С View все понятно – каждая страница приложения является частью View. Переход между страницами осуществляется с помощью Actions. В нашем случае за переход отвечает Action navigateTo.

AppListener {
    filter: ActionTypes.navigateTo

    onDispatched: {
            pageStack.push(Qt.resolvedUrl("../sailfish-only/views/pages/" + message.url));
    }
}

Для хранения значений, а также для реализации функций используются два Store. Один (мы назвали его TermInformationStore) отвечает за отдельный текущий термин. В нем содержится информация о термине: само слово, его транскрипция, значение, пример использования и синонимы к нему. В этом же Store происходит заполнение свойств, содержащих вышеперечисленную информацию.

Второй Store — TestStore — отвечает за процесс тестирования и прогресс в изучении слов. В нем содержится информация о текущем вопросе теста. Соответственно, здесь эти вопросы и составляются, и здесь же рассчитывается прогресс.

Чтобы разделить работу с данными и организацию взаимосвязи частей приложения был создан элемент Script, который отвечает за получение сигналов от View и вызов функций из Store в верном порядке, что решает проблему с вызовом новых действий, когда старые еще не завершились. Также этот элемент содержит в себе всю логику по перемещению между различными экранами приложения.

Реализованный функционал


Поскольку это было наше первое приложение для данной платформы, да и на QML вообще, то сначала мы конечно же взялись за самое простое — список терминов. Сам список реализован с помощью SilicaListView, в которую подгружается список терминов из XmlListModel (как было описано чуть выше). Вообще, это самый обычный список, а поскольку создание списков — это один из самых базовых и распространённых примеров для QML в общем, да и для Sailfish OS в частности, то и заострять внимание мы на данном моменте не будем.

При нажатии на элемент списка открывается страница с подробным описанием термина. Поскольку мы для приложения решили использовать архитектуру Flux, то процесс открытия данной страницы выглядит несколько необычно, по сравнению с MVC или MVVM. При нажатии на элемент списка создается Action с информацией об индексе нажатого элемента. Данный Action провоцирует TermInformationStore изменить информацию о текущем термине в зависимости от выбранного индекса элемента списка, а затем открывают страницу с описанием. Выглядит она достаточно просто:


Тестирование можно начать с главного экрана. Всего в тесте 20 вопросов по неповторяющимся терминам выбираемым случайным образом. Сам тип вопроса (как было описано в начале — у нас их три) и неправильные ответы (если они должны быть в данном типе вопроса) так же подбираются случайным образом. Как уже было сказано выше, за всю логику составления вопросов отвечает TestStore. Вопрос создается следующим образом:

function makeQuestion(index, type) {
    options = [];
    var element = dictionary.get(index);
    question = (type === 0) ? element.name : element.description;
    questionIndex = index;
    rightAnswer = (type === 0) ? element.description : element.name;
    alternativeRightAnswer = (element.synonym !== "") ? element.synonym : element.name;
    if(type !== 2) {
        var rightVariantNumber = Math.floor(Math.random() * 4);
        for(var i = 0; i < 4; i++) {
            if(i !== rightVariantNumber) {
                options.push(getWrongOption(index, type));
            } else {
                options.push((type === 0) ? element.description : element.name);
            }
        }
    }
}

В функцию передается индекс термина в словаре и тип вопроса. В зависимости от этих параметров заполняются свойства TestStore, отвечающие за текущий вопрос (question, options, rightAnswer и другие). Они затем будут использованы видом для отображения вопроса пользователю. Для каждого типа вопроса есть своя страница:




Вот пример кода для страницы с вопросом, где пользователю нужно выбрать термин по определению:

Page {
    SilicaFlickable {
        anchors.fill: parent
        contentHeight: column.height + Theme.paddingLarge

        VerticalScrollDecorator {}

        Column {
            id: column
            width: parent.width
            spacing: Theme.paddingLarge

            PageHeader { title: qsTr("Question ") + TestStore.questionNumber }

            Label {
                text: TestStore.question
                font.pixelSize: Theme.fontSizeMedium
                wrapMode: Text.Wrap
                anchors {
                    left: parent.left
                    right: parent.right
                    margins: Theme.paddingLarge
                }
            }

            Button {
                id: option0
                height: Theme.itemSizeMedium
                anchors {
                    left: parent.left
                    right: parent.right
                    margins: Theme.paddingLarge
                }
                text: TestStore.options[0]
                onClicked: {
                    AppActions.submitAnswer(option0.text);
                }
            }

            Button {
                id: option1
                height: Theme.itemSizeMedium
                anchors {
                    left: parent.left
                    right: parent.right
                    margins: Theme.paddingLarge
                }
                text: TestStore.options[1]
                onClicked: {
                    AppActions.submitAnswer(option1.text);
                }
            }

            Button {
                id: option2
                height: Theme.itemSizeMedium
                anchors {
                    left: parent.left
                    right: parent.right
                    margins: Theme.paddingLarge
                }
                text: TestStore.options[2]
                onClicked: {
                    AppActions.submitAnswer(option2.text);
                }
            }

            Button {
                id: option3
                height: Theme.itemSizeMedium
                anchors {
                    left: parent.left
                    right: parent.right
                    margins: Theme.paddingLarge
                }
                text: TestStore.options[3]
                onClicked: {
                    AppActions.submitAnswer(option3.text);
                }
            }

            Button {
                height: Theme.itemSizeLarge
                anchors {
                    left: parent.left
                    right: parent.right
                    margins: Theme.paddingLarge
                }
                text: qsTr("Skip question")
                onClicked: {
                    AppActions.skipQuestion();
                }
            }
        }
    }
}

Как видите, информация на странице заполняется очень легко просто обращаясь к свойствам TestStore.

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


При этом происходит пересчет прогресса пользователя. Сам пересчет связан с настройками приложения и будет показан ниже.

Результаты пользователя в изучении слов отображаются как для всего словаря, так и для каждого термина отдельно. Для отдельных терминов результат рассчитывается при выборе одного из вариантов ответа.

AppScript {
    runWhen: ActionTypes.submitAnswer

    script: {
        TestStore.checkResult(message.answer);
        TestStore.updateDictionaryProgress(TestStore.questionIndex);
        TermInformationStore.updateInfo(TestStore.questionIndex);
        AppActions.replacePage("QuestionResult.qml");
    }
}

Для всего словаря прогресс отображается в виде шкалы, отражающей суммарную степень «изученности» всех присутствующих терминов. Также приложение ведет статистику того, сколько слов из словаря уже успешно изучены пользователем. Данный прогресс отображается как на главной странице приложения, так и на его обложке:



Так как приложение рассчитано на продолжительное использование, необходимо было реализовать хранение результатов пользователя, чтобы весь накопленный результат не терялся между запусками приложения. Для сохранения прогресса решено было использовать предоставляемый Qt класс QSettings. Он предоставляет возможность постоянного хранения настроек и данных приложения. Для Salifish OS все данные сохраняются в ini файл, соответственно, формат хранимых данных – строка. Так как QSettings все-таки класс из Qt, необходимо было импортировать его как модуль в QML. Делается это в теле функции main следующим образом:

qmlRegisterType<Settings>("harbour.dictionary.trainer.settings", 1, 0, "Settings");

QQuickView* view = SailfishApp::createView();

QSettings data("FRUCT", "Dictionary Trainer");
data.setPath(QSettings::NativeFormat, QSettings::UserScope,
    QStandardPaths::writableLocation(QStandardPaths::DataLocation));
qmlEngine->rootContext()->setContextProperty("data", &data);
QQmlComponent dataComponent(qmlEngine, QUrl("TestStore"));
dataComponent.create();

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

function fillProgress() {
    progress = 0;
    learnedWords = 0;
    if(data.childGroups().indexOf("dictionary") !== -1) {
        for (var i = 0; i < dictionary.count; i++){
            progress += data.valueAsInt("dictionary/" + i.toString());
        }
        learnedWords = data.value("dictionary/learnedWords", 0);
    } else {
        for (var i = 0; i < dictionary.count; i++){
            data.setValue("dictionary/" + i.toString(), 0);
        }
        data.setValue("dictionary/learnedWords", 0)
    }
}

Запись/обновление степени изученности термина происходит в момент ее изменения, т. е. в момент выбора ответа в тесте. Происходит это таким образом:

function updateDictionaryProgress(index) {
    var currentStatus = data.valueAsInt("dictionary/" + index);
    var newStatus;
    if (result === "correct") {
        newStatus = getWordStatus(currentStatus + 1);
    } else {
        newStatus = getWordStatus(currentStatus - 2);
    }
    var statusChange = newStatus - currentStatus;
    calculateLearnedWords(currentStatus, newStatus);
    progress += statusChange;
    data.setValue("dictionary/" + index.toString(), newStatus);
}

Итог


В итоге нам удалось реализовать весь запланированный функционал и наше первое приложение под Sailfish OS было успешно создано. А совсем недавно мы опубликовали его Jolla Store, где оно доступно для скачивания и уже имеет около 2х сотен пользователей:


Авторы: Максим Костерин, Никита Романов
Tags:
Hubs:
+21
Comments 36
Comments Comments 36

Articles