четверг, 2 декабря 2010 г.

Анимированный виджет на Android. Анимация по клику.

Итак, анимированный виджет на Android сделать толком не получается. Идем в обход?

Идея проста. Делаем виджет. При нажатии на виджет появляется активити. Активити полностью прозрачна и целиком перекрывает рабочий стол. В том месте, где на рабочем столе расположен исходный виджет, на активити рисуем такой же "виджет". И уже этот "виджет" мы можем сделать анимированным.

Практика показала, что такой подход действительно работает. Но в нем много нюансов. О них и пойдет речь.

Тестовый проект AniClick
Рассмотрим простейший пример такого анимированного виджета. Для простоты вновь используем ту же анимацию, что в прошлый раз - крутящееся колесо. Анимация колеса состоит из 10 кадров. Статический виджет показывает колесо - один (по умолчанию, самый первый) кадр анимации. При нажатии на виджет колесо на экране начинает крутиться. При повторном нажатии колесо останавливается. Теперь виджет показывает другой кадр анимации - тот, на котором произошел клик. Если снова кликнуть виджет, колесо вновь начнет крутиться. И т.д.

Проект называется AniClick. В проекте 5 основных исходных файлов:
  • AniWidgetProvider.java - реализация провайдера виджета. Содержит код для вызова активити и обновления виджета после завершения работы активити.
  • DataStorage.java - отвечает за сохранение и загрузку данных (номер кадра) каждого экземпляра виджета в SharedPreferences.
  • MainActivity.java - прозрачная активити, которая перекрывает экран при нажатии на виджет. Внутренний класс ScreenView обеспечивает немедленную перерисовку экрана и позволяет достичь приемлемых значений FPS.
  • ScreenManager.java - эмулирует анимацию (перебирает кадр за кадром) и отрисовывет текущий кадр на переданный canvas.
  • Utils.java - вспомогательный класс, позволяющий конвертировать Rect в строку и обратно.
Проект небольшой, классы легковесные и довольно простые. Исходные коды можно просмотреть онлайн, можно скачать в виде 7z-архива.

Теперь переходим к особенностям реализации.

Вызов активити при нажатии на виджет
Первая проблема - получение координат исходного виджета. Нам они необходимы, чтобы мы могли нарисовать на активити картинку точно поверх исходного виджета.
//class AniWidgetProvider
...
public void onReceive(Context context, Intent intent) {
  final String action = intent.getAction();
  if (action.equals(ACTION_WIDGET_CONTROL)) {
    Uri uri = intent.getData();
    Rect widget_pos = intent.getSourceBounds();
  }
}
Все просто. Но код работает только под API 2.1. В более ранних версиях API функции getSourceBounds нет и координаты кликнутного виджета узнать невозможно (?).

Вызов activity из виджета выполняется следующим образом:
private void make_widget_action(Context context, int appWidgetId, Rect widgetPos) {
 Intent start = new Intent(context, MainActivity.class);
 start.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
 start.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
 start.putExtra(PARAM_WIDGET_ID, appWidgetId); 
 start.putExtra(PARAM_WIDGET_POSITION, Utils.RectToStr(widgetPos)); 
 context.startActivity(start);
}

Прозрачная activity
Наша активити должна быть прозрачной. Как делать прозрачные activity известно - нужно использовать тему @android:style/Theme.Translucent. Беда в том, что в этой теме свойство стиля android:windowNoTitle выключено. Поэтому, при нажатии на виджет, сверху будет появляться заголовок activity. Отключить заголовок можно, создав собственную тему:
<!-- res/values/TranslucentNoTitleBarTheme.xml -->
<?xml version="1.0" encoding="utf-8"?>
<resources>
  <style name="TranslucentNoTitleBarTheme" parent="@android:style/Theme.Translucent">
 <item name="android:windowNoTitle">false</item>
  </style>
</resources>
и применив ее в манефесте к нашей activity:
<activity android:name=".MainActivity"
  android:theme="@style/TranslucentNoTitleBarTheme"
  android:label="@string/app_name">
</activity>

Обновление виджета после завершения активити
Ткнули на крутящееся колесо, колесо остановилось, activity закрывается. Запомнили кадр, который показывался в момент клика. Теперь нужно сообщить виджету о том, что его картинку следует обновить. Это делается следующим образом. В activity даем команду виджиту:
public class MainActivity extends Activity {
....
@Override
public boolean onTouchEvent(MotionEvent event) {
//any click on the animation stops it  
 if (event.getAction() == MotionEvent.ACTION_DOWN) { 
    ...
    Intent active = new Intent(this, AniWidgetProvider.class);
    active.setAction(AniWidgetProvider.ACTION_UPDATE_WIDGET_CONTEXT);
    Uri uri = Uri.parse(AniWidgetProvider.URI_SCHEME + "://widget#");
    uri = uri.buildUpon()
      .appendQueryParameter("widget_id", String.valueOf(m_WidgetId))
      .build();
    active.setData(uri);      
    this.sendBroadcast(active); //сообщаем виджету об обновлении
   ...
 }
 return super.onTouchEvent(event);
}
...
}
В провайдере виджета принимаем и выполняем эту команду:
//class AniWidgetProvider
public static final String ACTION_UPDATE_WIDGET_CONTEXT 
  = "com.rammus.aniclick.UPDATE_WIDGET_CONTEXT";
...
@Override
public void onReceive(Context context, Intent intent) {
...
 if (action.equals(ACTION_UPDATE_WIDGET_CONTEXT)) {
   Uri uri = intent.getData();
   int widget_id = Integer.parseInt(uri.getQueryParameter("widget_id"));
   if (widget_id != AppWidgetManager.INVALID_APPWIDGET_ID) {
  //перерисовывем картинку
    WidgetInstanceManager wim = get_instance(widget_id);
    wim.ReinitializeWidgetBitmap(context);
  //даем команду виджету обновить картинку
    RemoteViews remote_views 
       = new RemoteViews(context.getPackageName()
            , R.layout.widget);
    update_widget_view(AppWidgetManager.getInstance(context)
       , widget_id
       , remote_views);
   } else super.onReceive(context, intent);
 }
}
private void update_widget_view(AppWidgetManager appWidgetManager
  , int appWidgetId, RemoteViews remoteViews) {
  WidgetInstanceManager wim = get_instance(appWidgetId);  
  remoteViews.setImageViewBitmap(R.id.widget_image_view, wim.GetBitmap() );
  appWidgetManager.updateAppWidget(appWidgetId, remoteViews);
}

View для рисования на activity.
Чтобы можно было рисовать на активити, нам нужен View. Изначально я пошел следующим путем:
public class ActivityMain extends Activity {
 private ScreenView m_MainView;
 ...

 @Override
 public void onCreate(Bundle savedInstanceState) {
   super.onCreate(savedInstanceState);     
   m_MainView = new ScreenView(this);
   setContentView(m_MainView);
   ...
 }

 private class ScreenView extends View { 
  public ScreenView(Context context){
   super(context);
  }

  //рисуем текущий фрейм анимации на активити
  @Override protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);   
    RedrawScreen();
  }
 } //ScreenView

 private class AnimationThread extends TimerTask {  
  @Override 
  public void run() {
  //запускается 25 раз в секунду
  //перейти к следующему кадру анимации
    MakeAnimationStep();
  //перерисовать активити
    ActivityMain.this.m_MainView.postInvalidate();
  }   
 }
}
У нас два вспомагательных класса. ScreenView - это View, на котором рисуется очередной фрейм анимации. AnimationThread - поток, который 25 раз в секунду увеличивает номер текущего кадра анимации и дает команду перерисовать активити. Все работает, но анимация рисуется рывками - часть кадров пропускается.

Причина пропусков очевидна. Команда invalidate не обновляет экран мгновенно - она лишь помещает команду redraw в очередь. У нас команды redraw генерируются с заданным FPS - 25 раз в секунду. А слишком частые redraw приводят к тому, что OS объединяет несколько redraw в одну команду (для оптимизации). Для покадрового вывода анимации такое поведение не подходит.

SurfaceView вместо View
При разработке игр вместо View используют SurfaceView. Основное отличие - в SurfaceView можно рисовать из фоновых потоков, что идеально подходит в нашем случае. Вот как выглядит код с SurfaceView:
class ScreenView extends SurfaceView implements SurfaceHolder.Callback { 
 private final String TAG_LOG = "com.rammus.flw.ScreenView";
 SurfaceHolder m_SurfaceHolder;
 
 public ScreenView(Context context){
  super(context);

  m_SurfaceHolder = this.getHolder();
  //ставим формат TRANSLUCENT чтобы получить 
  //прозрачную activity
  m_SurfaceHolder.setFormat(PixelFormat.TRANSLUCENT );
  m_SurfaceHolder.addCallback(this);
 }

  public void surfaceCreated(SurfaceHolder holder) {
  //необходимо реализовать интерфейс SurfaceHolder.Callback
  //и вызвать Repaint() при создании поверхности.
  //Иначе мы получим моргание экрана в момент запуска activity.
    Repaint(); 
  }

  public void surfaceDestroyed(SurfaceHolder holder) {
  }  
 
  public void surfaceChanged(SurfaceHolder holder, int format, int w, int h) {
  }  

  @Override protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);   
  }

  private void paint(Canvas canvas) {
    RedrawScreen();
  }
 
  public void Repaint() {
  //доступ к lockCanvas должен быть синхронизирован
  //чтобы функцию Repaint можно было вызывать из
  //разных потоков
    synchronized (m_SurfaceHolder) {
 Canvas c = m_SurfaceHolder.lockCanvas();
 if (c != null) {
     try {
     super.draw(c);
     paint(c);
   } finally {
     m_SurfaceHolder.unlockCanvasAndPost(c);
   }
       } else {
  Log.e(TAG_LOG, "c is null!");
       }
   }
 } //Repaint
} //ScreenView

public class ActivityMain extends Activity {
 private ScreenView m_MainView;
 ...
 @Override
 public void onCreate(Bundle savedInstanceState) {
   super.onCreate(savedInstanceState);     
   m_MainView = new ScreenView(this);
   setContentView(m_MainView);
   ...
 }

 private class AnimationThread extends TimerTask {  
   @Override 
   public void run() {
     m_ScreenManager.MakeAnimationStep();
     //перерисовка экрана вызывается из ФОНОВОГО потока.
     m_MainView.Repaint();
   }   
 } 
}
Вместо invalidate мы вызываем функцию Repaint, которая рисует картинку на SurfaceView.

Здесь хочу обратить внимание на несколько важных моментов.
  1. Для того, чтобы наша поверхность была прозрачной, необходимо вызвать
    setFormat(PixelFormat.TRANSLUCENT) По умолчанию установлен формат PixelFormat.OPAQUE, это не то.
  2. Перерисовка экрана вызывается из фонового потока AnimationThread. Чтобы перерисовка работа корректно, вызовы функций lockCanvas и unlockCanvasAndPost должны быть синхронизированы. Отсюда synchronize в коде.
  3. ScnreenView должен реализовывать интерфейс SurfaceHolder.Callback и обязательно вызывать функцию отрисовки при создании поверхности в функции surfaceCreated. Если этого не сделать, то при запуске activity экран на короткое мгновение будет становиться черным.
  4. Функция lockCanvas корректно отрабатывает только после полного создания activity. Если вызвать функцию Repaint в ActivityMain.OnCreate, то мы lockCanvas вернет null и мы получим ошибку "c is null!" в логе. Таким образом МГНОВЕННО запустить анимацию не получится - задержка равная времени создания activity неизбежна.
И последнее - наша activity прозрачна. Сквозь нее виден рабочий стол и, естественно, наш исходный виджет. В тестовом примере анимированное колесо крутится на активити, а под ним видно колесо, нарисованное на виджите. Думаю, что эта проблема вполне решаема, но в тестовом проекте я ее не касался.

Выводы
Подход с псевдоанимированным виджетом оказался вполне рабочим. Конечно, есть определенные проблемы - под активити видно исходный виджет, запуск анимации происходит не мгновенно, а лишь после создания активити, анимация в виджете работает только по клику на виджет и т.д. Однако, существует ряд приложений, в которых такой анимации более чем достаточно. Для них главное, чтобы значение FPS получалось пристойным - а этого можно добиться.

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

Исходные коды проекта
Просмотреть исходные коды, скачать исходные коды, скачать apk. Short description in English is here.

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

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