i-Free Group corporate blog
Website development
JavaScript
4 March 2014

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. Ну, и демка русская и английская из статьи, если кому интересно будет.

+33
52.5k 248
Comments 30