Widget が再び注目されるようになった。AndroidO でアプリから直接 Widget をインストールできる新たな API が追加された。ホーム画面の一等地からユーザーが必要なコンテンツにアクセスできる機能はエンゲージメントを高める。Widget 実装はシンプルなもの、内部実装は入り組んでいる。
マルチウィンドウやピクチャ・イン・ピクチャなど新機能が注目される中、昔ながらの Widget の良さが見直されている。AndroidO では Widget に新しい API が追加され、最大の欠点であったインストール導線の不明確さも解消された。
2 月に開催された DroidKaigi2018 では実装の流れ、デザイン、AndroidO 対応について解説を行い、Widget に注目する理由を示した。Widget 開発はチュートリアルに沿って行えば悩まずに実装することができる。いますぐ Widget 開発を行いたい方は、発表スライドをチェックして欲しい。 https://speakerdeck.com/ymnder/widgetkai-fa-zai-fang
Widget 開発で意識すべき事項は非常にシンプルである。xml を組み立てて Layout を構築し、Manager クラスを介して画面を更新し、そして BroadcastReceiver で更新タイミングの制御をすれば良い。悩むことは少ないものの、ふと内部実装を読み始めるとシンプルな見た目に反して複雑な世界が広がっている。
- AppWidgetService
- WidgetId の発行やランチャーへのリクエストを行う
- AppWidgetHost
- Widget とホーム画面の仲立ちを行う
- AppWidgetHostView
- Widget を表示するための表示領域を提供する
- AppWidgetManager
- Widget の状態を更新したり、情報を取得する管理クラス
- AppWidgetProvider
- Widget の各種のイベントをハンドリングする
- AppWidgetProviderInfo
- Widget のメタデータを扱う Widget 系のクラスは非常に少ない
実装で直接触れるクラスは更に絞られる。しかしクラスが相互に強く結びついており、触っている callback interface がどこで定義されているのか非常に見えにくい。クラス名を見ても、ぱっと見て何をするクラスなのか分かりにくい。本稿では Widget の内部実装を掘り下げることでフレームワークがどのように実装されているのかを紹介する。
Widget はどのように描画されるか
Widget はミニアプリであり、アプリの外で View の描画を行うための独自の仕組みを持つ。すなわち、RemoteViews の構造や操作の情報を元に AppWidgetHostView 上にレイアウトを構築される。RemoteViews は、各 View の描画・操作情報を保持するクラスであり、layoutId とレイアウトを操作する Action を内部に持つ。HostView は FrameLayout を継承した View であり、Widget を描画するための土台を提供する。
描画の流れは以下の通りである。①RemoteViews をインスタンス化する ②Manager の update に RemoteViews を渡す ③Manager を通して Service にアップデートの依頼を行う ④Host はコールバックを持ち、Service の依頼を起点に描画指示を行う ⑤HostView で描画を行う
RemoteViews は Actions を持つことで、他のプロセス上で ViewTree を構築し、必要な描画操作のコマンドを適用することを可能にしている。
//パッケージ名とlayoutIdを与えることによりインスタンス化される
val views = RemoteViews(context.packageName, R.layout.widget)
//対象となるViewにテキストをセットするActionが積まれる
views.setTextViewText(R.id.appwidget_text, widgetText)
アプリ外での描画を実現する仕組みをユーザーは意識しなくて良い
Widget もまた通常の View と同じく res/layout を inflate する。レイアウトの組み立ては Service を経由して行われており、アップデートを行う際に使う Manager クラスの updateAppWidget メソッドでは Service の updateAppWidgetIds が呼ばれている。その後、内部的に Handler が呼ばれ、handleNotifyUpdateAppWidget というメソッドにより Host へ通知される。Host はこの通知を受け取ると Handler を取得し update の指示を出すために updateAppWidgetView を呼ぶ。この中で HostView の updateAppWidget が叩かれる。そして、inflate 後に inflateAsync が実行され、View に対し Actions が適用されることで描画が行われる。長い描画までの道のりもようやく終点を迎える。
Actions の中身
SetOnClickPendingIntent
ReflectionAction
SetDrawableParameters
ViewGroupAction
ReflectionActionWithoutParams
SetEmptyView
SetPendingIntentTemplate
SetOnClickFillInIntent
SetRemoteViewsAdapterIntent
TextViewDrawableAction
BitmapReflectionAction
TextViewSizeAction
ViewPaddingAction
SetRemoteViewsAdapterList
TextViewDrawableColorFilterAction
SetRemoteInputsAction
LayoutParamAction
Actions の一覧(TAG 順)
Actions は Parcelable を継承したクラスである。Widget はアプリの外のプロセスで描画が行われるため、このような操作を行って欲しいという操作手順を渡す必要がある。このときに使われるのが Actions である。中身はシンプルで、対象となる viewId、メソッドを呼び出し適用する apply、View をマージするときの振る舞いの指定などを行う。viewid や View 操作の状態はこのクラスの中に保存されている。
Actions は ArrayList で RemoteViews が持ち、描画タイミングでループを回して適用される。存在するサブクラスは表の通りであり、17 種類の Actions が存在している。Actions を継承する際に TAG が付与されており、RemoteViews が parcel から読み出される際はこの TAG をもとにインスタンス化される。
この中でも特に重要なのは、ReflectionAction である。この Action は対象となる viewid、実行したい methodName、引数の型、適用したい引数を持つ。apply を見ると、findViewById で view を指定し、Reflection の Method.invoke を実行する。このような実装を持つことにより、アプリの外のプロセスで動的に変更を反映できる。なお、この仕組みゆえに、実行できるメソッドは RemotableViewMethod が付与されたメソッドに限られていることに留意したい。
@Override
public void apply(View root, ViewGroup rootParent, OnClickHandler handler) {
final View view = root.findViewById(viewId);
if (view == null) return;
Class<?> param = getParameterType();
if (param == null) {
throw new ActionException("bad type: " + this.type);
}
try {
getMethod(view, this.methodName, param).invoke(view, wrapArg(this.value));
} catch (ActionException e) {
throw e;
} catch (Exception ex) {
throw new ActionException(ex);
}
}
ReflectionAction の適用
クリックイベントはどのように発火するか
クリックイベントは SetOnClickPendingIntent の Actions として保持される。HostViews で Actions が apply されるタイミングで OnClickListener がつくられセットされる。OnClickListener では、OnClickHandler が実行されていることに注目したい。この handler は RemoteViews の中で定義され、インスタンス化されている。OnClickListener で apply されるときにこの handler が利用されることで PendingIntent を実行することができる。
これは ListView の各要素のイベントを紐付ける SetOnClickFillInIntent においても同様であるが少し事情が異なる。ドキュメントにある通り、個々のアイテムに PendingIntent を貼り付けることは高コストである。そこで、ListItem の状態を fillInIntent としてセットし、クリック時に ListView のテンプレートに埋め込んで発火させる。まず、setOnClickFillInIntent を用いて、ListItem に対してクリック時に渡したい値を intent にセットする。親となる ListView には setPendingIntentTemplate をセットする。
これは SetPendingIntentTemplate の Actions を内部的に呼び出し、ListView に OnItemClickListener がセットされるので、Click 時に子要素が渡ってくる。この子要素からは先程の fillInIntent を取得できるので、あとは Context#startIntentSender に任せれば良い。なお、親子それぞれに listener をセットすることになるがイベントのハンドリングが内部的に行われている。すなわち、SetOnClickFillInIntent の Actions では親要素が ListView など AdapterView の継承クラスであった場合はイベントが発火しないようになっている。
AppWidgetId はどこから来るか
AppWidgetId は Widget を特定するために必要な ID であり、作成時に割り振られる。Widget に応じた ID があることで、画面ごとの更新を行え、ユーザーはより自分好みのカスタムできる。そのためにもこの値はユニークであることが前提である。しかし本当にユニークなのであるか疑問を抱く。
Widget の Id は AppWidgetHost#allocateAppWidgetId の中で呼ばれ、AppWidgetServiceImpl#allocateAppWidgetId で割当が行われる。ID は 1 から割り当てられ、次の Widget が追加された場合はインクリメントする。Service クラスは端末にある Widget 全体にまたがり動作する。Service が把握する ID がユニークであればよく、そのため他のアプリに紐づく Widget と同じ ID プールが共有され使われている。自分のアプリにとって ID が連番になることは保証されず、次の ID が他のアプリの Widget を指す可能性がある。
このことは AppWidgetProvider#onRestored を見ても分かる。ID は 1 から順に割り振られるため、その共有プールの上ではユニークだが、リセットされることによりまた1から採番が行われる。従って、Restore されるタイミングで順番が変わる可能性がある。だから再割り振りされた番号を取得できるこのイベントが用意されている。
View の更新はどう行われるか
Manager クラスには updateAppWidget と partiallyUpdateAppWidget という2つの更新メソッドが用意されている。前者は通常の画面更新に使用するメソッドである。更新後に RemoteViews の状態が Service にキャッシュされる。後者は差分更新のメソッドである。API16 以前では差分更新の View はキャッシュされなかった。API17 以降は、一部の Actions を除いて差分を今の View とマージした上でキャッシュを保存するようになった。スクロールや画像送りなど部分的な差分更新を行うときに使用する。
更新メソッドをもう少し深掘りしよう。Service クラスには updateAppWidgetIds というメソッドがあり、update も partiallyUpdate もこのメソッドを内部的に呼んでいる。呼ぶときに isPartialUpdate という boolean 値が渡され、その後内部的に updateAppWidgetInstanceLocked が呼ばれる。このメソッド内で isPartialUpdate が true であった場合に RemoteViews の mergeRemoteViews が呼ばれる。既に保存してある Actions を新しい差分の Views とマージする。
private void updateAppWidgetInstanceLocked(Widget widget, RemoteViews views,
boolean isPartialUpdate) {
if (widget != null && widget.provider != null
&& !widget.provider.zombie && !widget.host.zombie) {
if (isPartialUpdate && widget.views != null) {
// For a partial update, we merge the new RemoteViews with the old.
widget.views.mergeRemoteViews(views);
} else {
// For a full update we replace the RemoteViews completely.
widget.views = views;
}
int memoryUsage;
if ((UserHandle.getAppId(Binder.getCallingUid()) != Process.SYSTEM_UID) &&
(widget.views != null) &&
((memoryUsage = widget.views.estimateMemoryUsage()) > mMaxWidgetBitmapMemory)) {
widget.views = null;
throw new IllegalArgumentException("RemoteViews for widget update exceeds"
+ " maximum bitmap memory usage (used: " + memoryUsage
+ ", max: " + mMaxWidgetBitmapMemory + ")");
}
scheduleNotifyUpdateAppWidgetLocked(widget, widget.getEffectiveViewsLocked());
}
}
Service クラスで merge 処理が行われている
mergeRemoteViews は RemoteViews のメソッドである。ここでキャッシュに対して差分更新を行う。Actions に定義された差分の置き換え戦略に従って置換か新規追加が行われる。
Action a = newActions.get(i);
String key = newActions.get(i).getUniqueKey();
int mergeBehavior = newActions.get(i).mergeBehavior();
if (map.containsKey(key) && mergeBehavior == Action.MERGE_REPLACE) {
mActions.remove(map.get(key));
map.remove(key);
}
// If the merge behavior is ignore, we don't bother keeping the extra action
if (mergeBehavior == Action.MERGE_REPLACE || mergeBehavior == Action.MERGE_APPEND) {
mActions.add(a);
}
mergeRemoteViews の要所は Actions に紐付いた Merge 戦略を見ているところ
Widget にはアプリの外で UI を描画するための仕組みが凝縮されている。登場人物となるクラスもそれほど多くないために、コードが追いやすい機能であると言える。本稿が Widget に関わるフレームワークを読み解く一里塚となれれば幸いである。