суббота, 13 ноября 2010 г.

Анимированный виджет на Android. Можно или нельзя?

Можно ли на Android сделать анимированный виджет? Можно, но есть серьезные ограничения. Давайте разберемся.

Android SDK не предоставляет средств для создания анимированных виджетов. Возможность периодически обновлять содержимое виджита конечно же есть. Достаточно время от времени вызывать что-то вроде:
RemoteViews remoteViews = new RemoteViews(context.getPackageName(), R.layout.AAA);
//... обновляем remoteViews ...
appWidgetManager.updateAppWidget(appWidgetId, remoteViews);
и содержимое виджита будет изменяться. Но ни о какой анимации здесь речи не идет. RemoteViews не позволяет сопоставить контролу анимацию - только картинку. А самостоятельно вызывать обновление виджета хотя бы несколько раз в секунду (для создания минимального эффекта анимации) не получится: слишком высоки будут накладные расходы и слишком быстро будет садится батарея.

Однако, кое что сделать можно. В частности, поставить альтернативный десктоп, поддерживающий анимированные виджеты. Например, бесплатный Launcher Plus. На сайте разработчика представлен готовый пример анимированного виджета.

Но можно обойтись и без альтернативных десктопов. Не так давно в сети появилось приложение FlipClock - анимированные часы, работающие на стандартном десктопе (разработка mobi.intuitit - именно они делают Launcher Plus).

Как работает FlipClock? Насколько я могу судить, в нем используется примерно та же идея, что изложена
здесь. Суть проста - вы эмулируете анимацию путем регулярного обновления виджита (например, 25 раз в секунду). Анимация в виджете работает лишь короткое время, при наступлении определенного события - при изменении времени, при клике по виджиту и т.д. В результате, можно избежать гигантских накладных расходов.

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

Пример анимированного виджета
Создадим простой виджет TestAnimatedWidget размером 2x2. На виджете будет изображено колесо. При нажатии на виджет колесо крутится - совершает один оборот. Анимация колеса состоит из 11 кадров.

Код для такого виджета будет выглядеть примерно так:
package com.rammus.taw;

import java.util.HashMap;
import java.util.Timer;
import java.util.TimerTask;

import android.app.PendingIntent;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.net.Uri;
import android.util.Log;
import android.widget.RemoteViews;

public class TestAnimatedWidgetProvider extends AppWidgetProvider{  
  //Флаг указывающий каким образом анимировать виджит. У нас два варианта:
  //1) false - передавать в remote view идентификатор очередного фрейма анимации
  //2) true - передавать в remote view сгенерированную битмапку. Битмапка передается та же - мы загружаем ее из ресурсов (см. WidgetInstanceContent)
  //вариант 1) позволяет устанавливать большие FPS. 
  //вариант 2) безбожно тормозит и сыпет ошибками FAILED BINDER TRANSACTION
  private static final boolean USE_GENERATED_BITMAPS = true; 
  private static final int FPS = 25; 
    
    private static final String LOG_TAG = "com.rammus.TAW";
    private static final String ACTION_WIDGET_CONTROL = "com.rammus.taw.WIDGET_CONTROL";
    private static final String EXTRA_APPWIDGET_ID = "com.rammus.taw.APP_WIDGET_ID";
    private static final String URI_SCHEME = "com.rammus.taw";
    private static HashMap<Integer, WidgetInstanceContent> m_Instances = new HashMap<Integer, WidgetInstanceContent>();
  
    @Override
    public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
       for (int widget_id : appWidgetIds) {          
         RemoteViews remote_view = new RemoteViews(context.get  Name(), R.layout.taw);
                 
          WidgetInstanceContent wic = new WidgetInstanceContent(widget_id
             , appWidgetManager.getAppWidgetInfo(widget_id).minWidth
             , appWidgetManager.getAppWidgetInfo(widget_id).minHeight
             , context);
          
        //make widget clickable
        Intent active = new Intent(context, TestAnimatedWidgetProvider.class);
        active.setAction(ACTION_WIDGET_CONTROL);
        Uri data = Uri.parse(URI_SCHEME + "://widget#");
        data = data.buildUpon()
          .appendQueryParameter("widget_id", String.valueOf(widget_id))
          .build();
        active.setData(data);
        PendingIntent pi = PendingIntent.getBroadcast(context, 0, active, 0);
        remote_view.setOnClickPendingIntent(R.id.bkview, pi);
          
         m_Instances.put(widget_id, wic);         
         update_widget_view(appWidgetManager, widget_id, remote_view);          
     }                  
     super.onUpdate(context, appWidgetManager, appWidgetIds);    
  }   
        
  private WidgetInstanceContent get_instance(int appWidgetId) {
    return m_Instances.get(appWidgetId);
  }    
   
  @Override
    public void onDeleted(Context context, int[] appWidgetIds) {     
        Log.d(LOG_TAG, "onDelete()");
        super.onDeleted(context, appWidgetIds);
    }
    
    @Override
    public void onReceive(Context context, Intent intent) {
        final String action = intent.getAction();
        if (action.equals(AppWidgetManager.ACTION_APPWIDGET_DELETED)) {
            final int appWidgetId = intent.getExtras().getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID);
            if (appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID) {
                this.onDeleted(context, new int[] { appWidgetId });
            }
        } else if (action.equals(ACTION_WIDGET_CONTROL)) {
          Uri uri = intent.getData();
            final int widget_id = Integer.parseInt(uri.getQueryParameter("widget_id"));
            if (widget_id != AppWidgetManager.INVALID_APPWIDGET_ID) {
              make_widget_action(context, AppWidgetManager.getInstance(context), widget_id);
            }  
        } else {
            super.onReceive(context, intent);
        }
    }    
    
    private void make_widget_action(Context context, AppWidgetManager instance, int appWidgetId) {
      WidgetInstanceContent wic = get_instance(appWidgetId);
      if (wic.IsAnimationInProcess) return; //ignore all clicks while animation is in process
      
      wic.IsAnimationInProcess = true;
      wic.FrameId = 0;
      AnimationThread at = new AnimationThread(context, instance, appWidgetId);

      Timer timer = new Timer();
      timer.scheduleAtFixedRate(at, 1, (int)(1000 / FPS) );      
    }
   
  private void update_widget_view(AppWidgetManager appWidgetManager, int appWidgetId, RemoteViews remoteViews) {
    WidgetInstanceContent wic = get_instance(appWidgetId);

    if (! USE_GENERATED_BITMAPS) {
      int step = wic.FrameId;
      remoteViews.setImageViewResource(R.id.bkview, WidgetInstanceContent.WHEEL_ANIMATION[step]);
      appWidgetManager.updateAppWidget(appWidgetId, remoteViews);
    } else {
      Bitmap bmp = wic.GetBitmapForCurrentFrame();
      if (bmp != null)   {
        remoteViews.setImageViewBitmap(R.id.bkview, bmp);
        appWidgetManager.updateAppWidget(appWidgetId, remoteViews);
      }
    }
  }
   
  private class AnimationThread extends TimerTask {
      private final String LOG_TAG = "com.rammus.startwidget.AnimationThread"; 
    private RemoteViews m_RemoteViews;
    private AppWidgetManager m_AppWidgetManager;
    private int m_AppWidgetId;
    
    public AnimationThread(Context context, AppWidgetManager appWidgetManager, int appWidgetId) {
      m_AppWidgetManager = AppWidgetManager.getInstance(context); //appWidgetManager;
      m_AppWidgetId = appWidgetId;
      m_RemoteViews = new RemoteViews(context.get  Name(), R.layout.taw);
    }
    
    @Override 
    public void run() {
      WidgetInstanceContent wic = get_instance(m_AppWidgetId);
      update_widget_view(m_AppWidgetManager, m_AppWidgetId, m_RemoteViews);
      if (wic.IsAnimationInProcess) {
        wic.FrameId++;
        if (wic.FrameId == WidgetInstanceContent.GetCountFrames()) {
          wic.FrameId = 0;
          wic.IsAnimationInProcess = false;
        }
      } else {
        cancel();
      }
    }
        
    @Override
    public boolean cancel() {
      return super.cancel();
    }
  }           

  public class WidgetInstanceContent {
    public int FrameId = 0;
    public boolean IsAnimationInProcess = false;
    public final int WidgetId;
    public final int WidgetWidth;
    public final int WidgetHeight;
    
    public static int WHEEL_ANIMATION[] = {
      R.drawable.a1, R.drawable.a2, R.drawable.a3, R.drawable.a4
      , R.drawable.a5, R.drawable.a6, R.drawable.a7, R.drawable.a8
      , R.drawable.a9, R.drawable.a10, R.drawable.a11
    };
    public static int GetCountFrames() {
      return WHEEL_ANIMATION.length;
    }
    public static Bitmap CACHE_BITMAPS[] = new Bitmap[WHEEL_ANIMATION.length];
    
    public WidgetInstanceContent(int widgetId, int widgetWidth, int widgetHeight, Context context) {
      this.WidgetId = widgetId;
      this.WidgetHeight = widgetHeight;
      this.WidgetWidth = widgetWidth;
      //cache all drawables
      for (int i = 0; i < GetCountFrames(); ++i) {
        Drawable drw = context.getResources().getDrawable(WHEEL_ANIMATION[i]);
        CACHE_BITMAPS[i] = drawable_to_bitmap(drw);
      }
      
    }

    public Bitmap GetBitmapForCurrentFrame() {
      return CACHE_BITMAPS[this.FrameId];  
    }
    
      private static Bitmap drawable_to_bitmap(Drawable drw) {
        Bitmap bmp = Bitmap.createBitmap(
                drw.getIntrinsicWidth(), drw.getIntrinsicHeight(), Bitmap.Config.ARGB_8888);
        Canvas canvas = new Canvas(bmp);
        drw.mutate().setBounds(0, 0, drw.getIntrinsicWidth(), drw.getIntrinsicHeight());
        drw.draw(canvas);
        return bmp;
      }    
  }
}
У нас два вспомагательных класса: AnimationThread и WidgetInstanceContent. AnimationThread - это поток, отвечающий за эмуляцию анимации. Он с заданной частотой вызывает функцию update_widget_view, передающую виджету очередной кадр анимации, а так же выполняет инкремент текущего кадра. Как только колесо делает полный оборот (все кадры показаны) поток завершает работу и обновление виджета прекращается.

WidgetInstanceContent содержит текущее состояния экземпляра виджета. В главном классе TestAnimatedWidgetProvider у нас есть карта HashMap<int, WidgetInstanceContent>, содержащая данные для всех экземпляров виджета. Т.о. на экран можно одновременно поместить несколько "колес" и все они будут крутиться независимо друг от друга.

Ключевой параметр в данном коде - константа USE_GENERATED_BITMAPS. Она определяет механизм обновления виджета. Варианта здесь два. Первый - передавать в remote view идентификатор битмапки. Второй - передавать саму битмапку. Соответственно, для обновления виджета вызываются разные функции - remoteViews.setImageViewResource(R.id.AAA, R.drawable.BBB) или remoteViews.setImageViewBitmap(R.id.AAA, bitmap);.

Проблемы
Если используется первый вариант (USE_GENERATED_BITMAPS = false), то колесо бодро крутится на экране даже при FPS = 25. Если второй вариант, колесо основательно тормозит, на 4-5 кадре анимации подвисает, а в логе появляются ошибки "!! FAILED BINDER TRANSACTION !!".

В чем дело - понятно. В первом случае мы в remote view передаем идентификатор ресурса, во втором - битмапку. Причина ошибки "!! FAILED BINDER TRANSACTION !!" - передача слишком больших объемов данных через IPC (переполняется внутренний буфер в 1 Мб). Ошибка приводит к тому, что виджет вообще перестает обновляться, в результате мы видим лишь первые кадры анимации. Ситуация, естественно, не меняется, если размеры виджета уменьшить до 1x1, ведь размер битмапки остается прежним. Так что использование функции remoteViews.setImageViewBitmap для создания эффекта анимации весьма и весьма проблематично.

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

Исходные коды
Исходные коды проекта TestAnimatedWidgetProvider. В архиве включены два варианта apk-файла. Один использует статические битмапки, другой - динамически генерируемые.

Download source codes, view source codes, read short description in English.

Update: Несколько интересных тикетов на android google code в тему: про анимированный виджет, про неработающую функцию setImageViewBitmap() на android 2.2 и про использование setImageViewUri вместо setImageViewBitmap.

Продолжение: результаты попытки использовать setImageViewUri для эмуляции анимации

4 комментария:

  1. Код уж очень запутанный, а можно такой же урок, токо с рисование на канве обычной? Для виджетов? А то никак не могу понять как...

    ОтветитьУдалить
  2. Спасибо, мне очень к месту пришлось

    ОтветитьУдалить
  3. Спасибо большое, то - что доктор прописал...:)

    ОтветитьУдалить
  4. Есть что еще про анимацию в виджетах?

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