вторник, 12 июля 2011 г.

Пишем виджет для Android

В сети есть достаточно много инструкций по написанию виджетов для Android. Большая часть из них "HelloWorld-ы", в остальных местах упущены какие-то важные мелочи... В общем давайте разберём создание виджета "по взрослому": с фоновыми процессами, обработкой кликов, локализацией. Пусть наш виджет получает курс валюты Bitcoin по отношению к доллару из API биржы Mt. Gox и отображает его текущее значение. В результате мы получим что-то вроде этого.

Во-первых, чем виджет отчается от Activity?
Виджет работает в рамках "рабочего стола" нашего смартфона и от этого имеет как плюсы так и минусы. Плюсы: можно настроить обновление средствами системы. Интервал обновления при этом не может быть меньше получаса (180000 ms). Приложение всегда на виду: клиенту можно что-то сообщать не дожидаясь его действий. Минусы: ограниченный набор компонентов, доступных для использования в интерфейсе (из компоновщиков можно использовать только  "FrameLayout", "LinearLayout" и "RelativeLayout". Из View: "AnalogClock", "Button", "Chromometer", "ImageButton", "ImageView", "ProgressBar" и "TextView".). Также ограничено время, которое отводится на исполнение запросов. Ну, и, само собой, ограниченный размер "рабочей площади". Также достаточно сложно реализовать обработку событий в виджете: onclickListener на кнопку в виджете "повесить" не получится.

Как регистрировать виджет?
Так же как и всё остальное в нашем приложении, виджет описывается в AndroidManifest.xml. В тег application добавляем структуру:

    <receiver android:name=".CourceWidget" android:label="@string/app_label">
      <intent-filter>
        <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
      </intent-filter>
      <meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_cource" />
    </receiver>

Тут .CourceWidget - класс, описывающий виджет, widget_cource - xml файл (фактически:  res/xml/widget_cource.xml), описывающий параметры виджета.

Как описать параметры виджета?
Вот, например, так:
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
  android:minWidth="146dip"
  android:minHeight="72dip"
  android:updatePeriodMillis="1800000"
  android:initialLayout="@layout/cource_message"
  />

Тут android:minWidth и android:minHeight соответственно ширина и высота виджета. Рекомендуется приводить размеры по формуле: число пикселей = (число ячеек * 74) – 2. Одну ячейку на "рабочем столе" занимает одна иконка. Значение android:updatePeriodMillis, как уже говорили, определяет период обновления виджета в миллисекундах. В атрибуте  android:initialLayout указываем ссылку на xml файл (res/layout/cource_message.xml) с описанием интерфейса виджета.

Как описать интерфейс виджета?
Вот, например, так:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="fill_parent"
  android:layout_height="wrap_content"
  android:gravity="center"
  android:background="@drawable/widget_bg">
  <LinearLayout
    android:orientation="vertical"
    android:gravity="center"
    android:layout_width="wrap_content"
    android:layout_height="fill_parent">
    <TextView
      android:layout_marginTop="10sp"
      android:layout_width="fill_parent"
      android:layout_height="wrap_content"
      android:textColor="#000000"
      android:gravity="center"
      android:text="@string/buy" />
    <TextView
      android:id="@+id/message_b"
      android:layout_width="fill_parent"
      android:layout_height="wrap_content"
      android:textColor="#ff0000"
      android:textSize="16sp"
      android:textStyle="bold"
      android:gravity="center"
      android:text="@string/loading" />
    <TextView
      android:layout_width="fill_parent"
      android:layout_height="wrap_content"
      android:textColor="#000000"
      android:gravity="center"
      android:text="@string/sell" />
    <TextView
      android:id="@+id/message_s"
      android:layout_width="fill_parent"
      android:layout_height="wrap_content"
      android:textColor="#ff0000"
      android:textSize="16sp"
      android:textStyle="bold"
      android:gravity="center"
      android:text="@string/loading" />
  </LinearLayout>    
</LinearLayout>

Это обычный LinearLayout с четырьмя TextView, два из которых содержат строковые константы ("покупка" и "продажа") а другие два заполняются данными, полученными из API. Строковые константы для поддержки "мультиязычности" выносим в файл res/values/strings.xml (значения по умолчанию - английская локализация) и res/values-ru/strings.xml (русская локализация).

Как обеспечить локализацию виджета?
Как сказано выше: вынести все константы в xml-файл вида
<resources>
  <string name="app_name">Exchange rate of Bitcoin</string>
  <string name="app_label">Bitcoin:USD exchange rate</string>
  <string name="buy">Buy:</string>
  <string name="sell">Sell:</string>
  <string name="loading">Loading</string>
  <string name="err_connect">Connect error</string>
</resources>

... и обращаться к ним как: @string/loading (вернёт "Loading"). Система сама выберет нужный файл с константами в зависимости от настроек языка в системе. В java-коде обращение к локализованным строковым константам будет выглядеть так: R.string.loading.

Как описать логику, которая должна выполняться при обновлении виджета?
Вот тут мы, наконец-то, начинаем писать java-код. Для описания логики обновления данных мы должны реализовать класс, имя которого указано в самом первом xml-конфиге, приведённом в этой статье. Это CourceWidget.java:

  1. public class CourceWidget extends AppWidgetProvider {
  2.  
  3.   @Override
  4.   public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
  5.     context.startService(new Intent(context, UpdateService.class));
  6.   }
  7.  
  8.   public static class UpdateService extends Service {
  9.  
  10.     @Override
  11.     public void onStart(Intent intent, int startId) {
  12.       RemoteViews updateViews = buildUpdate(this);
  13.       AppWidgetManager.getInstance(this).updateAppWidget(new ComponentName(this, CourceWidget.class), updateViews);
  14.     }
  15.  
  16.     /**
  17.      * Build a widget update
  18.      */
  19.     public RemoteViews buildUpdate(Context context) {
  20.  
  21.       RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.cource_message);
  22.  
  23.       String buy = "";
  24.       String sell = "";
  25.  
  26.       // get gata from JSON
  27.       try {
  28.         JSONObject resp = new JSONObject(Http.Request("https://mtgox.com/code/data/ticker.php"));
  29.         buy = resp.getJSONObject("ticker").getString("buy");
  30.         sell = resp.getJSONObject("ticker").getString("sell");
  31.       } catch (Exception e) {
  32.         buy = "...";
  33.         sell = "...";
  34.         e.printStackTrace(System.err);
  35.         Toast.makeText(this, R.string.err_connect, Toast.LENGTH_SHORT).show();
  36.       }
  37.       views.setTextViewText(R.id.message_b, "$" + buy);
  38.       views.setTextViewText(R.id.message_s, "$" + sell);
  39.       return views;
  40.     }
  41.  
  42.     @Override
  43.     public IBinder onBind(Intent intent) {
  44.       return null;
  45.     }
  46.   }
  47. }
Наш класс расширяет AppWidgetProvider, переопределяя его метод onUpdate. В этом методе мы ничего не делаем, кроме одного: мы запускаем сервис UpdateService, описанный тут же, как вложенный класс.

Что такое сервис и зачем он нам нужен?
В контексте виджета мы не можем обращаться к сетевым ресурсам: время обновления "рабочего стола" должно быть минимальным. Сервис - это фоновый процесс, который Android исполняет в отдельном потоке настолько долго, насколько это нам нужно. Чтобы зарегистрировать сервис в AndroidManifest.xml внутри тега application добавим:
<service android:name=".CourceWidget$UpdateService" />

Как сервис получит данные из API?
Очень просто. Отправив get-запрос, мы получаем строку, которая на самом деле представляет собой JSON-структуру. Парсинг JSON выполняем при помощи классов org.json.* которые доступны без подключения всяких сторонних библиотек. Передаём полученную из API строку в конструктор объекта JSONObject. Методами этого объекта мы получаем вложенные объекты и их поля, т.е. значения из ответа (строки 28-30). В случае любой ошибки присваиваем переменным значения по умолчанию.

Как сервис передаёт данные в виджет?
У виджета нельзя обратиться к отдельному элементу View, чтобы изменить его свойства. Можно только заменить всю иерархию компонентов целиком. Для этого создаём объект RemoteViews (строка 21) и "вкладываем" в него полученные из API данные с помощью метода setTextViewText. Потом иерархию с установленными свойствами выдаём в виджет с помощью метода updateAppWidget (строка 13).

И напоследок: как обрабатывать нажатие на виджет?
Тут мы с сожалением вспомним удобные Listener-ы из Activity. В виджетах всё существенно сложнее. Добавим в наш класс CourceWidget константу:
public static String ACTION_WIDGET_RELOAD = "reload";
Затем в нашем сервисе создадим событие:

Intent active = new Intent(context, CourceWidget.class);
active.setAction(ACTION_WIDGET_RELOAD);
PendingIntent actionPendingIntent = PendingIntent.getBroadcast(context, 0, active, 0);
... и зарегистрируем его (привязав к какому-нибудь id элемента из нашей иерархии RemoteViews):
views.setOnClickPendingIntent(R.id.reload, actionPendingIntent);
В данном случае мы "навешиваем" событие на элемент с id "reload".
Чтобы получить и обработать событие в классе CourceWidget переопределяем метод:
  @Override
  public void onReceive(Context context, Intent intent) {
    // Receive event
    final String action = intent.getAction();
    if (ACTION_WIDGET_RELOAD.equals(action)) {
      context.startService(new Intent(context, UpdateService.class));
    }
    super.onReceive(context, intent);
  }

Тут мы заново стартуем сервис прои нажатии на виджет, тем самым вызывая "внеочередное" обновление данных.

P.S: по многочисленным просьбам даю ссылку на исходники моего проекта, "по мотивам" которого была написана эта статья.

8 комментариев:

  1. У меня в строке:
    JSONObject resp = new JSONObject(Http.Request("https://mtgox.com/code/data/ticker.php"));
    выдает ошибку "Http cannot be resolved". Подскажите, в чем проблема?

    ОтветитьУдалить
  2. Куча ошибок((( Самое главное - не работает код Java! Поправьте, пожалуйста!

    ОтветитьУдалить
  3. Работает, однако вешает лаунчер (галакси с2). Спасибо за урок.

    ОтветитьУдалить
  4. Серьезно, автор, эта статья не подходит для новичков, повешайте заметку в заголовке темы о том, что статья без исходников и код нерабочий.
    Я не так давно программирую под Android, в основном увлекся графикой, нашел страничку через поиск, когда нужно было сделать виджет для программки. Но Ваша статья ни одним своим абзацем не годится для создания виджета.
    Нельзя же писать статьи для людей по методу "может кому поможет". Нет кода, нет комментариев, и статья не раскрывает тему.

    ОтветитьУдалить
    Ответы
    1. На счёт исходников: если вам нужны готовые куски кода для ctrl+c -> ctrl+v, то это не та задача, которую я хотел решить этой статьёй. Код в статье не для копирования а для иллюстрации тезисов, описанных в тексте.
      Впрочем, если так нужны исходники, пожалуйста: https://github.com/smmarat/bitcource

      Удалить
  5. Почитал коменты. Не пойму, что не нравится? Кратко, по делу, понятно. Пожалуй лучшее с чем я столкнулся за 3 месяца перелопачивания инета.
    Автору отдельное спс за превосходный материал.

    ОтветитьУдалить
  6. В целом согласен - кратко и по делу. Только мне кажется не нужно отвлекаться на мелочи, типа, как сделать локализацию. Если речь уже идет о сервисах с бродкаст ресиверами, то кто не знает как это сделать - найдет в инете.
    По поводу "не для новичков" - для новичков есть куча примеров как создать виджет. Тут речь идет уже о том как прикрутить сервис и сделать обработку ивентов.
    Единственное замечание - OnStart у сервиса уже deprecated в последних версиях.
    Автору спасибо.

    ОтветитьУдалить
  7. Скажите кто-нибудь, пожалуйста. Давно уже бьюсь над проблемой. Можно ли как-то получить размер виджета в пикселях? Мне даже не нужно знать сколько занимает ячеек, исходя из размера в пикселях сам решу как мне внутри пространство организовывать. В виджете только imageview, в который я при обновлении создаю и загружаю битмап. Для imageview можно ведь получить width/height? Но вот как бы получить imageview? Можно ли его как-то создавать программно и вставлять в layot?

    У кого есть мысли, подскажите пожалуйста.

    ОтветитьУдалить