А сегодня у нас статья хабрапользователя YoungSkipper о разработке игр для Android и особенностях Android Market.

Некоторое время назад я закончил портирование игры iDracula — Undead Awakening которую разработала компания Moregames Entertainment с платформы iPhone, на платформу Android.

Я готов поделиться с вами своими мыслями и проблемами, с которыми я столкнулся.
Хочу предупредить что я гораздо больше работал с платформой Android (исходя из специфики задачи :), но так же имею опыт разработки более «мелких» игр для платформы iPhone. Так же игры для iPhone я разрабатывал на cpp, а не ObjectC.

Cреда и инструменты разработки

Сложная тема, учитывая разность языков и операционных систем. Поэтому коротко и ИМХО.
xCode к сожалению на мой взгляд сложно назвать современной IDE, особенно когда приходиться много работать и рефакторить чужой код. В этом плане преимущество IntelliJ IDEA, или Eclipse (плагин для разработки под Android есть под обе IDE) чувствуется очень сильно. Мне в ряде случае было проще перенести код как есть, а потом уже сделать определенный рефакторинг кода уже в java версии. Отладка под xCode так же требует гораздо больше навыком и знания определенной специфики. К минусам инструментов платформы Android можно отнести некоторую нестабильность плагинов (особенно eclipse) — они периодически забывают adb.exe в памяти, что делает невозможным профилирование через DDMS. Необходимо соблюдать четкий порядок, сначала запустили ddms, потом уже отлаживаемся. Существенным минусом так же является тяжеловесность эмулятора — стартует он долго, работает медленно. При 35 fps на устройстве я на своем MacBook Pro имею 15 fsd на эмуляторе, и еще меньше в режиме отладки, что делает не возможность полноценного тестирования многих вещей. Симулятор для iPhone стартует практически мгновенно, и не уступает в скорости девайсу.

SDK

Единственная проблема с которой я толкнулся по iphone это отсутствие поддержки ogg. Проблема решилась простым портирование соответствующей библиотеки.

С Android SDK к сожалению не все так просто.

Самая серьезная проблема это утечки памяти при использовании OpenGL функций glXXXPointer. Фактически на каждый вызов функции мы имеем memory leak размеров в массив который мы устанавливаем. Т.е. что не делай при использовании этих функций мы рано или поздно получим вызов gc, а это от 200 до 1500 ms лаг. Вот тут есть подробности. Так же стоимость вызова функций glXXXPointer сильно выше, что впрочем ожидаемо.

Большая стоимость обработки тачскрина — т.е. тупо у вас 50 фпс, ставите палец на экран у вас 30 фпс. Ибо плодятся MotionEvent-ы и грузят систему. Решение делать sleep после получения MotionEvent решает проблему не польностью.

Отсутствие поддержки мультитача. Причем на уровне SDK, т.е. даже если у вас прошивка которая поддерживает мультитачь, или HTC Hero в котором так же есть поддержка мультитача — вы в данный момент не сможете использовать его в своем приложении.

Устройства

К сожалению в текущий момент мы имеем достаточно разные устройства. Основные проблемы связаны с HTC Dream (G1). G1 имеет гораздо более худшую чувствительность трекбола по сравнению с Magic и Hero. Там где игрок будет комфортно играть используя трекболл на Magic, он может иметь проблемы на Dream.

Есть проблемы и получением данных с сенсоров положения при включенной музыки. Если у вас играет музыкальный трек с громкостью 0,5 от максимальной, то даже в неподвижном состоянии вы будите получать дребезг датчиков от -40 до +40 из возможного диапазона от -90 до +90 (-180/+180). Фактически при таком дребезге не возможно использовать датчики положения для управления игрой. Только при снижении громкости до 0,2 от максимальной дребезг снижается до диапазона -4/+4, что уже приемлемо.

На G1 так же есть проблемы с малым количеством памяти, они усугубляется тем что виджеты которые пользователь размещает на своем рабочем столе могут потреблять мого памяти (до 15 мегабайт), но при этом пользователю не очевидно что они являются запущенными программами. Поэтому можно получать разные отзывы от пользователей с одинаковыми устройствами. От «вау игра летает» до «невозможно играть, одни лаги» (см. пункт выше о мемори ликах).

Проблемы с G1 с малым количеством места для установки программ, в среднем на нулевой системе у пользователя порядка 30 мегабайт. После установки приложений меньше. Если приложение большое (полная версия iDracula занимает 13 мегабайт) то могут быть проблемы с установкой. Ниже распишу более подробно.

Доставка контента пользователю

К сожалению Android Market по всем параметрам проигрывает AppStore.

В текущий момент на маркете нету возможности указать устройства под которые разработано ваше приложение.
Т.е. если вашему приложению нужна hardware клавиатура, или мультитач (когда его введут), то приложение будет видно владельцам устройств без этих опций и вы будите получать негативную оценку от них.

Проблемы с установкой больших приложения. Уже как лет пять на всех сотовых телефонах при установки приложения пользователь после скачивания приложения в случае нехватки места для установки помимо предупреждения получает диалог с возможностью удалить текущие приложения и сражу же продолжить установку. На android он вынужден вызвать множество меню для данного дейсвия. Более того если ваше приложение занимает 10 мегабайт, то для его установки требуется минимум 20 мегабайт (скаченый файл, и место для установки).
Одним из решений является установки приложения без ресурсов, и скачиванием ресурсов самим приложением с сохранением их на флешку.
Хотелось бы видеть это стандартной возможностью SDK — возможность пометить часть файлов для размещения на флешке.
Так же очень не хватает возможности получить помимо отзыва (например о падении приложения) информацию об устройстве на котором пользователь оставляет отзыв, а в идеале лог работы приложения как это сделано в iphone.
Не хватает раздела Entertaiment — игры вынуждены соревноваться в топах с приложениями типа «Красивый огонь на вашем экране».
Ну и отсутствие скриншотов, приводит к игру в большей мере выбирают исходя из названия (описания как известно никто не читает), такая проблема была кстати и на BREW рынке. Плюс еще есть ограничение на размер описания.

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

Изначально было желании написать более структурированный обзор и большее времени уделить iphone, то чувствую что так статья бы провалялась в черновиках еще бы месяц. Надеюсь, сложенное будет полезно и в таком виде. Я готов ответить на вопросы в комментариях.

P.S. Если у вас есть готовая игра под iphone или другую мобильную платформу и вы хотите ее порт под Android с условием shared revenu или фиксированной оплаты я с удовольствием пообщаюсь с вами. 🙂 Например по скайпу — youngskipper. Готов так же пообщаться с другими разработчиками — пообсуждать платформу.

Связаться с автором статьи можно по почте yunoshev[гав-гав]gmail.com

Previous ArticleNext Article
Технический директор IT-Dimension, компании-разработчика кросс-платформенного программного обеспечения

This post has 3 Comments

3
  1. Весьма сырой материал, хотя и изложены интересные факты. Заголовок вообще не соответствует содержанию.

  2. Мне понравился прежде всего из-за описания Android market %)

  3. Действительно, называйте вещи своими именами. Единственная нормальная статья про использования OpenGL в последнем СДК опубликована на оф сайте и она про GLSurfaceView. Если б еще таких статей… поскольку старые к новому сдк не подходят

Leave a Reply

Your email address will not be published. Required fields are marked *

О.

Ой-Ой-Ой! Google запретил использовать Android-устройства в качетсве модема!

Не ну надо же такому случиться! Google опять делает странное. Как сообщает CNET, T-Mobile не понравилась идея использования G1 в качестве модема, а Google из-за этого удалил все приложения, реализующие эту функцию, из Android Market. В T-Mobile настаивают, что использование такого рода приложений нарушает условия партнерского соглашения с Google.

Не очень, правда, понятно, раньше сообщалось, что Google сможет удалять ПО со смартфонов пользователей. Неизвестно, удалили они их со смартфонов или оставили.

Меня, как пользователя, такой подход ну очень огорчает. Как раз вот подумывал заказать себе девелоперскую версию G1, но недавно совсем Google запретил использование на этих устройствах приложений из Android Marker. Решил немного подождать и попробовать обычный G1, но после этой новости желание покупать это устройство отпало вобще.

Я пользуюсь Интернетом через коммкникатор постоянно, можно сказать, я живу там. По пути на работу – почитать почту, ответить всем в Skype/ICQ/Jabber. Дома вобще отказался от стационарного доступа к Интернет, пользую EDGE и коммуникатор в качестве модема. Не будь этой возможности, вобще не знаю что и делал бы.

С такими выходками Google Android потеряет какую-бы то ни было привлекательность для конечных пользователей. А ведь обидно, эта платформа позиционируется как открытая. Вот такая “открытость” получается. Вместо заботы о пользователях, идут на поводу у операторов. Обидно…

Р.

Работа с акселерометром в Android

Для одного из текущих проектов понадобилась поддержка акселерометра. Учитывая то, что еще месяц назад Android API я в глаза не видел, мне казалось что получение данных с акселерометра – это какой-то адский труд. Оказалось все намного проще.

Для работы с различными датчиками в Android используется класс Sensor. Список датчиков можно получить через SensorManager. Например таким вот образом при создании Activity можно получить объект Sensor, связанный с акселеромтером:

public class AccelerometerTest extends Activity {

	SensorManager mSensorManager;
	Sensor mAccelerometerSensor;

    /** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);

        mSensorManager = (SensorManager)getSystemService(Context.SENSOR_SERVICE);
        List<Sensor> sensors = mSensorManager.getSensorList(Sensor.TYPE_ALL);
        if(sensors.size() > 0)
        {
        	for (Sensor sensor : sensors) {
        		switch(sensor.getType())
        		{
        		case Sensor.TYPE_ACCELEROMETER:
        			if(mAccelerometerSensor == null) mAccelerometerSensor = sensor;
        			break;
        		default:
        			break;
        		}
		}
        }
    }


Для того, чтобы получать данные с акселерометра нам необходимо проделать еще несколько несложных операций:

  • Реализовать интерфейс SensorEventListener
  • , в частности нас интересует метод onSensorChanged()

  • Реализовать метод onResume() где подписать Activity на сообщения от акселеромтера
  • Реализовать метод onPause() где отписать Activity от сообщений акселерометра
public class AccelerometerTest extends Activity implements SensorEventListener {
    @Override
    protected void onPause() {
    	mSensorManager.unregisterListener(this);
    	super.onPause();

    }

    @Override
    protected void onResume() {
    	super.onResume();
    	mSensorManager.registerListener(this, mAccelerometerSensor, SensorManager.SENSOR_DELAY_GAME);
    	mSensorManager.registerListener(this, mMagneticFieldSensor, SensorManager.SENSOR_DELAY_GAME);
    }

	@Override
	public void onAccuracyChanged(Sensor sensor, int accuracy) {
	}

	@Override
	public void onSensorChanged(SensorEvent event) {
		float [] values = event.values;
		switch(event.sensor.getType())
		{
		case Sensor.TYPE_ACCELEROMETER:
			{
				// Здесь можно обрабатывать данные от сенсора
			}
			break;
		}
	}
}

Простейший пример отображения данных – отображать их в TextView

public class AccelerometerTest extends Activity implements SensorEventListener {
	SensorManager mSensorManager;
	Sensor mAccelerometerSensor;

	TextView mForceValueText;
	TextView mXValueText;
	TextView mYValueText;
	TextView mZValueText;

	/** Called when the activity is first created. */
	@Override
	public void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		setContentView(R.layout.main);
		...
		mForceValueText = (TextView)findViewById(R.id.value_force);
		mXValueText = (TextView)findViewById(R.id.value_x);
		mYValueText = (TextView)findViewById(R.id.value_y);
		mZValueText = (TextView)findViewById(R.id.value_z);
	}
	...
	@Override
	public void onSensorChanged(SensorEvent event) {
		float [] values = event.values;
		switch(event.sensor.getType())
		{
		case Sensor.TYPE_ACCELEROMETER:
			{
				mXValueText.setText(String.format("%1.3f", 
					event.values[SensorManager.DATA_X]));
				mYValueText.setText(String.format("%1.3f", 
					event.values[SensorManager.DATA_Y]));
				mZValueText.setText(String.format("%1.3f", 
					event.values[SensorManager.DATA_Z]));

				double totalForce = 0.0f;
				totalForce += Math.pow(
					values[SensorManager.DATA_X]/SensorManager.GRAVITY_EARTH, 2.0);
				totalForce += Math.pow(
					values[SensorManager.DATA_Y]/SensorManager.GRAVITY_EARTH, 2.0);
				totalForce += Math.pow(
					values[SensorManager.DATA_Z]/SensorManager.GRAVITY_EARTH, 2.0);
				totalForce = Math.sqrt(totalForce);
				mForceValueText.setText(String.format("%1.3f", totalForce));
			}
			break;
		}
	}
}

res/layout/main.xml

<?xml version="1.0" encoding="utf-8"?>
<TableLayout 	android:id="@+id/TableLayout01"
				android:layout_width="fill_parent"
				android:layout_height="fill_parent"
				xmlns:android="http://schemas.android.com/apk/res/android">
	<TableRow 	android:layout_width="wrap_content"
				android:layout_height="wrap_content"
				android:id="@+id/row_force"
				android:layout_margin="5dip">
		<TextView 	android:layout_width="wrap_content"
					android:layout_height="wrap_content"
					android:id="@+id/label_force"
					android:text="Force:"
					android:gravity="right"></TextView>
		<TextView 	android:layout_height="wrap_content"
					android:layout_width="fill_parent"
					android:layout_weight="1"
					android:id="@+id/value_force"
					android:text="-"
					android:layout_marginLeft="5dip"></TextView>
	</TableRow>
	<TableRow 	android:layout_height="wrap_content"
				android:layout_width="fill_parent"
				android:id="@+id/row_x"
				android:layout_margin="5dip">
		<TextView 	android:layout_width="wrap_content"
					android:layout_height="wrap_content"
					android:text="X:"
					android:id="@+id/label_x"
					android:gravity="right"></TextView>
		<TextView	android:layout_height="wrap_content"
					android:text="-"
					android:layout_width="fill_parent"
					android:layout_weight="1"
					android:id="@+id/value_x"
					android:layout_marginLeft="5dip"></TextView>
	</TableRow>
	<TableRow 	android:layout_height="wrap_content"
				android:layout_width="fill_parent"
				android:id="@+id/row_y"
				android:layout_margin="5dip">
		<TextView 	android:layout_width="wrap_content"
					android:layout_height="wrap_content"
					android:text="Y:"
					android:id="@+id/label_y"
					android:gravity="right"></TextView>
		<TextView 	android:layout_height="wrap_content"
					android:text="-"
					android:layout_width="fill_parent"
					android:layout_weight="1"
					android:id="@+id/value_y"
					android:layout_marginLeft="5dip"></TextView>
	</TableRow>
	<TableRow 	android:layout_height="wrap_content"
				android:layout_width="fill_parent"
				android:id="@+id/row_z"
				android:layout_margin="5dip">
		<TextView	android:layout_width="wrap_content"
					android:layout_height="wrap_content"
					android:text="Z:"
					android:id="@+id/label_z"
					android:gravity="right"></TextView>
		<TextView 	android:layout_height="wrap_content"
					android:text="-"
					android:layout_width="fill_parent"
					android:layout_weight="1"
					android:id="@+id/value_z"
					android:layout_marginLeft="5dip"></TextView>
	</TableRow>
</TableLayout>

В результате получим что-то подобное:

Из примера можно увидеть что в классе SensorManager есть константы DATA_X, DATA_Y, DATA_Z, которые используются в качетсве индексов в массиве значений, возвращаемых акселерометром.
Отображение данных в TextView – это, конечно, неплохо, но не дает общей картины изменений показаний акселерометра при изменении положения телефона. Для того, чтобы увидеть изменение показаний во времени, решил добавить отображение в виде графика.
Для создания графиков набрел на чудесную библиотеку AChartEngine. Библиотека бесплатная, доступна на Google Code.
Добавляем в layout пару кнопок – для начала/останова записи показаний акселерометра и для открытия окна с графиком.

<?xml version="1.0" encoding="utf-8"?>
<TableLayout 	android:id="@+id/TableLayout01"
				android:layout_width="fill_parent"
				android:layout_height="fill_parent"
				xmlns:android="http://schemas.android.com/apk/res/android">
...
<TableRow 	android:id="@+id/TableRow01"
				android:layout_height="wrap_content"
				android:layout_width="fill_parent">
		<ViewStub	android:id="@+id/ViewStub01"
					android:layout_width="wrap_content"
					android:layout_height="wrap_content"></ViewStub>
		<LinearLayout 	android:id="@+id/LinearLayout01"
						android:layout_height="wrap_content"
						android:layout_width="fill_parent"
						android:layout_weight="1">
			<Button 	android:layout_height="wrap_content"
						android:layout_weight="1"
						android:text="Start recording"
						android:layout_width="fill_parent"
						android:id="@+id/button_start"></Button>
			<Button 	android:layout_height="wrap_content"
						android:layout_weight="1"
						android:text="Show"
						android:layout_width="fill_parent"
						android:id="@+id/button_show"></Button>
		</LinearLayout>
	</TableRow>
</TableLayout>

В результате этих изменений получаем такой layout:

Теперь научим Activity реагировать на нажания кнопок:

package com.itdimension.accelerometertest;

import java.util.ArrayList;
import java.util.List;

import android.app.Activity;
import android.app.AlertDialog;
import android.content.Context;
import android.content.Intent;
import android.graphics.Color;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.os.Bundle;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.Button;
import android.widget.TextView;

import org.achartengine.*;
import org.achartengine.chart.PointStyle;
import org.achartengine.model.XYMultipleSeriesDataset;
import org.achartengine.model.XYSeries;
import org.achartengine.renderer.XYMultipleSeriesRenderer;
import org.achartengine.renderer.XYSeriesRenderer;

public class AccelerometerTest extends Activity implements SensorEventListener {
	...
	double margins[] = {0, 0};

	Button mStartButton;
	Button mShowButton;

	List<List<Double>> mValues;
	boolean mIsRecording = false;

	OnClickListener mStartButtonListener = new OnClickListener() {

		@Override
		public void onClick(View v) {
			mIsRecording = !mIsRecording;
			if(mIsRecording) {
				mValues.get(SensorManager.DATA_X).clear();
				mValues.get(SensorManager.DATA_Y).clear();
				mValues.get(SensorManager.DATA_Z).clear();
				margins[0] = 0;
				margins[1] = 0;
			}
		}
	};

	OnClickListener mShowButtonListener = new OnClickListener() {

		@Override
		public void onClick(View v) {
			try
			{
				Intent intent = getChartIntent();
				startActivity(intent);
			}
			catch (Exception e) {
				new AlertDialog.Builder(AccelerometerTest.this)
					.setTitle("Error")
					.setMessage(e.getMessage())
					.create()
					.show();
			}

		}
	};

    /** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
        ...
        mValues = new ArrayList<List<Double>>();
        mValues.add(new ArrayList<Double>());
        mValues.add(new ArrayList<Double>());
        mValues.add(new ArrayList<Double>());
	...
        mStartButton = (Button)findViewById(R.id.button_start);
        mShowButton = (Button)findViewById(R.id.button_show);

        mStartButton.setOnClickListener(mStartButtonListener);
        mShowButton.setOnClickListener(mShowButtonListener);
    }
    ...

	@Override
	public void onSensorChanged(SensorEvent event) {
		float [] values = event.values;
		switch(event.sensor.getType())
		{
		case Sensor.TYPE_ACCELEROMETER:
			{
				if(mIsRecording)
				{
					recordSensorValue(event);
				}
				...
			}
			break;
		}
	}

	private void recordSensorValue(SensorEvent event) {
		double value;
		for(int i = SensorManager.DATA_X; i <= SensorManager.DATA_Z; i++)
		{
			value = (double)event.values[i];
			margins[0] = Math.min(margins[0], value);
			margins[1] = Math.max(margins[1], value);
			mValues.get(i).add(value);
		}
	}

	Intent getChartIntent() {
		int [] colors = new int[] { 
		      Color.RED, Color.GREEN, Color.BLUE };
		PointStyle[] styles = new PointStyle[] { 
		      PointStyle.POINT, PointStyle.POINT, PointStyle.POINT };
		XYMultipleSeriesRenderer renderer = buildRenderer(colors, styles);
		setChartSettings(renderer, "Sensor Values", "Index", "Value",
	    		0,
	    		mValues.get(SensorManager.DATA_X).size(),
	    		margins[0] * 1.5,
	    		margins[1] * 1.5,
	        	Color.GRAY, Color.LTGRAY);
		return ChartFactory.getLineChartIntent(this, buildDataset(), renderer);
	}

	protected void setChartSettings(XYMultipleSeriesRenderer renderer, 
		      String title, String xTitle,
		      String yTitle, double xMin, 
		      double xMax, double yMin, double yMax, 
		      int axesColor, int labelsColor) {
		    renderer.setChartTitle(title);
		    renderer.setXTitle(xTitle);
		    renderer.setYTitle(yTitle);
		    renderer.setXAxisMin(xMin);
		    renderer.setXAxisMax(xMax);
		    renderer.setYAxisMin(yMin);
		    renderer.setYAxisMax(yMax);
		    renderer.setAxesColor(axesColor);
		    renderer.setLabelsColor(labelsColor);
		  }

	protected XYMultipleSeriesRenderer buildRenderer(int[] colors, PointStyle[] styles) {
	    XYMultipleSeriesRenderer renderer = new XYMultipleSeriesRenderer();
	    int length = colors.length;
	    for (int i = 0; i < length; i++) {
	      XYSeriesRenderer r = new XYSeriesRenderer();
	      r.setColor(colors[i]);
	      r.setPointStyle(styles[i]);
	      renderer.addSeriesRenderer(r);
	    }
	    return renderer;
	  }

	XYMultipleSeriesDataset buildDataset() {
		XYMultipleSeriesDataset result = new XYMultipleSeriesDataset();
		XYSeries xSeries = new XYSeries("X");
		XYSeries ySeries = new XYSeries("Y");
		XYSeries zSeries = new XYSeries("Z");

		int count = mValues.get(SensorManager.DATA_X).size();
		for(int i = 0; i < count; i++)
		{
			xSeries.add(i, mValues.get(SensorManager.DATA_X).get(i));
			ySeries.add(i, mValues.get(SensorManager.DATA_Y).get(i));
			zSeries.add(i, mValues.get(SensorManager.DATA_Z).get(i));
		}

		result.addSeries(xSeries);
		result.addSeries(ySeries);
		result.addSeries(zSeries);

		return result;
	}
}

После всех этих манипуляций, при нажатии на кнопку “Show” получим приблизительно такой график:


Ну вот, на этом пока все.
Скачать исходный код примера можно здесь.