onPaintが二度呼ばれる問題

Posted 2013.03.06 in 吉里吉里

画面の再描画を効率化しようと思う時、
誰しもがまず思いつくであろうことがLayerクラスのonPaintイベントの利用だ。
データが変更された時にすぐに再描画を行わずupdate関数を呼び出しておいて
画面の描画前に呼ばれるonPaintでまとめて遅延描画する仕組みである。

シンプルな仕組みのように思えるが…
実はそこには大きな落とし穴があった。

onPaintの不可思議な動作

onPaintを使って再描画を効率化した際、逆に再描画が重くなるという現象に遭遇した。
なぜなのか詳しく調べてみると、意外な事実が判明した。

onPaintが連続して二度呼ばれている!

さらに詳しく調べると、最初に実行されたonPaintを抜ける時に
onPaintを呼び出すことを意味するcallOnPaintフラグがtrueになっていることがわかった。
これが原因っぽい気がするが…吉里吉里のリファレンスには

吉里吉里リファレンス Layerクラス callOnPaint
「onPaint イベント が処理し終わるとこのプロパティは自動的に偽に戻されます」

とあるので、onPaintを抜ける時にtrueになっていても問題ではなさそうだ。
しかし念のためにソースコードを見てみると…
………onPaintを呼び出す前にcallOnPaintフラグを落としている!
リファレンスの誤りだろうか。
途中で仕様変更されたのだろうか。

ともかく、やはりonPaintを抜ける時にcallOnPaintフラグがtrueになっているのが問題なかんじがする。
むしろonPaintを呼び出す前にcallOnPaintフラグが落とされるという事実と照らし合わせるとこれは
onPaint内の処理でcallOnPaintフラグを立てるような処理がなされていることを意味する。
しかしonPaint内でupdateを呼び出してはいないし、
callOnPaintフラグを直接いじるようなこともしていない…
ん、まてよ、updateといえば…

描画プラグインで無効領域を報告するためにupdateを呼び出してる!

そう、プラグインで拡張された描画関数を呼び出していたのである。
その中のupdate呼び出しがcallOnPaintフラグをtrueにしている真犯人だったのだ。

さてさてonPaintを抜ける時にcallOnPaintがtrueになる原因は判明したが、
それでなぜonPaintが二度呼ばれることになるのか。
それを理解するにはまず吉里吉里の再描画の仕組みと、
update関数の作用について知る必要がある。

画面の再描画の流れ

吉里吉里はイベントシステムである。
吉里吉里 イベントシステム

画面の再描画もイベントによって管理されている。
画面の再描画は描画イベント(tTVPWinUpdateEvent)が実行された時に行われる。

描画イベントは原則的になんらかの描画関数を呼び出した時に発行される。
また描画関数は描画した領域を無効領域として報告する。
発行された描画イベントはイベントキューに蓄えられる。
イベントキューに蓄えられる描画イベントは原則として各画面につき1個で、
重複するイベントは無視される。
そして描画イベントはイベント配信処理において低優先度で実行される。

描画イベントは実行されるとまず
callOnPaintフラグがtrueな全てのレイヤーに対してonPaintの呼び出しを行う。
次に全てのレイヤーの無効領域の範囲のバッファの更新を行う。
そしてバッファの最終的な合成結果を画面に転送する。

update関数の作用

update関数には3つの作用がある。

1.callOnPaintフラグをtrueにする
2.無効領域を報告する
3.描画イベント(tTVPWinUpdateEvent)を発行する

まず1によって次回の画面の再描画時にonPaintを呼ぶことを予約する。
ここで重要な事実がある。
callOnPaintフラグはあくまでonPaintを呼ぶ予約にすぎないということだ。
callOnPaintフラグを立てただけでは、画面の再描画は行われない。
画面の再描画が行われるのは、描画イベントが発行され実行された時である。

つまりレイヤーの再描画を行わせる目的で
layer.callOnPaint = true;
などとやっても期待するような動作にはならない。
update関数はcallOnPaintフラグをtrueにするのと同時に描画イベントを発行するので、
update関数を呼び出せば期待通り画面の再描画が行われる。

onPaintが二度呼ばれる理由

ここまでわかればonPaintが二度呼ばれる理由はすぐわかる。
onPaint関数内で、update関数を呼び出したらどうなるか。
callOnPaintフラグがtrueになり、描画イベントが発行される。
描画イベントが発行されるということは…画面の再描画が行われる!
そしてcallOnPaintフラグがtrueなので、再びonPaintが呼び出される!

しかしここでまた不思議なことがある。
そのような流れで再びonPaintが呼ばれるのであれば、
onPaintが無限に呼び出され続けることになりそうなものである。
けれどもそうはならない。

これにはイベントキューの処理上の仕様が関係している。
描画イベントは原則としてイベントキューに各画面につき1個しか蓄えられないのだが、
描画イベント実行中は例外的に2個までイベントキューに蓄えられるのである。
つまり現在実行中の描画イベントに加えて、もう1個イベントキューに蓄えられるということである。
これは言い換えれば、画面の再描画が行われるのは
一度のイベント配信処理において最大2回までということである。
よって無限に呼び出されるようなことにはならない。

update関数を呼ばなくても二度目の再描画は行われる

描画イベントは標準の描画関数を呼び出した時にも発行されるので
実はonPaintで標準の描画関数を使った際にも二度目の画面の再描画処理は行われる。

けれどもこれはほとんどの場合問題にならない。
なぜならonPaintで発生した無効領域はその後のバッファの更新で処理されるからだ。
バッファの更新を終えるとそこまでに蓄えられていた無効領域はクリアされる。
二度目の画面の再描画時には無効領域は空になっているので
実際には具体的な再描画はなんら行われずすぐに処理を終える。

しかし1度目のonPaintでupdateを呼び出して2度目のonPaintで描画を行った場合は
再び無効領域が発生し画面の再描画が再度実施されてしまう。
これは非常に無駄な処理になりかねない。

プラグインでupdateを使うのは誤り?

プラグインで独自の描画関数を追加する際には、吉里吉里の標準の描画関数と同じように
描画した領域を無効領域として報告するようにするのが正しい実装である。
もしそうしなければ、レイヤーのバッファの更新が正しく行われない可能性がある。
たまたま吉里吉里の標準の描画関数を一緒に呼び出していて
うまく動作する場合もあるが…たまたまにすぎない。

プラグインからの無効領域の報告にはupdate関数が一般的に使用されているようだ。
しかし無効領域の発生を報告するためだけを目的として
update関数を使用するのはおそらく本来誤りだ。

なぜなら吉里吉里の標準の描画関数は、update関数の2と3の処理しか行わないからである。
つまり1の動作…callOnPaintフラグを立てる処理が余計なのである。
そもそもupdate関数は本来onPaintとセットで使うべきものなのだろう。

しかし無効領域の報告だけを目的としてupdate関数を使ったプラグインが既に多くある。
その現状を踏まえた上で、この問題を回避する方法について考えてみよう。

対策1: onPaintで明示的にcallOnPaintフラグを落とすようにする

ズバリ言って、updateによってcallOnPaintがtrueにされてしまうのであれば
明示的にcallOnPaintフラグを落としてしまえばいい。
ここまでごちゃごちゃと理屈を述べてきたが、実際これだけでおおかた問題を回避できる。


function onPaint()
{
  // なんらかの描画処理

  callOnPaint = false;
}

対策2: onPaintで無駄な再描画をしないようにしておく

再描画フラグなどを独自に設けて、無駄な再描画をしないようにしておくのも良い方法だ。
何かが何かして二度目のonPaintが実行されたとしても、これならば無駄な再描画を確実に回避できる。


function onPaint()
{
  if(!isNeedRedraw) return;
  isNeedRedraw = false;

  // なんらかの描画処理
}

対策3: updateを呼ぶ際にcallOnPaintを維持するようにする

プラグイン側で無効領域の発生を報告するためだけにupdate関数を使うのは誤り!
とは言ってもそのようなことを直接できる関数はupdate関数しかないので
現実問題としてupdate関数を使わざるを得ない。

そこで、意図しない不必要なonPaintの呼び出しを避けるために
callOnPaintフラグを一時保存してupdateを呼び出した後に書き戻すようにする。
たとえば次のような関数を作って使うようにする。


static tjs_uint32 g_Hint_callOnPaint = 0;
static tjs_uint32 g_Hint_update = 0;

bool Update(iTJSDispatch2* pLayer, const RECT* pRect)
{
  // レイヤークラス取得
  tTJSVariant Value;
  TVPExecuteExpression(TJS_W("Layer"), &Value);
  iTJSDispatch2 *pLayerClass = Value.AsObjectNoAddRef();

  // パラメータを用意
  const int PARAM_NUM = 4;
  tTJSVariant Params[PARAM_NUM];
  Params[0] = pRect->left;
  Params[1] = pRect->top;
  Params[2] = pRect->right - pRect->left;
  Params[3] = pRect->bottom - pRect->top;

  // パラメータポインタの配列を用意
  tTJSVariant* ParamPtrs[PARAM_NUM];
  for(int i=0; i < PARAM_NUM; i++) ParamPtrs[i] = &Params[i];

  // callOnPaintを退避
  tTJSVariant callOnPaint;
  if(TJS_FAILED(pLayerClass->PropGet(0, TJS_W("callOnPaint"), &g_Hint_callOnPaint, &callOnPaint, pLayer))) return false;

  // 領域更新
  tTJSVariant result;
  if(TJS_FAILED(pLayerClass->FuncCall(0, TJS_W("update"), &g_Hint_update, &result, PARAM_NUM, ParamPtrs, pLayer))) return false;

  // callOnPaintを復元
  if(TJS_FAILED(pLayerClass->PropSet(0, TJS_W("callOnPaint"), &g_Hint_callOnPaint, &callOnPaint, pLayer))) return false;

  return true;
}

まとめ

onPaint関数はさらにpiledCopy関数の呼び出し時や
トランジッション時にも呼ばれる可能性があるため問題はいっそう複雑だ。
update関数の使い方には細心の注意を心がけたい。


Leave a Reply

*