Документация
Android
Приложения NSH работают на Android с обычными Android views и StaticLayout текстом, начиная с Android 9 (API 28), на arm64-v8a,
armeabi-v7a и x86_64. Целые приложения, написанные на Rust и на TSX, запускаются и упаковываются так же, как на других платформах, а существующее приложение
может встроить модули в NshView и загружать скачанные подписанные пакеты с лимитами и возможностями (capabilities), которые оно задаёт. На Android os() — это Os::Android (platform.os — это "android" в TSX), и применяются props android={{…}} из TSX.
#Требования
- Android SDK (с
platforms;android-35, build tools иplatform-tools) и JDK на macOS, Windows или Linux. См. Установка. - Эмулятор или устройство, подключённое по
adb.
#Запуск приложения
Rust-приложение запускается из своего каталога, а для TSX-приложения указывается точка входа:
nsh run --target android # a Rust app
nsh run App.tsx --target android # a TSX app
Сначала запустите эмулятор или подключите устройство (adb devices выводит их список; NSH использует первое). Для Rust-приложения nsh
собирает libnsh.so приложения вместе с вашим crate, устанавливает APK и запускает его; при изменении Rust-кода crate
сборка и установка выполняются заново. Для TSX-приложения устанавливается APK dev-хоста (пересобирается только при изменении рантайма) и
пробрасывается порт dev-сервера через adb reverse, поэтому всё работает по USB без общей сети; приложение обновляется по мере
сохранения файлов, а логи выводятся в терминал.
#Упаковка
nsh package --target android --run # a Rust app: .nsh/out/<Name>.apk, installed and launched
nsh package App.tsx --target android --run # a TSX app
APK Rust-приложения содержит собственный libnsh.so, собранный вместе с вашим crate, а его activity монтирует корневой компонент.
APK собирается с помощью build tools из SDK, без Gradle, и подписывается debug-ключом, если не передан release- keystore. Подпись для релиза и иконки: Упаковка.
NSH собирает libnsh.so с выравниванием ELF по 16 КБ и упаковывает несжатые нативные библиотеки по границам ZIP в 16 КБ.
Release APK содержит libnsh.so без таблиц символов и отладочной информации. Полная библиотека лежит рядом с
рантаймом в symbols/<abi>/libnsh.so (для Rust-приложения — в .nsh/rust/android-rust-release/symbols/<abi>/): передайте её
ndk-stack или загрузите в Play Console как нативные отладочные символы, чтобы читать стеки нативных падений.
#Встраивание в Android-приложение
Существующее Android-приложение может показывать приложения NSH внутри собственного UI. Рантайм поставляется как Android-библиотека nsh.aar: вы
создаёте com.longbridge.nsh.NshView (FrameLayout), добавляете его в любой layout и загружаете в него TSX-приложение или подписанный пакет.
Приложение на Rust поставляется на Android как APK, который собирает nsh package --target android. Приложение NSH
отрисовывается обычными Android views рядом с вашими.
Каждый NshView — это один независимый движок со своей JS VM; глобальных синглтонов нет, поэтому несколько экземпляров могут работать
одновременно.
#Получение библиотеки
Дистрибутив содержит оба варианта:
dist/nsh/embed/android/nsh.aar release: bundles and signed packages
dist/nsh/embed/android/nsh-dev.aar also connects to a `nsh dev` server
AAR содержит Java-рантайм и libnsh.so для arm64-v8a, armeabi-v7a и x86_64 вместе с их символами: Gradle-сборка
приложения удаляет их при упаковке и сохраняет символы для отчётов о падениях. AAR объявляет разрешение
INTERNET и требует API 28 (Android 9) или новее. Он содержит свои правила R8 (proguard.txt), которые
минифицированная сборка применяет сама: нативная библиотека находит классы рантайма по имени, поэтому com.longbridge.nsh
остаётся без изменений.
Добавьте его в модуль приложения, например из папки libs:
// app/build.gradle.kts
dependencies {
debugImplementation(files("libs/nsh-dev.aar"))
releaseImplementation(files("libs/nsh.aar"))
}
#Поставка бандла вместе с приложением
Соберите TSX-приложение в assets приложения:
nsh build App.tsx --release --out app/src/main/assets/nsh
Эта команда записывает bundle.nshc (байткод), bundle.js (исходный код), bundle.js.map, capabilities.json (когда nsh.json
объявляет capabilities) и assets/ (изображения и шрифты, которые импортирует приложение).
#Показ приложения
import android.app.Activity;
import android.os.Bundle;
import android.util.Log;
import com.longbridge.nsh.NshView;
public final class MainActivity extends Activity {
private NshView nsh;
@Override
protected void onCreate(Bundle state) {
super.onCreate(state);
nsh = new NshView(this);
nsh.setErrorListener(message -> Log.e("nsh", message));
setContentView(nsh);
nsh.loadAssetBundle("nsh"); // runs assets/nsh/bundle.nshc
}
@Override
protected void onDestroy() {
nsh.destroy();
super.onDestroy();
}
}
На Kotlin:
class MainActivity : Activity() {
private lateinit var nsh: NshView
override fun onCreate(state: Bundle?) {
super.onCreate(state)
nsh = NshView(this)
nsh.setErrorListener { message -> Log.e("nsh", message) }
setContentView(nsh)
nsh.loadAssetBundle("nsh")
}
override fun onDestroy() {
nsh.destroy()
super.onDestroy()
}
}
NshView также можно поместить в любой layout, например под собственный тулбар:
LinearLayout column = new LinearLayout(this);
column.setOrientation(LinearLayout.VERTICAL);
column.addView(toolbar);
column.addView(nsh, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
setContentView(column);
Размер view — это окно приложения: NSH раскладывает приложение по размеру view и заново при каждом его изменении. Повторная
загрузка заменяет запущенное приложение. Полный пример находится в
examples/embed-android/src/com/longbridge/nsh/embeddemo/MainActivity.java.
#NshView
| Метод | Описание |
|---|---|
NshView(Context context), NshView(Context context, boolean trace) | Создаёт пустой view. trace логирует время каждого коммита. |
setErrorListener(ErrorListener listener) | Получает фатальные ошибки приложения (сбои загрузки, необработанные JS-исключения, VM, остановленная лимитом) в UI-потоке. Они всегда логируются. |
setLimits(int memoryMb, int stackKb, int loadMs, int callMs, int maxNodes) | Лимиты VM для приложений, загружаемых следующими; 0 оставляет значение по умолчанию. |
setGrantedCapabilities(String json) | Максимум, что могут делать приложения, загружаемые следующими, в виде JSON в форме capabilities из nsh.json; null выдаёт то, что объявляет каждое приложение. Возвращает false (и игнорируется) при недопустимом grant. |
setLocale(Locale locale) | Язык и регион приложения вместо пользовательских: Intl, locale() и компоненты сразу следуют им, а приложения, загружаемые позже, стартуют с ними. null следует системным настройкам. |
setFontScale(float scale) | Размер текста вместо системного размера шрифта (1 — по умолчанию): тексты и поля ввода заново раскладываются с ним, и fontScale() его читает — сразу и для приложений, загружаемых позже; длины сохраняют свой размер. 0 следует системным настройкам. |
setThemeOverrides(String json) | Бренд хоста поверх темы приложения: файл темы в формате "theme" из nsh.json. Его токены имеют приоритет над токенами приложения с теми же именами, в светлом и тёмном режимах, а остальные токены приложения сохраняются. Применяется сразу и к приложениям, загружаемым позже; null возвращает приложению его собственную тему. Выбрасывает IllegalArgumentException для файла, который не разбирается. |
setStorageDirectory(File dir) | Где приложения, загружаемые следующими, хранят своё хранилище; null: files/nsh/<app id> в приватном каталоге приложения, по собственному id приложения. |
setCacheOptions(NshCacheOptions options) | Где приложения, загружаемые следующими, хранят кэши, скачиваемые ими изображения, и сколько хранит каждое. Создаётся с помощью NshCacheOptions.builder(): location(File), systemLocation() (по умолчанию: nsh в каталоге кэша, каждое приложение в <app id>) или noDiskCache(); imagesMaxMegabytes(int) (по умолчанию 256) или imagesOff(). |
loadAssetBundle(String dir) | Запускает dir/bundle.nshc из assets APK; assets приложения берутся из dir/assets/. Возвращает false, если файл не удаётся прочитать (об этом также сообщается слушателю ошибок). |
loadBundleFile(File file) | Запускает файл bundle.js; assets берутся из assets/ рядом с ним. Отклоняет файлы .nshc. |
runPackage(NshPackage pkg) | Запускает проверенный подписанный пакет, заменяя запущенное приложение. Пакет запускается один раз. |
registerComponent(String name, NshComponent.Factory factory) | Регистрирует нативный компонент. |
setParams(Object params) | Параметры запуска приложений, загружаемых следующими (Map или JSONObject): приложение читает их при старте как host.params (host). Возвращает false, если они не являются JSON-объектом. |
send(String name, Object data) | Отправляет событие приложению (host.on(name, …)); хранится, пока приложение не начнёт его слушать, либо для следующего приложения, пока никакое не запущено. Любой поток; возвращает false после destroy(). |
handle(String name, HostHandler handler) | Обрабатывает вызовы приложения (host.call) и посты (host.post) для name в UI-потоке; null удаляет обработчик. Обработчик отвечает через свой NshHostReply один раз, из любого потока. |
connectDevServer(String url) | Разработка: загружает с сервера nsh dev и перезагружает при каждом изменении. Возвращает false с release AAR. |
handleBack() | Позволяет приложению обработать нажатие «назад» (закрывает диалоги, снимает страницы, запускает слушатели BackHandler); возвращает, обработало ли оно его. |
destroy() | Останавливает приложение и вызывает destroy() у каждого из его нативных компонентов. Этот view больше нельзя использовать. |
setLimits, setGrantedCapabilities, setStorageDirectory и setCacheOptions применяются со следующей загрузки: вызывайте их до loadAssetBundle и подобных методов.
Каждое приложение хранит своё хранилище под собственным id без какой-либо настройки: подписанный пакет — под id, за который ручается сертификат его release-ключа, бандл — под id из его nsh.json (nsh/app.json среди assets APK, записывается nsh package). Вызовите setStorageDirectory, чтобы хранить данные приложения в месте по выбору хоста:
nsh.setStorageDirectory(new File(getFilesDir(), "nsh/com.example.orders"));
nsh.loadAssetBundle("orders");
#Общение с приложением
View и запущенное в нём приложение общаются через собственный канал (host): параметры
запуска, которые приложение читает при старте, события от хост-приложения, а также вызовы и посты от приложения, с JSON-данными (на этой стороне это maps,
lists, строки, числа и булевы значения). Для этого не нужны ни view в приложении, ни нативный компонент.
// Before loading: what the app opens first (`host.params` in TSX).
nsh.setParams(Map.of("conversation", intent.getStringExtra("conversation")));
nsh.loadAssetBundle("nsh");
// The app's `await host.call("user.profile", { id })`, on the UI thread.
nsh.handle("user.profile", (data, reply) -> {
Object id = ((Map<?, ?>) data).get("id");
api.profile(id, profile -> reply.resolve(Map.of("id", id, "name", profile.name)),
error -> reply.reject(error.getMessage()));
});
// The app's `host.post("analytics", …)`: no answer.
nsh.handle("analytics", (data, reply) -> analytics.log(data));
// Later: the app hears `host.on("conversation.open", …)`.
nsh.send("conversation.open", Map.of("id", tappedConversation));
На Kotlin:
nsh.handle("user.profile") { data, reply ->
val id = (data as Map<*, *>)["id"]
lifecycleScope.launch {
runCatching { api.profile(id) }
.onSuccess { reply.resolve(mapOf("id" to id, "name" to it.name)) }
.onFailure { reply.reject(it.message ?: "failed") }
}
}
Обработчики выполняются в UI-потоке; вызов ждёт ответа в приложении, не блокируя ни одну из сторон. Ответ, который
так и не был дан, приводит к сбою вызова после сборки мусора, а вызов с именем, у которого нет обработчика, завершается сбоем сразу. События с именами,
которые приложение ещё не слушает, хранятся, пока оно не начнёт их слушать, а события, отправленные, пока приложение не запущено (до loadAssetBundle,
во время перезагрузки), попадают в следующее.
#Переопределение темы
Одно приложение, встроенное в несколько брендированных хост-приложений, принимает бренд каждого хоста: хост задаёт токены, которые меняет его бренд,
в виде файла темы в формате "theme" из nsh.json (cssVars из shadcn/ui, значения записываются как в CSS). Они имеют приоритет
над токенами приложения с теми же именами; остальные токены приложения сохраняются. Задайте значение токена для тёмного режима в dark
(набор только в light или theme означает этот цвет в обоих режимах):
nsh.setThemeOverrides("{ \"cssVars\": {"
+ " \"theme\": { \"radius\": \"0.75rem\" },"
+ " \"light\": { \"primary\": \"#e11d48\", \"primary-foreground\": \"#ffffff\" },"
+ " \"dark\": { \"primary\": \"#fb7185\", \"primary-foreground\": \"#1c1917\" } } }");
nsh.loadAssetBundle("orders");
Вызывайте до или после загрузки: классы вроде bg-primary и rounded-(--radius) перестилизуются сразу, а каждое приложение,
загружаемое позже, стартует с переопределениями уже в первом кадре. setThemeOverrides(null) возвращает приложению его собственную тему.
#Ссылки, которые открывает приложение
URL, которые открывает приложение (openUrl, ссылки TextView с links="open"),
сначала попадают в OpenUrlHandler view, в UI-потоке: верните true, если хост-приложение обработало URL, например
передав его своему роутеру, и false, чтобы система открыла его через intent ACTION_VIEW. Без
обработчика система открывает каждый URL. URL доходят до него после того, как их разрешила возможность openUrl приложения.
nsh.setOpenUrlHandler(url -> {
Uri uri = Uri.parse(url);
// The host app's own links open in the host app.
if ("lb".equals(uri.getScheme()) || "longbridge.com".equals(uri.getHost())) {
router.open(uri);
return true;
}
return false;
});
#Лимиты
// 128 MB heap, default stack, 10 s to load, 1 s per call, 50 000 nodes
nsh.setLimits(128, 0, 10_000, 1_000, 50_000);
По умолчанию заданы heap 256 МБ, стек 4096 КБ, 10 с на загрузку, 2 с на вызов и 200 000 узлов. Что делает каждый лимит, когда он достигнут: Песочница и capabilities.
#Capabilities
nsh.setGrantedCapabilities(
"{\"network\": {\"hosts\": [\"api.example.com\"]}, \"webview\": {\"hosts\": [\"help.example.com\"]}}");
nsh.loadAssetBundle("nsh");
Запрос должен быть разрешён и тем, что приложение объявляет в своём nsh.json, и grant. См.
Песочница и capabilities.
#Подписанные пакеты
Проверяйте скачанные пакеты вне UI-потока, а затем запускайте их в нём:
NshTrust trust = new NshTrust(new File(getFilesDir(), "nsh"));
trust.addRoot(rootPublicKey); // the contents of root.pub from `nsh keygen`
executor.execute(() -> {
try {
NshPackage pkg = trust.openPackage(downloadedFile);
runOnUiThread(() -> nsh.runPackage(pkg));
} catch (NshPackage.RefusedException e) {
Log.w("nsh", "refused: " + e.getMessage()); // the running app stays
}
});
| API | Описание |
|---|---|
new NshTrust(File stateDir) | Открывает или создаёт хранилище доверия. Его состояние (новейший список отзыва, наивысшая принятая сборка для каждого приложения) сохраняется там, и assets пакетов извлекаются в него. Не доверяет ни одному ключу до addRoot. Можно вызывать из любого потока; close() по завершении. |
boolean addRoot(String publicKey) | Доверяет корневому открытому ключу (base64, содержимое файла .pub). false для недопустимого ключа. |
boolean updateRevocations(String json) | Применяет список отзыва из nsh revoke, подписанный доверенным корнем и более новый, чем в хранилище. |
NshPackage openPackage(File file) throws NshPackage.RefusedException | Проверяет, извлекает assets и принимает пакет (более старые сборки приложения после этого отклоняются). Медленно для больших пакетов: вызывайте вне UI-потока. |
NshPackage | appId, appName, version, build; close(), если он не запускается. |
Как работают ключи, сертификаты и отзыв: Подписанные пакеты.
#Разработка
С nsh-dev.aar:
if (BuildConfig.DEBUG) {
nsh.connectDevServer("ws://127.0.0.1:8790/ws");
} else {
nsh.loadAssetBundle("nsh");
}
Запустите nsh dev App.tsx на своём компьютере. Для эмулятора или USB-устройства пробросьте порт, чтобы собственный
127.0.0.1 устройства достигал сервера:
adb reverse tcp:8790 tcp:8790
В остальных случаях используйте LAN-адрес, который выводит nsh dev. Assets приходят с dev-сервера по обычному HTTP, поэтому nsh-dev.aar
объявляет android:usesCleartextTraffic="true", что применяется к приложению при слиянии манифестов (network security
config приложения может это переопределить). Release nsh.aar этого не делает.
#Настройка Activity
-
Кнопка «назад».
NshViewсам получает нажатия «назад», пока приложение их хочет (открытый диалог, добавленная страница, слушательBackHandler), а в остальных случаях оставляет их activity. Сandroid:enableOnBackInvokedCallback="true"на Android 13 и новее он регистрируетOnBackInvokedCallback; в остальных случаях (Android 12 и старше или при выключенном атрибуте) он перехватывает клавишу «назад» раньшеonBackPressedactivity, то есть раньшеOnBackPressedDispatcherиз AndroidX и колбэков фрагментов. Пробрасывать ничего не нужно, ни в activity, ни во фрагменте. Хост, который уже передаёт нажатия вhandleBack(), продолжает работать: нажатие попадает в приложение один раз.Для predictive back на Android 13+ задайте
android:enableOnBackInvokedCallback="true"у<application>, как это делают упакованные приложения NSH. -
Клавиатура.
NshViewсам прокручивает поля ввода в фокусе так, чтобы клавиатура их не закрывала. Оставьтеandroid:windowSoftInputModeне заданным или используйтеadjustResize; приadjustPanсистема вместо этого сдвигает всё окно. -
Safe area. View сообщает в виде insets только те части системных панелей и клавиатуры, которые его перекрывают, поэтому
NshViewпод тулбаром не получает верхнего inset. Для контента от края до края рисуйте под системными панелями и позвольте приложению использовать paddingenv(safe-area-inset-*)/safeAreaInsets()(safe_area()в Rust). -
Изменения конфигурации. Activity, пересоздаваемая при повороте или смене темы, уничтожает view, и приложение запускается заново с нуля. Чтобы оно продолжало работать, объявите изменения, которые activity обрабатывает сама; упакованные приложения NSH используют:
<activity android:name=".MainActivity" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|density|fontScale|locale|layoutDirection" android:windowSoftInputMode="adjustResize" />NshViewсам следует новому размеру, цветовой схеме, масштабу шрифта, плотности экрана и направлению layout; приложение сохраняет своё состояние. Позиции прокрутки восстанавливаются в логических координатах в пределах новых границ контента. Sticky views и открытые модальные окна тоже следуют новой плотности. -
Жизненный цикл. Вызовите
destroy(), когда хостящая activity или фрагмент уничтожается.
#См. также
- Сборка с Rust
- Встраивание в приложения iOS
- Нативные компоненты
- Подписанные пакеты
- Упаковка: автономный APK без написания кода на Java