Rust и разработка кроссплатформенных решений для мобильных устройств


Недавно я начал изучать 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.

Читаемость кода

Давайте проверим следующий фрагмент кода — реализацию сортировки пузырьком:

Слева направо: C++, Rust, Swift. Источник Слева направо: C++, Rust, Swift. Источник Слева направо: C++, Rust, Swift. Источник
  • По синтаксису он близок к Swift.
  • Сделан скорее идиоматически: читаемо и понятно.
Безопасность

Ещё одна распространённая на C++ проблема, которая решается в Rust, — это обеспечение безопасной работы с памятью. Rust гарантирует безопасное использование памяти во время компиляции и затрудняет возникновение утечки памяти (хотя её возможность остаётся). В то же время он предоставляет широкий набор средств для самостоятельного управления памятью — оно может быть безопасным или небезопасным.

Применение в приложениях

Я просмотрел официальные примеры Rust и многие другие проекты на GitHub, но они определённо далеки от реального сценария применения мобильного приложения. Поэтому было очень непросто оценить сложность реальных проектов или объём усилий, связанных с переходом на Rust. Именно поэтому я решил создать пример, в котором будут освящены наиболее важные для меня аспекты, а именно:— организация сетевого взаимодействия;— многопоточность;— сериализация данных.

Бэкенд

Ради упрощения работы для бэкенда я решил выбрать API StarWars. Вы можете создать простой сервер Rust на основе этого официального примера.

Среда

Настроить среду и создать приложение для IOS и Android можно, используя очень подробные и простые официальные примеры:

  • Rust IOS;
  • Rust Android.

Пример для Android немного устарел. Если вы используете NDK 20+, вам не нужно создавать собственный набор инструментальных средств и можно пропустить этот этап:

mkdir NDK ${NDK_HOME}/build/tools/make_standalone_toolchain.py — api 26 — arch arm64 — install-dir NDK/arm64 ${NDK_HOME}/build/tools/make_standalone_toolchain.py — api 26 — arch arm — install-dir NDK/arm ${NDK_HOME}/build/tools/make_standalone_toolchain.py — api 26 — arch x86 — install-dir NDK/x86

Вместо этого добавьте в 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:

[target.aarch64-linux-android] ar = "<NDK_HOME>/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android-ar" linker = "<NDK_HOME>/toolchains/llvm/prebuilt/darwin-x86_64 /bin/aarch64-linux-android21-clang" [target.armv7-linux-androideabi] ar = "<NDK_HOME>/toolchains/llvm/prebuilt/darwin-x86_64/bin/arm-linux-androideabi-ar" linker = "<NDK_HOME>/toolchains/llvm/prebuilt/darwin-x86_64 /bin/armv7a-linux-androideabi21-clang" [target.i686-linux-android] ar = "<NDK_HOME>/toolchains/llvm/prebuilt/darwin-x86_64/bin/i686-linux-android-ar" linker = "<NDK_HOME>/toolchains/llvm/prebuilt/darwin-x86_64/bin/i686-linux-android21-clang" Многопоточность, HTTP-клиент и сериализация данных

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

  • Tokio runtime и Async/.await;
  • Reqwest — простой HTTP-клиент;
  • Serde — библиотека сериализации/десериализации JSON.

Вот пример того, как всё это можно сочетать для создания клиента SWAPI (StarWars API) в нескольких строках кода:

//Пользовательская потоковая среда выполнения lazy_static! { static ref RUN_TIME: tokio::runtime::Runtime = tokio::runtime::Builder::new() .threaded_scheduler() .enable_all() .build() .unwrap(); } //URL const DATA_URL_LIST: &str = "https://swapi.dev/api/people/"; //Response DTO (объект переноса данных) #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ResponsePeople { pub count: i64, pub next: String, pub results: Vec<People>, } //People DTO, для упрощения примера я удалил несколько полей #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct People { pub name: String, pub height: String, pub mass: String, pub gender: String, pub created: String, pub edited: String, pub url: String, } //Ключевое слово async означает, что оно возвращает Future. pub async fn load_all_people() -> Result<(ResponsePeople), Box<dyn std::error::Error>> { println!("test_my_data: start"); let people: ResponsePeople = reqwest::get(DATA_URL_LIST) .await? .json() .await?; Ok(people) } //Тест в main #[tokio::main] //Макрос для создания среды выполнения async fn main() -> Result<(), Box<dyn std::error::Error>> { let future = load_all_people(); block_on(future);//Блокирует программу до завершения future Ok(()) }

lazy_statlic — макрос для объявления statics с использованием ленивых (отложенных) вычислений.

Взаимодействие

Мы подходим к самой сложной части: взаимодействию между IOS/Android и Rust. Здесь мы будем использовать механизм FFI. Для осуществления взаимодействия он использует C-interop и поддерживает только совместимые с C типы. Взаимодействие с помощью C-interop может быть не таким простым. IOS и Android имеют собственные ограничения, справляются с которыми они тоже по-своему. Давайте посмотрим, как это происходит.

Для упрощения передачи данных также можно использовать протоколы побайтовой передачи: ProtoBuf, FlatBuffer. Оба протокола поддерживают Rust, но я исключил их из рассмотрения, потому что они имеют накладные расходы на производительность.

Android

Взаимодействие с Java-средой осуществляется через экземпляр JNIEnv. Вот простой пример, который возвращает строку в обратном вызове в том же потоке:

#[no_mangle] #[allow(non_snake_case)] pub extern "C" fn Java_com_rust_app_MainActivity_callback(env: JNIEnv, _class: JClass, callback: JObject) { let response = env.new_string("Callback from Rust").expect("Couldn't create java string!"); env.call_method( callback, "rustCallbackResult", "(Ljava/lang/String;)V", &[JValue::from(JObject::from(response))]).unwrap(); }

Выглядит просто, но у этого метода есть ограничение. JNIEnv не может быть просто разделён между потоками, потому что он не реализует типаж `Send` (типаж == протокол/интерфейс). Если вы обернёте call_method в отдельный поток, он завершится с соответствующей ошибкой. Вы, конечно, можете реализовать Send самостоятельно, так же как Copy и Clone, но во избежание шаблонного кода мы можем использовать rust_swig.Rust swig основан на тех же принципах, что и SWIG: чтобы предоставить вам реализацию, он использует DSL и генерацию кода. Вот пример псевдокода для Rust SwapiClient, который мы определили ранее:

foreign_class!(class People { self_type People; private constructor = empty; fn getName(&self) -> &str { &this.name } fn getGender(&self) -> &str { &this.gender } }); foreign_interface!(interface SwapiPeopleLoadedListener { self_type SwapiCallback + Send; onLoaded = SwapiCallback::onLoad(&self, s: Vec<People>); onError = SwapiCallback::onError(&self, s: &str); }); foreign_class!(class SwapiClient { self_type SwapiClient; constructor SwapiClient::new() -> SwapiClient; fn SwapiClient::loadAllPeople(&self, callback: Box<dyn SwapiCallback + Send>); });

Кроме обёртки RUST, он также сгенерирует для вас Java-код. Вот пример автоматически сгенерированного класса SwapiClient:

public final class SwapiClient { public SwapiClient() { mNativeObj = init(); } private static native long init(); public final void loadAllPeople(@NonNull SwapiPeopleLoadedListener callback) { do_loadAllPeople(mNativeObj, callback); } private static native void do_loadAllPeople(long self, SwapiPeopleLoadedListener callback); public synchronized void delete() { if (mNativeObj != 0) { do_delete(mNativeObj); mNativeObj = 0; } } @Override protected void finalize() throws Throwable { try { delete(); } finally { super.finalize(); } } private static native void do_delete(long me); /*package*/ SwapiClient(InternalPointerMarker marker, long ptr) { assert marker == InternalPointerMarker.RAW_PTR; this.mNativeObj = ptr; } /*package*/ long mNativeObj; }

Единственное ограничение здесь в том, что вам нужно будет объявить отдельный метод геттер для каждого поля 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-клиент и загрузка обратного вызова:

    //Создаётся клиент #[no_mangle] pub extern "C" fn create_swapi_client() -> *mut SwapiClient { Box::into_raw(Box::new(SwapiClient::new())) } //Освобождается память #[no_mangle] pub unsafe extern "C" fn free_swapi_client(client: *mut SwapiClient) { assert!(!client.is_null()); Box::from_raw(client); } //Для возвращения данных нужна ссылка на владельца контекста #[allow(non_snake_case)] #[repr(C)] pub struct PeopleCallback { owner: *mut c_void, onResult: extern fn(owner: *mut c_void, arg: *const PeopleNativeWrapper), onError: extern fn(owner: *mut c_void, arg: *const c_char), } impl Copy for PeopleCallback {} impl Clone for PeopleCallback { fn clone(&self) -> Self { *self } } unsafe impl Send for PeopleCallback {} impl Deref for PeopleCallback { type Target = PeopleCallback; fn deref(&self) -> &PeopleCallback { &self } } #[no_mangle] pub unsafe extern "C" fn load_all_people(client: *mut SwapiClient, outer_listener: PeopleCallback) { assert!(!client.is_null()); let local_client = client.as_ref().unwrap(); let cb = Callback { result: Box::new(move |result| { let mut native_vec: Vec<PeopleNative> = Vec::new(); for p in result { let native_people = PeopleNative { name: CString::new(p.name).unwrap().into_raw(), gender: CString::new(p.gender).unwrap().into_raw(), mass: CString::new(p.mass).unwrap().into_raw(), }; native_vec.push(native_people); } let ptr = PeopleNativeWrapper { array: native_vec.as_mut_ptr(), length: native_vec.len() as _, }; (outer_listener.onResult)(outer_listener.owner, &ptr); }), error: Box::new(move |error| { let error_message = CString::new(error.to_owned()).unwrap().into_raw(); (outer_listener.onError)(outer_listener.owner, error_message); }), }; let callback = Box::new(cb); local_client.loadAllPeople(callback); }

    На стороне Swift нам нужно будет использовать UnsafePointer и другие вариации обычного указателя для снятия обёртки с данных:

    /Обёртка для SwapiClient Rust class SwapiLoader { private let client: OpaquePointer init() { client = create_swapi_client() } deinit { free_swapi_client(client) } func loadPeople(resultsCallback: @escaping (([People]) -> Void), errorCallback: @escaping (String) -> Void) { //Мы не можем сделать обратный вызов из контекста C, нужно отправить ссылку на обратный вызов в C let callbackWrapper = PeopleResponse(onSuccess: resultsCallback, onError: errorCallback) //Указатель на класс обратного вызова let owner = UnsafeMutableRawPointer(Unmanaged.passRetained(callbackWrapper).toOpaque()) //Результаты обратного вызова C var onResult: @convention(c) (UnsafeMutableRawPointer?, UnsafePointer<PeopleNativeWrapper>?) -> Void = { let owner: PeopleResponse = Unmanaged.fromOpaque($0!).takeRetainedValue() if let data:PeopleNativeWrapper = $1?.pointee { print("data \(data.length)") let buffer = data.asBufferPointer var people = [People]() for b in buffer { people.append(b.fromNative()) } owner.onSuccess(people) } } //Ошибка обратного вызова С var onError: @convention(c) (UnsafeMutableRawPointer?, UnsafePointer<Int8>?) -> Void = { guard let pointer = $1 else {return;} let owner: PeopleResponse = Unmanaged.fromOpaque($0!).takeRetainedValue() let error = String(cString: pointer) owner.onError(error) } //Структура обратного вызова, определённая в Rust var callback = PeopleCallback ( owner: owner, onResult: onResult, onError: onError ) load_all_people(client, callback) } } //Вспомогательный класс для изменения контекста с Rust на Swift class PeopleResponse { public let onSuccess: (([People]) -> Void) public let onError: ((String) -> Void) init(onSuccess: @escaping (([People]) -> Void), onError: @escaping ((String) -> Void)) { self.onSuccess = onSuccess self.onError = onError } } //Преобразование массива C [указатель; длина] в массив Swift extension PeopleNativeWrapper { var asBufferPointer: UnsafeMutableBufferPointer<PeopleNative> { return UnsafeMutableBufferPointer(start: array, count: Int(length)) } }

    Здесь возникает резонный вопрос: зачем нам класс PeopleResponse в Swift и соответствующая структура PeopleCallback? Главным образом чтобы избежать вот этого:

    Вам нужно отправить объект обратного вызова в машинный код и вернуть его обратно с результатом:

    Подход #2

    В этом случае вместо `PeopleNative` мы будем использовать People (исходную структуру Rust), не предоставляя клиенту поле, а создавая методы, которые будут принимать указатель на DTO и возвращать требующийся элемент. Обратите внимание, что нам всё равно нужно будет обернуть массивы и обратные вызовы, как в предыдущем примере.

    Это касается только геттеров, то есть методов получателя, всё остальное практически то же самое:

    //Возвращаем имя pub unsafe extern "C" fn people_get_name(person: *mut People) -> *mut c_char { debug_assert!(!person.is_null()); let person = person.as_ref().unwrap(); return CString::new(person.name.to_owned()).unwrap().into_raw(); } //Или можно принять в качестве параметра указатель на имя #[no_mangle] pub unsafe extern "C" fn people_get_name_( person: *const People, name: *mut *const c_char, length: *mut c_int, ) { debug_assert!(!person.is_null()); let person = &*person; //для воссоздания строки нужны контент и длина. *name = person.name.as_ptr() as *const c_char; *length = person.name.len() as c_int; } Создание заголовков

    Завершив определение FFI, можно сгенерировать заголовок:

    cargo install cbindgen //Устанавливаем cbindgen, если его ещё нет //Создаём заголовок, который нужно включить в IOS проект cbindgen -l C -o src/swapi.h

    Чтобы автоматизировать этот процесс, можно создать конфигурацию сборки в build.rs:

    cbindgen::Builder::new() .with_crate(crate_dir) .with_language(C) .generate() .expect("Unable to generate bindings") .write_to_file("src/greetings.h"); If Android {} else IOS {}

    Чтобы разделить логику, присущую приложениям на IOS и Android, зависимости и прочее, можно использовать макросы (пример):

    #[cfg(target_os=”android”)] #[cfg(target_os=”ios”)]

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

    Как можете сделать вы. Как сделал я.

    Тестирование производительности

    Размер

    Оба проекта оценивались только с использованием кода и пользовательского интерфейса на Rust.

    Отладчик API на Android и общие библиотеки:

    Отладчик API на Android и общие библиотеки, Мб

    Отладчик приложения на IOS и общая библиотека:

    Размер отладчика приложения и общей библиотеки, Мб Скорость

    Время загрузки автономного решения Rust и его мостов, вызываемых через Android и iOS, а также реализации нативных решений Swift и Kotlin одного и того же сетевого вызова:

    Выполнение в миллисекундах, среднее по 10 запросам/замер времени на стороне клиента после получения обратного вызова с сериализованными данными
    • решение на iOS использует URL, URLSession и Codable;
    • Android использует сопрограммы с помощью kotlinx.serialization.

    Как видите, почти никакой разницы нет между вызовом автономного решения Rust и вызовом его через Android и Swift. А значит, FFI не создаёт никаких накладных расходов на производительность.

    Примечание: скорость запроса сильно зависит от временной задержки сервера (то есть от количества времени, уходящего на обработку запроса).Обе реализации можно найти в проекте на GitHub.

    Проект

    Полный пример проекта доступен на GitHub.

    Пользовательский интерфейс IOS и Android

    Заключение

    Rust — это очень перспективный язык, который даёт чрезвычайно высокую скорость при решении типичных для C++ проблем, связанных с использованием памяти. Надёжный и простой API облегчает его освоение и использование. Выбирая между ним и C++, я отдал бы предпочтение Rust, хотя он остаётся для меня более сложным, чем Swift или Kotlin.А самое сложное — создать правильный мост между Rust и фреймворками для разработки клиентской части проекта или приложения. Если вы сможете его сделать, у вас будет отличное решение для мобильных устройств.

    Полезные ссылки:

    Вот что мне удалось раскопать: Go + Gomobile для Android и IOS.Реализация и тестирование производительности.

    • Servo — браузерный движок с открытым исходным кодом, написанный с помощью Rust.
    • Поддержка WebAssembly для Rust.

    Перевод статьи Igor Steblii: Rust & cross-platform mobile development


    Поделиться статьей:


    Вернуться к статьям

    Комментарии

      Ничего не найдено.