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

Читайте также:


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

Предыдущая статья3 инструмента, чтобы начать программировать на недорогом гаджете
Следующая статьяЭйнштейн и самая красивая из всех теорий