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 працює в окремому системному потоці, а не в основному потоці застосунку.

Див. також