
Привет, Хабр! Меня зовут Артем, и вот уже два года, как я работаю над онлайн-кинотеатром PREMIER. Эта история началась, как и многие другие, со слов тимлида: “Артем, есть интересная задачка”.
Ситуация была следующая: библиотека, над интеграцией которой велись работы, не имела поддержки Android TV. Для этой библиотеки существовала мобильная версия и версия для веб-клиентов, написанная на JavaScript.
Поскольку поддержки Android TV, в частности навигации с помощью пульта, внутри библиотеки предусмотрено не было, я решил использовать web-версию библиотеки и кастомный интерфейс с поддержкой Android TV.
Что из этого вышло — читайте далее. Статья будет полезна тем, кто любит смелые эксперименты, работает с Android или Android TV и знает, что такое Javascript.
Кто-то же точно такое делал…
Решив использовать web-версию библиотеки, я начал искать подходящий инструмент для исполнения задуманного.
Первым шагом я решил обратить внимание на крупные фреймворки. Выбор пал на Rhino от Mozilla. Rhino — инструмент с практически безграничными возможностями: исполнение кода, подключение библиотек, создание интерфейса, — кажется, что пять минут и дело в шляпе это идеальный вариант для интеграции.
Однако при переходе на сайт фреймворка я обнаружил приветливое «Page not found». Но отчаиваться было рано — впоследствии мне все же удалось найти «живой» репозиторий. Помимо этого, также нашлись и более «нативные» адаптации Rhino под андроид — F43nd1r/rhino-android
Позже выяснилось, что Rhino не имеет возможности динамической подгрузки библиотек. Добавить библиотеку в Rhino можно через npm или, более простой вариант, — добавить в проект min.js файл, скачанный заранее. Но в нашем случае js-библиотеку, которую мы внедряли, нужно было каждый раз заново скачивать с сервера. То есть возможности добавить min.js файл библиотеки в проект у меня не было и от идеи использовать Rhino пришлось отказаться.
После отказа от Rhino я продолжил поиски. Выяснил, что существует ряд самописных решений, использующих под капотом в качестве «движка» WebView. Например вот это: evgenyneu/js-evaluator-for-android. Эти библиотеки позволяют исполнять простые js выражения внутри WebView. Но у таких решений также отсутствует возможность подключения библиотек. А это означало для нас только одно — такое решение нам все еще не подходит. “Все пропало, шеф?”
Стадия пятая. Принятие
После нескольких неудачных попыток найти готовое решение, которое полностью бы меня устроило, я решил попробовать реализовать свой небольшой “фреймворк”. За основу “фреймворка” я взял WebView, поскольку многие необходимые функции там уже есть “из коробки”. А дальше я постараюсь подробно рассказать, как можно превратить простой WebView в JS-интерпретатор.
Шаг первый. Подготовка
Для начала нужно подготовить WebView для наших целей. На этой стадии нужно учесть несколько моментов:
-
Чтобы использовать объект, его нужно создать 🙂
-
В настройках WebView нужно включить возможность исполнения JS-скриптов
-
Чтобы избежать неожиданных проблем, связанных с кэшированием, у WebView нужно отключить кэш. Так мы точно будем уверены, что все операции выполняются начисто и в контексте страницы не осталось хвостов от прошлых вызовов.
Ниже приведен пример создания WebView со всеми необходимыми настройками:
//Создаем WebView val webView = WebView(context) with(webView) { //Разрешаем исполнение JavaScript кода settings.javaScriptEnabled = true //Отключаем кэш у webView settings.cacheMode = WebSettings.LOAD_NO_CACHE }
Теперь, когда мы настроили все необходимое, можно переходить к созданию наших JS-скриптов.
Шаг второй. Подготовка JS-скриптов
Чтобы добавить наш первый JS-скрипт в проект, нужно создать html файл, в котором он будет находиться. Html файл нужно разместить в директории assets внутри проекта. С этим файлом будет работать WebView, а именно загружать его как локальную html страницу, догружая все необходимые зависимости.
Затем в файл нужно добавить стандартную структуру html страницы — теги head, body и т.д. (см. пример). После создания пустой страницы можно переходить к добавлению самих скриптов. Подключение скриптов происходит стандартным для html способом — через использование тега <script/>. Библиотеки подключаются так же. В нашем случае, для примера мы будем использовать популярную библиотеку moment.js, с помощью которой будем узнавать текущее время.
Назовем наш файл sample.html. Внутри него подключим библиотеку moment.js и создадим функцию checkMoment, которая будет возвращать текущее время в формате строки.
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> </head> <body> <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js" integrity="sha512-qTXRIMyZIFb8iQcfjXWCO8+M5Tbc38Qi5WzdPOYZHIlZpzBHG3L3by84BBBOiRGiEb7KKtAOAs5qYdUiZiQNNQ==" crossorigin="anonymous" referrerpolicy="no-referrer" ></script> <script type="text/javascript"> function checkMoment() { return moment().toString(); } </script> </body> </html>
Шаг третий. Запуск первого скрипта
WebView настроено, скрипты созданы — самое время переходить к запуску.
Для того чтобы запустить js-код, вначале надо загрузить нашу страницу со скриптами в WebView. Но при загрузке страницы нужно учитывать — вызов js-функции должен произойти после того, как WebView загрузит страницу и все ее содержимое.
Чтобы определить момент, когда WebView закончит загружать данные, нужно использовать WebViewClient. В нем нам понадобятся два метода — onPageFinished и onReceivedError. Метод onPageFinished вызывается в тот момент, когда WebView завершит загрузку данных, а onReceivedError сигнализирует, что в процессе загрузки возникла ошибка.
webView.webViewClient = object : WebViewClient() { override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url) //Страница загружена и готова к использованию } override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) { super.onReceivedError(view, request, error) //в процессе загрузки возникла ошибка } }
Загрузив все необходимое, переходим (наконец-то!) к исполнению нашего скрипта.
WebView из коробки имеет функцию evaluateJavaScript. Эта функция принимает в качестве аргумента js-выражение, которое будет исполняться в текущем контексте WebView. То есть после того, как мы загрузили html-страницу с подключенной библиотекой, мы можем через метод evaluateJavascript обращаться к методам библиотеки. Выглядеть это будет следующим образом:
webView.webViewClient = object : WebViewClient() { override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url) //Страница загружена и готова к использованию //вызываем js функцию checkMoment webView.evaluateJavascript("checkMoment()") { result -> Toast.makeText(requireContext(), "result: $result", Toast.LENGTH_SHORT).show() } } override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) { super.onReceivedError(view, request, error) //в процессе загрузки возникла ошибка } } webView.loadUrl("file:///android_asset/sample.html")
После добавления webViewClient код выше вызовет у webView метод loadUrl, который загрузит нашу страницу со скриптами, созданную ранее. После окончания загрузки дернется метод webViewClient.onPageFinished, в нем вызовется метод webView.evaluateJavascript, который, в свою очередь, вызовет нашу функцию checkMoment. Результат исполнения checkMoment (помним, что это текущая дата и время, сконкатенированные в одну строку) вернется в коллбек и финальным действием покажется тост, отображающий текущую дату.
Подробно проговорили принцип работы, запомнили, разложили по полочкам, двигаемся дальше.
А давайте сделаем это асинхронным?

Следующий вопрос, который встал передо мной: как быть в том случае, если нужно выполнить запрос из js-кода? А ведь ради этого все и затевалось. Ответ напрашивается сам собой — нужно написать обертку, которая позволила бы асинхронно выполнять нужные операции.
Для этого создадим свой класс, назовем его JSClient. В новый класс перенесем WebView и настройки для нее.
class JSClient(context: Context) { val webView = WebView(context) init { with(webView){ settings.javaScriptEnabled = true settings.cacheMode = WebSettings.LOAD_NO_CACHE webChromeClient = WebChromeClient() } } }
Как обсуждали ранее, перед тем как исполнять js-код, нужно загрузить в webView нашу страницу. Для этого создадим suspend функцию внутри нашего класса, которая будет отвечать за подготовку webView к работе. Назовем ее startConnection. Внутрь этой функции мы поместим код загрузки webView с использованием webViewClient из прошлого пункта
suspend fun startConnection() = suspendCancellableCoroutine<Boolean?> { continuation -> webView.webViewClient = object : WebViewClient() { override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url) if (continuation.isActive) { continuation.resume(true) } } override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) { super.onReceivedError(view, request, error) if (continuation.isActive) { continuation.resumeWithException(RuntimeException()) } } } webView.loadUrl("file:///android_asset/sample.html") } }
Теперь представим, что функция checkMoment из прошлого пункта делает запрос и может выполняться достаточно продолжительное время. В таком случае нужно создать вариант асинхронного вызова и для нее тоже.
suspend fun checkMoment() = suspendCoroutine<String> { continuation -> webView.evaluateJavascript("checkMoment()") { result -> when { !result.isNullOrEmpty() -> continuation.resume(result) else -> continuation.resumeWithException(Throwable()) } } }
А теперь соберем все вместе и выполним первый асинхронный запрос.
val client = JSClient(context) viewLifecycleOwner.lifecycleScope.launch { client.startConnection() val result = client.checkMoment() Toast.makeText(requireContext(), result, Toast.LENGTH_LONG).show() }
В коде выше инициализируется наш класс JSClient, затем вызывается функция startConnection. Эта функция подготавливает webView к работе и загружает скрипты. После окончания работы startConnection, происходит вызов асинхронной версии функции checkMoment, которая по-прежнему возвращает текущую дату и на экран выводится тост.
Плюсы, минусы, подводные камни
Следующей проблемой, с которой я столкнулся, было исполнение нескольких запросов подряд. В предыдущем решении есть большой минус — для выполнения каждого запроса нужно подгружать заново скрипты и библиотеки. Это лишний расход трафика, да и время это может занимать достаточно большое (зависит от размеров и количества подключенных библиотек). Ответ на вопрос “что теперь делать?” лежал на поверхности. Перед загрузкой нашей страницы со скриптами, нужно проверить — действительно ли их нужно загрузить или они уже были загружены ранее?
Для того чтобы проверять необходимость загрузки данных, я добавил в наш sample.html файл еще одну функцию — isScriptsLoaded. Основная роль этой функции — проверить, лежит ли библиотека внутри WebView.
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> </head> <body> <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js" integrity="sha512-qTXRIMyZIFb8iQcfjXWCO8+M5Tbc38Qi5WzdPOYZHIlZpzBHG3L3by84BBBOiRGiEb7KKtAOAs5qYdUiZiQNNQ==" crossorigin="anonymous" referrerpolicy="no-referrer" ></script> <script type="text/javascript"> function isScriptsLoaded() { return typeof moment === 'function'; } </script> <script type="text/javascript"> function checkMoment() { return moment().toString(); } </script> </body> </html>
В коде выше функция isScriptsLoaded с помощью оператора typeof сравнивает тип метода moment и функции. Это выражение будет истинно в том случае, если библиотека подгрузилась успешно и WebView готово к работе. Если в процессе загрузки произошла ошибка или данные не были загружены, оператор typeof вернет ‘undefined’
Теперь разберемся с тем, как эта функция поможет нам предотвратить лишнюю перезагрузку данных.
Для начала добавим ее в нашу функцию startConnection, перед загрузкой данных WebView.
suspend fun startConnection() = suspendCancellableCoroutine<Boolean?> { continuation -> webView.evaluateJavascript("isScriptsLoaded()") { result -> when(result) { "true" -> continuation.resume(true) else -> { webView.webViewClient = object : WebViewClient() { override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url) if (continuation.isActive) { continuation.resume(true) } } override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) { super.onReceivedError(view, request, error) if (continuation.isActive) { continuation.resumeWithException(RuntimeException()) } } } webView.loadUrl("file:///android_asset/sample.html") } } } }
Использование и способ вызова функции startConnection остаются неизменными:
val client = JSClient(context) viewLifecycleOwner.lifecycleScope.launch { client.startConnection() val result = client.checkMoment() Toast.makeText(requireContext(), result, Toast.LENGTH_LONG).show() }
Но теперь у нас есть возможность, при вызове startConnection определить, действительно ли нужно перезагружать данные. После вызова isScriptsLoaded мы определяем, загружены скрипты (isScriptsLoaded вернула “true”) или нет (isScriptsLoaded вернула “undefined”) и на этом основании либо возвращаем информацию о том, что webView готово к работе, либо загружаем данные заново.
Заключение
Вот так закончилось приключение под названием “интеграция JS в android приложение”. С помощью такого подхода можно подключить к проекту практически любую JS-библиотеку. При этом для интеграции не требуется добавление сторонних зависимостей в проект. Был рад знакомству, надеюсь, что статья была вам полезна. Если у вас остались или возникли вопросы, приглашаю всех продолжить обсуждение в комментариях!
ссылка на оригинал статьи https://habr.com/ru/company/gazprommedia/blog/694998/
Добавить комментарий