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


  1. cofolunat
    24.10.2022 19:48

    А такая штука, как Cordova вам бы не подошла? Тоже на WebView.


    1. arkofom Автор
      25.10.2022 12:51

      Привет! Cordova мы не рассматривали. Но при первом взгляде на нее показалось, что она не совсем про нашу задачу. Cordova выглядит как фреймворк для создания кроссплатформенных приложений на js с нуля. А приложение у нас уже есть. Ну и поскольку приложение для android TV, нужна была нативная верстка экрана, чтобы нормально работала навигация через пульт.


      1. cofolunat
        25.10.2022 13:34

        Ясно. Видел еще J2V8 для встраивания V8 в Java-приложения. Проект, вроде, живой.


  1. dpvpro
    25.10.2022 09:53

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


    1. arkofom Автор
      25.10.2022 20:33

      Привет! Ответственные люди, сказали, что такая фича уже есть в планах