Оболочка MIDP для Android/Портирование Ancient Empires II RM

Материал из Project D8
Перейти к: навигация, поиск

Эта часть рецепта посвящена портированию игры Ancient Empires II RM на Android. Несмотря на то, что она получилась гораздо длиннее предыдущих, ничего сверхсложного здесь по-прежнему нет. У тех, кто дочитает до конца, будет возможность в этом убедиться подмиг

Почему так «много букав»: в статье по мере возможности описаны не только инструкции, которые нужно выполнить для портирования данной конкретной игры, но и общие принципы портирования мидлетов с Java ME на Android, различия между этими платформами, «подводные камни», методики отладки программ, полезные функции среды Eclipse, и так далее. Опять же, по возможности на каждом шаге даются объяснения, почему что-то нужно делать именно так, а не иначе. Поскольку цель в данном случае — не портировать одну игру, а показать, как можно использовать оболочку для простого и быстрого переноса существующих программ с Java ME на Android, даже обладая поверхностными знаниями о разработке программ для этой системы[1].

Содержание

Создание проекта в Eclipse

  1. Создаем в Eclipse новый проект (File -> New -> Project)
    Создание проекта Ancient Empires II RM
  2. Выбираем Android Application Project
  3. Настройки проекта указываем такие:
    • Application Name и Project Name — здесь подойдет что-то вроде «AncientEmpiresII»[2].
    • Package Name — пишем app.aeii (в этом проекте имя пакета уже будет иметь значение).
  4. Нажимаем Next и на следующем экране меняем:
    • Create custom launcher icon — снять
    • Create activity — снять
  5. Нажимаем Finish

Подключение библиотеки с оболочкой

Эта процедура стандартная и ничуть не отличается от описанной ранее, поэтому здесь повторяться не будем.

Копирование исходников и ресурсов

Здесь пока тоже ничего сложного: распаковываем архив с исходниками, и копируем:

  1. содержимое папки src в соответствующую папку проекта для Android
  2. содержимое папки res в папку assets (не res!) в проекте для Android

Второй пункт следует пояснить отдельно. Дело в том, что в программах для Android существует несколько типов ресурсов. В папке res хранятся структурированные, специфические для Android ресурсы, которые обрабатываются особым образом. Например, там действует автоматический выбор нужной версии ресурса исходя из языковых настроек устройства, размера его экрана, и т. д. В принципе это скорее удобно, чем нет. Но в Java ME такого механизма не было, и все ресурсы обычно «свалены в кучу» в одной папке. Добавьте к этому, что при сборке проекта aapt пытается оптимизировать файлы в папке res — опять же, для мидлетов это иногда бывает нежелательно, а иногда и вовсе невозможно.

Содержимое папки assets, напротив, может иметь произвольную структуру и при сборке проекта почти никак не обрабатывается (единственное исключение — имена файлов не должны начинаться с символа «_» (подчеркивание), такие файлы просто игнорируются без объяснения причин). Поэтому в оболочке методы для доступа к ресурсам рассчитаны на работу именно с папкой assets.

Последний штрих — иконка программы. Вот ее нужно поместить уже в папку res в проекте Android. Обычно при создании проекта там будет несколько стандартных папок вроде drawable, values, layout и т. д. Все эти папки можно удалить, за исключением drawable-ldpi (если она отсутствует, ее нужно создать). Эта папка содержит графику, предназначенную для экранов низкого разрешения (а экран мобильного телефона как раз такой и есть). При запуске программы на смартфоне с экраном высокого разрешения картинки из этой папки будут автоматически масштабироваться так, чтобы они имели примерно одинаковый видимый размер (в миллиметрах) на разных устройствах.

Поэтому копируем файл gameicon.png из папки assets в папку drawable-ldpi. Удалять исходный файл в папке assets не стоит — много места он не занимает, но вот мидлету в дальнейшем может понадобится получить к нему доступ как к обычному ресурсу.

Исправление очевидных ошибок

В окне Package Explorer выделяем наш проект AncientEmpiresII, и обновляем его нажатием F5 на клавиатуре (либо из контекстного меню проекта). Видим, что ошибки остались в следующих файлах:

  • src/aeii/Main.java
  • arc/aeii/Renderer.java
  • src/com/one/file/Connector.java
  • src/com/one/file/InternalConnections.java
  • AndroidManifest.xml

Переименование пакета

Начнем мы, однако, не с исправления этих ошибок, а с перемещения исходников игры из пакета aeii в пакет app.aeii, который мы указали при создании проекта[3]:

  1. В окне Package Explorer выделяем пакет aeii и щелкаем по нему правой кнопкой мыши
  2. В контекстном меню выбираем Refactor -> Rename
    Форма переименования пакета
  3. В поле New name вводим app.aeii
  4. Нажимаем OK
  5. Появится предупреждение, которое сводится к тому, что пакет app.aeii уже существует в папке gen проекта.
    Предупреждение при переименовании пакета aeii
    В данном случае это предупреждение можно смело игнорировать — нажимаем Continue.
  6. После завершения процесса все классы из пакета aeii должны переместиться в пакет app.aeii
    Результат переименования пакета aeii

Теперь можно приступить непосредственно к устранению ошибок.

app.aeii.Main

Это основной класс мидлета, наследуемый от класса MIDlet. Как и в прошлый раз, этот класс унаследовал от оболочки абстрактный метод initApp(), которого не было в Java ME:

The type Main must implement the inherited abstract method MIDlet.initApp()

Этим методом нужно заменить конструктор мидлета. Но поскольку в данном случае класс Main конструктора в явном виде не содержит, нам достаточно просто добавить пустой метод initApp():

public void initApp()
{
}

Сохраняем файл и видим, что ошибка исчезла.

app.aeii.Renderer

Здесь ошибка уже более хитрая. Подсвечен метод

public final int getGameAction(int var1)

с пояснением

This instance method cannot override the static method from Canvas

Иными словами, класс Renderer наследуется от класса Canvas, и пытается переопределить метод getGameAction(). В Java ME это метод экземпляра, и никаких проблем не возникает[4]. В оболочке, однако, это метод класса, и он не может быть переопределен методом экземпляра.

Соответственно, где-то этот метод придется исправлять: либо в коде игры, либо в коде оболочки. Возможно, когда-нибудь он будет исправлен в самой оболочке, ну а сейчас исправим его в игре, благо это не трудно: заменяем модификатор final на static

public static int getGameAction(int var1)

и ниже в коде этого метода:

switch(super.getGameAction(var1))

на

switch(Canvas.getGameAction(var1))

и ошибка исчезает.

com.one.file.*

Этот пакет предназначен для унификации доступа к файловой системе на мобильных телефонах, на которых для этого используются разные API, то есть на старых моделях Siemens и Motorola. В оболочке для Android, как и в современных телефонах с Java ME, для доступа к файловой системе используется только JSR-75, и от пакета com.one.file здесь больше вреда, чем пользы. Самым простым решением будет этот пакет удалить совсем: выделяем его в окне Package Explorer, щелкаем правой кнопкой мыши, и в контекстном меню выбираем Delete. На вопрос об удалении отвечаем утвердительно.

После удаления этого пакета игра перестанет понимать, как ей обращаться к файлам, и еще в трех файлах появятся ошибки:

  • app.aeii.FileSystemObject
  • app.aeii.MainDisplayable
  • app.aeii.SpriteFrame

Но это не страшно, поскольку эти ошибки также легко устраняются.

app.aeii.FileSystemObject

Открываем файл и видим, что подчеркнута строчка

import com.one.file.*;
The import com.one.file cannot be resolved

Оно и понятно — мы же этот пакет только что удалили…

Особенность этого пакета в том, что он полностью подменяет собой стандартные API Java ME для работы с файлами. В данном случае это значит, что аналогичные классы уже есть в оболочке, имеют те же имена и набор методов (соответственно, в коде ничего менять не придется), но расположены в других пакетах. И здесь нам поможет одна очень полезная возможность Eclipse:

  1. Щелкаем в любом месте в редакторе исходного кода правой кнопкой мыши
  2. В контекстном меню выбираем Source -> Organize Imports

И видим, что вместо недостающих классов из пакета com.one.file импортируются три стандартных:

import javax.microedition.io.Connector;
import javax.microedition.io.file.FileConnection;
import javax.microedition.io.file.FileSystemRegistry;

Сохраняем файл и видим, что ошибка исчезла.

Остальные классы

В двух других классах — app.aeii.MainDisplayable и app.aeii.SpriteFrame — ошибки исправляются точно так же, как и в классе app.aeii.FileSystemObject, с помощью команды Organize Imports.

На этом очевидные ошибки в коде заканчиваются — можно идти дальше.

Редактирование AndroidManifest.xml

Сама процедура редактирования манифеста при портировании по большей части стандартна и подробно описана в предыдущей части. Отличаться будут только:

  1. На вкладке Application в поле Name выбираем класс Main (он там будет единственный в списке)
  2. Там же, в поле Icon нажимаем Browse, и в списке выбираем gameicon (ту самую иконку, которую мы ранее скопировали в папку res/drawable-ldpi)
    Выбор значка для портируемого мидлета
  3. Опционально в поле Theme нажимаем Browse и в списке выбираем Theme.Device.Light — это светлая тема из оболочки, соответствующая Theme.Holo.Light на устройствах, на которых есть новая тема «Holo», и Theme.Light на более старых устройствах без этой темы. Если нужна черная тема, поле Theme можно оставить пустым.
  4. В поле Label вписываем название портируемой игры — Ancient Empires II

Еще нужно будет добавить разрешения для доступа к файловой системе и управления вибрацией:

  1. Переключаемся на вкладку Permissions
  2. В списке Permissions нажимаем кнопку Add и выбираем Uses Permission
    Добавление элемента Uses Permission в AndroidManifest.xml
  3. Справа в списке Attributes for Uses Permission в поле Name выбираем android.permission.WRITE_EXTERNAL_STORAGE — разрешение на доступ к файловой системе (карте памяти)
  4. Аналогично добавляем android.permission.VIBRATE — разрешение на управление вибрацией
    Список разрешений для Ancient Empires II

Окончательный вид файла AndroidManifest.xml приведен ниже:

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="app.aeii"
    android:versionCode="1"
    android:versionName="1.0" >

    <uses-sdk
        android:minSdkVersion="10"
        android:targetSdkVersion="17" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
    <uses-permission android:name="android.permission.VIBRATE"/>

    <application
        android:allowBackup="true"
        android:icon="@drawable/gameicon"
        android:label="Ancient Empires II"
        android:theme="@style/Theme.Device.Light" android:name="Main">
        <activity android:name="javax.microedition.lcdui.Display$CanvasActivity"></activity>
        <activity android:name="javax.microedition.lcdui.Display$ScreenActivity"></activity>
        <activity android:name="javax.microedition.shell.ConfigActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
    </application>

</manifest>

Отлов неочевидных ошибок

Все ошибки исправлены, манифест сформирован, можно собирать игру и запускать? Да, можно. Будет ли она работать? Проверим!

Итак, запускаем игру, и видим экран настроек оболочки. Пока можно все оставить как есть. Нажимаем кнопку «Меню», выбираем «Пуск»… и ничего не происходит. Это значит, что где-то произошла ошибка, которая не дала мидлету даже запуститься, иначе бы мы увидели собственный экран игры с сообщением об ошибке («Fatal error!»). Причем, скорее всего, это перехватываемое исключение, иначе приложение было бы принудительно закрыто. В любом случае, придется искать эту ошибку в LogCat.

Поиск ошибок с помощью LogCat

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

Название (Filter Name) Тип фильтра Условие Для чего нужен
stdout by Log Tag System.out Показывает только вывод в поток System.out, то есть результат вызова функций System.out.println() и иже с ними. Здесь в основном будет отладочная информация, которую решил вывести сам программист (которая прописана в коде программы).
stderr by Log Tag System.err Показывает только вывод в поток System.err, то есть результат вызова функций System.err.println() и иже с ними. Это ценно не само по себе — System.err используется для вывода отладочной информации не так часто. А вот Throwable.printStackTrace() выводит этот самый stack trace именно в System.err, то есть данный фильтр покажет нам большинство перехватываемых исключений.
AEII by Application Name app.aeii Показывает только сообщения, относящиеся к приложению app.aeii, то есть к нашей игре. Может быть полезен для нахождения неперехватываемых исключений, а также в случаях, когда первые два фильтра не дают достаточно информации.

Активируются фильтры путем выделения нужного фильтра в списке. Вот что видно при выборе фильтра stderr:

java.lang.NullPointerException
	at java.io.DataInputStream.readToBuff(DataInputStream.java:159)
	at java.io.DataInputStream.readInt(DataInputStream.java:286)
	at app.aeii.PaintableObject.loadLocale(PaintableObject.java:164)
	at app.aeii.Renderer.<init>(Renderer.java:101)
	at app.aeii.Main.startApp(Main.java:26)
	at javax.microedition.shell.ConfigActivity.commandAction(ConfigActivity.java:450)
	at javax.microedition.lcdui.event.CommandActionEvent.process(CommandActionEvent.java:78)
	at javax.microedition.lcdui.EventQueue.run(EventQueue.java:299)
	at java.lang.Thread.run(Thread.java:1027)
java.lang.NullPointerException
	at app.aeii.Renderer.showErrMsg(Renderer.java:648)
	at app.aeii.Renderer.<init>(Renderer.java:111)
	at app.aeii.Main.startApp(Main.java:26)
	at javax.microedition.shell.ConfigActivity.commandAction(ConfigActivity.java:450)
	at javax.microedition.lcdui.event.CommandActionEvent.process(CommandActionEvent.java:78)
	at javax.microedition.lcdui.EventQueue.run(EventQueue.java:299)
	at java.lang.Thread.run(Thread.java:1027)

Произошло как минимум две ошибки. Логично начать с той, что произошла первой: исключение NullPointerException в методе DataInputStream.readToBuff(). Но это библиотечный метод, и предполагать, что в нем ошибка, следует в самую последнюю очередь (ошибки бывают и в библиотечных методах, просто вероятность этого, как правило, довольно мала). Спускаемся на строчку ниже и смотрим, откуда сей метод был вызван: из DataInputStream.readInt() — опять библиотечный. А вот он, в свою очередь, вызывается уже из app.aeii.PaintableObject.loadLocale(), который находится в нашей игре. Дважды щелкаем левой кнопкой мыши на этой строчке в LogCat, и попадаем сразу на то место в исходном коде, где произошла ошибка:

localeStrings = new String[dis.readInt()];

NullPointerException здесь мог возникнуть, только если DataInputStream dis, объявленный строчкой выше, сам равен null, либо, как в данном случае и произошло, был открыт с аргументом в виде null. В качестве аргумента, то есть базового потока ввода, ему передается результат вызова метода

Main.main.getClass().getResourceAsStream(var0)

Class.getResourceAsStream()

Вот это и есть первое отличие программ для Android от программ для обычных телефонов: метод Class.getResourceAsStream() в Android не работает. Вместо него можно использовать метод AssetManager.open(), который открывает файлы из папки assets и возвращает их как InputStream. В оболочке для вызова этого метода предусмотрена обертка в виде ContextHolder.getResourceAsStream().

Таким образом, исправление ошибки в данном случае сводится к замене всех вызовов Class.getResourceAsStream() на ContextHolder.getResourceAsStream(). В Eclipse этот процесс можно почти полностью автоматизировать:

  1. Выделяем в исходном коде строку «Main.main.getClass().getResourceAsStream»
  2. В меню выбираем Search -> File
    Форма поиска текста в файлах
  3. В поле Containing text обычно автоматически подставляется ранее выделенный текст. Если там что-то другое, вписываем туда строку «Main.main.getClass().getResourceAsStream» вручную (без кавычек).
  4. На панели Scope выбираем Enclosing projects, чтобы искать текст во всех файлах проекта
  5. Нажимаем кнопку Replace
    Форма замены текста в файлах
  6. На появившейся форме замены в поле With вводим «ContextHolder.getResourceAsStream» (без кавычек)
  7. Нажимаем кнопку OK

Везде, где раньше был вызов Main.main.getClass().getResourceAsStream(...), теперь стоит вызов ContextHolder.getResourceAsStream(...). При этом в нескольких файлах снова появились ошибки, в основном из-за того, что

ContextHolder cannot be resolved

Все правильно: чтобы использовать класс javax.microedition.util.ContextHolder, его сначала нужно импортировать. Для ускорения процесса снова воспользуемся функцией Source -> Organize Imports. В результате в классе app.aeii.Renderer все равно останется одна ошибка:

Unhandled exception type IOException

Но этим мы займемся несколько позже, а сейчас закончим с Class.getResourceAsStream(). Все ли «неправильные» вызовы мы заменили на правильные?

Поиск ссылок на элемент программы

Мы заменили первый попавшийся вызов Main.main.getClass().getResourceAsStream(var0), но getResourceAsStream() можно вызвать еще и другими способами:

  • Main.main.getClass().getResourceAsStream(var0);
  • Main.class.getResourceAsStream(var0);
  • getClass().getResourceAsStream(var0);
  • InputStream.class.getResourceAsStream(var0);
  • и т. д.

Если проект большой и сложный, в особенности — если код писали разные программисты, в нем может присутствовать несколько вариантов вызова одного и того же метода, в том числе getResourceAsStream(). А найти и заменить их нужно все.

И тут нам снова поможет Eclipse, а именно функция поиска ссылок на выбранный элемент программы. С ее помощью можно, например, найти все обращения к некоторой переменной во всех файлах проекта. Или все вызовы некоторого метода, независимо от формы самого вызова — то, что нам и требуется. Есть только одна проблема:

  1. Чтобы найти вызовы именно Class.getResourceAsStream() (а не ContextHolder.getResourceAsStream()), нужно установить курсор хотя бы на один такой вызов — чтобы Eclipse знала, что мы хотим найти.
  2. Все известные нам вызовы Class.getResourceAsStream() мы заменили на ContextHolder.getResourceAsStream().
  3. Как найти неизвестные вызовы?

Очень просто: нужно где-нибудь в коде искусственно добавить вызов метода Class.getResourceAsStream(). Куда именно добавлять — не принципиально, главное, чтобы это место в коде можно было потом легко отыскать и убрать лишний вызов. Можно, например, в Main.initApp:

public void initApp()
{
	Object.class.getResourceAsStream("");
}
Устанавливаем курсор на вызов метода getResourceAsStream(), и в меню выбираем Search -> References -> Project.
Результат поиска ссылок на getResourceAsStream()

Появится панель, на которой будут перечислены все вызовы указанного метода, независимо от формы самого вызова. В данном случае вызов нашелся только один — тот, что мы добавили в initApp(), поэтому больше ничего заменять не нужно. Однако, если бы в списке присутствовали другие вызовы, их пришлось бы заменять на ContextHolder.getResourceAsStream(), точно так же, как был заменен вызов Main.main.getClass().getResourceAsStream().

Теперь можно убрать вызов Object.class.getResourceAsStream("") из Main.initApp().

app.aeii.Renderer

Здесь в результате замены Class.getResourceAsStream() на ContextHolder.getResourceAsStream() возникла ошибка

Unhandled exception type IOException

Строго говоря, это баг оболочки — согласно API Java ME, метод getResourceAsStream() исключений создавать не должен. Но уж что есть, то есть. Благо исправляется довольно просто: заменяем

return ContextHolder.getResourceAsStream("/res/" + var0);

на

try
{
	return ContextHolder.getResourceAsStream("/res/" + var0);
}
catch(IOException e)
{
	e.printStackTrace();
	return null;
}

Что получилось

Если все сделать правильно, других ошибок остаться не должно. Значит, можно пробовать запустить игру вторично. И на этот раз она запустится!
Ancient Empires II RM на Android

Скомпилированного APK в этот раз не будет, потому что портирование, вообще говоря, еще не закончено: впереди локализация игры, унификация кода для Java ME и Android, возможно — новая версия оболочки, так что следите за обновлениями!

Рецепт

  1. Описание оболочки
  2. Подготовка необходимых библиотек в Eclipse
  3. Портирование простого мидлета
  4. Портирование Ancient Empires II RM

Примечания

  1. Предполагается, однако, что читатель умеет установить и использовать среду разработки в виде Eclipse + Android SDK
  2. Пробелы в Application Name допускаются, но если можно обойтись без них — лучше обойтись.
  3. Сделать наоборот, то есть указать в проекте пакет aeii вместо app.aeii, нельзя — Android SDK требует, чтобы имя пакета состояло как минимум из двух частей.
  4. Возникает только вопрос, зачем нужно было делать его методом именно экземпляра, а не всего класса: что, на разных холстах в пределах одного мидлета одному игровому действию могут сопоставляться разные клавиши?
Персональные инструменты
Пространства имён

Варианты
Действия
Навигация
Список
Инструменты