この記事はGoアドベントカレンダーの5記事目です。
WindowsのAPIを叩いて遊んでみた記事です、あまりGoっぽくないです。
ターミナルに文字を吐く以外のことをやってみたい
何らかの処理を行い、その実行結果をターミナルに出力するのはどの言語でもprint系の関数を利用すれば簡単に行う事ができます。
ターミナルに依存せず、起動するとGUIのウインドウが立ち上がるアプリケーションはいったいどのような仕組みで動いているんだろう、と疑問に思いました。
ウインドウを自力で生成する方法を探す
普通にGUIアプリケーションを作りたいときはフレームワークに頼るでしょう。
gotronやfyne、qtなど様々な選択肢があります。
しかし今回は仕組みを学ぶことに意義があります。
ウインドウが生成されるための最小限のコードはどんなコードだろう、と考えてみました。
同じアプリケーションであってもウインドウの細かいデザインはOSによって異なり、また同じOSであればアプリケーションの最大化、最小化機能などのUIは統一されています。
これはウインドウ生成の役割をOSが担当しているからです。
おそらくOS側の機能にウインドウを生成するためのシステムコールが存在するだろう、という推測と、Goにはシステムコールを呼ぶための機能が備わっているとGoならわかるシステムプログラミングに書いてある事から、多分できるだろうと考えました。(この記事のタイトルはそのオマージュです)
さて、探してみたところWindows APIというものに辿り着きました。
WisdomSoftの情報も発見しました、WindowsのOSには新しくウインドウを作るためのCreateWindow
というAPIがあるようです。
これらAPIはターミナルでコマンドを叩いて実行できる類の機能では無く、DLLとしてOSに搭載されている事がわかりました。
そしてGoでDLLの機能を実行するための方法がありました。
golang で型付きで DLL を呼び出す方法
mattnさんの記事ですね、強すぎる。
下記のようなイメージでウインドウを生成できるかな、というイメージが固まってきました。
package main
var (
dll = syscall.NewLazyDLL("欲しい機能がある.dll")
proc = dll.NewProc("ウインドウを生成するAPI")
)
func main() {
i := int32(123)
proc.Call(uintptr(unsafe.Pointer(&i)))
}
しかしCreateWindowAやCreateWindowExWなど似たような命令が沢山あり、引数の使い方とGoでのお作法もわかりません。
中々難しそうですね。
ここまで推測したタイミングで先駆者を発見しました。
こちらです、ありがとうnathan-osmanさん
お目当てのCreateWindow
はCreateWindowExW
が動き、user32.dll
に存在するようですね。
var (
user32 = syscall.NewLazyDLL("user32.dll")
pCreateWindowExW = user32.NewProc("CreateWindowExW")
pDefWindowProcW = user32.NewProc("DefWindowProcW")
pDestroyWindow = user32.NewProc("DestroyWindow")
pDispatchMessageW = user32.NewProc("DispatchMessageW")
pGetMessageW = user32.NewProc("GetMessageW")
pLoadCursorW = user32.NewProc("LoadCursorW")
pPostQuitMessage = user32.NewProc("PostQuitMessage")
pRegisterClassExW = user32.NewProc("RegisterClassExW")
pTranslateMessage = user32.NewProc("TranslateMessage")
)
もしお使いのPCで上記のコードが動かない場合、また同じようなノリで別のAPIを呼んでみたいが、何が使えるかわからないは、dumpbin /exports
コマンドでインストールされているuser32.dll
にどのようなAPIが実装されているかを確認できます。
参考: DLL 内の関数の識別
さて、nathan-osmanさんのコードを実際に動かしてみたところ、画像のようなウインドウが生成されました、すごい。
ほとんど白い画像で見づらいと思いますが、コードを実行すると真っ白なウインドウが立ち上がります。
ウインドウを生成するコードを読んでみる
nathan-osmanさんのコードを読んでみると、OSのCreateWindow
関数に渡す引数として、ウインドウの名前、クラス名等の設定などを渡しています。
Microsoftの公式ドキュメントにある通り、cやc++で動作していた世界にGoからアクセスするために、全ての引数はuintptrにキャストしています。
func createWindow(className, windowName string, style uint32, x, y, width, height int32, parent, menu, instance syscall.Handle) (syscall.Handle, error) {
ret, _, err := pCreateWindowExW.Call(
uintptr(0),
uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(className))),
uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(windowName))),
uintptr(style),
uintptr(x),
uintptr(y),
uintptr(width),
uintptr(height),
uintptr(parent),
uintptr(menu),
uintptr(instance),
uintptr(0),
)
if ret == 0 {
return 0, err
}
return syscall.Handle(ret), nil
}
引数のinstance
は、どの処理においてこの命令を実行するのかのコンテキストに相当するsyscall.Handle
のようです。
このような下準備が必要で難しいですね。
何もしない真っ白なウインドウを一個作るだけの処理で250行ものコードを書くことになりました(先駆者のお陰で私はコピペするだけでしたが)。
func getModuleHandle() (syscall.Handle, error) {
ret, _, err := pGetModuleHandleW.Call(uintptr(0))
if ret == 0 {
return 0, err
}
return syscall.Handle(ret), nil
}
ウインドウに情報を描画してみる
ウインドウが出ただけでもそれなりに嬉しいですが、適当な絵や文字を表示してみたいと思いました。
文字を描画するためのTextOut関数が用意されている事がWisdomSoftの情報からわかりました。
私のPCにはTextOutW
とTextOutA
がgdi32.dll
に実装されていました。
Goのコードで動く文字セットに対してはTextOutW
が良いようです。(実際に試してみました、TextOutAをコールすると文字化けしたような出力が描画されます)
下記のコードをnathan-osmanさんのコードに追記します。
var (
gdi32 = syscall.NewLazyDLL("gdi32.dll")
pTextOut = gdi32.NewProc("TextOutW")
)
func textOut(hwnd syscall.Handle, text string) (syscall.Handle, error) {
ret, _, _ := pTextOut.Call(
uintptr(hwnd),
uintptr(0),
uintptr(0),
uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(text))),
uintptr(len(text)),
)
return syscall.Handle(ret), nil
}
このコードを呼ぶタイミングは、メインプロセスを登録している部分で宣言しているwcx
のコールバック関数fn
に挟み込むと良さそうです。
fn := func(hwnd syscall.Handle, msg uint32, wparam, lparam uintptr) uintptr {
switch msg {
case cWM_CLOSE:
destroyWindow(hwnd)
case cWM_DESTROY:
postQuitMessage(0)
default:
ret := defWindowProc(hwnd, msg, wparam, lparam)
return ret
}
return 0
}
wcx := tWNDCLASSEXW{
wndProc: syscall.NewCallback(fn),
instance: instance,
cursor: cursor,
background: cCOLOR_WINDOW + 1,
className: syscall.StringToUTF16Ptr(className),
}
wcx.size = uint32(unsafe.Sizeof(wcx))
if _, err = registerClassEx(&wcx); err != nil {
log.Println(err)
return
}
このコールバック関数はウインドウの移動、拡大縮小など様々なイベント発生時に物凄い勢いで呼ばれています。
0.1秒に一回以上の頻度で呼ばれるので、ここにprintデバッグを仕込むとターミナルの挙動が凄い事になります。
fn := func(hwnd syscall.Handle, msg uint32, wparam, lparam uintptr) uintptr {
switch msg {
case cWM_CLOSE:
destroyWindow(hwnd)
case cWM_DESTROY:
postQuitMessage(0)
default:
ret := defWindowProc(hwnd, msg, wparam, lparam)
return ret
}
return 0
}
引数msg
で、ウインドウがどのようなタイミングでコールバックを呼んでいるかを識別できます。
cWM_DESTROYは名前の通りウインドウを破棄する際のイベントでしょう。
const (
cWM_DESTROY = 0x0002
cWM_CLOSE = 0x0010
)
調査の結果WM_PAINTが0x000f
として定義されている事がわかりました。
定数に追加します。
const (
cWM_DESTROY = 0x0002
cWM_CLOSE = 0x0010
cWM_PAINT = 0x000F
)
これでコールバック関数にウインドウの描画タイミングの挙動を実装する事ができます。
...がTextOutW
が正常に動く使い方を見出すのに苦戦。
最終的にこれも先駆者のコードを発見しました、ありがとうCXさん。
fn := func(hwnd syscall.Handle, msg uint32, wparam, lparam uintptr) uintptr {
switch msg {
case cWM_CLOSE:
destroyWindow(hwnd)
case cWM_DESTROY:
postQuitMessage(0)
case cWM_PAINT:
var ps PAINTSTRUCT
hdc := beginPaint(hwnd, &ps)
_, err := textOut(hdc, "Hello Window API")
if err != nil {
panic(err)
}
endPaint(hdc, &ps)
return 0
default:
ret := defWindowProc(hwnd, msg, wparam, lparam)
return ret
}
return 0
}
textOut
だけでなくbeginPaint
、endPaint
が増えました。
beginPaint
、endPaint
はどの領域を描画するか、という情報をポインタでやり取りするので、そのための構造体も必要になります。
画面描画は、究極的には画面の1ピクセルを任意の色に変更するシステムコールの繰り返しです。
何かある度に変更が無い箇所も含めて画面に映る全てを再描画していては無駄な処理が多すぎます。
そこで、再描画を行う範囲を絞ることで処理の効率化を計ることができます。
下記のコードを追加します。
type tRECT struct {
Left int32
Top int32
Right int32
Bottom int32
}
type tPAINTSTRUCT struct {
hdc syscall.Handle
fErace uint32
rcPaint tRECT
fRestore uint32
fIncUpdate uint32
rgbReserved byte
}
const(
pBeginPaint = user32.NewProc("BeginPaint")
pEndPaint = user32.NewProc("EndPaint")
)
func beginPaint(hwnd syscall.Handle, p *PAINTSTRUCT) syscall.Handle {
ret, _, _ := pBeginPaint.Call(
uintptr(hwnd),
uintptr(unsafe.Pointer(p)),
)
return syscall.Handle(ret)
}
func endPaint(hwnd syscall.Handle, p *PAINTSTRUCT) syscall.Handle {
ret, _, _ := pEndPaint.Call(
uintptr(hwnd),
uintptr(unsafe.Pointer(p)),
)
return syscall.Handle(ret)
}
背景色が真っ白だと寂しいので色を適当に変えてみます。
background: cCOLOR_WINDOW + 1,
だったコードを
background: cCOLOR_WINDOW + 2,
にしてみました。
wcx := tWNDCLASSEXW{
wndProc: syscall.NewCallback(wndProc),
instance: instance,
cursor: cursor,
background: cCOLOR_WINDOW + 2,
className: syscall.StringToUTF16Ptr(className),
}
これで背景色がグレーになり、ウインドウに文字を出力する事ができました。
これだけの事に随分と苦労し、動いた時は感動しました。
これにてHello World完了です。
まとめ、GUIアプリケーションって凄い
ここまで実装しましたが、これではWindowsでしか動かないですし、全てのWindows端末で動く保証はありません。
LinuxやMacでも動いて、文字だけでなく画像も表示したい、とか考えだすとキリが無いですね。
OSのAPIを愚直に利用する事で、普段利用しているアプリケーションフレームワークがどれだけ多くの事をやってくれるかわかりました。
是非皆さんも独自の機能を作ってみたり、linuxでもチャレンジしてみてください。
断片的なコード片のような書き方ばかりになってしまったので、全部まとめて整理したものをgithubに残します。