Привет, Хабр! Меня зовут Артем, и вот уже два года, как я работаю над онлайн-кинотеатром 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-библиотеку. При этом для интеграции не требуется добавление сторонних зависимостей в проект. Был рад знакомству, надеюсь, что статья была вам полезна. Если у вас остались или возникли вопросы, приглашаю всех продолжить обсуждение в комментариях!
Комментарии (5)
dpvpro
25.10.2022 09:53Передайте пожалуйста ответственным людям, что бы сделали элементарную вещь в приложении и на сайте, смену электронной почты. Сейчас этого нельзя сделать даже через тех поддержку. Поддержка не отвечает.
cofolunat
А такая штука, как Cordova вам бы не подошла? Тоже на WebView.
arkofom Автор
Привет! Cordova мы не рассматривали. Но при первом взгляде на нее показалось, что она не совсем про нашу задачу. Cordova выглядит как фреймворк для создания кроссплатформенных приложений на js с нуля. А приложение у нас уже есть. Ну и поскольку приложение для android TV, нужна была нативная верстка экрана, чтобы нормально работала навигация через пульт.
cofolunat
Ясно. Видел еще J2V8 для встраивания V8 в Java-приложения. Проект, вроде, живой.