Привет, Хабр! Меня зовут Георгий, я руководитель команды Ozon Банк iOS. Я занимаюсь разработкой и развитием мобильного направления финансовых продуктов Ozon.

Сегодня хочу поделиться опытом нашей команды по запуску мобильного приложения на WebView.  

Часто разработчики используют только встроенный API взаимодействия JavaScript c нативными кодом, например Web API, но нам этого оказалось мало, и мы расширили спектр возможностей подхода web-native. Внутри статьи я расскажу, какой подход выбрал, как к этому пришёл, и, как обошёл возникшие проблемы. Подчеркну плюсы и минусы использования своего решения и в конце предложу несколько идей дальнейшего развития выбранного пути.

Немного предыстории

Летом 2022 Ozon запустил собственный платёжный инструмент от собственного банка — Ozon Карту. Самостоятельный продукт дал пользователям доступ к финансовым сервисам, которые стал развивать собственный банк Ozon.

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

Функционал банка был предоставлен в виде web-версии. Бизнес развивается стремительно и наличие мобильной версии стало обязательным условием расширения возможностей. На сегодняшний день наличие функционала банка в кармане — это must have. С этим возник вопрос: каким путём идти разработке? Желание максимально быстро проверять гипотезы и скорость разработки становятся приоритетом запуска мобильного приложения.
Решением в моих поисках стало нативное приложение, включающее в себя компонент браузера, иначе говоря — гибрид.

Почему WebView?

Перед тем, как перейти к общим плюсам и минусам, дополнительно хочу описать наши особенности и ограничения. Существует большое количество решений, позволяющих писать код сразу под обе платформы, например, React Native, Flutter и т.д., однако, в нашем случае все равно пришлось бы реализовывать все страницы, фичи, компоненты и продуктовые сценарии. Иными словами, все равно требуется сделать базис приложения и только потом приступить к реализации продуктовых задач. Ещё замечу, что приложение должно избегать лишних зависимостей, так как его часть внедряется в другие продукты компании. В это же время mobile-web-версия сайта была готова и представляла собой «нулевую» версию, которую уже успешно внедрили в приложение Ozon в виде простой страницы с переходом на сайт банка. Такие вводные будем считать отправной точкой и теперь перейдём к описанию общих характеристик.

Плюсы

Синхронное обновление функционала на пользователей

В большинстве сценариев не требуется обновления мобильного приложения. Здесь мы не ждём высокого adoption, и фичи раскатываются одновременно на всех. Пользователи сразу получают доступ к новым сценариям. Кроме того, такой способ обновления часто выручает при обнаружении серьёзных багов и проблем. Также обновление не завязывается на результат ревью в App Store Connect, что существенно ускоряет доставку обновлённого функционала до конечного пользователя.

Поддержка небольшой командой

Обычно стартапы запускаются малочисленной командой, так как располагают небольшими ресурсами и бюджетами, но такой подход на самом деле справедлив и для крупных компаний. На первом этапе очень удобно и гораздо безопаснее выделить или набрать небольшую группу для оперативного запуска. Очевидно, скорость и стоимость разработки остаются определяющими критериями. Если говорить про техническую часть, то практически вся бизнес-логика и история находятся в WebView, и тут нужно только наладить взаимодействие с нативным кодом. Таким образом, мобильная команда превращается в платформенную команду, реализующую интерфейсы для web-разработки. 

Общая реализация UI

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

Один источник истины для аналитики

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

Минусы

Скорость загрузки WebView

Наверное, самый главный минус использования WebView. Нужно дождаться загрузки всех ассетов, скриптов и т.д. Порой это может занять большее количество времени, чем у нативного приложения. И при слабом интернете может сложиться впечатление бесконечной загрузки. Например, значение загрузки первого экрана может достигать 5-6 секунд при среднем сигнале LTE.
Также на время загрузки может сильно влиять местоположение пользователя. Скорость загрузки в Москве и отдалённой провинции может существенно, даже критически, отличаться. Помимо скорости, стоит помнить про объём передаваемых данных, так как обычно WebView значительно прожорливей обычного пользовательского трафика.

Низкая автономность

Пользователь элементарно не сможет пользоваться приложением, если есть проблемы с сетью или интернет вовсе отсутствует. Тут мобильное приложение становится беспомощным и может только обновить страницу целиком.

Проблема навигации

Навигация WebView накладывает некоторые ограничения, особенно если WebView содержит переходы и редиректы. Например, при попытке возврата по backstack пользователь может попасть в бесконечный редирект или навигация вперёд – назад по backstack является излишней для активной фичи.

Жизненный цикл WebView

Общая для всех мобильных платформ проблема. Render-процесс может упасть, надо пересоздавать WebView и это — нормальное поведение.
В частности, на iOS, когда устройству не хватает ресурсов (мощности и памяти), система может закрыть неиспользуемые приложения, но есть случаи, когда приложение остаётся активным и процесс контекста WebView умирает. Таким образом, пользователь может остаться в активном приложении, но с неактивной WebView. Такое поведение можно воссоздать, если остановить com.apple.WebKit-процессы для iOS-симулятора. Единственный выход для данной проблемы — это пересоздавать объект WebView.

Web-Native Bridge

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

Подобные минусы и ограничения можно обойти, но для этого нужно настроить взаимодействие и общение JavaScript-кода WebView с нативом. Для обращения к JavaScript достаточно использовать функцию evaluateJavaScript, но чтобы web-frontend мог пользоваться мобильными интерфейсами, нужно построить «мост» в обратную сторону — из WebView в нативный код. Схема общения и взаимодействия web-native достаточно простая:

Ниже приведу примеры реализации асинхронного и синхронного вызовов кода.

Асинхронный вызов нативного кода

Для вызова нужной фичи и обработки результата в JS используем Promise. Создаём скрипт с вызовом нужного интерфейса и инжектим в нашу WebView:

bridge.call_interface = function(interface_name, data) {
    let promise = new Promise(function(resolve, reject) {
        let callback_id = createUniqueID();
        bridge.callbacks[callback_id] = {
            "success": function(response) {
                let response_data = JSON.parse(response);
                bridge.callbacks[callback_id] = undefined;
                resolve(response_data)
            },
            "failure": function(error) {
                let response_data = JSON.parse(error);
                bridge.callbacks[callback_id] = undefined;
                reject(response_data)
            }
     } 
        window.webkit.messageHandlers.bridgeMessageHandler.postMessage({
            "interface_name": interface_name,
            "data": JSON.stringify(data),
            "callback_id": callback_id
        });
    });
    return promise;
};

На стороне нативного кода используем WKScriptMessageHandler-протокол, который используется, если приложению требуется способ реагировать на сообщения JavaScript. Далее ждём вызова нашего messageHandler:

class BridgeMessageHandler: WKScriptMessageHandler {
    var closure: HandlerClosure?
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        guard
            let body = message.body as? [String: Any],
            let callbackId = body["callback_id"] as? String,
            let interfaceName = body["interface_name"] as? String
        else {
            return
        }
        self.closure?(
            interfaceName,
            callbackId,
            Body
        )
    }
}

Со стороны JavaScript добавляем messageHandler и инжектим данный скрипт при инициализации WebView:

let bridgeScript = WKUserScript(source: jsScript, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
let messageHandler = BridgeMessageHandler()
messageHandler.closure = { interfaceName, callbackId, parameter in
    self.handleInterfaceCall(interfaceName, callbackId, parameter)
}
WebView.configuration.userContentController.add(messageHandler, name: "bridgeMessageHandler")
WebView.configuration.userContentController.addUserScript(bridgeScript)

Далее остается обработать вызов нужной функции в handleInterfaceCall и вернуть ответ в WebView:

window.bridge.callbacks['\(callbackId)'].success('\(result)')
или
window.bridge.callbacks['\(callbackId)'].failure('\(error)')

Всё, web-front может асинхронно обращаться к нативному коду через нативные интерфейсы.

Синхронный вызов нативного кода

Иногда вызовы, которые мы получаем в bridgeMessageHandler, должны отдаваться в том же потоке JavaScript, так как не всегда возможно и удобно использовать promise со стороны фронтенда, и поэтому работа с синхронным вызовом обстоит немного интереснее. WKWebView работает в том же процессе, что и остальное приложение, но взаимодействует с WebKit, который работает в своём собственном процессе. По сути, требуется синхронная связь между двумя разными процессами, что в целом противоречит их замыслу, но мы решили обойти это ограничение асинхронного вызова через prompt-функцию.

prompt() — это функция в JavaScript, которая вызывает диалоговое окно для получения данных от юзера на веб-странице.

Пример:

const name = prompt(‘Введите текст’);

Этот код вызовет диалоговое окно с запросом ввода строки. Таким образом выполнение кода приостановится, пока не будет получено значение в переменную name.

В iOS при работе с WebView можно ограничить появление диалогового окна и добавить свою реализацию обработки вызова.

Подготовим JS-скрипт по аналогии с асинхронным вызовом, но с использованием prompt:

bridge.call_interface_sync = function(interface_name, data) {
    let params = {
            "interface_name": interface_name,
            "data": JSON.stringify(data)
    };
    let msg = prompt(JSON.stringify(params), "call_interface_sync")
    return JSON.parse(msg);
};

Внедряем скрипт при инициализации объекта WebView так же, как и для асинхронного метода:

let bridgeScript = WKUserScript(source: jsScript_sync, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
webView.configuration.userContentController.addUserScript(bridgeScript)

Чтобы обработать вызов интерфейсов, используем WKUIDelegate для нашей WebView и добавляем функцию runJavaScriptTextInputPanelWithPrompt:

func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) {
    guard 
          let data = prompt.data(using: .utf8),
          let body = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
          let interfaceName = body["interface_name"] as? String,
          defaultText == "call_interface_sync",
          let interface = interfaces[interfaceName] else {
        completionHandler(nil)
        return
    }
 
    let result = interface.handleInterfaceCallSync(body)
    switch result {
    case .success(let successResult):
        completionHandler(successResult)
    case .failure(let error):
        completionHandler(error)
    }
}

Готово!

Получаем ответ в handleInterfaceCallSync и возвращаем полученный результат. Теперь frontend смогут синхронно получать значение из нужного им интерфейса.

Навигация и логика в гибридном приложении

Использование WebView накладывает свои трудности на навигацию в приложении, особенно если страницы имеют переходы и редиректы. Здесь требуется повторить нативный UX навигации, где, например, есть кнопка «Назад». Добавьте к этому возможность открытия нативных экранов в виде модального окна или в стеке UINavigationController. При переключении табов также должен сохраняться контекст уже открытых сценариев.
Мы решаем эти вопросы следующим образом:

  • На странице с WebView открывается SPA-страница (single page application).
    SPA — это веб-приложение, использующее единственный HTML-документ как оболочку для всех веб-страниц. Переходы между страницами происходят в рамках одной страницы, и почти все изменения и переходы осуществляются заменой текущего location. Таким образом, вы не сможете увидеть переход  в нативном коде в decidePolicyForNavigationAction, но такой подход даёт лучший пользовательский опыт, так как максимально приближён к нативному пользованию и нет дополнительных загрузок.

  • Поддержка стандартного UITabBarController. Сохраняет контекст каждой отдельной WebView и также позволяет вместо веб-сценариев открыть нативный флоу.
    Если одна WebView собирается сделать переход между табами в другую WebView, то использует наш «мост»:

window.bridge.move_to_tab({"id":"TAB_1","url":"https://finance.ozon.ru/main"})
  • Для построения навигации внутри WebView используем взаимодействие web-native, описанное выше, и превращаем UINavigationBar в кастомизируемый компонент. Web-Front сам решает, какие кнопки отрисовать и куда снавигировать пользователя. Если нужна навигация на экране без WebView, то мы перехватываем управление UINavigationBar и далее работаем с обычным нативным поведением.

window.bridge.set_navigation_bar({"leftIcon":"ic_back","rightIcon":"ic_info", "title":"Главная"})

При такой реализации страница WebView замкнута между UINavigationBar и UITabBar. Мы решаем вопросы и ограничения с навигацией в WebView, но возникает проблема, что WebView может отображать контент только в своей области. 

Чтобы отобразить контент поверх всего экрана, мы используем конструктор экранов и форм. Экраны собираются из готовых виджетов и примитивов. Подобным образом сценарии и экраны, использующие те же компоненты, собираются в приложении Ozon, только, в отличие от Ozon, в банке за отрисовку компонентов (передачу нужного контракта) отвечает не backend, а WebView.  

Подробнее про backend-driven-ui в Ozon можно прочитать тут.

Куда двигаться дальше?

  • Поднимать JSContext и вынести в него всю бизнес-логику. За отрисовку и работу с интерфейсами всё также отвечает JavaScript, и натив остаётся платформой. Здесь мы отказываемся от WebView, но вместе с этим и с её плюсами, так как обновление и поддержка актуальной логики в JSContext ложится на наши плечи.

  • Использовать webarchive. Если запускать web-страницу из локального хранилища, то мы обойдём проблемы с долгой загрузкой или отсутствием интернета. Загрузка WebView будет мгновенной, но вместе с этим появляется открытый вопрос: как проверять актуальность и безопасность кода внутри WebView, а также работоспособность локального кода?

  • Самый желанный для мобильного разработчика вариант — это, конечно же, натив. Поэтапный отказ от WebView и гибридного подхода. В заложенной нами логике мы без проблем можем отдельные сценарии заменять на нативные экраны, например, полностью собранные и автономные, как у приложения Ozon (выше была ссылка на статью про backend-driven-ui-подход).

Итоги

Конечно, у каждого проекта есть свои особенности, цели и ограничения. Все возможные вариации здесь охватить не получится. Я постарался вкратце изложить наш подход. Некоторые кейсы, которые ставят в упрёк использование WebView, мы обошли через web-native-bridge-подход, например, доступы к локальному хранилищу, пермишнам, связке ключей и ограничения навигации. Многие пишут о WebView как о временном варианте, но, по нашему опыту, такой подход жизнеспособный на больших дистанциях. Мобильные разработчики отвечают за платформу, фронты — за наполнение. Пользователи получат фичу максимально быстро, менеджеры успеют проверить свои гипотезы, бизнес запустит новый продукт.

Комментарии (10)


  1. grvelvet
    12.07.2024 07:52
    +1

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


    1. megavupsen Автор
      12.07.2024 07:52

      Вы правы, можно, но есть особенности. У webview нет публичного api для работы с подобным кэшем. Частично мы обходим это через наш native-bridge подход, предоставляя интерфейсы для работы с локальным хранилищем. Для банковского приложения также существуют дополнительные требования по безопасности и аудит накладывает ограничения по хранению части логики на устройстве


  1. ArLeKiN_O_o
    12.07.2024 07:52

    Так всё-таки приложение или webview.


    1. megavupsen Автор
      12.07.2024 07:52

      Приложение на webview :)


  1. alelam
    12.07.2024 07:52

    А как андроид у вас живёт при таком подходе? Бизнес/дизайн же часто желают чтобы внешне всё было "как на иос", а там из моего скромного опыта работать с web-контентом внутри натива несколько сложнее.


    1. megavupsen Автор
      12.07.2024 07:52

      Да, верно, сложности есть, но андроид приложение пользуется тем же подходом. Если появляются ограничения на какой-то из платформ, то мы приходим к общему решению для поддержки единого поведения. Что касается дизайна, то существует общая дизайн система справедливая для всех платформ


  1. andrey_27
    12.07.2024 07:52
    +1

    И вас эта браузерная инфекция укусила. С самого начала для себя решил, что не буду пользоваться озон-банком в качестве основного как раз из-за этого придурошного браузера внутри приложения, эстетика и UX очень ущербные. Даёшь натив!


    1. megavupsen Автор
      12.07.2024 07:52

      Банку требуется максимально быстро проверять гипотезы и раскатывать новые продуктовые фичи, поэтому скорость разработки стала приоритетом. Постепенно мы внедряем больше натива и готовим почву для решения, которое устроит и бизнес, и техническую команду. Следите за обновлениями :)


      1. andrey_27
        12.07.2024 07:52
        +1

        Верю, но, всё-таки, первое впечатление довольно важную роль играет


      1. Bardakan
        12.07.2024 07:52
        +4

        хорошо, тогда какой смысл в мобильном приложении, которое представляет собой обертку над сайтом? Те аргументы, которые вы привели, не объясняют, почему не сделать просто сайт