この記事は 食べログアドベントカレンダー2022 の20日目の記事です🎅🎄
はじめまして。
食べログシステム本部 アプリ開発部の須賀です。
Androidのコードリーディングでたまに現れては、忘れがちで困る厄介なAPIを再理解しようと思います。
今回の調査対象
前提知識
Viewのライフサイクル
View描画方法
描画は、レイアウトのルートノードから始まります。続いて、レイアウト ツリーの測定と描画がリクエストされます。描画の処理では、ツリーをたどって無効な領域と交差する各 View がレンダリングされます。その過程で、各 ViewGroup はすべての子に対して(draw() メソッドで)描画をリクエストする役割を担い、また各 View は自身の描画を行う役割を担います。ツリーは前順で走査されるため、親は子より前(つまり背後)に描画され、兄弟はツリーに表示された順序で描画されます。
View
requestLayout
Viewサイズの変更の必要がある場合、viewサイズの再計算からレイアウトの再描画をし、それを親階層までリサイズ依頼するというものです。
[公式情報]
このビューのレイアウトを無効にする何かが変更されたときにこれを呼び出します。これにより、ビュー ツリーのレイアウト パスがスケジュールされます。これは、ビュー階層が現在レイアウト パス ( isInLayout().そして次のレイアウトが発生します。
もう 1 つの非常に高コストな処理として、レイアウトのトラバースがあります。ビューが requestLayout() を呼び出すとき、Android UI システムは常にビュー階層全体をトラバースして、各ビューが必要とするサイズを検出する必要があります。競合する測定値が見つかると、何度もビュー階層をトラバースする必要が生じます。UI 設計者は、UI を適切に動作させるために、ネストされた ViewGroup オブジェクトによる深いビュー階層を作成することがあります。このような深いビュー階層は、パフォーマンス上の問題を引き起こします。
「レイアウトのトラバースがあります。」という単語が出てきますがtraversingは横断するという意味があるそうです。
よくレイアウト構造が複雑な場合、描画処理が重くなります。と言いますがその原因の1つがこれだと思います。
以下実際の処理ですが、親のrequestLayout
を呼んでトラバースしてます。
public void requestLayout() { if (mMeasureCache != null) mMeasureCache.clear(); if (mAttachInfo != null && mAttachInfo.mViewRequestingLayout == null) { // Only trigger request-during-layout logic if this is the view requesting it, // not the views in its parent hierarchy ViewRootImpl viewRoot = getViewRootImpl(); if (viewRoot != null && viewRoot.isInLayout()) { if (!viewRoot.requestLayoutDuringLayout(this)) { return; } } mAttachInfo.mViewRequestingLayout = this; } mPrivateFlags |= PFLAG_FORCE_LAYOUT; mPrivateFlags |= PFLAG_INVALIDATED; if (mParent != null && !mParent.isLayoutRequested()) { mParent.requestLayout(); } if (mAttachInfo != null && mAttachInfo.mViewRequestingLayout == this) { mAttachInfo.mViewRequestingLayout = null; } }
invalidate
Viewの再描画を行わせたい時に使うものです。
invalidate
の意味自体は「取り消す」「無効化する」のような意味があります。
動きとしては「Viewを強制的に描画させる」が正しい認識らしいです。
TextViewのsetText
,setGravity
、padding
、setTextSize
、など再描画が必要なタイミングは必ず呼び出します。
多くの場合、requestLayout
とセットで運用されていることがあります。
リサイズ等で再描画(invalidate
)された際にレイアウト整合性が取れなくなることがないようにするため行われます。
ViewGroup
ViewGroup.java(android-13.0.0_r12)
addView
動的なViewの追加の際に使います。
requestLayout
やinvalidate
が実行されます。そのため動的なViewの追加は負荷が高くなります。
※addViewはsetContentViewやinflate時にも発生します。
public void addView(View child, int index, LayoutParams params) { if (DBG) { System.out.println(this + " addView"); } if (child == null) { throw new IllegalArgumentException("Cannot add a null child view to a ViewGroup"); } // addViewInner() will call child.requestLayout() when setting the new LayoutParams // therefore, we call requestLayout() on ourselves before, so that the child's request // will be blocked at our level requestLayout(); invalidate(true); addViewInner(child, index, params, false); }
onInterceptTouchEvent
ViewGroupの子が正しくタッチイベントを受け取るようにするためのものです。(この機能だけのものではないですが)
ViewGroupでタッチイベントが検出されるたびに呼び出されるため、
タッチイベントを子に渡すかを制御します。(trueを渡せば子ビューに流れない)
基本的なView内のイベントの流れは以下のようになります。
onInterceptTouchEvent
を使用すれば、子より先に親でタッチイベントを確認できるようになります。
[公式情報]
onInterceptTouchEvent() メソッドは、子のサーフェスを含め、ViewGroup のサーフェスでタッチイベントが検出されるたびに呼び出されます。onInterceptTouchEvent() から true が返されると、MotionEvent がインターセプトされます。つまり、子に渡されるのではなく、親の onTouchEvent() メソッドに渡されます。 onInterceptTouchEvent() メソッドを使用すると、子より先に親でタッチイベントを確認できます。
タッチイベントの流れ
単一ビューの場合
dispatchTouchEvent → onInterceptTouchEvent → onTouchEvent
階層ありの場合(Viewが押下されたときのイメージ)
※補足
requestDisallowInterceptTouchEvent
というものがあり、
子から親に対してイベントの伝搬を止めないでと指示することができます。
parent.requestDisallowInterceptTouchEvent(true)
を指定することで親のonInterceptTouchEvent
が実行されなくなります。(厳密には無視されます)
なので親のonTouchEvent
は実行されます。
複雑な操作が必要な場合、Viewの親子で制御の奪い方を設計した方がよさそうですね。
他にも、子ビューにonTouch
を実行させない方法として以下の方法があります。
new View.OnTouchListener(){ public boolean onTouch(View v, MotionEvent event){ // View毎のイベントを検知して、特定のView以外は、リスナーを呼ばないともできる // trueにすると他のリスナーが呼ばれない return false; } });
RecyclerView
OnItemTouchListener
タッチリスナーを検知できます(onInterceptTouchEvent、onTouchEvent)。クリックリスナーと違い、タッチイベント(MotionEvent)が取り扱うことができます。
※ 後述するGestureDetector
を利用する場合、onInterceptTouchEvent
でGestureDetector
に処理を丸投げしましょう。
ちなみに、通常のRecyclerViewの押下処理の実装でOnItemTouchListener
出てくることはないと思います。
findChildViewUnder
画面上のxポイントとyポイントからRecyclerView上のどのViewを指定されているかを判断します。
MotionEvent自体がx
とy
を持っているのでこちらを利用する場合が多いです。
RecyclerView#getChildAdapterPosition(child: View)
と組み合わせて使うこともあり、座標から取得した子Viewのポジションが取得できます。
タッチイベントの結果、RecyclerViewのどこの処理をされているかというのをこれでできるようになるはずです。
GestureDetector
タッチイベント(MotionEvent)では言語化できなくて直感的にわかりにくかった部分を、実際のスマホ操作に合わせて自動検出してくれるものという認識です。
タッチイベントと常に併用可能なものだと思います。
使い方は多くの場合タッチイベントを奪って使うことになると思います。
private lateinit var detector: GestureDetectorCompat override fun onTouchEvent(event: MotionEvent): Boolean { detector.onTouchEvent(event) return super.onTouchEvent(event) }
[公式情報]
Android には、一般的な操作を検出するための GestureDetector クラスが用意されています。サポートされている操作には、onDown()、onLongPress()、onFling() などがあります。GestureDetector は、上記の onTouchEvent() メソッドと組み合わせて使用できます。
引用元:一般的な操作の検出#操作の検出
GestureDetectorのTips
いくつかリスナーの種類があるためトリセツです。
基本はSimpleOnGestureListener
を実装すればいいと思います。
- GestureDetector.OnGestureListener
- 基本形
- GestureDetector.OnDoubleTapListener
- 基本形(OnGestureListener)と併用して使う。これを使うなら後述する、SimpleOnGestureListenerを使ったほうがいい。
- GestureDetector.SimpleOnGestureListener
- 実践型。サブセットのみを使えるので、仕様が言語化できているなら、これだけ使えばいいと思います。
- ScaleGestureDetector
- ピンチイン、ピンチアウトを検出する。
おまけ(出現頻度は低いもの)
ViewConfiguration
(操作の)距離、速度、時間にアクセスするためのクラスです。
MotionEventと組み合わせて、独自でロングタップの"ロング"の判定を制御できたりします。
組み合わせ方によっては、細かい制御がさらに効くようになりそうです。誰が幸せになるの?
UXをどうしてもこだわりたい時にお世話になるかもしれないですね。
[公式情報]
「タッチスロップ」とは、ユーザーのタップがスクロールとして解釈されるまでにタップが動ける距離を、ピクセル単位で表したものです。タッチスロップは通常、ユーザーが画面上の要素をタップしているときなど、他のタップ操作を行っているときに誤ってスクロールしないようにするために使用します。
他によく使用される ViewConfiguration メソッドには、getScaledMinimumFlingVelocity() と getScaledMaximumFlingVelocity() の 2 つがあります。 これらのメソッドは、ピクセル / 秒で測定した、フリングを開始する最小速度と最大速度を(それぞれ)返します。次に例を示します。
最後に
Viewのタッチイベントを中心に学習し直す形になりました。
タッチイベントはコードリーディングする時混乱することがありましたが、実装する際はそこまで深く考えることはなかったです。
改めて整理してみると、複雑度が上がる部分ではあるので"Viewの階層をイメージしてタッチイベントを設計する"という考え方は持っていたいと思いました。
参考
明日は @weakboson の「大規模サービスにマッチした可変レート分散トレーシング」です。お楽しみに!