2013年3月31日日曜日

カスタムビューを作る際に重要なonMeasure()とは?

  • 親側やレイアウトxmlの属性で、任意の大きさを指定して、その大きさに応じて描画させるにはどうしたらいいのか?
  • onMeasure()で指定したとおりの大きさになっているのに、onDraw()メソッドの引数canvasのgetWidth()やgetHeight()の返す値と一致していないのはどういうことなのか?
みたいな話を書く予定が、前提のonMeasure()の内容が長くなりすぎたのであった。

androidでカスタムなViewを作りたいときは、Viewを継承したカスタムクラスを作って、onDraw()に描画内容を記述すればいい。ただそのままだと親のViewGroupのサイズをフルに占有しようとしてしまうので、onMeasure()をオーバーライドして、自身のViewが描画したいサイズを指定する。

その辺の話までは前提知識として持っているものとする。

---


onMeasure()とは何なのか?

それを理解するには、androidがビュー描画する際に行っている処理を理解するのが手っ取り早い。公式のHow android Draws Views日本語訳)にその説明がある。

androidのビュー描画は計測フェーズとレイアウトフェーズの二段階に分かれていて、このうち計測フェーズでルートとなるViewは自身のビュー階層の大きさを計測するメソッドmeasure()を呼び出す。

このmeasure()メソッドそのものはViewクラスでfinalメソッドとして宣言されており、この実装をサブクラスであるカスタムビュー側で変更することはできない。measure()メソッド内で呼び出されるメソッド、onMeasure()をサブクラス側でオーバーライドすることで、自身の大きさをmeasure()メソッドに通知するのである。

その通知方法はsetMeasuredDimension()に自身の大きさを渡すという方法を取っている。setMeasuredDimension()で渡した値はViewに保持され、getMeasuredWidth()またはgetMeasuredHeight()で取得できるようになる。(measure()内でsetMeasuredDimension()が呼び出されなかった場合は、例外が発生する)

最後にonMeasure()のsuper実装を呼び出す。これによって、必要があれば親クラス側でさらにsetMeasuredDimension()が呼び出され、値が更新される。

つまり、本当はsuper.onMeasure()はonMeasure()の最後に呼び出す必要がある(先頭で呼び出すと、親が設定したsetMeasuredDimension()の値を上書きしてしまうため。ネット上の日本語情報だとここを間違えてる人が多い)。

では子が設定したmeasuredDimensionの値を、親で更新すべきケースとは何だろう?それはbackgroundに大きさを持つdrawableを指定した場合だ。もしbackgroundのdrawableよりも、子がmeasuredDimensionで宣言した大きさの方が小さい場合、backgroundのdrawableの大きさを取得して上書きする。


…つまり、間違えてonMeasure()の冒頭で呼び出したところでほとんど実害はない。


しかし「backgroundに指定したdrawableの大きさが、Viewの最小の大きさになる」という性質は、知らないとレイアウトを組むときにハマる可能性があるので、覚えておいて損はない。


---

onMeasure()で重要なこととして、onMeasure()の引数として渡される値も、setMeasuredDimension()に渡す値も、単純なサイズではない、ということだ。

ここで引き渡す値は、サイズと制約条件を一つのintの値として保持したものである。上位2ビットに制約を、下位30ビットにサイズを保持している。

ビット演算によってサイズや制約条件を扱う必要があるが、幸いにもビット演算を使わなくとも変わりに処理を行ってくれるView.MeasureSpecクラスが存在するので、そちらを利用すればいい。

  • MeasureSpec.getSize(int measuredSpec);
    • measuredSpecから下位30ビットを取り出す(=サイズを取り出す)
  • MeasureSpec.getMode(int measuredSpec);
    • measuredSpecから上位2ビットを取り出す(=制約定数を取り出す)
  • MeasureSpec.makeMeasureSpec(int size, int mode);
    • 実はsizeとmodeを足しているだけ(=制約フラグ付きの値を生成)

setMeasuredDimension()は、サイズと制約定数を足した値を必要とする。ただしmakeMeasureSpec()と書いておいた方がソースコード上で意味も通りやすくなるだろう。

ところで、ネット上の日本語情報では、makeMeasureSpecを行わずにダイレクトにsetMeasuredDimension()に値を渡しているものもある。実はそれでも動く。

なぜなら、制約条件の定数の一つUNSPECIFIEDの値が0x00000000なので、そのまま値を渡した場合でも、「上位2ビットが0だからUNSPECIFIEDだ」と解釈されるだけだからだ。


しかしそれはMeasureSpecの存在を知らないのか、知った上で意図したものなのか区別が付かないので、明示的にMeasureSpec()で値を生成した方がより良い実装だろう。


---

ここで二つ新しい疑問が出てくる。

  • onMeasure()の引数として引き渡される値は何を意味するのか?
  • 制約条件とは一体何なのか?
onMeasure()の引数の値も、サイズと制約条件を保持したintの値なので、先に制約条件について説明する。この制約条件は計測モードなどとも呼ばれ、次の3つの値を取る。
  • AT_MOST
    • at mostは「多くても」「~以下」という意味。親からこの制約条件で値を受け取った場合、それより大きい値を指定してはいけない。
  • EXACTLY
    • exactlyは「ぴったり」という意味。親からこの制約条件で値を受け取った場合、子はこのサイズに合わせなければならない。
  • UNSPECIFIED
    • unspecifiedは「指示していない」という意味。つまり制約条件として何も指定されていないので、子は自身の好きなサイズを宣言することができる。
onMeasure()は引数として渡されるのは、親のサイズと制約である。

つまり本当は渡された制約条件を判定し、AT_MOSTやEXACTLYが指定された場合には、親のサイズに収まるようにビューの大きさを宣言しなければならない…が、自身のアプリ内で使うカスタムビューであれば、ほとんどの場合は制約条件を無視してUNSPECIFIEDとして大きさを宣言するだけで十分だろう。

また、必要があればここでレイアウトパラメータを考慮して大きさを宣言することもできる。デフォルトの実装ではそういうことを全く行っていない。そのため、自作したカスタムビューは(backgroundDrawableを指定しない限り)、親のViewGroupのサイズをフルに専有しようとする。

---

onMeasure()は複数回呼ばれることがあり、これがビュー描画速度を落とすボトルネックとなる場合がある。

例えばlayout_widthを0にして、weightでレイアウトしている場合。親ビューは子ビューのサイズを知る目的で、UNSPECIFIEDでonMeasure()を呼び出し、もし子ビューの総サイズが親ビューよりも大きくなった場合、AT_MOSTで子の取れる最大サイズを指定した上でもう一度onMeasure()を呼び出す。

レイアウトが入り組んでいる場合、再帰的にこの処理が行われてしまう。

そこでビュー描画処理を高速化するのに、相対レイアウト(Relative Layout)が良いと言われる。相対レイアウトは相対的に位置を決定するだけなので、計測フェーズが一度しか行われないことが保証される。

しかしレイアウトXMLエディタが絶望的に使いにくい限りは、LinearLayoutを選ぶよね…。


---

んで、冒頭の疑問なんだけど、setMeasuredDimension()で自身のサイズを宣言して、実際にそうレイアウトされていても、端末によってはonDraw()の引数canvasからwidth()やheight()で値を取ると一致しないケースがある。

結局view自身のgetWidth()やgetHeight()を使えば正しい値が取れるので、それで解決したのだけどスッキリしない。

canvasのwidth()やheight()の値を正しい値で更新するにはどうしたらいいんだろう。そもそもcanvasのwidthやheightが何を参照しているのかについてコードを見てみたものの、CanvasはC++のSkiaライブラリで書かれているので、ちょっと保留。

1 件のコメント:

  1. カメラプレビューをアスペクト比を維持して全画面に表示する際に以下の情報が役に立ちました!
    > 本当はsuper.onMeasure()はonMeasure()の最後に呼び出す必要がある
    ありがとうございます。

    返信削除