JavaScript to APK. Подводные камни разработки под Android для тех, кого задолбал PhoneGap. Построение мостов из Java в JavaScript

  • Tutorial
Привет, Хабра!

Я люблю игры на JavaScript и стараюсь сделать их код пуленепробиваемыми для портирования на все платформы. Полгода назад я уже писал о сборке Android приложений и сегодня хотел бы раскрыть тему более подробно.

Сразу предупрежу, что мне пришлось отказаться от PhoneGap, т.к. опыт использования его в двух проектах меня огорчил. Он отлично справляется с «Hello World» приложениями, но при конвейерной сборке всего подряд всплывают нюансы.

Почему PhoneGap не пошел:
1. Он изначально пустой. Постоянно приходится подключать все новые и новые модули.
2. Многие модули написаны криво. Они либо берут много лишнего, либо ведут себя неожиданно. Например, из двух модулей под Android для отправки SMS, один не работал, второй — отправлял true при любых условиях.
3. Не решены элементарные вещи, вроде получения EMEI телефона. Нужно постоянно допиливать.



Я так и не понял сути PhoneGap. Изначально ожидал одну кнопку «сделать хорошо», а не деле он ничего не делает. Под каждую платформу мне все равно надо ставить SDK. Под каждую задачу — искать и ставить модуль. Сами модули тоже ограничены. Они могут делать что-то только под часть платформ, а если нужно под другие — то приходится снова искать модули, которые смогу сделать это на других девайсах. Много мусора и ненужных вещей, а ведь хочется собирать билды с минимумом затрат. Все эти факторы заставляют писать нативно. И вот тут начинают вылезать подводные камни.

Почему CocoonJS не пошел:
С CocoonJS работал мало, поэтому никаких особых вопросов не возникло. Билды с canvas действительно работают быстрее. Но в общем — смысла работать с CocoonJS не увидел, т.к. он платный.

Что касается сборки под другие платформы и прочие нюансы — про это будет отдельная статья, и дальнейшее обсуждение по этой теме или теме PhoneGap выходит за рамки этой.

Перейдем к сути



Для начала начнем с основы — WebView с запущенной HTML страничкой на весь экран. В onCreate MainActivity пишем:

vw = (WebView) findViewById(R.id.webview);
vw.setVerticalScrollBarEnabled(false);        // отключили прокрутку
vw.setHorizontalScrollBarEnabled(false);      // отключили прокрутку
vw.getSettings().setJavaScriptEnabled(true);  // включили JavaScript
vw.getSettings().setDomStorageEnabled(true);  // включили localStorage и т.п.
vw.getSettings().setSupportZoom(false);       // отключили зум, т.к. нормальные приложения подобным функционалом не обладают
vw.getSettings().setSupportMultipleWindows(false);   // отключили поддержку вкладок.  
                                                     // Т.к. пользователь должен сидеть в SPA приложении
vw.addJavascriptInterface(new WebAppInterface(this), "API");   // прокидываем объект в JavaScript. 
                                                               // Это будет наш мост в мир Java. В JavaScript`е создается объект API
vw.loadUrl("file:///android_asset/index.html");      // загрузили нашу страничку
vw.setWebViewClient(new WebViewClient());

Все спорные ситуации будем решать в Java. Помните, пишите вы для Bada или SmartTV — всегда есть какой-то стандартный функционал, который позволяет кидать мосты в JavaScript. В нашем случае для Android`а мы кинули экземпляр класса WebAppInterface, а сам класс будет выглядеть так:

public class WebAppInterface {
	Context mContext;

	/** Instantiate the interface and set the context */
	WebAppInterface(Context c) {
		mContext = c;
	}

	/** Далее идут методы, которые появятся в JavaScript */
	@JavascriptInterface
	public void sendSms(String phoneNumber, String message) {
		... какой-то нативный код
	}
}

Подводный камень: Работа с такими мостами обычно может быть асинхронна или непредсказуема и полна сюрпризов.

Если у вас возникла необходимость из Java сообщить JavaScript`у какое-либо событие на ровном месте, самый простой способ достучаться до него — это стучать в URL:

vw.loadUrl("javascript: ... какой-либо код на JavaScript"); 

Подводный камень: В Android`ах > 4 от Samsung`а при тач-событии DOM элементы могут подсвечиваться синим цветом.



Обратите внимание на этот нюанс. Типичная «защита» вам не поможет:

* {
    -webkit-tap-highlight-color: rgba(255, 255, 255, 0);
    -webkit-focus-ring-color: rgba(255, 255, 255, 0);
    outline: none;

	-moz-user-select: -moz-none;
	-o-user-select: none;
	-khtml-user-select: none;
	-webkit-user-select: none;
	user-select: none;

    -webkit-text-size-adjust: none;
}

Чтобы обойти багу, надо добавить:

if (document.addEventListener) {
	document.addEventListener("touchstart", function () {
	}, true);
}

Но это не всегда решает проблему. Возможно, на проблему влияет сама верстка. Например, возьмем два приложения: Судоку и Тест. В судоку доска сверстана таблицей, и для таблицы такое решение помогло. В Тесте же кнопки это. Вроде бы все по стандартам семантики HTML5, и все должно быть более чем прекрасно, но на деле приходиться добивать таким CSS комбо:

.some_button:focus,
.some_button:focus:active {
    background-color: rgba(0, 0, 0, 0);
}

Так же заметил, что синее выделение не появляется, если тач-событие пришлось точно на текст кнопки (текст при этом должен быть очень большим).

Подводный камень: В Android < 4 шрифты могут расползтись.



Способ борьбы с багом — либо делать проверки, либо отключить шрифты. Хотя, с другой стороны, возможно, у меня были сами шрифты кривые.

Подводный камень: Android чувствителен к регистру.

Если у вас среди кучи ресурсов .jpg затерялась картинка с .JPG — вы вряд ли когда-либо заметите разницу в браузере, а вот в WebView картинка не загрузится.

Подводный камень: Android чувствителен к зарезервированным словам.

Например, у меня была в assets`ах папка с именем classijizm. Android отказывался собирать проект и не мог внятно объяснить ошибку. Переименовал в klassijizm — заработало. Опять же, в обычном браузере того-же Android`а таких проблем не было.

Подводный камень: Тег audio на Android не работает.

Точнее, он работает в браузере, но не работает, когда вы используете его внутри WebView. Чтобы обойти это ограничение, можно прокинуть мост и переписать на нативном коде. В onCreate добавляем:

mp = new MediaPlayer();

А для WebView расширяем JavaScript интерфейс:

@JavascriptInterface
public void audio(String url) {
	try {
		soundClick = getAssets().openFd(url);
		mp.reset();
		mp.setDataSource(soundClick.getFileDescriptor(), soundClick.getStartOffset(), soundClick.getLength());
		mp.prepare();
		mp.start();
	} catch (IOException e) {
		e.printStackTrace();
	}
}

Подводный камень: Вместо закрытия Android сворачивает приложения. Поэтому, если вы используете Аудио — вам нужно как-минимум отключить звук.

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

@Override
public void onBackPressed() {
	vw.loadUrl("javascript:  windowClose();");
	MainActivity.this.finish();
}

@Override
public void onPause() {
	super.onPause();
	vw.loadUrl("javascript:  windowClose();");
	MainActivity.this.finish();
}

@Override
public void onResume() {
	super.onResume();
	vw.loadUrl("javascript:  windowOpen();");
}

@Override
public void onDestroy() {
	super.onDestroy();
	vw.loadUrl("javascript:  windowClose();");
	MainActivity.this.finish();
}

Командой MainActivity.this.finish(); я пытаюсь закрыть приложение при каждом удобном случае. Так можно быть более уверенным, что Android в следующий раз просто начнет все с начала, а не будет пытаться что-либо восстановить. Понятно, что в играх типа Судоку так делать нельзя, но в большинстве игр — можно, т.к. они достаточно просты (тот же FlappyBirds или Тесты). Советую опасаться попыток Android вернуть все как было, т.к. появляются другие баги.

Подводный камень: Android при onResume не всегда удачно восстанавливает приложения. Да и вообще на некоторых девайсах есть проблемы с повторным запуском.



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

Подводный камень: при сворачивание/открытии приложения может возникнуть несколько WebView, которые будут работать параллельно и мешать друг другу.

Чтобы наверняка уйти от такой проблемы, допишем в манифест:

<activity
	...
    // а этой строкой запретим пересоздание WebView при смене ориентации экрана. 
	android:configChanges="keyboardHidden|orientation|screenSize"
	...
    // а это комбо гарантирует один экземпляр приложения в любой момент времени
	android:clearTaskOnLaunch="true"
	android:noHistory="true"
	android:launchMode="singleTask" >

Чтобы наше приложение на JavaScript выглядело ещё лучше, его можно запустить во весь экран, убрав черную полоску сверху. Для этого в манифест нужно добавить:

<application
	...
	android:theme="@android:style/Theme.NoTitleBar.Fullscreen">

Подводный камень: localStorage, в который вы сохраняете данные будет уничтожен после закрытия приложения.

Чтобы сохранить данные между двумя вызовами, нужно сохранить данные в нативном SharedPreferences. Прокинем два моста на сохранение и загрузку:

@JavascriptInterface
public void saveSomeThing(String message, String id) {
	if(numberDataForSave > Integer.parseInt(id)) return;
	numberDataForSave = Integer.parseInt(id);
	SharedPreferences preferences = getSharedPreferences("com.example.something", MODE_PRIVATE);
	SharedPreferences.Editor editor = preferences.edit();
	editor.putString("somethingID", message);
	editor.commit();
}

@JavascriptInterface
public String loadSomeThing() {
	SharedPreferences preferences = getSharedPreferences("com.example.something", MODE_PRIVATE);
	String message = preferences.getString("somethingID", "");
	return message;
}

Подводный камень: Методы работают асинхронно (или мне показалось?!). Если вызывать сохранение очень часто, то данные могут прийти не в том порядке.

На деле бага будет выглядеть так — сохранилась не последняя строка, а предыдущая. Чтобы решить проблему, номеруем данные в JavaScript и нативном коде. В методе для сохранения есть переменная numberDataForSave, которая проверяет индекс сохраняемых данных. Если индекс меньше, чем последний сохраненный, данные игнорируются.

Подводный камень: В layout`е главного activity обычно много лишнего.

Для одного WebView во весь экран нам столько не надо. Можно сократить, оставив:

<?xml version="1.0" encoding="utf-8"?>
<WebView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/webview"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    android:scrollbars="none" />

Подводный камень: Отправили смс без сим-карты. СМСка не ушла, а callback вернул true.

Если вы используете PhoneGap, возможно, модуль отправки СМС под Android написан криво (во всяком случае лично я столкнулся с такой проблемой). Он возвращает true при любом исходе. Чтобы реализовать это нормально, прокинем мост для отправки СМС с Java в JavaScript:

@JavascriptInterface
public void sendSms(String phoneNumber, String message) {
	SmsManager sms = SmsManager.getDefault();
	sms.sendTextMessage(phoneNumber, null, message, sendSmsPendingIntent, null);
}

И добавим метод в класс MainActivity:

private PendingIntent registerSentSmsReceiver() {
	String SENT = "SMS_SENT";
	PendingIntent sentPI = PendingIntent.getBroadcast(MainActivity.this, 0, new Intent(SENT), 0);
	sendSmsReceiver = new BroadcastReceiver() {
		public void onReceive(Context arg0, Intent arg1) {
			switch (getResultCode()) {
				case Activity.RESULT_OK:
					vw.loadUrl("javascript: smsSend(true);");
					break;
				default:
					vw.loadUrl("javascript: smsSend(false);");
					break;
			}
		}
	};
	registerReceiver(sendSmsReceiver, new IntentFilter(SENT));
	return sentPI;
}

Чтобы приложение не вылетало с новым классом, нужно обновить onDestroy:

@Override
public void onDestroy() {
	super.onDestroy();
	if (sendSmsReceiver != null) {
		unregisterReceiver(sendSmsReceiver);
	}
}

Помните, sendSmsReceiver всегда нужно разрегистрировать при дестрое.

Подводный камень: На Android`ах от Samsung`а WebView тормозит на тач-событиях. Более того, он может вообще остановить отрисовку при зажатом пальце.

От этой баги никуда не уйти. Так уж его сделали. Это стало одной из причин для продвижения CocoonJS. При желании вы можете переписать элемент canvas на нативный (используя все те же мосты), но это уже трэш. Лучше писать сразу все на Java. Но, тем не менее, собрать APK файл все-таки имеет смысл, т.к. кроме Samsung`а есть ещё куча телефонов от других производителей, и там все может быть не так плохо. Ну а в том же Tizen`е в этом плане вообще сказка.

Заранее прошу прощения за кривые моменты на Java, т.к. все-таки мой профиль JavaScript. Ну, и демка русская и английская из статьи, если кому интересно будет.
i-Free Group 74,95
Компания
Поделиться публикацией
Похожие публикации
Ой, у вас баннер убежал!

Ну. И что?
Реклама
Комментарии 30
  • 0
    Что такое экспрессионализм? о_О Опечатка?
    • +1
      Это из той же оперы, что и сУреализм (вообще-то такой стиль существует, но тут речь явно не про него) и возрАждение
      • +2
        И «Ренессанс» с двумя «с» пишется. И шрифт диснеевский :)
      • +2
        Да опечатка. Спасибо, исправлю.
        • 0
          Вы на ближайшем fronttalks будите об этом же говорить?
          • +3
            И да, и нет. Это один из частных случаев. На fronttalks будет про верстку и архитектуру больше. Буду JS в EXE`шники загонять на XP без NodeJS =)
            • +1
              О! Расскажите, пожалуйста, потом об этом здесь? А то до Екатеринбурга далеко ехать…
              • +2
                Потом будет пост на Хабре со всеми плюшками. А то как-то нехорошо получится наперед постить.
                • +1
                  а в чем сложности-то, когда в винде есть IE? Еще с IE4 эти функции в MSHTML имеются — бери да дёргай… собственно хоть на xp, хоть на новейших…
                  • 0
                    Что-то не подумал об этом. А инструмент для этого не подскажите? Уж очень не хочется руками с WinAPI общаться.
                    • 0
                      Ну можно использовать jscript и его компайлер (jsc.exe) (хотя это с натягом можно назвать «javascript», но всё же....)… В целом — варианты есть, тот же COM интерфейс осла никто не отменял…
                  • 0
                    Есть как минимум 2 нативных способа получить JS среду в exe без nodejs
                    1) использовать jscript.exe (хотя там поддержки стандартов почти нету)
                    2) использовать PhantomJS (это такой WebView+V8 на стероидах, написанный на чистом Qt)
                    • 0
                      PhantomJS совсем не по этой части. Главное его достоинство — ему не требуется графическая система. Его нельзя использовать для упаковки веб-приложений. Основной сценарий его использование — это автотестирование.
                      • 0
                        изначально речь шла просто про JavaScript — если нужно с графической частью тоже — то есть Titanium и node-webkit
                        • 0
                          Как же хочется прочитать где-то в одном месте что могут различные упаковщики для запуска WebApp в виде самостоятельных приложений: Titanium, PhoneGap, node-webkit… А то вариантов вроде много, но все проекты, что видел, затачиваются под конкретный способ упаковки и конкретную среду исполнения (например, свежий гитхабовский атом), вместо того, чтобы писать простой WebApp, а потом запаковать под разные платформы. По-моему было бы круто если кто-то написал пост с описанием и сравнением. Если уже есть такое, то киньте, пожалуйста, ссылку.
              • 0
                А зачем игре получать IMEI? Просто интересно
                • 0
                  Конкретно в этой — не зачем. А вообще в платных играх иногда нужна возможность достоверно определить абонента. IMEI — остается при смене сим-карты, но правда у некоторых китайских телефонов его нет вообще. Поэтому при покупке чего либо и т.п., сервер точно знает что это старый игрок помня все IMEI.
                • +2
                  Приложение делалось, очевидно, для телеканала Disney или его поклонников?
                  • 0
                    Подводный камень: В Android < 4 шрифты могут расползтись.

                    А под iOS 7 — наоборот, «сползтись», причем в совершенно неожиданных местах. Проблема интересная — iOS в новой версии начал обрабатывать шрифтовые кернинг-пары (насколько я, как программист, понял из рассказа специалиста — это пара букв и заданный создателем шрифта интервал между ними) и в них иногда встречаются неадекватные значения. Может быть, корни проблемы с Android < 4 где-то там же.
                    • 0
                      Попробуйте посмотреть в сторону Qt на Android. Пока только одним глазком — многое не готово, конечно, но мой опыт меня порадовал)
                      • +2
                        Есть еще несколько важных моментов.

                        Для отключения контекстных меню по долгому клику:
                        webView.setLongClickable(false);
                        


                        Для исправления бага с sublings и псевдоклассами CSS в более старых версиях webkit на Android:
                        body { -webkit-animation: bugfix infinite 1s; }
                        
                        @-webkit-keyframes bugfix { 
                           from { padding: 0; } 
                           to { padding: 0; } 
                        }
                        


                        И самое важное, аппаратное ускорение, которое по умолчанию отключено! В AndroidManifest.xml нужно добавить следующий параметр для <activity>: android:hardwareAccelerated="true"
                        • +1
                          Добавлю свои 5 копеек:
                          Подводный камень: В Android < 4.4 в WebView не работают вебсокеты :(

                          Если вы используете PhoneGap, то есть плагины исправляющие этот недостаток:
                          github.com/FreakDev/PhoneGap-Android-HTML5-WebSocket
                          github.com/anismiles/websocket-android-phonegap
                          github.com/mkuklis/phonegap-websocket
                          • 0
                            А откуда инфа, что тег аудио не работает в андроидовских webview? То что запустить аудио нельзя вне потока, инициализируемого юзером, это да (кроме нюанса, который заключается в том, что поток, запущенный с помощью loadUrl тоже имеет права пользовательского), что проигрывается только один файл одновременно, это тоже да, но в целом пашет же.
                            • 0
                              Насколько я понимаю, например в iOS нет Java, а есть конверторы (например) в ObjectC, да и то не для GUI-кода. Как же тогда с
                              Я люблю игры на JavaScript и стараюсь сделать их код пуленепробиваемыми для портирования на все платформы
                              • 0
                                Но webview то везде есть, а в нем javascript.
                              • +1
                                Столкнулся с такой фичей браузера Андроида 4.x: после событий touchstart — touchend он всегда посылает mousedown — mouseup (после интервала в 300мс), даже если было запрошено event.preventDefault(), event.stopPropagation() или вообще удален handler. Это сильно мешает логике, если приложение хочет обрабатывать и мышку и тачи. Хорошего решения нет, кроме как отслеживать если mouseup пришел в интервале до 400мс после touchend с похожими координатами, то игнорировать его.
                                • 0
                                  Вот за такое API
                                  vw.setVerticalScrollBarEnabled(false);
                                  


                                  Разработчика нужно п… дить кирзачами отрывать руки дать в репу заставить самого этим пользоваться.
                                  Этож просто какая-то жесть.

                                  А по итогу-то что получается — только Java, только хардкор? Как не посмотрю — все кривенько :(
                                  • +1
                                    А по итогу-то что получается — только Java, только хардкор?

                                    Ну в идеале разработчики PhoneGap должны были учесть эти ньюансы и уже готовое решение дать. Но что поделать :(

                                    Этож просто какая-то жесть.

                                    Объясните пожалуйста, что не так. Что бы на будущее учесть.
                                    • –2
                                      Ох, как-то привык в JS к тому что создать объект ничего не стоит, так что совсем красиво не сделать, в яве хешмапы жуть какая-то (как-то раньше пристально не смотрел на нее).

                                      А вообще конечно по нормальному нужно вести от общего к частному

                                      vw.setScrollBarVerticalEnabled(false);
                                      


                                      Что все равно ужасно, но хоть логичнее по структуре.
                                  • 0
                                    Очень близкая тема! Сам с PhoneGap и Samsung Galaxy Note 2 воевал.
                                    Всё очень и очень сырое, даже вертикальный скролл делается через одно место. Правда сейчас верстаю под WebView под iOS и там тоже всё очень специфично (а работать приходится с multi-column, flex-box и прочими радостями).

                                    Пока на этих платформах ИМХО html5 стек готов но в очень ограниченом применении. Очень надеюсь на Qt.

                                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.