Site icon AppTractor

Reflex: архитектура для создания мобильных приложений с использованием агентов

В статье «Нативная разработка снова стала будущим мобильных приложений в Shopify» мы объяснили, почему возвращаемся к Swift и Kotlin, а в материале про Helix рассказали, как агенты заново собирают экраны через небольшие контрольные точки и строгие проверки. В обеих статьях упоминается интерфейс командной строки, который позволяет агенту проверять состояние приложения и выполнять действия без симулятора. Эта статья посвящена архитектуре, которая стоит за этим инструментом. Мы называем её Reflex.

Когда мы решили заново построить наши приложения нативно, мы вернулись к исходной точке и задали простой вопрос: как должно быть устроено мобильное приложение, если большую часть кода пишут агенты? Самым большим ограничением оказался цикл обратной связи. Агент может написать экран за несколько минут, но чтобы доказать, что экран действительно работает, нужны эмулятор, сборка, сценарий нажатий и много терпения. Мы уже вложились в инструменты для симуляторов, чтобы ускорить этот процесс, но в какой-то момент упёрлись в предел того, насколько быстро всё это вообще может работать.

Так появился Reflex. Логика приложения работает без интерфейса, каждый экран открывает своё состояние и доступные действия через небольшой единый контракт, а интерфейс командной строки управляет этим контрактом на обеих платформах из одного и того же файла теста. Это позволило нам получить чрезвычайно быстрые циклы обратной связи и одновременно держать две нативные реализации синхронизированными. Ниже мы по частям разберём, как всё это работает и что потребуется, чтобы построить нечто подобное для собственного приложения.

В этой статье:

Обзор Reflex

Reflex — это архитектура на основе MVVM, которая организует приложение так, чтобы агент мог быстро его создавать, тестировать и управлять им, вообще не отрисовывая интерфейс. Она строится на четырёх принципах:

Вместе эти четыре принципа дают агенту возможность самостоятельно исследовать приложение функцию за функцией с чрезвычайно быстрым циклом обратной связи и без зависимости от устройства. Поскольку один и тот же файл теста выполняется для iOS и Android, функция не может выйти на одной платформе, если вторая от неё отстаёт.

С точки зрения агента всё выглядит так:

$ dev cli ios --fixtures
> ls
InventoryDetail
  state   {"productId": "1", "stockCount": 12, "isSaved": false}
  actions Increment, Decrement, Save, SetQuantity {quantity}, goBack
> do Increment
> ls
InventoryDetail
  state   {"productId": "1", "stockCount": 13, "isSaved": false}
  actions Increment, Decrement, Save, SetQuantity {quantity}, goBack

Вот и весь пользовательский интерфейс. Никакого TUI, цветов или спиннеров. Агентам всё это безразлично, а время, которое мы не потратили на украшение терминала, ушло на улучшение самого цикла обратной связи.

Общий тест, а не общий код

Два нативных приложения означают две реализации каждого экрана, и что-то должно поддерживать их эквивалентность. Наш ответ превратился в принцип, к которому мы постоянно возвращаемся: делить между платформами нужно тест, а не код. Код пишется дважды, тесты — один раз. Этот подход основан на двух решениях.

  1. Никакого общего кода между платформами. Swift на iOS, Kotlin на Android, каждая платформа использует собственный набор UI-инструментов и свою модель конкурентности, без промежуточного кроссплатформенного слоя. Когда большую часть кода пишут агенты, вторая нативная реализация стоит значительно дешевле, чем раньше. А абстракционный слой по-прежнему имеет полную стоимость: он добавляет поверхность для сопровождения, скрывает особенности платформы, которые позже понадобятся для оптимизации производительности, и превращает каждое обновление платформы в двухэтапную миграцию. Написать одну и ту же ViewModel дважды дешевле, чем поддерживать мост между двумя платформами.
  2. Паритет обеспечивается тестами, а не кодом. Если код не общий, что-то другое должно удерживать платформы в синхронном состоянии. Для нас этим чем-то стал набор тестов: каждый тест поведения выполняется на обеих платформах, и pull request нельзя объединить, если он проходит только на одной из них. Общим артефактом является тест.

Следующий вопрос — какие именно тесты использовать. У каждого привычного варианта есть проблема.

В итоге мы остановились на промежуточном варианте, который ближе к тестированию «серого ящика». Тест не видит пиксели и не видит внутреннюю реализацию. Он видит ровно то же, что видит интерфейс: состояние ViewModel и действия, которые ViewModel принимает. Всё, что находится ниже, настоящее: реальные редюсеры, реальные сервисы, настоящий GraphQL-клиент, настоящая навигация. Подменяются только данные на границах, где приложение иначе обращалось бы к сети или устройству. Во всём наборе тестов нет ни одного фреймворка для моков.

Контракт, построенный с нуля

Убираем интерфейс из уравнения

Для обсуждения архитектуры на высоком уровне возьмём небольшой пример: простое приложение для управления запасами, где пользователь выбирает товар из списка, открывает экран с подробностями и изменяет количество товара на складе. Этого достаточно, чтобы показать общую структуру; многие детали намеренно опущены. Код приведён на Swift, но на стороне Kotlin структура такая же и читается почти идентично.

Приложение на каждой платформе разделено на два модуля плюс отдельный модуль для интерфейса командной строки.

Core никогда не импортирует Mobile. Core объявляет протоколы для всех возможностей платформы, которые ему нужны, и получает реализации через конструктор. Это обычная инверсия зависимостей без фреймворка внедрения зависимостей. Мобильное приложение и интерфейс командной строки — просто среды, которые создают Core. CLI вместо настоящих платформенных сервисов использует подготовленные данные.

// ── The app: InventoryManagementMobile/InventoryManagementApp.swift ──────────
let core = InventoryManagementCore(
    storage: KeychainStorage()
)

// ── The CLI: InventoryManagementCLI/main.swift — same core, no platform/UI ─
let core = InventoryManagementCore(
    storage: InMemoryStorage()
)

Такое разделение даёт несколько преимуществ:

В самом Reflex нет сторонних зависимостей. Базовый класс ViewModel, один небольшой протокол для интроспекции и дисциплина, не позволяющая Core импортировать платформенный код. Для людей соблюдать эту дисциплину сложно. Для агентов это повторяемый шаблон, а повторять шаблоны они умеют хорошо.

Структура приложения

Внутри Core приложение представляет собой дерево обычных объектов. В корне находится объект приложения, который владеет сетевым клиентом, текущей сессией и постоянным хранилищем. Ниже расположены навигаторы — Tab, Push и Modal, — каждый из которых хранит список маршрутов, отображаемых в данный момент. На листьях дерева находится по одной ViewModel на экран. Ничто в этом дереве ничего не знает об UI.

Представления живут вне Core, в модуле Mobile. Представление привязывается к одной ViewModel, читает её состояние и вызывает send(action:). Это вся связь между представлением и остальной частью приложения.

Introspection Kit — отдельный модуль, определяющий контракт между ViewModel и интерфейсом командной строки. Каждая ViewModel получает небольшое расширение, которое описывает её как узел: имя, состояние в JSON и принимаемые действия. CLI и набор инструментов тестирования взаимодействуют только с этим слоем. Само приложение никогда его не вызывает.

Оставшаяся часть раздела разбирает каждый из этих элементов: ViewModel, узел интроспекции и то, как узлы образуют дерево, по которому может перемещаться CLI.

ViewModel: действие на входе, состояние на выходе

Каждый экран поддерживается ViewModel, структура которой вдохновлена архитектурой Elm. Она состоит из следующих частей:

Весь контракт представления выглядит так: прочитать состояние, вызвать send(action:). Представление никогда не вызывает методы ViewModel напрямую и никогда не изменяет состояние.

Далее приведена ViewModel экрана подробностей. Это псевдо-Swift: без аннотаций конкурентности, без управления доступом и без всего, что не требуется для демонстрации структуры.

// InventoryManagementCore — pseudo-Swift, boilerplate omitted
final class InventoryDetailViewModel: ViewModel {
    class State {
        let productId: String
        var stockCount = 0
        var isSaved = false
    }

    enum Action {                 // what a View (or the CLI) can do
        case increment, decrement, save
        case setQuantity(Int)     // actions can carry values
    }

    enum Message {                // everything the reducer handles
        case viewAction(Action)
        case saved                // results from services come back as messages
    }

    let service: InventoryService   // a protocol, injected through the constructor

    override func update(state: State, message: Message) {
        switch message {
        case .viewAction(.increment):
            state.stockCount += 1
        case .viewAction(.decrement) where state.stockCount > 0:
            state.stockCount -= 1
        case .viewAction(.setQuantity(let quantity)):
            state.stockCount = quantity
        case .viewAction(.save):
            service.save(state.stockCount) { self.send(message: .saved) }
        case .saved:
            state.isSaved = true
        default:
            break                 // e.g. decrementing at zero stock is a no-op
        }
    }

    func send(action: Action) { send(message: .viewAction(action)) }
}

Action — публичная поверхность, Message — внутренняя, а update — единственное место записи. Поскольку структура жёсткая, агент может правильно повторить её несколько сотен раз.

Introspection Kit: ViewModel снаружи

CLI нужен способ прочитать ViewModel и отправить ей действия. Этот слой мы называем Introspection Kit, а контракт, которому должна соответствовать ViewModel, состоит всего из одного протокола.

public protocol IntrospectionNode {
    var name: String { get }
    var state: [String: JSONValue] { get }
    var actions: [IntrospectionAction] { get }
    var child: (any IntrospectionNode)? { get }
}

ViewModel открывает себя наружу, возвращая узел из своего расширения +Introspection, которое хранит ссылку обратно на ViewModel. На Android идея та же, но с небольшим отличием: расширения Kotlin не могут заставить тип реализовать интерфейс, поэтому узел представляет собой отдельный класс InventoryDetailViewModelIntrospectionNode, расположенный рядом с ViewModel. Само приложение никогда ничего из этого не вызывает. Удалите этот файл — приложение по-прежнему соберётся и запустится; вы просто потеряете возможность управлять им из терминала.

// InventoryManagementCore/InventoryDetailViewModel+Introspection.swift
extension InventoryDetailViewModel: IntrospectionProviding {
    var introspectionNode: IntrospectionNode { Introspection(viewModel: self) }

    private struct Introspection: IntrospectionNode {
        unowned let viewModel: InventoryDetailViewModel
        let name = "InventoryDetail"

        var state: [String: JSONValue] {
            [
                "productId": .string(viewModel.state.productId),
                "stockCount": .number(viewModel.state.stockCount),
                "isSaved": .bool(viewModel.state.isSaved),
            ]
        }

        var actions: [IntrospectionAction] {
            [
                IntrospectionAction(title: "Increment") {
                    viewModel.send(action: .increment)
                },
                IntrospectionAction(title: "Decrement") {
                    viewModel.send(action: .decrement)
                },
                IntrospectionAction(title: "Save") {
                    viewModel.send(action: .save)
                },
                IntrospectionAction(title: "SetQuantity", input: SetQuantityInput.self) {
                    viewModel.send(action: .setQuantity($0.quantity))
                },
            ]
        }
    }
}

@IntrospectionInput   // generates the JSON schema the CLI shows for this action. This can also be done manually.
struct SetQuantityInput: Codable {
    let quantity: Int
}

У узла четыре части:

Писать это «вручную» — то есть с помощью агентов — вполне нормально: агенты хорошо воспроизводят такую структуру. Но поскольку форма узла настолько строгая, её можно и генерировать: макроса или аннотации на ViewModel достаточно, чтобы создать такой узел во время сборки.

@Introspectable          // generates the node above from State and Action at build time
final class InventoryDetailViewModel: ViewModel {
    class State { ... }
    enum Action { case increment, decrement, save, setQuantity(Int) }
    ...
}

Три правила:

  1. Если пользователь может это увидеть, оно находится в state. Если пользователь может это сделать, оно находится в actions. Узел — это обычное JSON-представление ViewModel.
  2. Узел никогда не повторяет UI-логику. Он всегда перечисляет все действия без условий. Сработает ли Decrement, когда запас равен нулю, решает ViewModel внутри update — ровно там же, где это решается при нажатии кнопки. Если вы ловите себя на том, что пишете if внутри узла, значит правило должно находиться в ViewModel, а результат — в состоянии.
  3. Действия проходят только через send(action:). Узел никогда не вызывает метод ViewModel напрямую и не изменяет состояние. CLI не может сделать ничего такого, чего не могло бы сделать представление.

Этот контракт предназначен не только для тестов. Мы хотели, чтобы приложение изначально было ориентировано на агентов, и та же поверхность состояния и действий, которую использует CLI, применяется Sidekick — ИИ-помощником Shopify — для выполнения действий внутри приложения. Отдельного интеграционного слоя для него нет. Всё, что агент может сделать из терминала, агент внутри приложения тоже может сделать.

Дерево

Узлы интроспекции отражают дерево объектов, описанное в начале этого раздела: по одному узлу на объект, связанные через child. Узел корня приложения содержит навигатор как дочерний элемент, а узел навигатора — ViewModel верхнего маршрута. CLI всегда фокусируется на самом глубоком узле этой цепочки — так же, как пользователь взаимодействует только с тем, что находится сверху.

Контейнеры могут намеренно передавать некоторые действия дочернему узлу в фокусе. Навигатор-стек передаёт goBack, навигатор вкладок — selectTab. Обычная ViewModel ничего не передаёт. Если попросить CLI выполнить действие, которого нет у узла в фокусе и которое не передал ни один предок, CLI откажется и перечислит доступные действия.

Транспорт

В режиме без интерфейса основной процесс читает JSON, разделённый переводами строк, из stdin и пишет его в stdout. В настоящей сборке небольшой сокет-сервер выполняет ту же работу через порт, причём сокет взаимодействует с теми же ViewModel, к которым привязан экран. Именно поэтому симулятор обновляется прямо во время ввода команд: никакого взаимодействия с отрисованным UI, представления просто наблюдают за состоянием. Сервер полностью исключается из релизных сборок, поэтому в поставляемом пользователям приложении его нет.

Наша «тестовая чаша»

Распределение тестов у нас в итоге стало больше похоже не на пирамиду, а на чашу с очень широкой средней частью:

Вот несколько особенностей среднего слоя.

Никакой сети — принудительно

Сессия тестирования поведения устанавливает защиту, которая блокирует каждый HTTP-запрос в процессе-хозяине и завершает тест с ошибкой, если была хотя бы одна попытка обращения к сети, даже если приложение само поймало ошибку и восстановилось. Каждый сетевой запрос и каждая внешняя зависимость должны иметь подготовленные данные.

Плюс в том, что набор тестов не использует ввод-вывод и не страдает от нестабильности. Цена — большие наборы подготовленных данных. Один сценарий может содержать десяток ответов GraphQL, что утомительно поддерживать человеку, но не представляет проблемы для агента.

Ошибки легко читать

Когда тест «серого ящика» падает, сообщение ясно указывает, какое именно утверждение не выполнилось. Агент может действовать на основании этого сообщения, не открывая отладчик.

Тесты читаются как пользовательские сценарии

Мы написали небольшой набор инструментов тестирования для Introspection Kit поверх тестового фреймворка Bun. Он использует тот же JSON-протокол, что и CLI, не имеет зависимостей времени выполнения и добавляет к bun:test две вещи:

precondition — ждёт, пока узел в фокусе не станет тем, который ожидается;

do — выполняет действие и ждёт выполнения ожидания по обновлённому состоянию.

Далее показано, как выглядит тест экрана подробностей товара. Он выполняется без изменений на iOS и Android; тестовая оболочка сама выбирает платформу.

// Every journey starts on the list and opens one product.
async function openInventoryDetail(session, id = "1") {
  // wait until the focused node is "InventoryList" before doing anything
  await session.precondition("InventoryList");

  // run an action, then poll the focused node until the expectation passes
  await session.do({
    action: "SelectProduct",
    payload: {id},
    expectation: ({name, state}) => {
      expect(name).toBe("InventoryDetail");   // we navigated...
      expect(state.productId).toBe(id);       // ...to the right product
    },
  });
}

test("given save succeeds, the user can adjust the quantity and save it", async () => {
  // boots a headless core with canned responses; closes it when the test ends
  await using session = createBehaviorSession({
    fixtures: [fixture("Inventory.stockCount2")],
  });
  await openInventoryDetail(session);

  // decrement down to zero
  await session.do({
    action: "Decrement",
    expectation: ({state}) => expect(state.stockCount).toBe(1),
  });
  await session.do({
    action: "Decrement",
    expectation: ({state}) => expect(state.stockCount).toBe(0),
  });

  // Decrement is always listed; the ViewModel decides it does nothing at zero
  await session.do({
    action: "Decrement",
    expectation: ({state}) => expect(state.stockCount).toBe(0),
  });

  // actions with an input take a JSON payload
  await session.do({
    action: "SetQuantity",
    payload: {quantity: 40},
    expectation: ({state}) => expect(state.stockCount).toBe(40),
  });

  await session.do({
    action: "Save",
    expectation: ({state}) => expect(state.isSaved).toBe(true),
  });
});

test("given save fails, the quantity stays unsaved", async () => {
  await using session = createBehaviorSession({
    fixtures: [fixture("Inventory.saveFails")],
  });
  await openInventoryDetail(session);

  await session.do({
    action: "Save",
    expectation: ({state}) => expect(state.isSaved).toBe(false),
  });
});

Оба вспомогательных метода опрашивают состояние в течение пары секунд, а затем завершаются ошибкой с последним увиденным состоянием, поэтому нет ни sleep, ни вспомогательных функций вроде «ждать появления элемента». Тест представляет собой расшифровку того, что сделал пользователь и что он увидел.

Можно делать такие тесты сколь угодно мелкими, но мы намеренно этого не делаем. В модульных тестах один маленький тест на каждый случай позволяет точно понять, где произошла ошибка. Здесь само сообщение об ошибке уже содержит эту информацию: на каком шаге сценария, после какого действия, какое состояние ожидалось и какое было получено. Агенты хорошо умеют читать такую информацию и находить причину без отдельного точечного теста, поэтому мы объединяем шаги пользовательского сценария в один тест и экономим время выполнения и сопровождения. Сценарии ошибок, которым нужны отдельные подготовленные данные, например ошибка сохранения, остаются отдельными не потому, что отличаются утверждения, а потому, что у них другой набор исходных данных.

Вот насколько быстро выполняются такие тестовые сценарии. После этапа прогрева приведённый выше тест выполняется за несколько сотен миллисекунд без устройства на обеих платформах.

Тот же тест в симуляторе iOS занимает около двух секунд.

А как насчёт открытого исходного кода?

Эта статья и есть открытый исходный код.

Публиковать библиотеку бессмысленно, потому что полезная часть — не наш код. Introspection Kit связан с нашей моделью сессий, нашими навигаторами и нашими форматами подготовленных данных, и всё это плохо переносится в другое приложение. Переносится сам Reflex — архитектура, описанная выше.

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

Что нужно, чтобы перенести Reflex в своё приложение:

  1. Отделите платформу от ядра. Один модуль без импортов UI, который создаётся со всеми платформенными возможностями, переданными снаружи. Если вы не можете собрать ядро на macOS или JVM, значит, вы ещё не дошли до нужной точки.
  2. Сделайте все ViewModel одинаковыми. Действие на входе, состояние на выходе, один update, сервисы через конструктор.
  3. Добавьте узел интроспекции как расширение. Состояние в JSON, каждое действие — замыкание над send(action:), никаких условий. Агенты могут генерировать эти узлы из ViewModel.
  4. Постройте дерево и фокусируйтесь на листе. Корень приложения и навигаторы тоже являются узлами. Разрешите контейнерам передавать вниз несколько действий. Всё остальное недоступно — так же, как для пользователя недоступно всё, что находится за пределами текущего экрана.
  5. Завершайте тест ошибкой при любом обращении к сети. Подготовленные данные для всего. Только так большой набор тестов остаётся детерминированным.
  6. Пишите навыки, а не документацию. Научите агентов запускать CLI, добавлять подготовленные данные и давать имена тестам. CLI полезен лишь настолько, насколько агент умеет вовремя им воспользоваться.
  7. Уберите сложность, предназначенную только для людей. Никакого терминального интерфейса, красивого вывода или интерактивных мастеров. Это инструмент для агентов — он должен оставаться простым и быстрым.

Reflex дал нам цикл обратной связи, соответствующий скорости агента, и единый набор тестов, который удерживает два нативных приложения в синхронном состоянии. Сегодня этот набор является общим источником истины между нашими приложениями для iOS и Android, большую его часть пишут агенты, а самый быстрый способ понять, что делает приложение, по-прежнему заключается в том, чтобы открыть терминал и набрать ls.

Часто задаваемые вопросы

Насколько полезны такие тесты, если вы не тестируете интерфейс?

Очень полезны. Большинство проблем на экране возникает в его логике, и именно её покрывают эти тесты. Проверка того, что кнопка действительно подключена к send(action:), редко является источником ошибок.

Проверка UI происходит в другой части нашего процесса: каждая контрольная точка Helix проходит визуальную проверку относительно эталонного приложения, прежде чем изменение может попасть в основную ветку. Для небольшого числа компонентов, где действительно важны пиксели, мы сохраняем тесты по снимкам экрана.

Кроме того, мы сами ежедневно пользуемся приложением, поэтому поверх этого постоянно идёт естественное тестирование. Именно сочетание тестов поведения по состоянию ViewModel с этими двумя слоями позволяет нам решать, готов ли экран к выпуску.

Почему не использовать модульные тесты плюс UI-тесты с XCUITest и Espresso?

Модульные и нативные UI-тесты уже обсуждались выше: оба типа пишутся отдельно для каждой платформы и ничего не говорят о паритете.

Но в таком сравнении упускается ещё одна вещь — исследование приложения. Ни один из этих подходов не позволяет агенту самостоятельно ходить по приложению за пределами заранее написанного теста, а оказалось, что это не менее важно, чем сами тесты.

Агент, который может исследовать приложение с помощью ls, быстрее отлаживает его и быстрее создаёт новые функции. Кроме того, та же поверхность делает приложение изначально ориентированным на агентов для Sidekick, который в противном случае пришлось бы прикручивать отдельным слоем.

А что насчёт Appium или автоматизации устройства с помощью агентов?

Мы используем такие инструменты там, где они подходят. Но поскольку кодовая база принадлежит нам, мы смогли предоставить интерфейс «серого ящика», чтобы агент читал состояние напрямую, а не пытался восстановить его по дереву доступности или снимку экрана. Это экономит дополнительные циклы, которые агент иначе тратил бы на чтение экрана.

Но ещё более важная причина — выполнение без интерфейса: тысячи тестов завершаются за несколько минут в CI. Сквозным тестам такого масштаба понадобились бы часы, чтобы прогнать все сценарии, и значительная инфраструктура, чтобы выполнять их параллельно.

Источник

Exit mobile version