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ライブラリで書かれているので、ちょっと保留。

2013年3月26日火曜日

「OpenGL ES 2.0 Androidグラフィクスプログラミング」読んでる。

「初めてのOpenGL ES」は読んだことがあったんだけど、2.0系についても理解しておこうと思って購入。

どうせ最近のAndroidだとCanvasを使ってもOpen GLに変換して描画してくれるっぽいし、本格的に3DやるならUnityとかのゲーム向けライブラリ使う方がいいんだろうしで、OpenGL ESを直接学ぶ必要性があるのかよく分からないんだけど、まぁ趣味的に。

OpenGL関係の翻訳本などを手がけている著者だけあって、用語がちゃんとしてて、説明もこなれている感じがある。

ただこの本、プログラマブルシェーダーの初期化等を独自のUtilityライブラリに回しており、その部分に関する説明がバッサリ飛ばされているので、後半の章を読むまで分からない。

そのUtilityライブラリを手に入れるには、会員登録必須のサイトに有効期限のあるダウンロードコードを入力するという手続きを踏まなければならないので、そうしないとUtilityライブラリがブラックボックスのせいで写経して動かすことすらできないのが、なんだかなーって感じ。

面倒な手続きをひとまず隠蔽することで、動くものを作り、それを改造して学んでいくというモチベーションを保ちやすい学習スタイルは入門書として凄くいい本っぽいんだけど、個人的には「おまじない」というマジックワードで濁されて、ブラックボックスのままにされてしまうとそっちが気になってしまう。この辺は趣味の問題かな。

---

ところでOpenGL ES 2.0の本を読んでいて気になるのは、2.0はGL20クラスのstaticメソッドの利用が中心になっているのだから、静的import使えばいちいちGL20.~って打たなくていいし、コードの見栄えがC言語に近くなっていいと思うんだけど、なぜかそう書いてるケースが見当たらない。なんでなんだろ。

GL10(ES 1.0)とメソッド名が同じ(1.0はインスタンスメソッドだから区別は付くとはいえ、非常に紛らわしいことに違いはない)とか、静的importを使うことによって変数が汚染されてトラップに嵌るとか、なんかいろいろあるのかもしれないけど…。

2013年3月15日金曜日

blocksのclosures的特性を使ったGCDキャンセル法。

例えばゲームの当たり判定処理と画面描画処理のように「必ずこなさなくてはならないタスクと、処理能力によってはスキップすべき重いタスク」がセットになっている場合や、リアルタイム検索のように「ネットワーク検索タスクを実行するたびに、古いタスクが陳腐化してキャンセル処理が必要になる」などのケースで微妙に使えそうな気がします。

-(void) queue{
  static u_int32_t token;

  @synchronized(self) {
    token = arc4random_uniform(UINT32_MAX);
  }

  u_int32_t itsToken = token;

  dispatch_async(_serialQueue, ^{

    //タスク開始までにtoken値が更新されていれば、それ以上処理しない
    @synchronized(self) {
      if(token != itsToken) return
    }

    //重いタスク
    [self doHeavy];

  });
}

上記のqueueメソッドは呼び出す度に、静的ローカル変数であるtokenの値を、arc4random()関数を使ってランダムな値で書き換えます。そしてその値をローカル変数であるitsTokenにコピーします。

その後、dispatch_async()関数で、直列キューに対して何らかのタスクを与えます。

dispatch_async()に渡されたblocksは、コンテキストをキャプチャするときに、静的ローカル変数であるtokenは参照コピーをして、一方__block修飾子もないただのローカル変数であるitsTokenは、constとしてコピーします。

結果として、このblocksはtokenの値が更新されたかどうか(=キューに新たなタスクが積まれたかどうか)をitsTokenとの比較によって知ることができるのです。

応用例1

再描画命令など、タスク同士の実行間隔に時間の制約を付けたいときに、ローカル静的変数に最終処理時間を保持しておくとスマートに記述できます。

-(void) doOneSecondInterval{
  static NSTimeInterval lastProcessDate;
  
  dispatch_async(queue, ^{
    NSTimeInterval now = [[NSDate date] timeIntervalSinceReferenceDate];

    //もし前回の処理から1秒未満ならリターン
    @synchronized(self) {
      if(lastProcessDate != 0 || now - lastProcessDate > 1) return;
    }

    [self doProcess];

    //最終処理時間を更新
    @synchronized(self) {
      lastProcessDate = [[NSDate date] timeIntervalSinceReferenceDate];
    }
  });
}

ブロックにキャプチャされたstatic変数は、__blocks修飾子がなくても読み書きが可能です。static変数は0で初期化されることを利用して、初回実行時には必ずdoProcessメソッドを呼ぶように判定を書く必要があります。

応用例2

例えば、データベースなど排他制御が必要な資源に対して、インスタンスの現在の状態を保存したいとき、stateSaveメソッドを呼び出した「その瞬間の状態」が保存されることが保証されて欲しいならば、コールスタックに一時コピーを作ってキャプチャさせるというテクニックが使えます。

-(void) stateSave{
  id instanceState = [[self.state copy] autorelease];

  dispatch_barrier_async(queue, ^{
    [databaseHelper writeData:instanceState];
  });
}

コピーされたオブジェクト、instanceStateはその場でautoreleaseされますが、ブロックにretainされ処理が完了するまでの生存が保証されます。そして、実際のブロックの実行時には既にself.stateが変更されていたとしても、キューがデータベースに保存するのはstateSaveが呼び出された瞬間のコピーなのです。


GCDとblocksの組み合わせは、思わぬ循環参照を招いて問題を起こすこともありますが、static変数、自動変数、__blocks修飾子変数、オブジェクトなどがそれぞれどのようにキャプチャされるのか、ということを理解して使いこなせば、非常に強力だと思います。