Привет, Хабр! Меня зовут Артем, и вот уже два года, как я работаю над онлайн-кинотеатром 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-библиотеку. При этом для интеграции не требуется добавление сторонних зависимостей в проект. Был рад знакомству, надеюсь, что статья была вам полезна. Если у вас остались или возникли вопросы, приглашаю всех продолжить обсуждение в комментариях!