2013-03-11

古くて新しいGUI座標系の型の話

こないだからの話題にちょっと関連する話として、さいきんChromeOSの仕事で手伝っていたマルチディスプレイ対応の機能について解説しよう。

現在販売されているChromebookには何らかのかたちで外部ディスプレイに接続する機能があるが、古いバージョンではミラーリングしかできなかった。それじゃ役に立たないので、複数のディスプレイをくっつけるような拡張デスクトップの機能をサポートした(まぁ、今時どんなOSにだってはじめっから入ってるような機能ではありますが)。Chromeのバージョン25以降でこの機能がオンになっている。ところが、この機能がなかなかの難産だった。

もともとChromeはブラウザだったから、そのGUI部分はウィンドウ基準の座標系だけを気にするようなつくりになっている。一方、ChromeOSではAuraという独自のウィンドウマネージャを実装したが、Auraにはデスクトップがあるので、デスクトップの座標系を持たなければならない。この場合、マウスイベントなどのイベントは画面座標系でやってくることになる。そこで、これをウィンドウ座標系に変換して渡す。GUIプログラミングではごく当たり前のつくりだ。

Auraのウィンドウは親をたどっていくと最終的にはRootWindowというオブジェクトにたどり着く。このオブジェクトが実際のX11上のウィンドウと対応していて、マウスイベントやキーボードイベントなどのX11イベントを受け取り、Auraのイベントに変換され処理が進むようになっている。

ここまでは何の問題もないと思う。だが、これをマルチディスプレイ化することで、複雑な問題がいろいろ出てきた。マルチディスプレイ環境では、複数のRootWindowが存在し、それぞれのRootWindowが各ディスプレイに対応するというつくりになっている。するとどうなるか?

まず第一に、スクリーン座標系と別にグローバルな座標系が必要になる。スクリーン座標系は画面の左上を(0, 0)とする座標系だが、グローバルな座標系はプライマリなディスプレイの左上を(0, 0)とする座標系だ。ディスプレイが横につながっていたら、セカンダリのディスプレイの左上は(0, 0)とは限らない。現実的にはスクリーン座標系は便利だし、大半のコードはスクリーン座標系で動作するようになっているから、両方を持って適宜変換することになる。これは、まあいい。簡単ではないが、どんなOSでも発生する問題ではある。

次に、ネイティブの座標系とAuraの座標系の不一致という問題がある。ChromeOSは下位レイヤーにX11を使っているが、X11が配置するウィンドウのレイアウトと、Aura上のRootWindowの論理的なレイアウトは一致しない。Aura上でRootWindowの接続を左右から上下に移したとしても、X11レベルでは何も起きず、Aura上の論理関係だけを更新する。あらゆるX11のイベントはネイティブ座標系で渡ってくるが、RootWindowではこれをAuraの座標系に変換しなければならない。

さらに問題をややこしくしていたのがChromebook Pixelの存在だ。Pixelというのは最近発表された高解像度ディスプレイの製品だが、高解像度なため、2倍表示させている(MacのRetinaのように)。つまり、物理的には2x2の4ピクセルを論理的には1ピクセルとして扱い、本来の解像度は2560x1700だが、論理的な画面サイズは1280x850として表示するようになっている。このために高精細な画像になり、画面はものすごく綺麗だ。綺麗だが……当然ネイティブ座標系は物理的なピクセル値でイベントを生成する。これをAuraの論理的な座標系に移す際に、適宜2で割ったり、倍にしたり、といった処理が必要になる。

基本的には、すべてRootWindow内で物事を完結させ、外部からはこういうややこしい問題は見えないようにする。当然、そうなっているべきだし、そうしている。が、そうするためには、もともと単一ディスプレイしかなくてネイティブ座標系、グローバル座標系、スクリーン座標系がぴったり一致した世界だったところに変換を噛ませないといけない。

もちろん、すべてを変換すればいいだけの話だし、テストを書いて問題が再発しないようにしている。そもそも大元のコードパスを直すのはそれほど大変じゃない。だが、意外なところに抜け道があったりして、検証を難しくしている。しかも、当然だが変換を忘れてもたいていの場合はそこそこ動く。だが、最後に完成度を高める部分はけっこう大変だ。外部モニタをつないで、ディスプレイの配置を変え、プライマリディスプレイを入れ替え、ウィンドウじゃなくてタブを直接ドラッグして、高解像度ディスプレイから普通のディスプレイにドラッグして移そうとした時にだけなんか挙動が変、みたいなバグを直したりしないといけない。どこかで変換しわすれが発生しているのだが、どこなのかはすぐわかるものでもない。おかしくなった箇所と変換の箇所は遠く離れている、ということはよくあるからだ。

あるとき、2xディスプレイがらみのバグを直していた時に、いいかげん嫌になって、型を変えるべきなんじゃないかという話を同僚としたことがある。いまさらのタイミングだったのでそうはならなかったし、その判断は間違いではなかったと思うが、ここからは実際を離れて理論面を考えてみたい。

理論的には、ネイティブ座標系とかグローバル座標系とかスクリーン座標系とかいうのは、本質的には異なった型だ。インタフェースは同じ、xとかyとかだが、意味が違う。本質的に異なる型なので、プログラミング言語上でも異なる型にしてしまうと、こういう問題はかなりの部分は解決される。変換わすれのようなミスはコンパイラがすべて見つけてきてくれるからだ。

インタフェースが同じだからといって同じ型だと思ってしまったり、型のチェックを持たないと、こういう問題を見つけることは難しい。したがって、動的型言語ではこういう問題について、入出力のテストをいっぱい書いて問題が起きないようにする。だが、実際にこのような変更を途中でするときに、すべてのテストを書き切るのは難しい。問題を発見するたびにテストケースを足して漏れを直すのだが、どうしても後手に回っている感は否めない。

けっきょく、これは時定数の大きな問題だ。ここで異なる型を導入すると、いきなりぶっ壊れてしまうので、おいそれとはできない(Chromiumの規模のコードベースのあちこちで使われている型だから、そう簡単には変えられない)。機能自体も作るのにそれなりに時間を要するし、インタフェースもその間にはころころ変わるから、事前にこうと設計するのも難しい。後出しじゃんけん的には、プリプロセッサを使ってみるとか、もっと初期の段階で型を分けておくべきだったとか、いろいろ思うところはあるが、現実的であったかどうかは難しいところだ。結局、そういうわけで、僕らもテストを書いて対処した。だがそれでも……とたまに思う。

common-lispではいい手はあるのかな? generic functionやオプショナルな型チェックはできるだろうし、マクロを使って型チェックを導入する(しかもリリースビルドではチェックを省略するとか)できるだろう。が、そう簡単にはいかないのではないか?という気がする。どこにチェックするかを指定する、ということは、テストを書くのと同じぐらい大変だ。型レベルの変更は、コンパイラが網羅的に検証してくれるので、楽だ、というのがここでの要旨なので。

まとめ。
  • 世の中には、インタフェースや実体は同じだが、意味が違うために異なる型になっているモノというのがある。静的型の検証は、そういう「意味」を表現できる
  • もちろん、意味はテストでも表現できる。テストのほうが表現力は柔軟でもある
  • だが、型の検証は自動的かつ網羅的なので、便利な面もある
と思うけど、どうでしょうか。