Источник: Nuances of Programming
Недавно я начал изучать Android и iOS на предмет возможности обмена между ними бизнес-логикой. Этот поиск привёл меня к Rust — очень интересному и относительно новому языку программирования. Поэтому я решил попробовать его.
Что такое Rust?
Два самых важных момента, которые я нашёл в документации:
Rust невероятно быстр и экономичен в использовании памяти: в отсутствие среды выполнения или сборщика мусора он может обеспечивать функционирование быстродействующих сервисов, запускаться на встроенных устройствах и легко интегрироваться с другими языками.
Это язык нативного уровня, как и C++.
Модель владения Rust и система типов с широкими возможностями гарантируют безопасность использования памяти и потокобезопасность, позволяя устранять многие ошибки во время компиляции.
Его компилятор убережёт вас от типичных ошибок при работе с памятью.
Он популярен?
Согласно опросу 2019 года, Rust — один из самых любимых и желанных языков среди инженеров-разработчиков:
Хотя общая динамика не так оптимистична:
RUST появился в 2010 году почти одновременно с Go (2009). Версия 1.0 была выпущена в 2015 году, но её создатели и не думают останавливаться и добавляют всё больше новых функциональных возможностей, откликаясь на пожелания пользователей.
К сожалению, пока что Rust используется лишь в нескольких крупных компаниях.
Насколько он хорош?
Первое, на что вам следует обратить внимание, — это производительность. Rust является, вероятно, одним из лучших в этом смысле. Вот несколько тестов производительности (слева направо):
— Rust против Go;
— Rust против Swift;
— Rust против C++.
В целом он сопоставим с C/C++ и, возможно, немного быстрее, чем Swift. Конечно, всё зависит от задачи и реализации.
Go или Java обычно на 10 позиций ниже, чем Rust.
Читаемость кода
Давайте проверим следующий фрагмент кода — реализацию сортировки пузырьком:
- По синтаксису он близок к Swift.
- Сделан скорее идиоматически: читаемо и понятно.
Безопасность
Ещё одна распространённая на C++ проблема, которая решается в Rust, — это обеспечение безопасной работы с памятью. Rust гарантирует безопасное использование памяти во время компиляции и затрудняет возникновение утечки памяти (хотя её возможность остаётся). В то же время он предоставляет широкий набор средств для самостоятельного управления памятью — оно может быть безопасным или небезопасным.
Применение в приложениях
Я просмотрел официальные примеры Rust и многие другие проекты на GitHub, но они определённо далеки от реального сценария применения мобильного приложения. Поэтому было очень непросто оценить сложность реальных проектов или объём усилий, связанных с переходом на Rust. Именно поэтому я решил создать пример, в котором будут освящены наиболее важные для меня аспекты, а именно:
— организация сетевого взаимодействия;
— многопоточность;
— сериализация данных.
Бэкенд
Ради упрощения работы для бэкенда я решил выбрать API StarWars.
Вы можете создать простой сервер Rust на основе этого официального примера.
Среда
Настроить среду и создать приложение для IOS и Android можно, используя очень подробные и простые официальные примеры:
Пример для Android немного устарел. Если вы используете NDK 20+, вам не нужно создавать собственный набор инструментальных средств и можно пропустить этот этап:
Вместо этого добавьте в PATH свой комплект разработчика и предварительно скомпилированный пакет инструментальных средств:
export NDK_HOME=/Users/$USER/Library/Android/sdk/ndk-bundle
export PATH=$NDK_HOME/toolchains/llvm/prebuilt/darwin-x86_64
/bin:$PATH
И поместите все это в cargo-config.toml:
Многопоточность, HTTP-клиент и сериализация данных
Rust предоставляет довольно надёжный API для организации сетевого взаимодействия с использованием следующих библиотек:
Вот пример того, как всё это можно сочетать для создания клиента SWAPI (StarWars API) в нескольких строках кода:
lazy_statlic — макрос для объявления statics с использованием ленивых (отложенных) вычислений.
Взаимодействие
Мы подходим к самой сложной части: взаимодействию между IOS/Android и Rust.
Здесь мы будем использовать механизм FFI. Для осуществления взаимодействия он использует C-interop и поддерживает только совместимые с C типы. Взаимодействие с помощью C-interop может быть не таким простым. IOS и Android имеют собственные ограничения, справляются с которыми они тоже по-своему. Давайте посмотрим, как это происходит.
Для упрощения передачи данных также можно использовать протоколы побайтовой передачи: ProtoBuf, FlatBuffer. Оба протокола поддерживают Rust, но я исключил их из рассмотрения, потому что они имеют накладные расходы на производительность.
Android
Взаимодействие с Java-средой осуществляется через экземпляр JNIEnv. Вот простой пример, который возвращает строку в обратном вызове в том же потоке:
Выглядит просто, но у этого метода есть ограничение. JNIEnv не может быть просто разделён между потоками, потому что он не реализует типаж `Send` (типаж == протокол/интерфейс). Если вы обернёте call_method в отдельный поток, он завершится с соответствующей ошибкой. Вы, конечно, можете реализовать Send самостоятельно, так же как Copy и Clone, но во избежание шаблонного кода мы можем использовать rust_swig.
Rust swig основан на тех же принципах, что и SWIG: чтобы предоставить вам реализацию, он использует DSL и генерацию кода. Вот пример псевдокода для Rust SwapiClient, который мы определили ранее:
Кроме обёртки RUST, он также сгенерирует для вас Java-код. Вот пример автоматически сгенерированного класса SwapiClient:
Единственное ограничение здесь в том, что вам нужно будет объявить отдельный метод геттер для каждого поля DTO. Хорошо то, что его можно объявить внутри DSL. Библиотека имеет обширный список конфигураций, которые можно найти в документации.
Кроме того, в репозитории rust-swig в android-example можно найти интеграцию Gradle.
IOS
Поскольку в Swift для взаимодействия с Rust не требуется никаких прокси (типа JNIEnv), мы можем использовать непосредственно FFI. Тем не менее существует множество вариантов доступа к данным:
- Предоставление DTO, совместимых с C.
Для каждого такого объекта DTO нужно создать совместимую с C копию и сопоставить её с ним перед отправкой в Swift. - Предоставление указателя на структуру без каких-либо полей.
Для каждого поля в FFI создаётся геттер, который в качестве параметра принимает указатель на объект хоста.
Здесь есть ещё два возможных подварианта:
2.1. Метод может вернуть (return) результат от геттера.
2.2. Или вы можете передать указатель и загрузить значение в качестве параметра (для строки C вам понадобится указатель на начало символьного массива и его длину).
Давайте проверим реализацию обоих подходов.
Подход 1
Swapi-клиент и загрузка обратного вызова:
На стороне Swift нам нужно будет использовать UnsafePointer и другие вариации обычного указателя для снятия обёртки с данных:
Здесь возникает резонный вопрос: зачем нам класс PeopleResponse в Swift и соответствующая структура PeopleCallback? Главным образом чтобы избежать вот этого:
Вам нужно отправить объект обратного вызова в машинный код и вернуть его обратно с результатом:
Подход 2
В этом случае вместо `PeopleNative` мы будем использовать People (исходную структуру Rust), не предоставляя клиенту поле, а создавая методы, которые будут принимать указатель на DTO и возвращать требующийся элемент. Обратите внимание, что нам всё равно нужно будет обернуть массивы и обратные вызовы, как в предыдущем примере.
Это касается только геттеров, то есть методов получателя, всё остальное практически то же самое:
Создание заголовков
Завершив определение FFI, можно сгенерировать заголовок:
cargo install cbindgen //Устанавливаем cbindgen, если его ещё нет
//Создаём заголовок, который нужно включить в IOS проект cbindgen -l C -o src/swapi.h
Чтобы автоматизировать этот процесс, можно создать конфигурацию сборки в build.rs:
If Android {} else IOS {}
Чтобы разделить логику, присущую приложениям на IOS и Android, зависимости и прочее, можно использовать макросы (пример):
#[cfg(target_os=”android”)]
#[cfg(target_os=”ios”)]
Самым простым способом разделить решаемые задачи было бы создание отдельного макроса поверх файла — по одному модулю на каждую платформу.Но мне показалось это слишком хлопотным, тем более что нельзя использовать его в build.rs, поэтому я отделил специфическую для платформы логику в разных проектах от базовой логики.
Тестирование производительности
Размер
Оба проекта оценивались только с использованием кода и пользовательского интерфейса на Rust.
Отладчик API на Android и общие библиотеки:
Отладчик приложения на IOS и общая библиотека:
Скорость
Время загрузки автономного решения Rust и его мостов, вызываемых через Android и iOS, а также реализации нативных решений Swift и Kotlin одного и того же сетевого вызова:
Как видите, почти никакой разницы нет между вызовом автономного решения Rust и вызовом его через Android и Swift. А значит, FFI не создаёт никаких накладных расходов на производительность.
Примечание: скорость запроса сильно зависит от временной задержки сервера (то есть от количества времени, уходящего на обработку запроса).
Обе реализации можно найти в проекте на GitHub.
Проект
Полный пример проекта доступен на GitHub.
Пользовательский интерфейс IOS и Android
Заключение
Rust — это очень перспективный язык, который даёт чрезвычайно высокую скорость при решении типичных для C++ проблем, связанных с использованием памяти. Надёжный и простой API облегчает его освоение и использование. Выбирая между ним и C++, я отдал бы предпочтение Rust, хотя он остаётся для меня более сложным, чем Swift или Kotlin.
А самое сложное — создать правильный мост между Rust и фреймворками для разработки клиентской части проекта или приложения. Если вы сможете его сделать, у вас будет отличное решение для мобильных устройств.
Полезные ссылки:
Вот что мне удалось раскопать: Go + Gomobile для Android и IOS.
Реализация и тестирование производительности.
Читайте также:
Читайте нас в телеграмме и vk
Перевод статьи Igor Steblii: Rust & cross-platform mobile development