Kotlin Multiplatform — технология, позволяющая объединять бизнес-логику для приложений разных платформ. В ней доступен полный контроль над тем, какие нативные инструменты использовать, а какие вынести в общий модуль (shared). Это позволяет применять данную технологию в уже существующих проектах, что существенно отличает Kotlin Multiplatform от других кроссплатформенных фреймворков таких, как Cordova или Flutter.

Использование приложениями общего модуля Kotlin Multiplatform позволяет:

  • Дополнить привычный для платформы функционал.

  • Стандартизировать подходы.

  • Упорядочить конфигурационные файлы.

  • Упростить написание приложений.

Приложение на базе ОС Аврора может работать через компонент QML WebView, поставляемый ОС Аврора, на базе Kotlin Multiplatform JS. На данный момент нет нативной поддержки Kotlin Multiplatform, информацию о доступных фреймворках можно найти по ссылке. Доступен также способ разработки кроссплатформенного приложения с использованием Flutter, информацию по статусу развития можно найти здесь.

При наличии у разработчика готового приложения, написанного на Kotlin Multiplatform (KMP), оно может быть портировано на ОС Аврора. Либо можно написать новое приложение с использованием Kotlin Multiplatform JS под ОС Аврора.

В данной статье показаны основные этапы портирования существующего приложения Kotlin Multiplatform на ОС Аврора. Портирование выполняется через цель сборки JS (Target JS). Для демонстрации используется открытое приложение для чтения RSS-новостей KMM RSS Reader. Портированное приложение можно найти по ссылке.

Схема взаимодействия приложения с общим модулем может выглядеть следующим образом:

Схема взаимодействия приложения с общим модулем
Схема взаимодействия приложения с общим модулем

Компонент Wrapper является связующим модулем между общим модулем Kotlin Multiplatform и приложением Qt/QML. Wrapper нужен для того, чтобы обеспечить работу с асинхронными функциями из QML, не модифицируя общий модуль KMP.

Структура

Приложение будет состоять из трех основных элементов:

  • Wrapper — библиотека JavaScript, которая связывает общий модуль Kotlin Multiplatform и QML.

  • QML-компонент (KMMAgent) — QML-обёртка над WebView, которая связывает нативный код QML и Wrapper.

  • Kotlin Multiplatform — общий модуль, который реализует бизнес-логику.

Примечание.
В связи признанием устаревшим названием продукта "Kotlin Multiplatform Mobile" в статье используется новая аббревиатура Kotlin Multiplatform - KMP.
Название портируемого приложения (KMM RSS Reader) и компонента (KMMAgent) остается прежним.

Схема взаимодействия элементов выглядит следующим образом:

Схема взаимодействия элементов
Схема взаимодействия элементов

Kotlin Multiplatform собирается в библиотеку Kotlin Multiplatform JS, которую можно подключить к любым проектам JS. Собранная библиотека Kotlin Multiplatform JS подключается к JS-проекту Wrapper, который обеспечивает работу асинхронных функций через Promise с помощью событий events. Собранный webpack Wrapper подключается в файле index.html (файл index.html подключается локально как точка входа для QML-компонента). Далее QML-компонент вызывает функции Wrapper и слушает события events с помощью WebView, которые приходят из библиотеки Wrapper. WebView выполняет роль обслуживания бизнес-логики и скрыт от пользователя.

Структура директорий может быть выстроена на усмотрение разработчика с учетом платформы. Важно понимать, что три компонента (Kotlin Multiplatform, Wrapper, приложение Аврора) собираются отдельно, но в конечном итоге JavaScript-библиотека Wrapper должна быть доступна приложению Аврора при сборке, для этого она должна находится в разделе qml.

Шаги по портированию KMP-приложения на платформу ОС Аврора могут быть следующими:

  1. Подготовить исходный приложения созданного с помощью Kotlin Multiplatform. Например, KMM RSS Reader.

  2. Собрать общий модуль KMP из исходного кода, как NPM-пакет, с помощью Gradle-плагина npm-publish.

  3. Создать проект приложения Qt/QML для ОС Аврора, используя Aurora SDK.

  4. Добавить в проект QML-компонент на основе WebView для обеспечения работы с асинхронными функциями.

  5. Создать JS-библиотеку Wrapper, которая свяжет KMP и QML-компонент.

  6. Приступить к разработке пользовательского интерфейса приложения под ОС Аврора с использованием бизнес-логики Kotlin Multiplatform.

Сборка общего модуля Kotlin Multiplatform

Для добавления JavaScript в Kotlin Multiplatform нужно собрать модуль как JavaScript-библиотеку. В приведенном примере используется версия 1.8.0 плагина multiplatform. Подробная информация по интеграции целей сборки JS доступна в документации "Kotlin/JS IR compiler". Сначала требуется добавить секцию js в kotlin {} в конфигурационный файл Gradle:

Файл <проект>/shared/build.gradle.kts

js(IR) {
    moduleName = "shared"
    version = "0.0.1"
    nodejs()
    binaries.library()
}

Для сборки модуля Kotlin Multiplatform как JS npm-пакета можно использовать Gradle-плагин npm-publish. Его можно добавить следующим образом:

Файл <проект>/shared/build.gradle.kts

plugins {
    id("dev.petuska.npm.publish") version "3.3.1"
}

npmPublish {
    packages {
        named("js") {
            packageJson {
                version.set("0.0.1")
            }
        }
    }
}

Плагин npm-publish добавит метод packJsPackage. Его можно вызвать из командной строки для сборки npm-пакета:

./gradlew packJsPackage

QML-компонент (KMMAgent)

QML-компонент будет содержать WebView. WebView по умолчанию может работать только с синхронными функциями. Поэтому нужно добавить к WebView поддержку Promise.

Объект Promise представляет возможное завершение (или сбой) асинхронной операции и ее результирующее значение.

Promise

Портируемое приложение будет обращаться в сеть для запроса XML-данных RSS. Так как запросы будут выполняться через WebView, следует отключить CORS.

CORS (Cross-origin resource sharing) — это механизм, который позволяет запрашивать ограниченные ресурсы на веб-странице из другого домена за пределами домена, из которого обслуживался первый ресурс.

Cross-Origin Resource Sharing (CORS)

Далее нужно создать скрипт framescript.js в приложении ОС Аврора. Он позволит слушать события events приходящие со стороны библиотеки Wrapper.

Файл <проект>/auroraApp/RSSReader/qml/shared-js/framescript.js

addEventListener("DOMContentLoaded", function (aEvent) {
    aEvent.originalTarget.addEventListener("framescript:log",
        function (aEvent) {
            sendAsyncMessage("webview:action", aEvent.detail)
    });
});

Скрипт framescript.js нужно подключить к WebView, на основе которого создается QML-компонент KMMAgent.

Файл <проект>/auroraApp/RSSReader/qml/pages/KMMAgent.qml

WebView {
    id: webview

    height: 0
    width: 0
    url: Qt.resolvedUrl("../shared-js/index.html")
    visible: false

    onViewInitialized: {
        // Подключение слушателя событий
        webview.loadFrameScript(Qt.resolvedUrl("../shared-js/framescript.js"));
        webview.addMessageListener("webview:action")
    }

    onRecvAsyncMessage: {
        switch (message) {
        case "webview:action":
            // Получение асинхронных ответов
            break
        }
    }

    Component.onCompleted: {
        // Отключение CORS
        WebEngineSettings.setPreference("security.disable_cors_checks", true, WebEngineSettings.BoolPref)
    }
}

В WebView доступен метод runJavaScript. Метод запускает фрагмент JavaScript в контексте загруженного документа DOM. С его помощью можно добавить функцию в QML-компонент, которая выполнит запрос к JavaScript-функции и подготовит данные для получения ответа со стороны JavaScript-библиотеки.

Файл <проект>/auroraApp/RSSReader/qml/pages/KMMAgent.qml

property var stateResponse: ({})

function run(method, result, error) {
    // По ключевому слову функция определит тип запроса: асинхронные данные или нет
    if (method.indexOf("return") === -1) {
        // Далее будет добавлен функционал, который возвращает ключ асинхронного запроса
        webview.runJavaScript("return " + method, function(key) {
            // Следует запомнить функции, чтобы выполнять их после ответа
            stateResponse[key] = [result, error]
        }, error);
    } else {
        // Запуск по умолчанию, синхронные функции не требуют вмешательства
        webview.runJavaScript(method, result, error);
    }
}

Получить событие можно в методе WebView onRecvAsyncMessage. При выполнении асинхронной функции QML-компонент (KMMAgent) выполняет запрос к синхронной функции Wrapper, которая производит запуск асинхронной функции KMP и возвращает уникальный ключ. Сохраняется пара QML-функция и уникальный ключ. При получении события вызывается соответствующая QML-функция по ключу. Данные события можно получить через переменную data. Обработка в onRecvAsyncMessage представлена ниже:

Файл <проект>/auroraApp/RSSReader/qml/pages/KMMAgent.qml

onRecvAsyncMessage: {
    switch (message) {
    case "webview:action":
        try {
            // Первое событие, которое обозначает готовность компонента
            if (data.caller === 'init') {
                root.completed()
            // Другие события
            } else if (root.stateResponse[data.caller] !== undefined) {
                if (data.response.hasOwnProperty('stack')) {
                    // Обработка ошибки
                    root.stateResponse[data.caller][1](data.response.message)
                } else {
                    // Обработка ответа
                    root.stateResponse[data.caller][0](data.response)
                }
            }
        } catch (e) {
            // Общая ошибка запроса, парсинга данных
            root.stateResponse[data.caller][1](e.toString())
        }
        break
    }
}

QML готов к выполнению функций Kotlin Multiplatform. Через событие init будет получен сигнал от WebView о готовности компонента KMMAgent к работе. Теперь в приложении ОС Аврора есть возможность выполнять запросы к Wrapper. На главной странице приложения в компоненте QML ApplicationWindow нужно инициализировать KMMAgent, тогда его можно будет вызвать на всех дочерних страницах:

Файл <проект>/auroraApp/RSSReader/qml/RSSReader.qml

// Инициализация на странице приложения
KMMAgent {
	id: agent
}

// Запуск асинхронной функции
agent.run(
    "shared.Service.get.getAllFeeds()",
    function(response) {
        console.log(response)
    },
    function(error) {
        console.log(error)
    }
)

// Запуск синхронной функции
agent.run(
    "return shared.Service.get.getAllFeeds()", 
    function(response) { 
        console.log(response)
    },
    function(error) {
        console.log(error)
    }
)

// Получение данных, которые могут содержать переменные KMP
agent.run(
    "return shared.AppConstants.links.API_URL", 
    function(response) { 
        console.log(response)
    },
    function(error) {
        console.log(error)
    }
)

Wrapper

Взаимодействие Kotlin Multiplatform и QML обеспечивает библиотека Wrapper. В ней будет полный доступ к модулю Kotlin Multiplatform, что позволит подготовить данные для QML. Библиотеку нужно подключить в точку входа — файл index.html, который будет загружен в QML-компонент KMMAgent. В index.html, расположенный в директории qml приложения ОС Аврора, нужно подключить библиотеку Wrapper и отправить событие с информацией о том, что инициализация WebView успешно завершилась. Это позволит точно определить готовность WebView. Содержимое файла index.html будет следующим:

Файл <проект>/auroraApp/RSSReader/qml/shared-js/index.html

<!DOCTYPE html>
<html lang="en">

<head>
	<meta charset="utf-8">
	<title>KMP</title>
	<script src="./dist/shared.js"></script>
</head>

<body>
    <p id="demo"></p>
    
	<script type="text/javascript">
		function init() {
			async function init() {
				// Проверка работоспособности библиотеки
				document.getElementById("demo").innerHTML = shared.Helper.randomUUID()
				// Отправка события готовности
				shared.Helper.sendEvent("init")
			}
			// Здесь можно добавить задержку отправки готовности 
			setTimeout(init, 0)
		}
		init()
	</script>
</body>
</html>

Создать в корне проекта директорию для библиотеки Wrapper и добавить в неё webpack.config.js для сборки.

Файл <проект>/auroraApp/wrapper/webpack.config.js

const path = require('path');

module.exports = {
    entry: path.resolve(__dirname, 'src/index.js'),
    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: 'shared.js',
        library: 'shared',
        libraryTarget: 'umd',
        hashFunction: "sha256"
    },
    module: {
        rules: [
            {
                test: /\.(js)$/,
                exclude: /node_modules/,
                use: ['babel-loader'],
            },
        ],
    },
    resolve: {
        extensions: ['.js'],
        modules: [
            path.resolve(__dirname, 'src'),
            path.resolve(__dirname, 'node_modules')
        ],
    },
    mode: 'development',
    devtool: 'sourceMap',
};

В папку JavaScript-библиотеки добавить package.json и подключить нужные зависимости.

  • dependencies - раздел для зависимостей realtime.

    • uuid - идентификатор для генерации уникальных ключей.

    • shared — путь к NPM-библиотеке KMP.

  • devDependencies - раздел для зависимостей, необходимых для сборки.

В build нужно добавить webpack с копированием нужных файлов в приложение ОС Аврора после сборки:

Файл <проект>/auroraApp/wrapper/package.json

{
  "name": "kmm-wrapper",
  "version": "0.0.1",
  "scripts": {
    "build": "webpack && cp -R dist ../qml/shared-js && cp index.html ../qml/shared-js",
    "test": "jest"
  },
  "dependencies": {
    "uuid": "^9.0.0",
    "shared": "file:../../shared/build/packages/js"
  },
  "devDependencies": {
    "@babel/core": "^7.5.5",
    "@babel/preset-env": "^7.5.5",
    "babel-eslint": "^10.0.2",
    "babel-loader": "^8.0.6",
    "eslint": "^6.1.0",
    "webpack": "^4.46.0",
    "webpack-cli": "^4.0.0-rc.1"
  }
}

В директорию библиотеки Wrapper добавить папку src, в которой будет находиться объединяющий JavaScript-код. В index.js добавить экспорт нужных компонентов, к которым можно обратиться из QML.

Файл <проект>/auroraApp/wrapper/src/index.js

export * from './Helper';
export * from './Service';

Дальше c Wrapper можно работать как с обычной библиотекой JS, и вызывать все нужные функции в QML.

Объект Helper (вспомогательный класс) поможет создать уникальный ключ и отправит событие после ответа асинхронной функции Kotlin Multiplatform.

Файл <проект>/auroraApp/wrapper/src/Helper.js

import {v4 as uuidv4} from 'uuid';

export const Helper = {
    // Создать случайный UUID
    randomUUID: function () {
        return uuidv4()
    },
    // Обертка с событием отправки после получения данных
    request: function (fun, callback, delay) {
        const caller = Helper.randomUUID()
        setTimeout(async () => {
            try {
                Helper.sendEvent(caller, callback(await fun()))
            } catch (e) {
                Helper.sendEvent(caller, e)
            }
        }, delay)
        return caller
    },
    // Отправить событие
    sendEvent: function (caller, response) {
        const customEvent = new CustomEvent("framescript:log", {
            detail: {
                response: response,
                caller: caller
            }
        });
        document.dispatchEvent(customEvent);
    }
}

Helper упростит написание обёртки на асинхронные функции Kotlin Multiplatform. Код обёртки будет выглядеть следующим образом:

Файл <проект>/auroraApp/wrapper/src/Service.js

import shared from "shared";
import {Helper} from "./Helper";

const JsRssReader = new shared.com.github.jetbrains.rssreader.core.JsRssReader()

export const Service = {
    get: {
        getAllFeeds: function (forceUpdate = true) {
            return Helper.request(async () => {
                return await JsRssReader.getAllFeedsPromise(forceUpdate)
            }, (response) => {
                return response.toArray();
            }, 0 /** задержка, если это необходимо **/)
        },
    }
}

Выполнить сборку библиотеки Wrapper:

npm run build

Собранную библиотеку Wrapper нужно добавить в папку qml и подключить её в index.html.

<head>
	<script src="./dist/shared.js"></script>
</head>

В итоге получится три независимых проекта, объединенных в приложение на платформе ОС Аврора. Полный код проекта доступен по ссылке: KMM RSS Reader.

Оценка производительности

Для оценки производительности написаны одинаковые тесты для Android и ОС Аврора. Код тестов открыт и доступен для самостоятельного выполнения (Android, ОС Аврора). Тесты интеграционные выполняют функцию, доступную KMP-модулю с разным объемом данных. Функция делает запрос в сеть для получения данных и анализа ответа XML в модели, используемые на платформах. Тесты выполнялись по 5 раз, и был взят лучший результат.

Test Android / ОС Аврора
Test Android / ОС Аврора

Подробный отчет времени выполнения:

Size

Items

Emulator (Android)

Emulator (Aurora)

Xaomi (Android)

NS220 (Aurora)

NS220 (Android)

SM-J106F (Android)

INOI R7 (Aurora)

10M

4922

2687ms

1148ms

8500ms

8171ms

28279ms

24057ms

12609ms

5M

2436

1556ms

558ms

5117ms

3581ms

16718ms

12165ms

5533ms

1M

485

846ms

210ms

1898ms

1340ms

6361ms

3452ms

2156ms

100K

48

127ms

102ms

352ms

407ms

428ms

378ms

514ms

20K

9

103ms

78ms

177ms

220ms

229ms

153ms

265ms

PC

  • OS — Ubuntu 22.04

  • CPU — AMD 3950x

  • RAM — DDR4 / 2133 MHz / 56GiB

Xaomi A2

  • OS — Android 10

  • CPU — Qualcomm Snapdragon 660

  • RAM — 4GiB

NS220

  • OS — ОС Аврора 4.0.2.269 / Android 8.1.0

  • CPU — MediaTek MT8735A

  • RAM — 4GiB

SM-J106F

  • OS — Android 6.0.1

  • CPU — Spreadtrum SC9830I

  • RAM — 1GiB

INOI R7

  • OS — ОС Аврора 4.0.2.269

  • CPU — Qualcomm Snapdragon 212

  • RAM — 2GiB

Результат тестов показал, что Kotlin Multiplatform JavaScript в данной задаче быстрее, чем собранная JVM-библиотека для Android. Замеры производились на реальном функционале приложения. Kotlin Multiplatform выполняет свою задачу в ОС Аврора на «отлично».

Заключение

Реализация небольшой библиотеки JS позволяет соединить Kotlin Multiplatform с приложением, написанным на Qt/QML для ОС Аврора. Пользователь получает нативный интерфейс ОС Аврора и бизнес логику на Kotlin Multiplatform с минимальным использованием С++. Портировать приложения очень легко и скорость работы такой связки отличная.

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


  1. dea
    09.08.2023 15:46
    +1

    не хватает замеров по потреблению памяти


  1. JajaComp
    09.08.2023 15:46
    +1

    Спасибо за статью. Хотелось бы так же почитать про возможные подводные камни с которыми можно столкнуться. На первый взгляд непонятно как реализовывать кеширование и работу с БД, например SQLDelight.


  1. anonymous
    09.08.2023 15:46

    НЛО прилетело и опубликовало эту надпись здесь


  1. ChPr
    09.08.2023 15:46
    +1

    Зачем JS? Можно же собрать нативную либу и юзать ее из QT? Или Аврова не съест Linux либу?