пятница, 10 октября 2014 г.

J2ME. С чего начать?

http://rsdn.ru/article/java/J2MEFirstSteps.xml

J2ME. С чего начать?

Автор: Данилов Кирилл aka Donz 
Источник: RSDN Magazine #4-2007
Опубликовано: 15.03.2008
Версия текста: 1.0.2

Введение

Наверное, составлять еще одно руководство по первым шагам в J2ME уже несколько поздно, так как платформа немолода, но я решил попробовать описать свой опыт и помочь начинающим обойти совершенные мной ошибки. К тому же темы с названием, которое взято для этой статьи, в интернет-форумах возникают довольно регулярно. Я не буду подробно останавливаться на реализации конкретных библиотек или классов - об этом вы можете узнать сами из документации. Основной упор будет сделан на вещи, до которых мне в свое время пришлось доходить самому, или чье описание показалось мне неочевидным, а также на мое видение правильной работы с графикой.
Статья подразумевает знание языка Java и основных пакетов J2SE (java.lang.*, java.util.*, java.io.*), и касается в основном CLDC и MIDP.
Если вы не знаете языка или только-только начали знакомство с Java, то эта статья пока не для вас, и единственно правильный совет – прежде чем переходить на J2ME, вы должны уверенно себя чувствовать в J2SE.


Платформа, ее конфигурации и профили

Платформа J2ME предназначена для устройств с ограниченными ресурсами, таких как мобильные телефоны, смартфоны, наладонники, коммуникаторы, и является безопасной для использования: при доступе в интернет, использовании других коммуникаций, отправке SMS, доступе к файловой системе пользователь обязательно будет об этом уведомлен, и для продолжения использования функции необходимо его подтверждение. При корректной реализации спецификаций единственным вариантом несанкционированного доступа является программа-троян, которая под видом необходимости выполнения полезной пользователю функции на самом деле будет использовать полученные права в своих целях.
Устройства, на которых сможет работать J2ME-приложение, определяются поддерживаемой конфигурацией (конфигурация определяет самые базовые классы, такие как класс System, Runtime, Thread и т.д., то есть является фундаментом платформы) и профилем платформы (который определяет более специфичные для устройства свойства).

Существующие конфигурации:

Конфигурация CLDC (Connected Limited Device Configuration) предназначена для устройств с ограниченным объемом памяти и вычислительной мощностью. Содержит только базовые пакеты java.lang.*, java.io.*, java.util.*, javax.microedition.io.* и добавленный в версии 1.1 пакет java.lang.ref.*. Пакеты, совпадающие с J2SE, содержат минимальный набор классов, необходимых для создания приложений.
По реализации пересекающихся с J2SE классов, версии байт-кода CLDC 1.0 соответствует JDK 1.1, CLDC 1.1 –- JDK 1.3. Иногда к названию конфигурации добавляют HI, что означает HotSpot Implementation (виртуальная машина с улучшенными алгоритмами оптимизации выполняемого кода в целом и часто выполняемых кусков кода в частности, для J2SE она стала виртуальной машиной по умолчанию с версии 1.3).
CDC (Connected Device Configuration) предназначена для устройств с достаточным объемом памяти и производительностью. В дополнение к пакетам CLDC содержит классы для работы с архивами zip и jar, более развита рефлексия, работа с сетью, коллекциями, текстом, и определен класс BigInteger для операций с большими числами.

Профили:

MIDP (Mobile Information Device Profile) – профиль для конфигурации CLDC. Он содержит пакеты для работы с графикой, звуком, взаимодействия с консолью (клавиатура и экран), базовый набор классов для отображения стандартных экранов и control-ов. Существуют две основные версии API – 1.0 и 2.0 (самих версий MIDP несколько больше, но в них изменения касаются безопасности и не затрагивают само API).
IMP (Information Module Profile) – подкласс профиля MIDP для встраиваемых устройств, где не предполагается взаимодействие с пользователем. В этом профиле отсутствуют возможности работы с экраном.
Foundation Profile – профиль для конфигурации CDC, не имеющий функциональности для работы с GUI. Предназначен для встраиваемых устройств.
Personal Basis Profile – профиль для конфигурации CDC, содержащий основные элементы GUI. Является надстройкой над Foundation Profile.
Personal Profile – профиль для конфигурации CDC, содержащий графический интерфейс пользователя, основанный на AWT. Является надстройкой над Personal Basis Profile.
Дальнейшие возможности устройств с точки зрения разработчика обуславливаются поддержкой дополнительных пакетов, например, работа с мультимедиа – MMAPI (JSR-135).
ПРИМЕЧАНИЕ
JSR (Java Specification Request) – официальная спецификация какой-либо Java технологии или библиотеки, созданное сообществом JCP (Java Community Process).
Место платформы J2ME и иерархия конфигураций и платформ хорошо представлена на следующей схеме (источник http://java.sun.com):

Рис. 1 Иерархия платформ Java.
На рисунке упоминается KVM вместо JVM для конфигурации CLDC. Это связано с тем, что KVM (Kilobyte Virtual Machine) является намного более упрощенной версией виртуальной машины, чем ее привыкли представлять разработчики настольных приложений, и разница в названии как раз акцентирует внимание на значительных отличиях, часть которых будет разобрана ниже.
Далее рассматривается только связка CLDC 1.0 (версия конфигурации на этапе знакомства с платформой не имеет значения) и MIDP 2.0 (хотя большая часть материала в равной мере относится и к MIDP 1.0), как наиболее распространенная. Наверное, я не сильно ошибусь, если оценю долю MIDP-приложений (они также называются мидлетами) среди всех J2ME-программ в 95-99%. Такая популярность обусловлена распространенностью устройств с поддержкой этого профиля (почти все не самые бюджетные мобильные телефоны, смартфоны, КПК) и обширной аудиторией потенциальных пользователей (у кого в наше время нет мобильника?).

Средства разработки и эмуляторы

  • Для разработки, естественно, понадобится JDK версии не ниже 1.1 (для CLDC 1.0), но логично выбрать самую последнюю версию – 1.6. JDK необходим не только для компиляции исходного кода в байт-код (используется тот же javac), но и требуется для установки эмуляторов устройств. Запустить мидлет на виртуальной машине J2SE не получится не только из-за отсутствия специфических для мобильных приложений библиотек, но и из-за упоминавшихся ранее существенных отличий виртуальных машин. Поэтому на этапе тестирования логики и общей для всех устройств функциональности используют эмуляторы, которые обычно поставляются со всем необходимым для разработки.
  • WTK (Sun Java Wireless Toolkit for CLDC). Однако нужно учесть, что содержащиеся в нем эмуляторы не имеют ничего общего с реальными устройствами, поэтому в каждом конкретном случае лучше выбирать инструмент от производителя мобильных телефонов, например, Sony Ericsson SDK for the Java ME Platform. Он создан на базе WTK, и, хотя включенный набор эмуляторов тоже далек от реальных устройств, но в них как минимум совпадают размеры экранов, внешний вид, наборы кнопок, их коды и поддерживаемые библиотеки. Кроме того, все телефоны Sony Ericsson поддерживают технологию On Device Debug, позволяющую отлаживать мидлет непосредственно на самом телефоне со всеми возможностями привычной отладки: точки останова, пошаговое исполнение, просмотр содержимого полей и т.д. Ради справедливости надо отметить, что с недавних пор некоторые устройства Nokia и Motorola тоже предоставляют такую функцию. К тому же эмуляторы от Nokia более детально повторяют поведение реальных телефонов, но для начала хватит инструмента от Sony Ericsson. И запомните главное – ни один эмулятор не повторит работу «живого» устройства, так что, в конце концов, все придется тестировать «по-честному». Для выполнения примеров, разобранных в статье, установите инструментарий от Sony Ericsson. В дальнейшем вы, естественно, можете выбрать другой SDK.
  • IDE. Точнее, сама IDE нам на первых порах не понадобится. Первой главной моей ошибкой была установка Sun One Studio ME Edition. «Благодаря» ей, а точнее, моему решению как можно скорее получить готовый мидлет, я долгое время даже не задумывался над тем, как происходит сборка приложения, для чего нужны те или иные атрибуты, из чего, собственно, состоит мидлет и т.д. Практически любой шаг в сторону серьезно тормозил процесс разработки и порождал много вопросов. Так что до явного упоминания конкретной IDE при описании всех действий можно использовать любой редактор кода (можно и редактор из IDE, главное, не собирайте пока проект автоматизированными средствами) и командную строку.
Для удобства советую прописать в системную переменную PATH пути до каталогов bin JDK и SE SDK. При установке в каталоги по умолчанию это будут:
C:\Program Files\Java\jdk1.6.0_01\bin\
и
C:\SonyEricsson\JavaME_SDK_CLDC\PC_Emulation\WTK2\bin

Манифест и дескриптор приложения

Для установки приложения необходимы два файла: jad и jar. Точнее, эти файлы необходимы для установки OTA (Over The Air), то есть через браузер телефона. Устройствам, которые позволяют устанавливать мидлеты через Bluetooth, инфракрасный порт или другие средства связи, jad обычно не требуется.
jad – это дескриптор приложения (Application Descriptor) (MIME-тип text/vnd.sun.j2me.app-descriptor, его необходимо прописать в настройках Web-сервера, чтобы он понимал, что делать при запросе данного типа файлов).
jar – это сама программа, точнее, это пакет мидлетов (Midlet Suite) (MIME-тип application/java-archive).
Для корректной обработки запросов файлов этих типов Web-сервером их необходимо прописать в его конфигурации. Например, для Apache добавьте в файл .htaccess следующие строки:
AddType text/vnd.sun.j2me.app-descriptor .jad
AddType application/java-archive .jar
В абсолютном большинстве случаев в одном jar-файле находится один мидлет, но их может быть и больше. Формат мобильного jar’а не отличается от формата настольного – он так же создается утилитой jar, и так же содержит манифест.
Возникает логичный вопрос: «Для чего нужен дескриптор, если есть манифест?». Дело в том, что мидлеты в основном предназначены для установки OTA, а значит, придется тратить трафик и время на закачку приложения. Дескриптор же обычно занимает не больше трехсот байт, и из него можно сразу узнать название приложения, производителя, версию, размер приложения и самое главное – есть возможность отсеять неподходящий для данного устройства мидлет до начала его установки.
Спецификация дескриптора приложения приведена в спецификации MIDP любой версии. Более удобный вариант можно посмотреть на сайте Sun – Описание атрибутов дескриптора. Главные моменты перечислены ниже:
  • jad – это текстовый файл, в котором указаны атрибуты приложения в кодировке UTF-8, поэтому для названия приложения, вендора и т.д. можно использовать не только ASCII-символы, но на практике этого лучше не делать, так как некоторые устройства не смогут обработать такой дескриптор.
  • Каждый атрибут записывается отдельной строкой в формате: [название атрибута]: [значение атрибута], например, MIDlet-Name: Test Midlet.
  • В дескрипторе обязательно должны быть указаны следующие атрибуты:
Название атрибутаОписание
MIDlet-Nameпользовательское название пакета мидлетов
MIDlet-Versionверсия мидлета
MIDlet-Vendorразработчик или издатель мидлета
MIDlet-Jar-URLабсолютный или относительный (от местоположения самого дескриптора) URL до jar-файла
MIDlet-Jar-Sizeразмер jar-файла в байтах
  • Формат манифеста точно такой же, как и у jar-файла, содержащего приложение J2SE.
  • В манифесте обязательно должны быть указаны атрибуты (их назначение такое же, как и у соответствующих атрибутов в дескрипторе, и их значения должны полностью совпадать): MIDlet-Name, MIDlet-Version, MIDlet-Vendor.
  • В дескрипторе или в манифесте обязательно должны быть указаны атрибуты:
Название атрибутаОписание
MIDlet-Описание каждого мидлета в пакете мидлетов (формат будет разобран ниже)
MicroEdition-ProfileПрофиль содержащихся в пакете мидлетов
MicroEdition-ConfigurationКонфигурация мидлетов
Последние два лучше указывать в обоих местах: в jad’е они помогут определить возможность запуска мидлетов при установке OTA, в манифесте они понадобятся при установке через другие коммуникации, естественно, их значения должны совпадать.
  • Формат атрибута MIDlet- таков: n – порядковый номер описываемого мидлета, он должен начинаться с 1 и для последующих мидлетов увеличивается на единицу без пропусков. Значение атрибута: [пользовательское название мидлета], [полный путь до пиктограммы мидлета от корня архива jar], [полный путь к классу, унаследованному от javax.microedition.midlet.MIDlet]. Пиктограмму можно пропустить, в этом случае сразу идет следующая запятая. Например, MIDlet-1: Test MIDlet, , test.Hello
  • Следующие атрибуты не обязательны. Они могут находиться в любом из двух описательных файлов:
Название атрибутаОписание
MIDlet-DescriptionОписание мидлета
MIDlet-IconПиктограмма пакета мидлета, в общем случае показывается именно она. Если пакет мидлетов содержит только один мидлет, то в этом атрибуте следует указать то же, что и в MIDlet-1.
MIDlet-Info-URLURL подробного описания пакета мидлетов.
MIDlet-Data-SizeВ спецификации указано, что это минимальный размер хранилища (RecordStore). На практике же часто случается так, что этот параметр задает фактический размер хранилища, который нельзя ни уменьшить, ни увеличить.
MIDlet-PermissionsПрава, необходимые приложению для функционирования. Если указать этот параметр, то запрошенные права будут показаны пользователю при установке, и он заранее сможет отказаться от мидлета, если решит, что не хочет предоставлять эти права. Для неподписанных мидлетов указывать этот атрибута не обязательно, так как на действия, которые должны контролироваться пользователем, служба управления приложением (Application Management Service, AMS) все равно выдаст запрос на подтверждение.
MIDlet-Permissions-OptПрава, которые могут понадобиться приложению для функционирования. То же самое, что и предыдущий атрибут с той разницей, что права, запрошенные в этом параметре, необязательны для нормального функционирования приложения, а могут понадобиться для какой-либо вспомогательной функции.
MIDlet-Push-Регистрирует мидлет для его вызова при указанном входящем соединении.
MIDlet-Install-NotifyURL, по которому AMS сделает запрос при установке мидлета или каких-либо проблемах при установке.
MIDlet-Delete-NotifyURL, по которому AMS сделает запрос при удалении мидлета.
MIDlet-Delete-ConfirmТекст, запрос с которым будет выведен пользователю при удалении мидлета.
MIDlet-Certificate--Указывается для подписанного мидлета и содержит сертификат в кодировке base64.
  • В дескрипторе и манифесте могут быть указаны любые другие атрибуты, используемые в мидлете. Их названия не должны начинаться с MIDlet и Microedition.
ПРИМЕЧАНИЕ
Подписывать мидлеты нужно в основном для упрощения работы, чтобы пользователь мог предоставить приложению нужные права и не отвечать по несколько раз «Разрешаю» для некоторых функций. Подпись дает информацию о разработчике, поэтому предполагается, что приложение, подписанное имеющим доверие сертификатом, не может быть вредоносным. В некоторых случаях устройства в принципе не дают неподписанным мидлетам доступа к определенной функциональности. Но в большинстве случаев подпись не требуется, тем более на первых порах.
Не стоит злоупотреблять слишком длинными значениями атрибутов или превращать дескриптор приложения в базу данных. Примером пользовательского атрибута может служить адрес сервера, к которому мидлет должен иметь доступ (если разработчику не принципиально, к какому именно хосту будет подключаться его приложение). Таким образом, при изменении адреса сервера не требуется перекомпиляция приложения. А вот тексты, которые используются приложением, в дескрипторе или манифесте хранить не стоит, так как они очень сильно раздуют размер этих файлов.
Пример дескриптора:
MIDlet-Jar-URL: test.jar
MIDlet-Jar-Size: 84835
MIDlet-Name: Test midlet
MIDlet-Vendor: Test Inc.
MIDlet-Version: 1.2.1
MIDlet-1: Test midlet, /icon.png, test.Test
MicroEdition-Configuration: CLDC-1.0
MicroEdition-Profile: MIDP-2.0
MIDlet-Icon: /icon.png
MIDlet-Delete-Confirm: Are you sure to delete this midlet?
MIDlet-Install-Notify: http://testinc.com/installNotify.php?midlet=Test
Published: 31.10.2007

«Hello, World!» и самые основы

Итак, можно приступить к написанию первого мидлета. Точкой входа в мидлет является класс, унаследованный от javax.microedition.midlet.MIDlet, как уже говорилось выше. Переопределять конструктор по умолчанию необязательно, но если требуется при старте мидлета однократно выполнить какие-либо действия, не связанные с загрузкой ресурсов, созданием потоков и использованием соединений, то их можно выполнить именно в конструкторе. После инициализации нового объекта AMS вызывает метод startApp(), сигнализируя о переходе мидлета в активное состояние, именно отсюда правильно начинать пользовательский жизненный цикл приложения (системным циклом жизни управляет AMS). В классе MIDlet также есть еще два абстрактных метода, pauseApp и destroyApp. AMS вызывает метод pauseApp(), например, при сворачивании приложения (на самом деле вызывается только некоторыми устройствами), указывая мидлету освободить все возможные ресурсы, закрыть потоки и соединения. destroyApp(boolean unconditional) сигнализирует о необходимости закрытия мидлета и также указывает освободить все ресурсы и закрыть потоки и соединения. Если в качестве параметра было передано false, и приложение не готово сейчас окончить работу, можно сгенерировать исключение MIDletStateChangeException и продолжить работу до следующего вызова этого метода.
Для работы с экраном необходимо с помощью вызова Display.getDisplay(MIDlet midlet) получить текущий объект класса Display, который создается и инициализируется системой, и задать через его метод setCurrent объект Displayable, который должен отобразиться на экране.
Создайте каталог для тестового мидлета (далее при указании относительного пути подразумевается, что этот каталог является текущим), а в нем подкаталоги src, classes и preverified. Пакет приложения будет называться test, а сам класс – Hello:
Hello.java
package test;

import javax.microedition.lcdui.*;
import javax.microedition.midlet.MIDlet;

/**
 * Тестовый мидлет «Hello, world!», демонстрирующий процесс сборки приложения и
 * его запуска на эмуляторе
 */
public class Hello extends MIDlet implements CommandListener
{
  /**
   * Команда выхода из мидлета, кнопка вызова которой отображается на экране.
   * Команды могут быть прикреплены к любому объекту Displayable
   */
  private Command exitCommand;

  /**
   * Метод, с которого должен начинаться пользовательский жизненный цикл
   * приложения. Вызывается AMS
   */
  public void startApp()
  {
    //Alert - экран, предназначенный для отображения предупреждений,
    //сообщений и т.д. Можно сказать, аналог MessageBox
    Alert helloAlert =
      new Alert("Testing", "Hello, world!", null, AlertType.INFO);
    //Установка времени показа сообщения "навечно". В противном случае
    //экран автоматически сменится на предыдущий или на явно
    //указанный в методе объекта Display смены текущего Displayble
    helloAlert.setTimeout(Alert.FOREVER);
    //Инициализация команды. Типа команды - Command.EXIT говорит только о
    //том, где системе стоит расположить команду (место, соответствующее
    //команде выхода из приложения). Само действие команды описывает
    //программист, и оно может быть любым
    exitCommand = new Command("Exit", Command.EXIT, 1);
    //Добавление команды на экран
    helloAlert.addCommand(exitCommand);
    //Установки листенера команд
    helloAlert.setCommandListener(this);
    //Получение объекта Display, который автоматически инициализируется
    //AMS и уникален для каждого объекта мидлета
    Display display = Display.getDisplay(this);
    //Установка текущего экрана (объекта Displayable)
    display.setCurrent(helloAlert);
  }

  /**
   * Вызов метода сигнализирует о необходимости максимально освободить ресурсы
   * и закрыть соединения и потоки
   */
  public void pauseApp()
  {
  }

  /**
   * Вызов сигнализирует о том, что мидлет будет переведен в состояние
   * Destroyed и закрыт после выхода из этого метода. Этот метод создан для
   * вызова AMS, но им удобно пользоваться и для вызова из самого мидлета, так
   * как все необходимые действия перед закрытием будут описаны в одном методе,
   * который точно будет вызван хотя бы раз. Если destroyApp был вызван AMS, то
   * метод notifyDestroyed() будет проигнорирован, если был вызван
   * программистом из самого мидлета, то notifyDestroyed() отработает, но AMS
   * не будет вызывать destroyApp
   */
  public void destroyApp(boolean unconditional)
  {
    //Вызываем метод объекта MIDlet, сигнализирующий AMS, что мидлет готов
    //к завершению. После этого вызова AMS не будет вызывать
    //метод destroyApp, так как предполагается, что перед явной
    //сигнализации о закрытии мидлета, программист закрыл соединения и
    //остановил потоки.
    notifyDestroyed();
  }

  /**
   * Метод обратного вызова, вызываемый AMS при активации какой-либо команды
   * @param c - команда, которую активировали
   * @param d - объект Displayable, команду которого активировали
   */
  public void commandAction(Command c, Displayable d)
  {
    //Если вызвана наша команда для выхода из мидлета, ...
    if (c == exitCommand)
      //...то вызываем destroyApp
      destroyApp(true);
  }
}
Файл Hello.java находится в каталоге src/test.
Компилируем код:
javac.exe -target 1.1 -source 1.3 -bootclasspath C:\SonyEricsson\JavaME_SDK_CLDC\PC_Emulation\WTK2\lib\cldcapi10.jar 
-cp C:\SonyEricsson\JavaME_SDK_CLDC\PC_Emulation\WTK2\lib\midpapi20.jar -d classes src/test/*.java
Компилятором, несомненно, пользовались все программисты Java, но стоит пояснить значение непривычных для настольной Java флагов.
  • -target 1.1 – указывает, что байт-код должен соответствовать версии JDK 1.1 (соответствует CLDC 1.0);
  • -source 1.3 – исходный код написан в соответствии со спецификацией языка, используемого в JDK 1.3;
  • -bootclasspath задает библиотеку, в которой находятся базовые классы, используемые приложением. Без указания этой опции будет взят rt.jar из используемого JDK.
Следующим шагом является процесс предварительной проверки. Виртуальная машина J2SE проводит верификацию кода на лету. Этот процесс проверяет правильность формата class-файла, правильность объявлений классов, их членов и локальных переменных, корректность вызовов и т.д. Для увеличения производительности KVM этот процесс в значительной степени переносится в стадию сборки. Также при предварительной верификации в байт-код добавляются аннотации, позволяющие KVM провести более быстрый анализ кода, и они тоже позволяют повысить производительность виртуальной машины. Дополнительно проводится анализ кода на отсутствие блоков finalize, прямых вызовов методов ОС (native methods) и, для CLDC 1.0, на отсутствие данных и операций с плавающей точкой.
preverify.exe –cldc -classpath 
C:\SonyEricsson\JavaME_SDK_CLDC\PC_Emulation\WTK2\lib\midpapi20.jar;
C:\SonyEricsson\JavaME_SDK_CLDC\PC_Emulation\WTK2\lib\cldcapi10.jar 
-d preverified classes
Теперь можно собрать jar-файл, но перед этим необходимо написать manifest.mf:
MIDlet-Vendor: Test Inc.
MIDlet-Version: 1.0.0
MicroEdition-Configuration: CLDC-1.0
MIDlet-1: Hello, , test.Hello
MIDlet-Name: Hello
MicroEdition-Profile: MIDP-2.0
Итак, сборка:
jar.exe cvfm hello.jar manifest.mf -C preverified .
Последний шаг: написание дескриптора hello.jad:
MIDlet-Jar-URL: hello.jar
MIDlet-Jar-Size: 1382
MIDlet-Name: Hello
MIDlet-Vendor: Test Inc.
MIDlet-Version: 1.0.0
MIDlet-1: Hello, , test.Hello
MicroEdition-Configuration: CLDC-1.0
MicroEdition-Profile: MIDP-2.0
MIDlet-Delete-Confirm: Are you sure to delete this midlet?
Атрибут MIDlet-Jar-Size, возможно, придется подкорректировать.
Теперь можно запускать:
C:\SonyEricsson\JavaME_SDK_CLDC\PC_Emulation\WTK2\bin\emulator.exe -Xdevice:SonyEricsson_K750_Emu -Xdescriptor:hello.jad
Все современные эмуляторы поддерживают UEI (Unified Emulator Interface), который, в частности, регламентирует параметры командной строки для запуска. Но некоторые SDK расширяют набор опций, их можно посмотреть в документации, поставляемой с этим SDK.
Если все сделано правильно, то должен появиться этот экран:

Рисунок 2. Экран “Hello, world!”

Использование графики и анимации

В предыдущем разделе для вывода строки использовался стандартный экран, но любой программист, которому не безразличны внешний вид и удобство его программы, вряд ли будет использовать стандартные экраны и компоненты даже в бизнес-приложениях, не говоря уж об играх, которые занимают почти весь рынок. Так или иначе, в более-менее сложном мидлете придется использовать графику, и маловероятно, что ее дизайн совпадет со стандартным Look and Feel. Кроме того, при использовании стандартных экранов будут большие сложности с переносимостью, так как расположение компонентов, их поведение и даже наличие определенных команд придется адаптировать к гораздо большему количеству устройств. Использование встроенного пользовательского интерфейса может оправдать только наличие функциональности, которую в силу ограниченности платформы невозможно и или очень трудно реализовать самому, например, набор текста с T9 или специфичная для некоторых устройств функция выбора номера телефона из записной книжки, когда при этом не поддерживается библиотека для работы с контактами. При разработке игр вам придется использовать не только полностью свой GUI, но и свои стилизованные шрифты. Можете представить меню Quake с Arial TTF? Я тоже нет.
Для вывода графики используется класс Canvas из пакета javax.microedition.lcdui. (рекомендую внимательно прочитать всю документацию по нему, это избавит начинающих от стандартных ошибок), а точнее, метод paint(Graphics g). Он вызывается системой в следующих случаях:
  • Произошло событие showNotify() (Canvas переведен на передний план (foreground)).
  • Какая-либо область помечена как недействительная. Проще говоря, был вызван метод repaint(x, y, width, height) (или просто repaint() для всего экрана) – он указывает, что все пиксели указанного прямоугольника не соответствуют актуальному состоянию и ставит вызов paint в очередь событий, таким образом, вызов paint является асинхронным по отношению к repaint. Важно, что в объекте Graphics, полученном в качестве параметра в методе paint, необходимо перерисовать каждый пиксель, иначе возможны ситуации получения мусора на экране. Если нужно перерисовать только конкретный кусок экрана, вызывайте repaint с соответствующими параметрами. Также надо помнить, что Graphics, передаваемый в paint, не обязан быть одним и тем же объектом при разных вызовах, поэтому запоминать его в поле класса не имеет смысла.

Вывод графики

Нарисуем картинку, чтобы заодно рассмотреть загрузку ресурсов:

Картинка в проекте располагается в каталоге «res».
Hello.java
package test;

import javax.microedition.lcdui.Display;
import javax.microedition.midlet.MIDlet;
import javax.microedition.midlet.MIDletStateChangeException;

/**
 * Тестовый мидлет «Hello, world!», демонстрирующий работу с Canvas и загрузку
 * ресурсов
 */
public class Hello extends MIDlet
{
  /**
   * Объект Canvas, на котором будем рисовать
   */
  private static HelloCanvas canvas;

  /**
   * Объект Display, чтобы не вызывать getDisplay каждый раз, так как будет
   * получен один и тот же объект
   */
  private static Display display;

  /**
   * Создаем объекты Canvas и Display, так как нам это нужно только один раз за
   * время жизни мидлета
   */
  public Hello()
  {
    display = Display.getDisplay(this);
    canvas = new HelloCanvas(this);
  }

  /**
   * Метод, с которого должен начинаться пользовательский жизненный цикл
   * приложения. Вызывается AMS
   */
  public void startApp()
  {
    // Вызываем инициализацию класса HelloCanvas, так как любому вызову
    // startApp предшествует либо первый запуск мидлета, либо вызов
    // pauseApp, в котором мы должны осводить все ресурсы и остановить
    // созданные нити
    canvas.init();
    //Установка текущего экрана (объекта Displayable), который должен
    //показываться
    display.setCurrent(canvas);
  }

  /**
   * Вызов метода сигнализирует о необходимости максимально освободить ресурсы
   * и закрыть соединения и потоки
   */
  public void pauseApp()
  {
    //Вызываем метод, освобождающий ресурсы
    try
    {
      canvas.freeResources();
    }
    catch (IllegalStateException ignored)
    {
      // Игнорируем исключение, так как при любом исключении,
      // произошедшем в этом методе, мидлет аварийно прекратит свою
      // работу с вызовом destroyApp. Хотя мы по документации обязаны
      // освободить максимум ресурсов или завершить приложение через
      // исключение, но работа моего мидлета для меня важнее :) При
      // освобождении ресурсов навряд ли случилось что-то фатальное для
      // самого мидлета
    }
  }

  /**
   * Вызов сигнализирует о том, что мидлет будет переведен в состояние
   * Destroyed и закрыт после выхода из этого метода. Этот метод создан для
   * вызова AMS, но им удобно пользоваться и для вызова из самого мидлета, так
   * как все необходимые действия перед закрытием будут описаны в одном методе,
   * который точно будет вызван хотя бы раз. Если destroyApp был вызван AMS, то
   * метод notifyDestroyed() будет проигнорирован, если он был вызван
   * программистом из самого мидлета, то notifyDestroyed() отработает, но AMS
   * не будет вызывать destroyApp.
   * @param unconditional обязательно ли мидлет должен завершить работу или
   * можно бросить исключение, сигнализируя о невозможности закрытия в данный
   * момент
   * @throws MIDletStateChangeException при невозможности завершить работу
   * мидлета в данный момент
   */
  public void destroyApp(boolean unconditional)
    throws MIDletStateChangeException
  {
    // Если при освобождении ресурсов произошло исключение, и флаг
    // показывает, что пока можно не завершать работу, то генерируем
    // исключение, сигнализирующее AMS о невозможности закрытия мидлета в
    // данный момент
    try
    {
      canvas.freeResources();
    }
    catch (IllegalStateException e)
    {
      if (!unconditional)
        throw new MIDletStateChangeException(e.getMessage());
    }
    // Вызываем метод объекта MIDlet, сигнализирующий AMS, что мидлет готов
    // к завершению. После этого вызова AMS не будет вызывать метод
    // destroyApp, так как предполагается, что перед явной сигнализацией о
    // закрытии мидлета программист закрыл соединения и остановил потоки.
    notifyDestroyed();
  }
}
HelloCanvas.java
package test;

import javax.microedition.lcdui.Canvas;
import javax.microedition.lcdui.Font;
import javax.microedition.lcdui.Graphics;
import javax.microedition.lcdui.Image;
import javax.microedition.midlet.MIDletStateChangeException;
import java.io.IOException;

/**
 * Класс Canvas, который будет использоваться для вывода информации на экран и
 * для уведомления о нажатых клавишах или других действиях пользователя
 */
class HelloCanvas extends Canvas
{
  /**
   * Путь к картинке, которую будем выводить на экран
   */
  private static final String IMAGE_PATH = "/aloha.png";

  /**
   * Белый цвет
   */
  private static final int COLOR_WHITE = 0x00FFFFFF;

  /**
   * Строка, которая будет выводиться, если картинка не проинициализирована
   */
  private String loading = "Loading...";

  /**
   * Наш объект MIDlet
   */
  private Hello midlet;

  /**
   * Картинка, которую будем выводить на экран
   */
  private Image helloImage;

  /**
   * Инициализируем объект
   * @param _midlet наш объект MIDlet
   */
  HelloCanvas(Hello _midlet)
  {
    midlet = _midlet;
  }

  /**
   * Инициализация объекта Canvas
   */
  void init()
  {
    //Развертываем Canvas на полный экран
    setFullScreenMode(true);
    // Загрузку картинки производим в отдельном потоке, так как это длительная
    // операция, а метод init вызыван в системной нити, и пока мы его не
    // отпустим, до мидлета не будут доходить события. В некоторых случаях
    // долгое выполнение системных callback'ов может привести к аварийному
    // завершению приложения
    new Thread(new Runnable()
    {
      public void run()
      {
        try
        {
          helloImage = Image.createImage(IMAGE_PATH);
          repaint();
        }
        catch (IOException ioe)
        {
          loading = ioe.getMessage();
        }
      }
    }).start();
  }

  /**
   * Освобождение ресурсов по максимуму
   * @throws IllegalStateException при какой-либо ошибке
   */
  void freeResources() throws IllegalStateException
  {
    try
    {
      //В данном случае единственный объемный ресурс - это картинка
      helloImage = null;
    }
    catch (Exception e)
    {
      throw new IllegalStateException(e.getMessage());
    }
  }

  protected void paint(Graphics g)
  {
    //Производим очистку области, которая помечена как недействительная,
    //то есть заново прорисовываем каждый пиксель в этой области
    g.setColor(COLOR_WHITE);
    g.fillRect(g.getClipX(), g.getClipY(), g.getClipWidth(),
      g.getClipHeight());
    if (helloImage != null)
      //Рисуем картинку, если она проинициализирована
      g.drawImage(helloImage, getWidth() / 2, getHeight() / 2,
        Graphics.VCENTER | Graphics.HCENTER);
    else
      //Если картинка непроинициализирована, то рисуем строку.
      //Центрирование по вертикали выполняем сами, так как оно
      //не определено в MIDP для рисования строк
      g.drawString(loading, getWidth() / 2,
        (getHeight() - Font.getDefaultFont().getHeight()) / 2,
        Graphics.HCENTER | Graphics.TOP);
  }

  protected void keyPressed(int keyCode)
  {
    //При нажатии на клавишу 0 завершаем выполнение мидлета
    if (keyCode == KEY_NUM0)
      //Опять же создаем новый поток, так как освобождение ресурсов в
      //общем случае - длительная операция
      new Thread(new Runnable()
      {
        public void run()
        {
          try
          {
            midlet.destroyApp(false);
          }
          catch (MIDletStateChangeException ignored)
          {
            //Если невозможно завершить мидлет, то
            //продолжаем выполнение
          }
        }
      }).start();
  }
}
В данном примере создается два потока, и, хотя к ним даны комментарии, стоит остановиться на этом подробнее. Методы startApp, pauseApp, keyPressed и т.д. вызываются AMS, то есть в системной нити, которая обрабатывает очередь событий и вызывает методы согласно их месту в этой очереди. Если мы загрузим какой-либо callback-метод длительными операциями или работой с внешними ресурсами (загрузка файла, сетевое взаимодействие), то очередь не будет обрабатываться, пока мы не отпустим системную нить. Если же произойдет исключение, то мидлет с очень большой вероятностью аварийно завершит свою работу. Поэтому все методы, вызываемые AMS, должны отрабатывать мгновенно – оптимально просто взводить флажок, что был сделан такой-то вызов, а сами действия производить уже в своей нити (в данном случае операция создания и старта нити приемлема по времени исполнения). Единственным исключением является метод paint. Хотя спецификация виртуальной машины не оговаривает, должна ли для его выполнения создаваться отдельная нить, чтобы не мешать обработке очереди событий, обычно разработчики устройств отдельную нить для него выделяют. Но все равно не стоит загружать paint объемными вычислениями, оставьте ему только рисование примитивов.
Не забудьте модифицировать строку упаковки jar’а, чтобы добавить в архив картинку:
jar.exe cvfm hello.jar manifest.mf -C preverified . -C res .

Анимация и обработка клавиатуры

Картинка уже есть, осталось ее подвигать с помощью джойстика. Центральная кнопка останавливает картинку, остальные изменяют вектор движения в соответствующую сторону:
Hello.java
package test;

import javax.microedition.lcdui.Display;
import javax.microedition.midlet.MIDlet;
import javax.microedition.midlet.MIDletStateChangeException;

/**
 * Тестовый мидлет Hello, world!, демонстрирующий работу с Canvas и загрузку
 * ресурсов
 */
public class Hello extends MIDlet
{
  /**
   * Объект Canvas, на котором будем рисовать
   */
  private static HelloCanvas canvas;

  /**
   * Объект Display, чтобы не вызывать getDisplay каждый раз, так как будет
   * получен один и тот же объект
   */
  private static Display display;

  /**
   * Создаем объекты Canvas и Display, так как нам это нужно только один раз за
   * время жизни мидлета
   */
  public Hello()
  {
    display = Display.getDisplay(this);
    canvas = new HelloCanvas(this);
  }

  /**
   * Метод, с которого должен начинаться пользовательский жизненный цикл
   * приложения. Вызывается AMS
   */
  public void startApp()
  {
    //Вызываем инициализацию класса HelloCanvas, так как любому вызову
    //startApp предшествует либо первый запуск мидлета, либо вызов
    //pauseApp, в котором мы должны осводить все ресурсы и остановить
    //созданные нити
    canvas.init();
    //Установка текущего экрана (объекта Displayable)
    display.setCurrent(canvas);
  }

  /**
   * Вызов метода сигнализирует о необходимости максимально освободить ресурсы
   * и закрыть соединения и потоки
   */
  public void pauseApp()
  {
    canvas.toPauseState();
  }

  /**
   * Вызов сигнализирует о том, что мидлет будет переведен в состояние
   * Destroyed и закрыт после выхода из этого метода. Этот метод создан для
   * вызова AMS, но им удобно пользоваться и для вызова из самого мидлета, так
   * как все необходимые действия перед закрытием будут описаны в одном методе,
   * который точно будет вызван хотя бы раз. Если destroyApp был вызван AMS, то
   * метод notifyDestroyed() будет проигнорирован, если был вызван
   * программистом из самого мидлета, то notifyDestroyed() отработает, но AMS
   * не будет вызывать destroyApp
   * @param unconditional обязательно ли мидлет должен завершить работу или
   * можно сгенерировать исключение, сигнализируя о невозможности закрытия в
   * данный момент
   * @throws MIDletStateChangeException при невозможности завершить работу
   * мидлета в данный момент
   */
  public void destroyApp(boolean unconditional)
    throws MIDletStateChangeException
  {
    canvas.toExitState();
  }

}
HelloCanvas.java
package test;

import javax.microedition.lcdui.Canvas;
import javax.microedition.lcdui.Font;
import javax.microedition.lcdui.Graphics;
import javax.microedition.lcdui.Image;
import java.io.IOException;

/**
 * Класс Canvas, который будет использоваться для вывода информации на экран и
 * для уведомления о нажатых клавишах или других действиях пользователя
 */
class HelloCanvas extends Canvas implements Runnable
{
  /**
   * Путь к картинке, которую будем выводить на экран. В данном случае указан
   * абсолютный путь относительно корня архива. Можно указывать и относительные
   * пути, но так как часто бывает сложно определить текущий каталог, то лучше
   * всегда указывать абсолютное значение
   */
  private static final String IMAGE_PATH = "/aloha.png";

  /**
   * Состояние инициализации
   */
  private static final int STATE_INIT = 1;
  /**
   * Нормальная работа
   */
  private static final int STATE_WORKING = 2;
  /**
   * Необходимо освободить ресурсы
   */
  private static final int STATE_RELEASE = 3;
  /**
   * Необходимо выйти
   */
  private static final int STATE_EXIT = 4;

  /**
   * Скорость движения картинки пикселей в секунду
   */
  private static final int SPEED = 50;

  /**
   * Минимальный временной интервал одного цикла. Нужен для быстрых устройств,
   * чтобы не загружать процессор бессмысленными FPS'ами
   */
  private static final int TICK_MIN = 50;

  /**
   * Так как в CLDC 1.0 отсутствует возможность работы с дробными числами, то
   * сделаем это искусственно. Так как один тик у нас настолько короток, что
   * приращение после умножения тика на скорость может быть менее единицы, то
   * будем считать приращение, умноженное на эту константу, а перед выводом на
   * экран делить координату на это число
   */
  private static final int FLOAT_EMULATE = 1000;

  /**
   * Белый цвет
   */
  private static final int COLOR_WHITE = 0x00FFFFFF;
  /**
   * Строка, которая будет выводиться, если картинка не проинициализирована
   */
  private String loading = "Loading...";

  /**
   * Наш объект MIDlet
   */
  private Hello midlet;

  /**
   * Картинка, которую будем выводить на экран
   */
  private Image helloImage;

  /**
   * Отражает текущее состояние приложения
   */
  private volatile int state;

  /**
   * Должна ли выполняться наша нить
   */
  private volatile boolean isRunning;

  /**
   * Текущая координата X картинки
   */
  private int x;
  /**
   * Текущая координата Y картинки
   */
  private int y;

  /**
   * Ширина экрана, чтобы не вызывать метод в каждом цикле перерисовке
   */
  private int canvasWidth;
  /**
   * Высота экрана, чтобы не вызывать метод в каждом цикле перерисовке
   */
  private int canvasHeight;

  /**
   * Текущее направление движения картинки по горизонтали
   */
  private int movingHDirection;

  /**
   * Текущее направление движения картинки по вертикали
   */
  private int movingVDirection;

  /**
   * Предыдущее время тика
   */
  private long timePrevious;

  /**
   * Поле для синхронизации отрисовки и обсчета логики
   */
  private boolean isPaintEnds;

  /**
   * Инициализируем объект
   * @param _midlet наш объект MIDlet
   */
  HelloCanvas(Hello _midlet)
  {
    midlet = _midlet;
  }

  /**
   * Инициализация объекта Canvas
   */
  void init()
  {
    state = STATE_INIT;
    isRunning = true;
    new Thread(this).start();
  }

  /**
   * Загрузка ресурсов
   */
  private void loadResources()
  {
    try
    {
      helloImage = Image.createImage(IMAGE_PATH);
      x = (getWidth() - helloImage.getWidth()) * FLOAT_EMULATE / 2;
      y = (getHeight() - helloImage.getHeight()) * FLOAT_EMULATE / 2;
    }
    catch (IOException ioe)
    {
      loading = ioe.getMessage();
    }
  }

  void toPauseState()
  {
    state = STATE_RELEASE;
  }

  void toExitState()
  {
    state = STATE_EXIT;
  }

  /**
   * Освобождение ресурсов по максимуму
   * @throws IllegalStateException при какой-либо ошибке
   */
  private void releaseResources() throws IllegalStateException
  {
    try
    {
      // В данном случае единственный объемный ресурс - это картинка
      helloImage = null;
      // Останавливаем нашу нить
      isRunning = false;
    }
    catch (Exception e)
    {
      throw new IllegalStateException(e.getMessage());
    }
  }

  protected synchronized void paint(Graphics g)
  {
    // Производим очистку области, которая помечена как недействительная,
    // то есть заново прорисовываем каждый пиксель в этой области
    g.setColor(COLOR_WHITE);
    g.fillRect(g.getClipX(), g.getClipY(), g.getClipWidth(),
      g.getClipHeight());
    if (helloImage != null)
    {
      // Устанавливаем рамки для рисования, так как на некоторых устройствах
      // попытка отрисовки за экраном может привести к исключению
      g.setClip(0, 0, canvasWidth, canvasHeight);
      // Выводим картинку, если она проинициализирована
      int locX = x / FLOAT_EMULATE;
      int locY = y / FLOAT_EMULATE;
      g.drawImage(helloImage, locX, locY, Graphics.LEFT | Graphics.TOP);
      if (locX < 0)
      {
        locX += canvasWidth;
      }
      else if (locX + helloImage.getWidth() > canvasWidth)
      {
        locX -= canvasWidth;
      }
      if (locX != x / FLOAT_EMULATE)
      {
        g.drawImage(helloImage, locX, locY, Graphics.LEFT | Graphics.TOP);
      }
      if (locY < 0)
      {
        locY += canvasHeight;
      }
      else if (locY + helloImage.getHeight() > canvasHeight)
      {
        locY -= canvasHeight;
      }
      if (locY != y / FLOAT_EMULATE)
      {
        g.drawImage(helloImage, x / FLOAT_EMULATE, locY,
          Graphics.LEFT | Graphics.TOP);
        if (locX != x / FLOAT_EMULATE)
        {
          g.drawImage(helloImage, locX, locY, Graphics.LEFT | Graphics.TOP);
        }
      }
    }
    else
    {
      // Если картинка не проинициализирована, то рисуем строку.
      // Центрирование по вертикали выполняем сами, так как оно не
      // определено в MIDP для рисования строк
      g.drawString(loading, getWidth() / 2,
        (getHeight() - Font.getDefaultFont().getHeight()) / 2,
        Graphics.HCENTER | Graphics.TOP);
    }
    isPaintEnds = true;
    notifyAll();
  }

  protected void keyPressed(int keyCode)
  {
    // Получаем тип действия по коду нажатой клавиши
    int gameAction = getGameAction(keyCode);
    // И в зависимости от типа действия выставляем направление движения
    switch (gameAction)
    {
      case LEFT:   movingHDirection = -1;                   break;
      case RIGHT:  movingHDirection = 1;                    break;
      case UP:     movingVDirection = -1;                   break;
      case DOWN:   movingVDirection = 1;                    break;
      case FIRE:   movingHDirection = movingVDirection = 0; break;
    }
    if (keyCode == KEY_NUM0)
    {
      // При нажатии на клавишу 0 завершаем выполнение мидлета
      state = STATE_EXIT;
    }
  }

  public void run()
  {
    while (isRunning)
    {
      long tick = tickTime();
      switch (state)
      {
        case STATE_INIT:    doInitState();        break;
        case STATE_WORKING: doWorkingState(tick); break;
        case STATE_RELEASE: doReleaseState();     break;
        case STATE_EXIT:    doExitState();        break;
      }
    }
  }

  /**
   * Операции для выхода из приложения
   */
  private void doExitState()
  {
    releaseResources();
    midlet.notifyDestroyed();
  }

  /**
   * Операции для освобождения ресурсов
   */
  private void doReleaseState()
  {
    releaseResources();
  }

  /**
   * Обработка логики программы в рабочем режиме
   * @param tick время тика
   */
  private synchronized void doWorkingState(long tick)
  {
    // Направление движения (-1 - влево, 1 - вправо, 0 - стоим)
    // умножаем на скорость (пикселей в секунду), на количество
    // прошедших миллисекунд, на константу эмуляции работы с плавающей
    // запятой и делим на количество миллисекунд в секунде (так как
    // скорость завязана именно на секундах)
    x += movingHDirection * SPEED * tick * FLOAT_EMULATE / 1000;
    // То же самое с координатой Y
    y += movingVDirection * SPEED * tick * FLOAT_EMULATE / 1000;
    if (x / FLOAT_EMULATE < -helloImage.getWidth())
    {
      x += canvasWidth * FLOAT_EMULATE;
    }
    else if (x / FLOAT_EMULATE > canvasWidth)
    {
      x -= canvasWidth * FLOAT_EMULATE;
    }
    if (y / FLOAT_EMULATE < -helloImage.getHeight())
    {
      y += canvasHeight * FLOAT_EMULATE;
    }
    else if (y / FLOAT_EMULATE > canvasHeight)
    {
      y -= canvasHeight * FLOAT_EMULATE;
    }
    //Синхронизация метода paint с обсчетом логики
    isPaintEnds = false;
    repaint();
    while (!isPaintEnds)
    {
      try
      {
        wait();
      }
      catch (InterruptedException ignored)
      {
      }
    }
  }

  /**
   * Отработка инициализации ресурсов и т.д.
   */
  private void doInitState()
  {
    //Развертываем Canvas на полный экран
    setFullScreenMode(true);
    // Загрузку ресурсов (в данном случае картинки) выполняем в отдельном
    // потоке, так как это длительная операция, а метод init вызыван в
    // системной нити, и пока мы его не «отпустим» до мидлета не будут
    // доходить события. В некоторых случаях долгое выполнение системных
    // callback'ов может привести к аварийному завершению приложения
    loadResources();
    canvasWidth = getWidth();
    canvasHeight = getHeight();
    repaint();
    state = STATE_WORKING;
  }

  /**
   * Расчет времени тика
   * @return время тика
   */
  private long tickTime()
  {
    long tick = System.currentTimeMillis() - timePrevious;
    if (tick < TICK_MIN)
    {
      try
      {
        Thread.sleep(TICK_MIN - tick);
      }
      catch (InterruptedException ignored)
      {
        //Ничего страшного, если проспим меньше положенного
      }
      tick = System.currentTimeMillis() - timePrevious;
    }
    timePrevious = System.currentTimeMillis();
    return tick;
  }
}
Как видно из листинга, для синхронизации логики с отрисовкой экрана используется стандартный механизм Java – synchronized, wait и notifyAll, причем для усыпления нити используется цикл while с переменной. Этому есть две причины:
  1. На одном объекте может спать много нитей, а метод notify будит только одну по своему усмотрению, то есть мы не можем гарантировать, что разбудят именно нашу.
  2. В одной из версий VM была допущена ошибка, из-за которой нить могла проснуться сама по себе. До некоторого времени эта причина оставалась теоретической, но, к сожалению, пару месяцев назад я с ней столкнулся на Motorola L6.
В MIDP 2.0 присутствуют свои методы синхронизации paint:
  • Использование GameCanvas и блокирующий вызов flushGraphics для отрисовки экрана. Его использование чревато проблемами на некоторых устройствах, производители которых не потрудились прочитать спецификацию, а также потерей 5-7% производительности. К тому же использование этого класса приводит к некоторому увеличению кучи.
  • Блокирующий вызов Canvas.serviceRepaints(). Также немного проседает производительность. Основная причина не использовать его – неадекватное поведение на некоторых устройствах и возможная остановка обработки системной очереди событий.
  • Асинхронный вызов Display.callSerially(Runnable). При этом метод run указанного объекта типа Runnable будет поставлен в системную очередь событий и отработает только после вызова paint, но не обязательно сразу. Здесь основная проблема в том, что при такой схеме метод run должен отработать мгновенно, что невозможно для приложения сложнее «Hello, World!»
В принципе, можно использовать и второй вариант. Среди современных устройств багов в использовании этого способа замечено не было, я же использую стандартную для Java схему синхронизации скорее по инерции, и потому, что она проверена не на одном десятке устройств.
Последний вариант приведен только для информации о его существовании, в реальных проектах его использовать нельзя.
Что касается GameCanvas, то он мог бы быть полезен, если документация более четко описывала бы поведение устройств при одновременном нажатии нескольких клавиш. Менять же Canvas на его наследника только из-за более продуманной по сравнению с serviceRepaints процедуры обновления экрана смысла нет. Практика показывает, что самописный код работает оптимальнее, разве что проблема двойной буферизации решается внутри этого класса, но устройств, которые не поддерживают метод вывода на экран через дополнительный буфер, к счастью, почти не осталось, и у ведущих производителей такие модели отсутствуют уже давно.
ПРИМЕЧАНИЕ
Двойная буферизация необходима для устранения мелькания экрана. Дело в том, что в объекте Graphics, полученном методом paint, рисуются примитивы, при этом программист не может знать, когда устройство решит вывести свой буфер на экран. Получается, что при отдельно взятой перерисовке на экране может отобразиться только часть примитивов, так как на момент принятия решения об обновлении физического экрана в буфер были занесены только они. Проще говоря, обновление физического экрана может произойти в середине метода paint. Данная ситуация решается путем введения еще одного буфера, в этом случае сначала все рисуется именно в него, а после полной отрисовки картинка буфера скидывается в системный буфер. Проверить, поддерживает ли устройство двойную буферизацию, можно через вызов Canvas.isDoubleBuffered(). Если не поддерживает, то эту схему надо реализовать самому через введение дополнительного Image, который и будет служить буфером.
В примечании упоминается объект Image, в который можно рисовать. Этот объект отличается от объекта Image, который использовался в примерах работы с графикой. В MIDP картинки бывают двух типов: изменяемые (mutable) и неизменяемые (immutable). В первые, соответственно, можно рисовать, но они не поддерживают прозрачность, вторые же не могут быть изменены, но могут содержать прозрачные и полупрозрачные (зависит от устройства) пиксели, и отрисовка их проходит быстрее.
Второе, на чем надо остановиться при разборе последнего примера – для чего введено искусственное ограничение FPS. Платформа J2ME предназначена для мобильных устройств, а значит, в абсолютном большинстве случаев они работают на аккумуляторах. Пользователь вряд ли отличит двадцать кадров в секунду от ста, но при этом энергия не будет тратиться на вычисления, результаты которых никто все равно не увидит.
И последнее в этом разделе – от чего должна зависеть скорость анимации. Те, кто помнит старые ДОСовские игры, однозначно скажут – не от производительности компьютера. За основу должно браться время. Скорость изменения картинки, движение объекта – все должно быть завязано на секундах, а не частоте конкретного процессора. И надо учитывать, что в реальной жизни мгновенного набора скорости не существует, поэтому движение, реализованное с ускорением (школьная формула x = x0 + V0*t + a*t2/2), будет смотреться приятнее, хотя наличие ускорения и не всегда можно определить на глаз.

Хранилище (RecordStore)

В примерах не рассмотрен важный момент – постоянное хранение данных. В принципе, ничего сложного в этом нет, и любому программисту с небольшим опытом не составит труда разобраться в документации, поэтому данный раздел будет содержать в основном информацию о подводных камнях.
В стандартном MIDP есть только один механизм постоянного хранения данных – RecordStore (javax.microedition.rms). Пакет позволяет создавать отдельные хранилища с записями, данные которых не удаляются при закрытии мидлета. Основной проблемой является ограниченность их размера. Для настроек звука, номера пройденного уровня и таблицы рекордов, конечно, хватит, но хранение большого объема данных возможно не на всех устройствах. К счастью, таковых становится все меньше.
Ограничения:
  • Фрагментация хранилища. При удалении записи на ее место может быть записан только меньший или равный по размеру блок данных, поэтому для приложений с активным использованием RecordStore рекомендуется регулярно (например, при старте приложения) считывать нужные данные из хранилища в память, удалять его и создавать заново, сохраняя ранее считанные в память записи.
  • Кроме общего ограничения на размер хранимых данных, может существовать ограничение на размер одного хранилища и одной записи. Правда, это касается только старых устройств.
  • Для обеспечения целостности данных хранилище должно быть открыто только непосредственно на время работы с ним, то есть нельзя открывать его при старте и закрывать при выходе из приложения, так как выход может быть непредусмотренным.
  • Стоит учитывать, что операции с RecordStore довольно медленны.

Особенности разработки на J2ME

Многообразие виртуальных машин

Основная беда платформы J2ME – фрагментированность, то есть существование очень многих версий виртуальных машин. Практически каждый производитель успел придумать свою реализацию KVM, либо вообще использует AOT (Ahead Of Time) компиляцию (при установке мидлета его байт-код транслируется в родной для устройства бинарный код, который в итоге и будет исполняться). Кроме того, существуют отдельные фирмы и энтузиасты, которые тоже не прочь «оптимизировать» существующие проекты. При этом, к сожалению, Sun практически забросила сертификацию мобильных VM (другого объяснения примитивным багам я не вижу), решив отделаться reference design’ом. В настоящий момент исходный код их оригинальный виртуальной машины доступен как Open Source – phoneme.
Пару лет назад к различиям в реализации добавлялись и собственные пакеты от производителей, которые были призваны помочь разработчикам создавать более продвинутые мидлеты, но, к сожалению, они поддерживались только самими производителями устройств. Исключение составляло Nokia UI API, которое сертифицировал Sony Ericsson. На данный момент нужда в дополнительных нестандартизованных пакетах практически отпала, и они, если и поддерживаются, то в основном для совместимости.

ООП или не ООП?

Наверное, первое, что бросается в глаза в коде, – это использование всего двух классов, хотя картинка и методы обсчета ее координат вполне могут составить отдельную сущность. Причина проста: использование классов, полиморфизма, интерфейсов и прочих сущностей, облегчающих жизнь программисту, выливается в увеличение размера кода, используемой памяти и снижает производительность программы, что очень критично для мобильных устройств. Всего год с небольшим назад мне пришлось делать версию игры для Nokia Series 40 Developer Platform 1.0 (ограничение на размер jar’а – 655xx байт). При этом использовалось всего три класса, размер основного составлял около 12000 строк. Кажется немыслимым?Но добавление хотя бы еще одной сущности означало ухудшение качества графики, которая и так уже была близка к минимуму. Естественно, что в данном случае должен мучиться программист, а не пользователь, выложивший за игру кровные деньги.
В интернете не единожды возникали споры, в которых ортодоксы Java пытались доказать, что такой подход неправилен, раз в названии есть Java, то должно быть полное OOP и OOD, и предлагали различные выходы, начиная с отказа поддержки устройств, которые не потянули бы «правильно» спроектированное приложение, до бойкота вообще всей платформы J2ME. Мое же мнение таково: определитесь, что и для кого вы пишете. Если для самоудовлетворения или для знакомых, либо если заранее известно, что конечными устройствами будут топовые смартфоны и коммуникаторы, то не мучайте себя, в этих случаях использование объектного подхода полностью оправдано. Если же ваш продукт должен быть массовым, чтобы пользователи как можно большего количества устройств смогли им воспользоваться, не пожалев денег и трафика, то в первую очередь необходимо позаботиться о комфортной работе в приложении, и только потом о собственном удобстве при разработке. Абсолютное большинство успешных проектов подтверждает именно этот подход.
Помните, что любая парадигма или технология – всего лишь средство для достижения цели, но не самоцель.

Универсальный мидлет

Наверное, это самая частая ошибка разработчиков, которые уже освоили саму платформу, и начинают писать свой первый серьезный проект. Понятное желание не вести параллельно несколько версий для разных моделей, а также нормальное для Java-программиста неприятие препроцессора приводит к тому, что в одну версию пытаются запихнуть (по-другому и не скажешь) специфическое для разных моделей поведение, что раздувает код и опять же приводит к нерациональному использованию ресурсов.
Далеко не каждое бизнес-приложение позволит сделать это разумно, об играх и говорить нечего – графику для всех поддерживаемых разрешений экранов невозможно поместить в один архив приемлемого объема. Если слово «препроцессор» вызывает отвращение, то перечитайте предыдущий раздел, повторяться не имеет смысла, так как аргументы будут теми же самыми.
Основные причины для портирования:
  1. Активно используется графика.
  2. Возможность работы (то есть, приложением можно будет пользоваться и без этого) с пока еще не очень распространенными библиотеками: JSR-075 (FileConnection и PIM API), JSR-082 (Bluetooth API), JSR-211(CHAPI) и т.д. К тому же производители и даже команды, работающие над разными моделями, могут по-своему понимать спецификации, или не понимать их вообще.
  3. Конечные устройства могут быть очень ограничены в ресурсах, что потребует изменения алгоритма или сокращения возможностей.
  4. Поддержка специфичных устройств (некоторые Sharp, Panasonic и т.д.). Некоторые, например, не смогут понять стандартный дескриптор, так как опять же разработчики не удосужились вникнуть в документацию.
Если ваш проект подходит хотя бы по одному пункту, то очень рекомендую разделять версии для разных устройств с самого начала разработки. В противном случае вам повезло, но при массовом распространении приложения (через контент-провайдеров, либо через собственный сайт) настоятельно рекомендую для каждой группы совместимых устройств сделать отдельную копию приложения с соответствующими названиями дескриптора и jar’а, так как универсальный мидлет вызывает обоснованные подозрения в низкой квалификации разработчика.

Обфускация

В мире настольных и серверных приложений обфускацию (запутывание кода программы для затруднения ее декомпиляции и анализа) применяют в основном для защиты от взлома и кражи алгоритмов. Для мобильных же приложений она в первую очередь необходима как оптимизатор. При обфускации названия классов, методов, полей сокращаются до одного-двух символов, что очень положительно сказывается на размере байт-кода, современные обфускаторы предоставляют также несколько способов оптимизации производительности приложений (инлайнинг методов, объединение классов и т.д.).

Интерфейс пользователя и отличия от «настольного» приложения

Насколько оправдано использование стандартного GUI, уже говорилось, хочу лишь добавить, что пользователь мобильного телефона очень отличается от пользователя настольного компьютера или ноутбука. Учтите это при разработке интерфейса и обработке клавиш.
Главные особенности:
  • Небольшой экран, поэтому информация должна быть краткой и понятной (перематывание текста очень утомляет), а картинки детализированы.
  • В большинстве случаев невозможно или очень трудно нажать несколько клавиш, поэтому ориентируйтесь на одну нажатую клавишу в каждый конкретный момент.
  • Исполнение приложения может быть прервано в любой момент входящим звонком или SMS, причем этого не может предполагать и сам пользователь.
  • Как следствие из предыдущего пункта – любой внешний ресурс (хранилище, файл, сетевой поток) должен использоваться только тогда, когда он нужен, и закрываться после совершения необходимых действий.

Выбор тестовых устройств и список портирования

Перед началом выбора тестовых устройств необходимо понять, для кого именно создается мидлет. Если это массовое приложение или игра, то она должна приемлемо работать на как можно большем количестве устройств. Здесь очень помогла бы статистика контент-провайдеров и операторов, но они не любят ей делиться.
Написание мидлета для корпоративных целей одного заказчика обычно предполагает одно устройство, которое к тому же должен посоветовать разработчик, но и здесь надо учитывать возможное желание клиента расширить список совместимых устройств. К сожалению, таких проектов я в своей жизни не видел.
Бизнес-приложение, не рассчитанное на постоянных потребителей мобильного контента. В данном случае можно немного расслабиться и составить список рекомендуемых устройств, на которые и делать упор.
В любом случае устройства придется группировать. Для Sony Ericsson и Nokia делать это, можно сказать, одно удовольствие. Эти производители не стесняются делиться с разработчиками спецификациями телефонов и даже сами составляют группы совместимости. Так что в первую очередь уделяйте внимание именно этим производителям. Motorola тоже предоставляет информацию об устройствах, но с группами совместимости не все так гладко, как и у остальных производителей, вам придется самим их составлять на основе тем в форумах и открытых проектов. А вот у Samsung (в России, да и в мире он находится в тройке лидеров) почти каждая модель уникальна в самом худшем значении этого слова. У них тоже есть портал для разработчиков, где имеются спецификации для части устройств, и даже когда-то были документы по переносу приложений и спискам совместимости. Плохо одно – часто информация не соответствует действительности, или какая-нибудь модель имеет такой баг, что приходится делать отдельную версию, хотя вроде бы по остальным параметрам все сходится.
Другие производители мобильных устройств о J2ME-разработчиках не беспокоятся, насколько необходимы версии приложений под них, решайте сами (хотя за вас могут решить контент-провайдеры). При разработке игр я не обошел бы вниманием коммуникаторы с Windows Mobile, как они довольно распространены и обычно имеют предустановленную VM TAO Intent JMM, что означает некую совместимость и приличную возможность «отделаться» одной версией для одинаковых экранов.

Настройка IDE и сборка проекта

Уже довольно давно появились плагины для современных IDE, облегчающие жизнь разработчика J2ME. Но даже самые продвинутые из них не обеспечивают той гибкости, которую можно получить при помощи сборщика проектов. Я использую Ant и Antenna (набор задач для Ant’а и препроцессор) версии 0.9.13, и в этом разделе будет рассмотрена настройка двух наиболее популярных IDE при работе с Ant’ом. Для других сред разработки действия будут аналогичными.

Ant и Antenna

Как уже было сказано выше, Antenna – это набор задач для Ant’а, который облегчает процесс сборки приложения. В принципе, все можно написать самому через тот же Ant, но полученный код будет довольно громоздким и, естественно, не получится использовать препроцессор.
Простейший скрипт сборки может выглядеть так:
build.xml
name
="Test" default="debugBuild" basedir="."> name="src.dir" value="src"/> name="srcPreprocess.dir" value="srcPreprocess"/> name="classes.dir" value="classes"/> name="res.dir" value="res"/> name="preprocess.TestSymbol" value="TEST"/> name="preprocessTest.dir" value="${srcPreprocess.dir}/${preprocess.TestSymbol}"/> name="jar.name" value="test"/> name="jad.name" value="test"/> name="project.username" value="Test"/> name="vendor" value="Test Inc."/> name="version" value="01.00.00"/> name="midletclass" value="test.Hello"/> "antenna.properties" classpath="C:\MobileDevUtils\Antenna.Edited\antenna.jar"/> name="wtk.home" value="C:\SonyEricsson\JavaME_SDK_CLDC\PC_Emulation\WTK2"/> name="wtk.proguard.home" value="C:\MobileDevUtils\proguard3.8beta"/> name="wtk.midp.version" value="2.0"/> "api"> "${wtk.home}\lib\cldcapi10.jar"/> "${wtk.home}\lib\midpapi20.jar"/> name="releaseBuild" depends="jad, packRelease"> name="debugBuild" depends="jad, packDebug"> name="preprocess"> "${src.dir}" destdir="${preprocessTest.dir}" symbols="${preprocess.TestSymbol}" verbose="true"/> name="clean"> "${classes.dir}" failonerror="false"/> "${jar.name}.jar" failonerror="false"/> "${classes.dir}"/> name="packDebug" depends="clean, preprocess"> "true" destdir="${classes.dir}" srcdir="${preprocessTest.dir}" target="1.1" source="1.3" bootclasspathref="api"/> "${jar.name}.jar" jadfile="${jad.name}.jad" obfuscate="false" preverify="true" bootclasspathref="api"> "${classes.dir}"/> "${res.dir}"/> name="packRelease" depends="clean, preprocess"> "false" destdir="${classes.dir}" srcdir="${preprocessTest.dir}" target="1.1" source="1.2" bootclasspathref="api"/> "${jar.name}.jar" jadfile="${jad.name}.jad" obfuscate="true" preverify="true" bootclasspathref="api"> "${classes.dir}"/> "${res.dir}"/> name="jad"> "${jad.name}.jad" name="${project.username}" vendor="${vendor}" version="${version}" update="false"> name="${project.username}" icon="/icon.png" class="${midletclass}"/> name="MIDlet-Icon" value="/icon.png"/> name="runSE_debug" depends="debugBuild, runSE_debug_Alone"> name="runSE_debug_Alone"> "C:\SonyEricsson\JavaME_SDK_CLDC\PC_Emulation\WTK2\bin\emulator.exe"> "-Xdevice:SonyEricsson_K750_Emu"/> "-Xdebug"/> "-Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5005"/> "-Xdescriptor:./${jar.name}.jad"/> name="runSE" depends="debugBuild, runSE_Alone"> name="runSE_Alone"> "C:\SonyEricsson\JavaME_SDK_CLDC\PC_Emulation\WTK2\bin\emulator.exe"> "-Xdevice:SonyEricsson_K750_Emu"/> "-Xdescriptor:./${jar.name}.jad"/>
Перед запуском замените метод startApp на следующий для демонстрации препроцессора:
  public void startApp()
  {
    //Вызываем инициализацию класса HelloCanvas, так как любому вызову 
    //startApp предшествует либо первый запуск мидлета, либо вызов 
    //pauseApp, в котором мы должны осводить все ресурсы и остановить
    //созданные нити
    canvas.init();
    //Установка текущего экрана (объекта Displayable), который должен 
    //показываться
    display.setCurrent(canvas);
    //Для теста препроцессора
    //#if TEST
    //# System.out.println("Preprocess is working!");
    //#endif
  }
При запуске эмулятора вы увидите в консоли «Preprocess is working!»

IntelliJ IDEA

Версия 7.0.2
Каталог, содержащий исходный код и ресурсы уже есть, он и будет корневым каталогом проекта.
Выберите New Projeсt…, далее «Create project from scratch», на следующем экране укажите корневой каталог проекта. На последнем экране мастера проверьте, что напротив каталога src стоит галка, и нажмите «Finish».
Проект создан, теперь его надо настроить: File -> Settings. На закладке General укажите «No JDK». Так как библиотеки у вас свои (CLDC и MIDP), а собираться проект будет через Ant, то JDK действительно не нужен, и даже наоборот, он будет захламлять IDEA, которая будет пытаться сканировать его библиотеки на предмет нужных классов и JavaDoc.
Перейдите на закладку «Libraries», нажмите на плюсик и задайте имя библиотеки, например, «CLDC&MIDP», укажите созданный ранее модуль.
ПРИМЕЧАНИЕ
Модуль в IDEA – минимальная функциональная единица, которую можно компилировать, отлаживать, запускать и т.д. Проект может состоять из нескольких модулей.
Далее нажмите «Attach Classes…» и выберите нужные библиотеки из SE SDK: cldcapi10.jar и midpapi20.jar из каталога C:\SonyEricsson\JavaME_SDK_CLDC\PC_Emulation\WTK2\lib\. В некоторых SDK все пакеты находятся в одном архиве с названием midp.jar или аналогичным. Нажмите «Attach JavaDoc..» и добавьте нужную документацию – каталог C:\SonyEricsson\JavaME_SDK_CLDC\docs\j2me\midp_2_0 (документация по обеим библиотека объединена в одну, так удобнее пользоваться ей в браузере, не переключаясь между двумя закладками).
Рекомендуется на закладке Modules исключить из созданного модуля все каталоги, кроме src. Это позволит несколько поднять производительность IDEA при синхронизации проекта с файловой системой, если были внешние изменения, а они точно будут, так как сборка приложения производится с помощью Ant.
Следующий шаг – настройка отладчика. Run -> Edit Configurations, жмете на плюсик и выбираете конфигурацию «Remote». Укажите название и уберите все галки. В принципе, можно указать запуску эмулятора в Ant’е опцию spawn=”true” (выполнение задачи в отдельном потоке) и выбрать исполнение этой задачи перед стартом отладчика, но тогда потеряется консоль, а вывод информации через System.out бывает очень полезен, так что я этой опцией не пользуюсь.
Подключите build.xml, нажав на плюсик на панели Ant Build, расположенной справа. В любом месте панели щелкните правой кнопкой и перейдите в свойства скрипта. На закладке «Execution» необходимо указать JDK, так как по умолчанию он соответствует JDK проекта, который был специально отключен ранее. Также удобно будет проводить сборку в фоновом режиме, поэтому стоит выбрать «Make build in background».
Теперь запустите «runSE_debug», после появления окна эмулятора выберите Run->Debug. Для начала отладки запустите приложение в эмуляторе.

Eclipse

Версия 3.3.1.1
Создайте новый проект: File->New->Java Project. Введите имя проекта и укажите каталог src. В области Contents на следующем экране мастера в качестве папки с исходным кодом оставьте только src. На закладке Libraries удалите все библиотеки и добавьте cldcapi10.jar и midpapi20.jar из каталога C:\SonyEricsson\JavaME_SDK_CLDC\PC_Emulation\WTK2\lib\ (кнопка «Add External JARs…»)
Проект создан. В окне Window->Preferences->Java->Installed JREs убедитесь, что текущая JRE указывает на нужный JDK (именно JDK, а не JRE, иначе Ant не сможет собрать проект). В меню Project снимите галку «Build Automatically», так как компиляция на лету в данном случае бесполезна. В свойствах проекта (Project->Properties) выберите JavaDoc Location и укажите путь C:\SonyEricsson\JavaME_SDK_CLDC\docs\j2me\midp_2_0. Далее выберите Run->Open Debug Dialog. В появившемся окне создайте конфигурацию Remote Java Application, введите название и измените порт по умолчанию на 5005, указанный в build.xml.
Теперь нужно настроить выполнение скрипта Ant. Выберите Window->Show View->Ant и добавьте файл build.xml. Запустите «runSE_debug» и выберите Run->Open Debug Dialog… В этом окне нажмите кнопку «Debug». После первого запуска отладки можно будет просто выбирать пункт Run->Debug.

Полезные ссылки

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

Спецификации

Официальные порталы для разработчиков

Эти сайты поддерживаются непосредственно производителями мобильных телефонов. Эмуляторы, документацию, спецификации устройств следует в первую очередь искать именно здесь.

Эмуляторы и документация других производителей

Некоторые производители, например, LG, никогда не имели программ для разработчиков. Некоторые успели обанкротиться (Siemens). Но кое-что по их устройствам удалось найти:

Базы данных по устройствам

ПРИМЕЧАНИЕ
UAP (User Agent Profile) – профиль телефона с информацией, предназначенной в основном для разработчиков wap-сайтов. Некоторая информация полезна и для J2ME-разработчиков.
  • Компания (точнее, сообщество) JavaVerified занимается организацией тестирования и сертификации мидлетов, в том числе выдают сертификат для подписи, но в качестве документации для клиентов присутствует и группы совместимости: http://javaverified.com/

Форумы (кроме форумов на порталах производителей)

  • http://forum.juga.ru/ – наиболее оживленный русскоязычный форум для разработчиков J2ME.
  • http://www.mgdc.ru/board/ – тоже русскоязычный форум разработчиков, но более ориентированный на мобильную индустрию в целом, чем конкретно на программистов.
  • http://forum.java.sun.com – форум от Sun.
  • http://www.j2meforums.com – довольно крупный англоязычный форум.

Книги

К сожалению, книги по платформе J2ME, вышедшие на русском языке, не заслужили признания разработчиков, так что данный раздел будет ограничен перечислением существующей литературы. При желании мнение программистов можно найти на форуме http://forum.juga.ru
  • «Платформа программирования j2me для портативных устройств», Автор: Вартан Пирумян
  • «Программирование мобильных телефонов на Java2 Micro Edition», Автор: Горнаков С.
  • «Пишем программы и игры для сотовых телефонов», Автор: Буткевич Е.Л.
  • «Программирование игр для сотовых телефонов на J2ME», Автор: Любавин С.А.
  • «Программируем игры для мобильных телефонов», Автор: под ред. Виноградова А.В.
  • «Создание игр для мобильных телефонов», Автор: Моррисон Майкл
Я в свое время прочитал Вартана Пирумяна, в примерах много ошибок, но в целом книга дала мне полезную информацию, тем более, что в то время ресурсы на русском по J2ME практически не встречались.

Заключение

Данная статья описывает только первые этапы разработки профессионального приложения. Не менее важно правильно оптимизировать мидлет, обойти баги устройств и учесть их специфику, на что начинающий J2ME-программист потратит большую часть времени. И это только технические стороны создания успешного проекта, которые сами по себе требуют отдельных статей, в которых вряд ли можно будет полностью описать все нюансы. В сети уже немало материалов на эту тему, но основные знания можно почерпнуть из общения с коллегами, то есть на форумах (не забывая о поиске, естественно!), так что уверен, скоро встретимся.


Эта статья опубликована в журнале RSDN Magazine #4-2007. Информацию о журнале можно найти здесь

Комментариев нет:

Отправить комментарий