Wailsアプリがプロダクションで落ちる理由と低レイヤーの制御法
26 juillet 2026
0
Computing/SoftwareComments (0)
Log in to leave a comment
No posts yet
Log in to leave a comment
No posts yet
Goでデスクトップアプリを作る際、Wailsは魅力的な選択肢だ。ElectronのようにChromiumを丸ごとパッケージングしないため、軽量で速い。しかし、チュートリアルを抜けて本物のサービスを作ろうとすると、すぐに壁にぶつかる。CGoメモリが漏れ始め、WindowsとMacでWebViewが異なる動きをするのだ。
C/C++やObjective-Cとの連携経験がないバックエンドエンジニアにとって、ここは「嘆きの壁」となる。公式ドキュメントの華やかなサンプルの裏に隠されたメモリリークやOSごとのWebView断片化を解決できなければ、プロダクションへのデプロイは不可能だ。
CGoを使う際に最も陥りがちな勘違いは、GoのガベージコレクターがC領域まで面倒を見てくれるという期待だ。当然だが面倒は見ない。C.CStringやC.mallocで割り当てたメモリはC領域に残り、アプリケーションが終了するまでメモリを食いつぶし続ける。
GoのスライスをC関数に渡す際も注意が必要だ。スライスヘッダー自体のアドレスを渡すとメモリ汚染が発生する。最初の要素の実際のアドレスであるunsafe.Pointer(&slice[0])を渡すのが安全だ。macOSのObjective-Cコードを呼び出す際は、ARCを過信してはならない。CGoスレッドループ内で生成されたオブジェクトがNSAutoreleasePoolに溜まり続ける。明示的に@autoreleasepool { ... }ブロックで囲み、こまめに解放してあげる必要がある。
Windows環境であれば、わざわざCGoコンパイラ(MinGW)を常に引き連れて歩く必要はない。CGoのオーバーヘッドなしでsyscallパッケージを使ってDLLを直接叩けば十分だ。dwmapi.dllを読み込んでダークモードを有効化するコードは、思いのほかシンプルである。
`go
// system_windows.go
//go:build windows
package native
import (
"syscall"
"unsafe"
)
var (
modDwmApi = syscall.NewLazyDLL("dwmapi.dll")
procDwmSetWindowAttribute = modDwmApi.NewProc("DwmSetWindowAttribute")
)
const DWMWA_USE_IMMERSIVE_DARK_MODE = 20
func SetWindowsDarkMode(hwnd uintptr, enable bool) error {
var val int32
if enable {
val = 1
}
ret, _, err := procDwmSetWindowAttribute.Call(
hwnd,
uintptr(DWMWA_USE_IMMERSIVE_DARK_MODE),
uintptr(unsafe.Pointer(&val)),
uintptr(unsafe.Sizeof(val)),
)
if ret != 0 {
return err
}
return nil
}
`
ネイティブ制御モジュールを作成する際は、まず共通インターフェース(system_interface.go)を定義する。そしてmacOS実装(system_darwin.go)には//go:build darwinディレクティブとともにObjective-Cのロジックを記述し、Windows実装には//go:build windowsとともにPure-GoのSyscallを記述して分離する。CGoでメモリを確保した直後にdefer C.freeを付ける習慣をつけるだけでも、メモリリークでアプリが落ちる事態は防げる。
WailsはOSにすでにインストールされているWebViewを利用する。macOSはWebKit(Safari)、WindowsはWebView2(Chromium)だ。バイナリサイズが15MB前後に削減される代償として、ブラウザエンジン間の断片化に直接対処しなければならない。
例えば、タイトルバーのないフレームレスウィンドウでドラッグ領域を設定する場合、WebView2は--wails-draggable: dragを指定するだけで動作するが、WebKitは-webkit-app-region: dragも一緒に明記しないとウィンドウが動かない。
イベント処理でもトラブルが発生する。Goのゴルーチンからruntime.EventsEmitを毎秒数千回も発行すると、WebViewの単一UIスレッドが悲鳴を上げ、画面がフリーズしてしまう。バックエンド側で60fps(約16ms)周期でイベントをスロットリングするバッファを置くべきだ。フロントエンドでユーザーがF5を押して状態を飛ばしてしまったり、右クリックメニューが表示されてしまうといった不都合も、グローバルパッチで防ぐ必要がある。
`typescript
// eventPatch.ts
export function applyGlobalUIFixes() {
window.addEventListener('contextmenu', (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (target.tagName !== 'INPUT' && target.tagName !== 'TEXTAREA') {
e.preventDefault();
}
});
window.addEventListener('keydown', (e: KeyboardEvent) => {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const modifier = isMac ? e.metaKey : e.ctrlKey;
if (e.key === 'F5' || (modifier && e.key.toLowerCase() === 'r')) {
e.preventDefault();
e.stopPropagation();
}
});
}
`
CSSには両エンジン用のドラッグ属性を同時に記述しておき、アプリの打 entry point(main.tsやApp.tsx)でapplyGlobalUIFixes()を実行する。バックエンドのイベント発行部にはスロットリングタイマーを仕掛けておく。OSごとのWebViewの特性によって生じる異常動作は、これくらいの仕掛けを用意しておけば大半を整理・解決できる。
Wailsアプリは通常、RAMを35MB〜50MBほど消費する。200MB以上消費するElectronに比べれば非常に優秀だ。問題は、大容量ファイルやバイナリデータをフロントエンドへ渡す際に発生する。
デフォルトで提供されているJSON RPCバインディングで50MBほどのデータをやり取りすると、JSONシリアライズの過程で瞬間的にRAM使用量が180MB以上に跳ね上がる。この現象を回避するには、AssetServer.AssetsHandlerオプションを使用してカスタムHTTPストリーミングを実装する必要がある。メモリコピーの発生しないZero-copy方式でデータを渡せば、アイドル時および作業時のメモリ消費量を22MB〜30MB程度に抑え込むことができる。
Windowsユーザー環境への対応も必要だ。WebView2ランタイムがインストールされていないクライアント向けに、ビルド時に-webview2 downloadフラグを指定してブートストラップを一緒に同梱する。
`yaml
name: Multiplatform Release Build
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: macos-latest
platform: darwin/universal
output_name: OptimizedApp-macOS-Universal
- os: windows-latest
platform: windows/amd64
output_name: OptimizedApp-Windows-Installer
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Wails
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
- name: Build macOS Universal Binary
if: runner.os == 'macOS'
run: wails build -platform darwin/universal -clean
- name: Build Windows Installer
if: runner.os == 'Windows'
run: |
choco install nsis -y
wails build -platform windows/amd64 -nsis -webview2 download -clean
`
main.goのAssetServer.AssetsHandlerにカスタムのhttp.Handlerを接続することで、一時的なメモリの跳ね上がりを抑える。そして.github/workflows/release.ymlに上記のパイプラインを記述しておく。タグを打ち込むたびにmacOSユニバーサルバイナリとWindows NSISインストーラーが生成され、GitHub Releasesにアップロードされる仕組みだ。
低レイヤー領域のメモリ管理を自らしっかり行い、WebViewエンジンの差分をコードで吸収すれば、Wailsであっても非常に堅牢なデスクトップアプリを構築できる。