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



Книга 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 можно, используя очень подробные и простые официальные примеры:



Пример для 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 для организации сетевого взаимодействия с использованием следующих библиотек:



Вот пример того, как всё это можно сочетать для создания клиента 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. Тем не менее существует множество вариантов доступа к данным:


  1. Предоставление DTO, совместимых с C. 
    Для каждого такого объекта DTO нужно создать совместимую с C копию и сопоставить её с ним перед отправкой в Swift.
  2. Предоставление указателя на структуру без каких-либо полей.
    Для каждого поля в 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 запросам/замер времени на стороне клиента после получения обратного вызова с сериализованными данными


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


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


Проект


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


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




Заключение


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


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


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



978   0  

Comments

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