Недавно я начал изучать 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+, вам не нужно создавать собственный набор инструментальных средств и можно пропустить этот этап:
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 и общие библиотеки:
Отладчик приложения на IOS и общая библиотека:
Скорость
Время загрузки автономного решения Rust и его мостов, вызываемых через Android и iOS, а также реализации нативных решений Swift и Kotlin одного и того же сетевого вызова:
- решение на 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.
Читайте также:
- Изучаем WebAssembly с помощью Rust(Откроется в новой вкладке браузера)
- Топ-5 трендовых библиотек для Android за 1 квартал 2020 года
- Топ-10 самых популярных библиотек Android и iOS
Перевод статьи Igor Steblii: Rust & cross-platform mobile development