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

В основе инженерных специальностей лежат методики, разработанные десятилетия назад, — так, методу конечных элементов скоро исполнится 70 лет, а он до сих пор не потерял актуальности. В программировании же все меняется со скоростью, близкой к скорости света. И нам приходится постоянно что-то изучать, пробовать чужие решения, изобретать собственные велосипеды, менять алгоритмы, внедрять стеки новых технологий — словом, участвовать в гонке без финиша. И если инженерам в их работе помогают умные книги, проверенные временем, то в нашем случае найти информацию, порой даже в официальных источниках, бывает очень проблематично. Не веришь? Что ж, тогда добро пожаловать в [А]ндрои[Д]...

 

Виджет и документация

Сегодня мы рассмотрим Android и его виджеты — только практику, никакой теории. За последней отсылаю тебя к недавней статье, где мы подробно рассматривали процесс создания «хакерского» виджета.

Итак, виджет представляет собой реализацию широковещательного приемника, каркас которого представлен ниже:

public class SkeletonAppWidget extends AppWidgetProvider {
  @Override
  public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
    for (int appWidgetId : appWidgetIds) {
      ...
      appWidgetManager.updateAppWidget(appWidgetId, views);
      Log.d(DEBUG_TAG, "onUpdate : id = " + String.valueOf(appWidgetId));
    }
  }

  @Override
  public void onDeleted(Context context, int[] appWidgetIds) {
    Log.d(DEBUG_TAG, "onDeleted");
    super.onDeleted(context, appWidgetIds);
  }

  @Override
  public void onDisabled(Context context) {
    Log.d(DEBUG_TAG, "onDisabled");
    super.onDisabled(context);
  }

  @Override
  public void onEnabled(Context context) {
    Log.d(DEBUG_TAG, "onEnabled");
    super.onEnabled(context);
  }
}

Когда пользователь помещает первый экземпляр виджета на домашний экран, срабатывает метод onEnabled(), после удаления которого вызывается парный onDisabled(). Метод onDeleted() вызывается всякий раз, когда пользователь перетаскивает представление экземпляра виджета в корзину.

Метод onUpdate() в цикле обновляет все экземпляры виджета по идентификаторам, хранящимся в массиве, дергая updateAppWidget().

Все сказанное можно найти в официальной документации Google, а также в любой книге по программированию под Android. Типичный виджет представлен на рис. 1.

Рис. 1. Обычный виджет
Рис. 1. Обычный виджет
 

Виджет vs программист

В качестве разминки предлагаю забыть половину из того, что мы написали, так как оно не работает! Я не зря поставил отладочную печать всех методов. Запустив код в эмуляторе или на реальном девайсе, можно легко убедиться, что ни onEnabled, ни onDisabled никогда не вызываются в Android 4.4 и ниже! (Чтобы не быть голословным, здесь и далее я тестирую код на устройствах с Android 4.4, 5.1, 6.0.)

Почему так? Видимо, сие есть тайна, доступная только джедаям Google. Вопрос в другом: почему об этом прямо не написать в документации? Особенно доставляют в общем-то правильные по сути механизмы запуска фонового сервиса (или сигнализации) для обновления информации виджетов в onEnabled с остановкой в onDisabled в примерах книг, предназначенных для Android 4. Получается, авторы не тестируют код, который сами же пишут?

Кто-то скажет, что в 5-й версии мобильной ОС это исправлено. Да, исправлено, но сбрасывать со счетов Android 4 пока рано. Кроме того, такие вещи не должны выявляться программистом опытным путем.

 

Виджет vs здравый смысл

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

<activity android:name=".WidgetSetup" android:label="@string/setup">
  <intent-filter>
    <action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
  </intent-filter>
</activity>

Чтобы назначить для виджета эту активность, нужно добавить ее в соответствующий тег appwidget-provider с помощью атрибута configure, указав полное имя пакета:

<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider
  xmlns:android="http://schemas.android.com/apk/res/android"
  ...
  android:configure="com.hacker.widgetSample.WidgetSetup"
/>

Активность настройки ничем не отличается от обычной, но должна указать RESULT_OK в качестве результата и вернуть намерение с дополнительным параметром EXTRA_APPWIDGET_ID, являющимся идентификатором виджета. В противном случае считается, что пользователь отменил свое решение (например, нажал «Назад»), и виджет не будет добавлен.

Фрагмент активности:

private int appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID;

@Override
public void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);
  setContentView(R.layout.main);

  Intent intent = getIntent();
  Bundle extras = intent.getExtras();
  if (extras != null) {
    // Получаем идентификатор виджета, который настраиваем
    appWidgetId = extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID);
  }
  setResult(RESULT_CANCELED, null); // Значение по умолчанию

  Log.d(DEBUG_TAG, "onConfigureWidget : id = " + String.valueOf(appWidgetId));
}

private void completedConfiguration() {
  // Сохраняем настройки
  ...
  // Возвращаем RESULT_OK и закрываем активность
  Intent result = new Intent();
  result.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
  setResult(RESULT_OK, result);
  finish();
}

Нибиру я не открыл, лишь привел стандартный механизм работы с настройкой виджета. Теперь внимание, вопрос для рубрики «Задачи на собеседованиях»: что мы увидим в отладочной печати?

05-24 12:44:51.028 2765-2765/com.hacker.widgetSample D/widget: onUpdate : id = 7
05-24 12:44:51.457 2765-2765/com.hacker.widgetSample D/widget: onConfigureWidget : id = 7

Заметил? Сначала срабатывает метод обновления onUpdate (виджета еще нет, но система уже присвоила ему уникальный номер id = 7), а только затем создается активность настройки. По логике все должно быть с точностью до наоборот — сначала onConfigureWidget, затем onUpdate. И это еще не все: после настройки виджета onUpdate автоматически не вызывается. То есть программисту приходится как-то обновлять свежеиспеченный виджет — например, тем же фоновым сервисом или сигнализацией. Налицо проблема, созданная совершенно на пустом месте.


Если ты думаешь, что данный баг уже исправлен, спешу тебя огорчить: в Android 6 все работает так, как описано выше. Упоминается ли это в официальной документации? Разумеется, нет.

Преждевременное создание виджета с последующей отменой пользователем породило проблему так называемых фантомных виджетов (Phantom Widgets). Если мы поместим еще несколько экземпляров виджета на экран и отменим каждый на этапе настройки, то в конечном счете можем увидеть такую картину:

05-24 13:01:05.333 2765-2765/com.hacker.widgetSample D/widget: onUpdate : id = 7
05-24 13:01:05.333 2765-2765/com.hacker.widgetSample D/widget: onUpdate : id = 8
05-24 13:01:05.343 2765-2765/com.hacker.widgetSample D/widget: onUpdate : id = 9
05-24 13:01:05.383 2765-2765/com.hacker.widgetSample D/widget: onUpdate : id = 10
05-24 13:01:05.413 2765-2765/com.hacker.widgetSample D/widget: onConfigureWidget : id = 10

Чем это грозит? Да тем, что приложение не в состоянии определить, есть ли у него работающие виджеты на экране или нет. Таким образом, код ниже логичен, но внезапно может вернуть температуру на Марсе (кто сказал «DOOM»?):

private int widgetsInstalled(Context context) {
  ComponentName thisWidget = new ComponentName(context, SkeletonAppWidget.class);
  AppWidgetManager mgr = AppWidgetManager.getInstance(context);
  return mgr.getAppWidgetIds(thisWidget).length;
}
 

Виджет vs невнимательность

Рассмотрим вопрос, который часто всплывает на Stack Overflow. Речь пойдет о динамическом обновлении UI-элементов виджета — например, показе ProgressBar’а во время обращения к базе данных, или изменении надписи TextView, или детонации устройства (шучу).

Стандартный подход — определить специальный Action и зарегистрировать его в манифесте:

<receiver
  android:name=".SkeletonAppWidget"
  android:label="@string/name">
  <intent-filter>
    <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
    <action android:name="com.hacker.widgetSample.FORCE_WIDGET_TURN_ANIMATION" />
  </intent-filter>
</receiver>

Здесь APPWIDGET_UPDATE — системный Action для обновления содержимого виджета, а FORCE_WIDGET_TURN_ANIMATION — определенное нами действие для включения (или выключения, это сейчас непринципиально) анимации загрузки данных. Так как виджет — это приемник, осталось обработать метод onReceive:

public static final String FORCE_WIDGET_TURN_ANIMATION = "com.hacker.widgetSample.FORCE_WIDGET_TURN_ANIMATION";

@Override
public void onReceive(Context context, Intent intent){
  super.onReceive(context, intent);
  if (FORCE_WIDGET_TURN_ANIMATION.equals(intent.getAction())) {
    turnAnimation(context, intent.getIntExtra(WIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID));
  } else ...
}

Функция turnAnimation вполне может быть такой:

private void turnAnimation(Context context, int appWidgetId){
  if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) return;
  AppWidgetManager appWidgetManager = AppWidgetManager.getInstance(context);
  RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget);
  views.setViewVisibility(R.id.bReload, View.INVISIBLE);
  views.setViewVisibility(R.id.progressBar, View.VISIBLE);

  // Если читаешь статью по диагонали, не делай так!
  appWidgetManager.updateAppWidget(appWidgetId, views);
}

Теперь для настройки GUI виджета достаточно из любого места программы отправить намерение (Intent):

Intent myIntent = new Intent(SkeletonAppWidget.FORCE_WIDGET_TURN_ANIMATION);
myIntent.putExtra(SkeletonAppWidget.WIDGET_ID, widgetId);
sendBroadcast(myIntent);

Запустив этот код, ты обнаружишь, что все прекрасно работает... Но раз уж ты давно читаешь «Хакер», то, наверное, догадался, что все не так просто. Да, код будет работать до поры до времени (в Андроиде эта «пора» обычно наступает в момент убиения-пересоздания чего-либо из-за нехватки памяти или при повороте устройства). Внезапно виджет перестает реагировать на нажатие кнопок, сбрасывается разметка — появляются фризы, через некоторое время он может частично вернуться к жизни, но вскоре все повторяется...

Виной тому метод updateAppWidget с неочевидным на первый взгляд механизмом работы (к слову, в официальной документации все описано). При непосредственном вызове вносимые в разметку изменения (видимость элементов, надписи, изображения, обработчики кликов и подобное) дополняются (кешируются), а при пересоздании замещают (!) старые, то есть те, которые были изначально установлены в onUpdate. А когда система начинает сама обновлять виджет посредством трансляции APPWIDGET_UPDATE (например, подошло время обновления в android:updatePeriodMillis), вызывается onUpdate, возвращающий все параметры виджета, в том числе и клики. Отсюда и кажется, что виджет то работает, то нет.

Решение — использовать вместо updateAppWidget метод partiallyUpdateAppWidget:

appWidgetManager.partiallyUpdateAppWidget(appWidgetId, views);

А что же книги? Молчат как партизаны. Метод partiallyUpdateAppWidget даже не рассматривается.

Кстати, есть еще одна проблема с фризами виджетов, но она уже относится к багам самого Андроида (парадоксально, что жалуются в основном пользователи Nexus’ов) — ищи ссылку в выносе.

 

Виджет vs супервиджет

На десерт рассмотрим использование виджетов, основанных на коллекциях. Нас будут интересовать данные в виде списка (ListView), каждый элемент которого отображается как строка со своим экземпляром разметки (к большому сожалению, RecyclerView не поддерживается).

Для данного типа виджетов потребуется интерфейс RemoteViewFactory, который фактически ведет себя как адаптер к виджету, снабжающий его данными, и сервис RemoteViewService для его инициализации и управления. Ниже приведу типичный каркас кода, так любимый авторами книг:

public class MyRemoteViewsService extends RemoteViewsService {
  @Override
  public RemoteViewsFactory onGetViewFactory(Intent intent) {
    return new MyRemoteViewsFactory(getApplicationContext());
  }

  class MyRemoteViewsFactory implements RemoteViewsFactory {
    private ArrayList<String> myText = new ArrayList<>();
    private Context context;
    private int widgetId;

    public MyRemoteViewsFactory(Context context, Intent intent){
      this.context = context;
      widgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID);
    }

    public void onCreate(){
      myText.add("Happiness");
      myText.add("The Pop Kids");
      ...
    }

    public void onDataSetChanged() {
      Log.d(DEBUG_TAG, "onDataSetChanged : id = " + String.valueOf(widgetId));
      // Работаем с виджетом с идентификатором widgetId:
      // 1. Включаем анимацию (например, транслируем FORCE_WIDGET_TURN_ANIMATION)
      // 2. Запрашиваем данные
      // 3. Выключаем анимацию
    }

    public int getCount() { return myText.size(); }
    public int getViewTypeCount() { return 1; }
    public boolean hasStableIds() { return false; }
    public RemoteViews getLoadingView() { return null; }
    public long getItemId(int index) { return index; }
    public void onDestroy() { myText.clear(); }

    public RemoteViews getViewAt(int index) {
      // item — разметка элемента ListView с единственной текстовой меткой title
      RemoteViews rv = new RemoteViews(context.getPackageName(), R.layout.item);
      rv.setTextViewText(R.id.title, myText.get(index));
      return rv;
    }
  }
}

В конструктор MyRemoteViewsFactory передается контекст (Context) и уникальный идентификатор виджета (widgetId), извлекаемый из намерения методом intent.getIntExtra. В onDataSetChanged обычно помещается код извлечения данных (из БД или сети), а getViewAt возвращает готовый элемент списка: в нашем скромном случае просто строку из статического ArrayList. Остальные методы нас не интересуют. Важно понимать, что для каждого экземпляра виджета RemoteViewsFactory свой, то есть уникальный.

Возвращаемся к методу onUpdate приемника AppWidgetProvider и в цикле задаем параметры всех экземпляров виджета:

for (int appWidgetId : appWidgetIds) {
  // listViewWidget — разметка виджета, содержащая компонент ListView с идентификатором lv
  RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.listViewWidget);

  Intent intent = new Intent(context, MyRemoteViewsService.class);
  intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
  views.setRemoteAdapter(R.id.lv, intent);

  appWidgetManager.updateAppWidget(appWidgetId, views);
}

Запускаем приложение, помещаем виджет на домашний экран... и робко убеждаемся, что все работает. Но прежде чем бежать за смолой и перьями для автора этой статьи, помести на экран второй виджет. WTF? У второго виджета глючит анимация и он не обновляется по нажатию кнопки (рис. 2)!

Рис. 2. У виджета внизу не установился аватар и крутится ProgressBar
Рис. 2. У виджета внизу не установился аватар и крутится ProgressBar

И снова вопрос для рубрики «Задачи на собеседованиях». Почему?

Как всегда, смотрим отладочную печать:

05-31 11:27:19.547 3729-3729/com.hacker.widgetSample D/widget: onUpdate : id = 187
05-31 11:27:19.677 3729-3729/com.hacker.widgetSample D/widget: onConfigureWidget : id = 187
05-31 11:27:21.037 3729-3742/com.hacker.widgetSample D/widget: onDataSetChanged : id = 187
05-31 11:27:48.887 3729-3729/com.hacker.widgetSample D/widget: onUpdate : id = 188
05-31 11:27:48.927 3729-3729/com.hacker.widgetSample D/widget: onConfigureWidget : id = 188
05-31 11:27:50.497 3729-3741/com.hacker.widgetSample D/widget: onDataSetChanged : id = 187

Разгадка кроется в самой последней строчке — в onDataSetChanged у второго виджета id = 187 вместо положенного 188! Другими словами, значение переменной widgetId, определяемой в конструкторе класса RemoteViewsFactory, задается неверно, хотя onUpdate в AppWidgetProvider отрабатывает нормально. Таким образом, подозрение падает на передаваемое намерение (Intent), и не зря: при создании намерения для сервиса виджета дополнительные поля никак не анализируются на изменение значений и метод putExtra для второго виджета фактически не способен изменить значение параметра AppWidgetManager.EXTRA_APPWIDGET_ID. Формально, конечно, он его меняет, но Андроид не создает нового (уникального) намерения, а использует уже имеющееся (то есть предназначенное для первого виджета). Такое поведение характерно для всех версий операционной системы и является не багом, а особенностью работы отложенных намерений PendingIntent.

Если остальные недосказанности я готов простить авторам книг по программированию, то эту — ни за что! Ведь указанный подход вшит в рекомендуемый каркас кода виджета. Согласись, несколько обидно получить внешне работоспособный код, взятый прямиком из серьезной книги, но с непредсказуемым результатом работы.

Для решения «проблемы» достаточно сделать передаваемое в адаптер (setRemoteAdapter) намерение уникальным. Как ты уже понял, параметры extra (связка putExtra — getIntExtra) здесь не помогут, а вот стандартный механизм URI — самое то.

Исправленный onUpdate класса AppWidgetProvider:

for (int appWidgetId : appWidgetIds) {
  ....
  Intent intent = new Intent(context, MyRemoteViewsService.class);
  intent.setData(Uri.fromParts("widget", String.valueOf(appWidgetId), null));
  views.setRemoteAdapter(R.id.lv, intent);

  appWidgetManager.updateAppWidget(appWidgetId, views);
}

Идентификатор виджета упаковываем в URI и с помощью setData гарантируем создание нового уникального намерения. Теперь осталось только извлечь значение widgetId в конструкторе MyRemoteViewsFactory:

public MyRemoteViewsFactory(Context context, Intent intent){
  ...
  try {
    widgetId = Integer.valueOf(intent.getData().getSchemeSpecificPart());
  } catch (Exception e) {
    widgetId = AppWidgetManager.INVALID_APPWIDGET_ID;
  }
}

Вот теперь все работает именно так, как задумано.

 

Виджет vs выводы

Как видишь, все проблемы, возникающие при разработке виджетов, вполне решаемы. Другое дело, что очень хочется, чтобы книги и официальная документация были более дружелюбны к программисту и ему в меньшей степени приходилось изучать логи отладочной печати и бежать на Stack Overflow в поисках ответов. Ну и разумеется, пока есть журнал «Хакер», за детище Google — Android можно не переживать :).

Книжная полка

Главные роли бичуемых в данной статье играли:

  1. Р. Майер «Android 4. Программирование приложений»
  2. С. Коматинени, Д. Маклин «Android 4 для профессионалов. Создание приложений для планшетных компьютеров и смартфонов»
  3. П. Дейтел, Х. Дейтел, Э. Дейтел «Android для разработчиков»

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

3 комментария

  1. Аватар

    A.T.

    27.07.2016 в 07:45

    Кто-нибудь дочитал это говно до конца?
    Я нет.

Оставить мнение