TrueAsync Mobile

(демонстрационный проект, экспериментально, репозиторий native-bridge, Android)

Асинхронный PHP отлично подходит именно для UI-приложений: интерфейс не должен зависать, пока где-то происходит взаимодействие с сетью или загрузка по сети, чтение с диска или ожидание следующего действия пользователя. У TrueAsync для этого есть отдельный C-API: Trigger Event (ZEND_ASYNC_NEW_TRIGGER_EVENT() в zend_async_API.h). Это объект с единственным методом trigger(), который любой код на C или C++ может вызвать из другого потока, чтобы потокобезопасно разбудить реактор PHP и передать ему управление на обработку события.

native-bridge реализует такую интеграцию для Android: PHP встраивается в приложение как постоянный процесс, стартует один раз в фоновом потоке, крутит цикл событий (тот же TrueAsync reactor, что и в остальных проектах экосистемы) и общается с Kotlin в обе стороны.

Зачем постоянный процесс, а не запрос-ответ

Обычный сценарий PHP: веб-запрос, процесс поднялся, обработал один запрос, завершился. Для мобильного приложения это не подходит: PHP должен жить всё то время, пока открыто приложение, и реагировать на события пользователя (нажатия, датчики, геолокацию) так же, как обработчик реагирует на HTTP-запрос. native-bridge даёт именно это: PHP поднимается один раз при старте приложения и живёт в своём потоке до явной остановки, а корутины TrueAsync внутри этого потока обрабатывают события и фоновые задачи конкурентно.

Архитектура моста

Мост работает в двух направлениях:

  1. Android → PHP. Kotlin кладёт события (нажатие, датчик, геолокация, произвольное пользовательское событие) в очередь, PHP забирает их из своего цикла.
  2. PHP → Kotlin. PHP вызывает методы, которые реализованы на стороне Kotlin (показать Toast, завибрировать, скопировать текст в буфер обмена и так далее).

Оба направления работают через JNI (Java Native Interface): это стандартный механизм Android, который позволяет коду на C вызывать код на Kotlin/Java и наоборот. Ни на одном из направлений данные не проходят через JSON или другой текстовый формат: значения передаются в типизированном виде, без лишних преобразований.

PHP работает в собственном системном потоке и никогда не блокирует поток интерфейса Android (UI-поток). Если PHP ждёт данные, отвечает поток интерфейса, и наоборот.

Направление 1: события от Android к PHP

Kotlin отправляет события через JNI в очередь, к которой PHP обращается методом NativeBridge::poll(). Когда очередь пуста, poll() сразу возвращает null, и PHP-приложение само решает, ждать ли следующее событие или заняться чем-то ещё (в демо-приложении это короткая пауза usleep(), во время которой TrueAsync успевает выполнить фоновые корутины и таймеры).

Есть четыре типа событий: касание экрана, данные геолокации, данные датчиков (акселерометр и подобные) и произвольное событие с именем и текстовым payload'ом. Именно таким событием в демо-приложении помечены нажатия кнопок:

php
use TrueAsync\NativeBridge;

while (!NativeBridge::shouldStop()) {
    $event = NativeBridge::poll();
    if ($event === null) {
        usleep(1000);
        continue;
    }

    if ($event['type'] === NativeBridge::EVENT_GENERIC) {
        match ($event['event_name']) {
            'count_a' => $counterA->toggle(),
            'fetch'   => spawn(fn() => fetchDemo()),
            default   => null,
        };
    }
}

Первые три типа событий (касание, геолокация, датчики) не требуют выделения памяти под строки, поэтому обходятся дёшево даже при высокой частоте вызовов (например, для потока данных с акселерометра).

Направление 2: вызовы PHP → Kotlin

Когда PHP вызывает метод модуля, например Toast::show('Привет', true), есть два варианта, как этот вызов доберётся до Kotlin:

Обычный путь

По умолчанию PHP упаковывает аргументы в компактный типизированный буфер (без строкового формата вроде JSON, поэтому Kotlin читает его без разбора текста и без лишних аллокаций) и передаёт его через один вызов NativeBridge::invoke(). Добавление нового модуля или метода на этом пути не требует правок на C: меняются только Kotlin и сгенерированная PHP-обёртка, а значит достаточно пересобрать только Kotlin-часть приложения (Gradle-сборку), без пересборки нативной библиотеки.

Быстрый путь: #[FastPath]

Для «горячих» методов, которые вызываются очень часто (например, приём данных с датчика на каждый кадр), в PHP-спецификации метод помечается атрибутом #[FastPath]. Для него генератор создаёт отдельную типизированную функцию на C, которая вызывает Kotlin напрямую через JNI, без промежуточного буфера. Такой метод требует пересборки нативной библиотеки (.so-файла) при каждом изменении, зато исполняется быстрее и без лишних выделений памяти. Поведение метода при этом не меняется, меняется только способ передачи вызова через границу PHP/Kotlin.

Как описывается модуль: #[BridgeModule]

Контракт модуля описывается на стороне PHP как интерфейс с атрибутом #[BridgeModule]:

php
namespace TrueAsync\Android\Spec;

#[BridgeModule]
interface ToastInterface
{
    #[Ui]
    public function show(string $text, bool $long): void;

    public function batteryLevel(): int;
}
  • Имя модуля выводится из имени интерфейса (ToastInterface → модуль Toast), либо задаётся явно: #[BridgeModule('Clipboard')].
  • #[Ui] над методом означает, что Kotlin-реализация должна выполняться в UI-потоке Android (генератор сам добавляет переключение на нужный поток).
  • #[FastPath] над методом включает быстрый путь вызова, описанный выше.

Что генерирует tools/bridge/gen.php

Из PHP-спецификации (#[BridgeModule]-интерфейса) генератор собирает заново при каждом запуске:

  • Kotlin-класс с абстрактными методами (ToastSpec);
  • код маршрутизации вызова к нужному методу (Kotlin);
  • PHP-обёртку (Toast::show(...)), которую использует остальной код приложения на PHP;
  • для методов с #[FastPath] дополнительно типизированный код на C, который вызывает Kotlin напрямую.

Жизненный цикл PHP-приложения

  1. Kotlin запускает PHP в фоновом потоке и передаёт ему путь к входному PHP-скрипту.
  2. PHP-скрипт вызывает NativeBridge::init(), после этого мост готов принимать события и вызовы.
  3. Дальше приложение работает в цикле: забирает события через poll(), обрабатывает их, при необходимости запускает фоновые корутины TrueAsync (например, для сетевых запросов).
  4. Остановка идёт штатно: Kotlin вызывает NativeBridge.stop(), PHP-цикл видит это через NativeBridge::shouldStop(), завершает работу и корректно освобождает ресурсы.

Пример: счётчик на кнопке

Упрощённый пример на основе демо-приложения: кнопка запускает и останавливает бесконечный счётчик, а его значение обновляется прямо в интерфейсе. Запуск и остановка реализованы через spawn()/cancel() обычной корутины TrueAsync, без блокировки потока интерфейса:

php
use TrueAsync\NativeBridge;
use TrueAsync\Android\Ui;
use function Async\spawn;
use function Async\delay;

NativeBridge::init();

$root = Ui::newLinearLayout();

$button = Ui::newButton();
Ui::setText($button, '▶ start counter');
Ui::setOnClickListener($button, 'toggle');
Ui::addView($root, $button);

$label = Ui::newTextView();
Ui::setText($label, 'stopped');
Ui::addView($root, $label);

Ui::setContentView($root);

$counter = null;

function tick(int $label): void
{
    $n = 0;
    while (true) {
        Ui::setText($label, 'tick ' . ++$n);
        delay(400);
    }
}

while (!NativeBridge::shouldStop()) {
    $event = NativeBridge::poll();
    if ($event === null) {
        usleep(1000);
        continue;
    }

    if ($event['type'] === NativeBridge::EVENT_GENERIC && $event['event_name'] === 'toggle') {
        if ($counter === null) {
            $counter = spawn(fn() => tick($label));
            Ui::setText($button, '■ stop counter');
        } else {
            $counter->cancel();
            $counter = null;
            Ui::setText($button, '▶ start counter');
        }
    }
}

Второй клик по кнопке отменяет корутину $counter через cancel(), счётчик останавливается на том значении, на котором был. Полный пример с несколькими независимыми счётчиками находится в android/app.php репозитория.

Статус и ограничения

  • Поддерживается только Android; поддержка iOS в планах, пока не реализована.
  • Через мост сейчас передаются простые типы: строки, целые числа, числа с плавающей точкой, булевы значения. Передача составных объектов (по полям, без строкового формата) пока в планах.
  • Направление PHP → Kotlin синхронное: метод возвращает результат сразу, отложенные (асинхронные) результаты с этой стороны пока не поддерживаются.
  • Кеш PHP (opcache) на Android принудительно отключён: песочница приложения не даёт ему использовать нужный для работы файл блокировки и исполняемую память.
  • Требуется потокобезопасная (ZTS) сборка PHP, поскольку PHP работает в отдельном системном потоке, а не в основном потоке приложения.

См. также