Pull to refresh

Готовим web-приложение под зоопарк версий Android

Reading time 9 min
Views 27K
Совсем недавно и достаточно неожиданно для самого себя я оказался ответственным за разработку программки для Android. Но ни под Android, ни вообще на Java мне ранее писать не приходилось. Нужно было сделать web-приложение, вроде phonegap и прочих, которое почти полностью работает в компоненте браузера. И все это под версии 2.2 — 4.3 (SDK 8 — 18).

О некоторых выкрутасах Android и костылях под них с точки зрения человека, впервые это все увидевшего, я и хотел бы рассказать. Надеюсь, вышло без HelloWorld, «OMG! Java», и т.п.

Поворот экрана/смена ориентации
Network unreachable
Грузим локальные ресурсы
Мост между Java и JavaScript


Вся программа по сути один WebView, в котором загружены локальные ресурсы (assets), и доп обвязка к нему на Java. Что JS не может выполнить сам — дергает по мосту Java<=>JS синхронно/асинхронно яву. Последняя тоже может «втихую» похимичить на странице.

Поворот экрана


В Android смена ориентации устройства приводит к пересозданию Activity (UI). Для WebView это приводит к перезагрузке текущей страницы. Соответственно, теряются заполнения input полей, текущая позиция прокрутки, состояние JS и т.п.
В интернете масса способов борьбы с данной ситуацией разной степени работоспособности и с учетом разных версий SDK. В итоге рабочим под все нужные версии SDK вышел следующий вариант:

В манифесте для нужной нам activity указываем в android:configChanges «screenSize|orientation». Именно оба, иначе найдется версия, где ничего работать не будет.
В layout'е НЕ описываем WebView вообще. В том месте, где будет наш браузер, описываем заглушку:
    <FrameLayout android:id="@+id/webViewPlaceholder"
        ...
    />

Нет браузера — нет проблем с ним :)

Теперь в классе нашей Activity:
protected FrameLayout mWebViewPlaceholder; // та самая заглушка
protected WebView mWebView; // вот наконец-то и наш браузер

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    initUI();
}

protected void initUI() {
    mWebViewPlaceholder = ((FrameLayout)findViewById(R.id.webViewPlaceholder));

    if (mWebView == null) {
        mWebView = new WebView(this);
        mWebView.loadUrl("file:///android_asset/index.html");
    }
    mWebViewPlaceholder.addView(mWebView);
}

@Override
public void onConfigurationChanged(Configuration newConfig) {
    if (mWebView != null) {
        mWebViewPlaceholder.removeView(mWebView); // перед сменой конфига выдергиваем WebView из UI
    }
    super.onConfigurationChanged(newConfig);
    setContentView(R.layout.activity_main);
    initUI(); // возвращаем WebView обратно в UI
}

@Override
protected void onSaveInstanceState(Bundle outState) {
    super.onSaveInstanceState(outState);
    mWebView.saveState(outState);
}

@Override
protected void onRestoreInstanceState(Bundle savedInstanceState) {
    super.onRestoreInstanceState(savedInstanceState);
    mWebView.restoreState(savedInstanceState);
}

Что получается в итоге?
При обработке событий поворота и смены разрешения, на время «выдергиваем» WebView из UI. Заодно независимо сохраняем и восстанавливаем состояние WebView, т.к. activity уничтожается при переходе на основной экран системы/другую программу, или открытии других activity в нашей же программе.
К слову, если нужно сохранять значения элементов страницы (тех же input'ов), то им следует присваивать id. Иначе не сохранится.

Network unreachable


Если в WebView открывать страницу со своих внешних серверов, то ситуация с отключенной сетью на устройстве (авиа-режим, выключен wifi...) складывается весьма забавно. Вроде бы казалось, ловим WebViewClient.onReceivedError и ругаемся. А если все спокойно и произошел вызов WebViewClient.onPageFinished, то страница у нас загрузилась. Это отлично работало раньше, но на 4.3 (sdk 18) onReceivedError вызывается спустя 2 минуты после начала загрузки. Каких я только настроек таймаутов не пробовал искать и менять… onPageStarted срабатывает, а потом все, тишина на 2 минуты…
Решение? Открывать локальный ресурс, а из него уже подкачивать нужное с сервера.
Если кто знает в чем дело — напишите в комментах, может еще когда пригодится.

Грузим локальные ресурсы


В WebView без обращения к сети данные можно загрузить передав либо строку loadData("..."), либо адрес локального ресурса loadUrl(«file:///...»). Первый вариант с loadData проявил себя неожиданно с кириллицей UTF-8. Несмотря на то, что loadData принимает одним из параметров кодировку строки, в Android от 4.1+ (в 4.0 еще, насколько помню, не проявлялось) рендерится страница явно в однобайтной кодировке. И ни meta charset=utf-8, ни webView.getSettings().setDefaultTextEncodingName(«utf-8») ситуацию не исправили.
Решение? Грузить из файла через loadUrl.

Мост между Java и JavaScript


Открыть страничку в стандартном компоненте браузера — это конечно задача неподъемная только подготовка. Важным является взаимодействие JS кода с Java, причем по инициативе любой из сторон.
И вот вроде бы есть удобнейшая вещь — addJavascriptInterface (пример из офф документации):
class JsObject {
    @JavascriptInterface
    public String toString() { return "injectedObject"; }
}
webView.addJavascriptInterface(new JsObject(), "injectedObject");
webView.loadData("", "text/html", null);
webView.loadUrl("javascript:alert(injectedObject.toString())");

Задаем «мост» и его имя для JS и всё, пользуемся. Вызов loadData без содержимого страницы может показаться странным, но интерфейс взаимодействия не инициализируется (injectedObject не создается в JS) пока не будет загружено хоть что-то (можно даже и пустую строку).
Передача параметров из JS в Java так же возможна, главное совпадение числа и типов параметров.
Пишем, запускаем в эмуляторах и реальных устройствах: 4.3 — работает, 4.2 — работает,… 2.3 — приложение падает в корку (вылетает на стартовый экран ОС)… подождите, ЧТО?… 2.2 — работает. Снова 2.3 — в корку.
Падение происходит при обращении к injectedObject.toString (даже без вызова). Обращение к injectedObject происходит спокойно.
Появление «callJNIMethod<>» в backtrace обещает интересное проведение досуга в ближайшее время…
Гугление и прочес SO показывает, что проблема массовая и происходит с вероятностью близкой к 100% на всей линейке 2.3.* (sdk 9 — 10). Вроде даже иногда на 2.2 (sdk 8), но я встречал всего пару таких сообщений, а реального устройства на этой версии у меня нет, так что подтвердить не могу. Причем как на эмуляторах, так и реальных устройствах. Главное, чтобы WebView использовал в качестве JS движка не V8.
Крики масс и просьбы в Google починить багу начались в 2010, но официального исправления так и не выпустили.

За эти годы появилось несколько вариантов хоть какого-то обхода проблемы, workaround'а конкретно под 2.3.*. Не рассматривая варианты, когда программа ругалась и отказывалась работать под затронутыми версиями, решения можно разделить на следующие подходы:

1. Вместо прямых запросов к Java выполняем, образно, следующее:
    window.location = 'http://OurMegaPrefix:MethodName:Param0:Param1';

Затем в Java такой запрос обрабатываем через
webView.setWebViewClient(new WebViewClient() {
    @Override
    public boolean shouldOverrideUrlLoading(WebView view, String url) {
        if (!url.startsWith("OurMegaPrefix")) {
            return false;
        }

        // наша обработка: парсим строку, выделяя имя метода и параметры, затем вызываем
    }

    // один из реальных примеров:
    public void onPageFinished(WebView view, String url) {
        super.onPageFinished(view, url);

        if (javascriptInterfaceBroken) {
            String handleGingerbreadStupidity =
                "javascript:function openQuestion(id) { window.location='http://jshandler:openQuestion:'+id; }; "
                + "javascript: function handler() { this.openQuestion=openQuestion; }; "
                + "javascript: var jshandler = new handler();";
            webView.loadUrl(handleGingerbreadStupidity);
        }
    }

});

if (!javascriptInterfaceBroken) {
    webView.addJavascriptInterface(new JsObject(), "jshandler");
}

После загрузки страницы в onPageFinished выполняем inject кода в страницу для реализации нужного нам прокси-объекта.
Кроме откровенно костыльного решения этот способ еще и не позволяет сделать синхронный вызов с получением результата. Можно передать имя callback функции… Но мы так делать не будем, пойдем дальше.

2. Другим вариантом является использование стандартного интерфейса javascript по получению синхронного (для js) ответа от пользователя: prompt(). Главное его поймать :)
var res = prompt('OurMegaPrefix:meth:param0:param1');

В res как-то нужно уже из Java сохранить результат вызова. И ничего не показывая пользователю.
webView.setWebChromeClient(new WebChromeClient() {
    @Override
    public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
        final String prefix = JSAPI.class.getSimpleName();

        if (!message.startsWith(prefix)) {
            return super.onJsPrompt(view, url, message, defaultValue, result);
        }

        // обработка

        return true; // не показываем prompt диалог пользователю
    }
});

Здесь JSAPI — имя прокси-класса (выше назывался «JsObject»).
Получив так или иначе имя метода и параметры (если есть) остается вызвать соответствующий метод у JSAPI.
var jsapi = new JSAPI();

Статья не про рефлексию в Java, так что вкратце делаем или так:
Method method = JSAPI.class.getMethod(methodName, new Class[] {String.class});

для вызова метода с одним строковым параметром.
Или так для поиска просто по имени:
Method method = null;
for (Method meth : JSAPI.class.getMethods()) {
    if (meth.getName().equals(methodName)) {
        method = meth;
        break;
    }
}

Потом вызываем найденный метод (опять же пример для одного параметра):
method.invoke(jsapi, parameter);


В общем случае, этого уже достаточно для взаимодействия в обход addJavascriptInterface. Но предложу свою обертку на всем этим делом.
Я сам backend'щик (php, python, bash, C и т.п.) и мой JS не так крут, чтобы за него было не стыдно. Приму предложения по совершенствованию кода :)

Для передачи более сложных объектов, чем строки, используем json:
  • на стороне js через JSON.stringify и JSON.parse;
  • на стороне java через JSONObject и JSONArray.

И по DOMContentLoaded:
if (typeof JSAPI == 'object') {
    return;
}

(function(){
    var JSAPI = function() {
        function bridge(func) {
            var args = Array.prototype.slice.call(arguments.callee.caller.arguments, 0);
            var res = prompt('JSAPI.'+func, JSON.stringify(args));
            try {
                res = JSON.parse(res);
                res = (res && res.result) ? res.result : null;
            }
            catch (e) {
                res = null;
            }
            return res;
        }

        this.getUserAccounts = function() { // пример подмены метода
            return bridge('getUserAccounts');
        }
    };

    window['JSAPI'] = new JSAPI();
})(window);

Если JSAPI не объявлен (addJavascriptInterface не вызывался) — формирую обертку.
Суть обертки — формируем вызов prompt('JSAPI.getUserAccounts', '[]'), где вторым параметром идет json массив параметров вызова.
А результат prompt должен нам вернуть {result: ОТВЕТ}.

Теперь независимо от версии Android SDK вызываем наш метод:
var res = JSAPI.getUserAccounts();
// конкретно этот метод у меня возвращает json строку с массивом:
console.log(JSON.parse(res)[0]);


На стороне Java:
webView.setWebChromeClient(new WebChromeClient() {
    @Override
    public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
        // вызов идет в формате prompt(message='JSAPI.method', defaultValue='params')

        final String prefix = JSAPI.class.getSimpleName() + ".";

        if (!message.startsWith(prefix)) {
            return super.onJsPrompt(view, url, message, defaultValue, result);
        }

        final String meth_name = message.substring(prefix.length());

        String json_result = null;

        try {
            // парсим параметры из json массива
            final JSONArray params = new JSONArray(defaultValue);
            final int len = params.length();
            final Object []paramValues = new Object[len];
            for (int i = 0; i < len; ++i) {
                paramValues[i] = params.opt(i);
            }

            // ищем метод, основываясь только на его имени
            Method method = null;
            for (Method meth : JSAPI.class.getMethods()) {
                if (meth.getName().equals(meth_name)) {
                    method = meth;
                    break;
                }
            }

            if (null == method) {
                throw new NoSuchMethodException();
            }

            // результат вызова метода перевожу в "{\"result\":РЕЗУЛЬТАТ}"
            final JSONObject res = new JSONObject();
            res.put("result", method.invoke(new JSAPI(), paramValues));
            json_result = res.toString();
        }
        catch (JSONException e) {
            // ...
        }
        catch (NoSuchMethodException e) {
            // ...
        }
        catch (InvocationTargetException e) {
            // ...
        }
        catch (IllegalAccessException e) {
            // ...
        }

        result.confirm(json_result);
        return true;
    }
});

И непосредственно сам метод для целостности примера:
class JSAPI
{
    @JavascriptInterface
    public String getUserAccounts()
    {
        final JSONArray json = new JSONArray();

        final String emailPattern = Patterns.EMAIL_ADDRESS.pattern();
        final AccountManager am = AccountManager.get(getApplicationContext());
        if (am != null) {
            for (Account ac : am.getAccounts()) {
                if (!ac.name.matches(emailPattern)) {
                    continue;
                }

                final JSONArray item = new JSONArray();
                item.put(ac.type);
                item.put(ac.name);
                json.put(item);
            }
        }

        return json.toString();
    }
}

Если метод принимает параметры, их обязательно нужно указать. Результат у меня всегда для унификации задан как String, не зависимо от возвращаемого результата.
Аннотация @JavascriptInterface нужная для свежих версий SDK. В старых и без нее работало.

В качестве заключения еще хочу сказать, что часто в качестве условия «поломанный андроид» фигурировал код, проверяющий Build.VERSION.RELEASE == «2.3». Плохая проверка, лучше уж через Build.VERSION.RELEASE.startsWith(«2.3»). А еще лучше, имхо, проверять непосредственно версию SDK: (Build.VERSION.SDK_INT == 9) || (Build.VERSION.SDK_INT == 10).

Желающим проверять еще и сборку JS (на V8, повторюсь, баг не воспроизводится) предлагаю погулить, там все просто.


И еще на закуску вызов, инициированный со стороны Java: когда нужно, или в качестве callback.
public void callJS(final String jsCode)
{
    runOnUiThread(new Runnable() {
        @Override
        public void run() {
            webView.loadUrl("javascript:" + jsCode);
        }
    });
}

jsapi.callJS("alert('Hello, Habr!');");

runOnUiThread требуется, если callJS вызывается не в UI потоке (почти наверяка так и будет).
Но :) В 2.2 у меня срабатывало и без этого. В старших версиях поправили стало логичнее.

Вот так у меня пишется первое Java приложение :) Надеюсь, кому-то было интересно или, вдруг, даже полезно.

P.S. Android Studio на днях доросла до 0.2.11, и ей уже вполне можно пользоваться, если вам нравится продукция JetBrains. Не без неприятных изъянов, но вполне функционально.

P.P.S. В примеры кода внесены некоторые правки исходя из полученных комментариев.
Tags:
Hubs:
+19
Comments 16
Comments Comments 16

Articles