Приключения онлайн-кинотеатра Premier в Android TV, или Как мы javascript внедряли

от автора

Привет, Хабр! Меня зовут Артем, и вот уже два года, как я работаю над онлайн-кинотеатром 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *