プログラミング/小ネタ集/ウィンドウ位置の正しい復元 のバックアップの現在との差分(No.1)


  • 追加された行はこの色です。
  • 削除された行はこの色です。
Win32 SDKでウィンドウ位置を''正しく''保存・復元する方法。

#contents

*概要 [#about]

Windowsプログラミングにも慣れてきた初心者と中級者の間くらいのプログラマが往々にして実装したがる機能があります。~
それが今回取り扱う''ウィンドウ位置・状態の保存・復元機能''です。

アプリケーションの終了時にその時点でのウィンドウの位置と状態(最大化されているか否か)を設定ファイル等に保存し、次回起動時に同じ位置と状態で起動するという、プログラマの自己満足度をかなり補充することのできる機能です。

ですがこの機能、簡単なようで意外と奥深い面があります。~
見た目はうまく実装できているようでも、いざ配布してみると思いもよらないバグが報告されたりもします。~
特にマルチモニタ環境で使うユーザに捕まったりした日には、まず手元にマルチモニタ環境が無いと現象の再現すらできません。

そこでこのコンテンツでは、誰でも実装できるようで誰でも間違っている可能性のあるこの機能を正しく実装してみたいと思います。

*実装する内容 [#implement]

今回は、以下の2関数の実装を目的とします。

#code(c){{
#include <windows.h>

// ウィンドウ状態をINIファイルの [window] セクションに保存します。
// WM_DESTROY メッセージ内で呼び出して下さい。
/**
 * @brief ウィンドウ状態をINIファイルの [window] セクションに保存する。
 * @param[in] hWnd 対象となるウィンドウハンドル。
 * @param[in] iniFilePath INIファイルのパス。
 *
 * WM_DESTROY メッセージ内で呼び出すこと。
 */
void SaveWindowState(HWND hWnd, LPCTSTR iniFilePath);

// INIファイルの [window] セクションからウィンドウ状態を復元します。
// WM_CREATE 又は WM_INITDIALOG メッセージ内で呼び出して下さい。
/**
 * @brief INIファイルの [window] セクションからウィンドウ状態を復元する。
 * @param[in] hWnd 対象となるウィンドウハンドル。
 * @param[in] iniFilePath INIファイルのパス。
 *
 * WM_CREATE 又は WM_INITDIALOG メッセージ内で呼び出すこと。
 */
void LoadWindowState(HWND hWnd, LPCTSTR iniFilePath);
}}

今回はINIファイルへ保存するようにしましたが、別に保存先はレジストリでも構いません。~
作成するアプリケーションにとって適切な場所へ保存して下さい。~
マルチユーザ環境の考慮も忘れずに。

また、 @code{RECT}; 構造体の内容を保存、復元する以下の関数を定義します。

#code(c){{
#include <windows.h>
#include <tchar.h>

// RECT構造体の内容を [window] セクションに保存します。
/**
 * @brief RECT構造体の内容を [window] セクションに保存する。
 * @param[in] pRect 対象となるRECT構造体。
 * @param[in] iniFilePath INIファイルのパス。
 */
void SaveRect(const RECT* pRect, LPCTSTR iniFilePath)
{
    TCHAR strNum[64];
    _ltot(pRect->left, strNum, 10);
    WritePrivateProfileString(
        _T("window"), _T("left"), strNum, iniFilePath);
    _ltot(pRect->top, strNum, 10);
    WritePrivateProfileString(
        _T("window"), _T("top"), strNum, iniFilePath);
    _ltot(pRect->right, strNum, 10);
    WritePrivateProfileString(
        _T("window"), _T("right"), strNum, iniFilePath);
    _ltot(pRect->bottom, strNum, 10);
    WritePrivateProfileString(
        _T("window"), _T("bottom"), strNum, iniFilePath);
}

// RECT構造体の内容を [window] セクションから復元します。
// 保存されていない場合は元データを書き換えません。
/**
 * @brief RECT構造体の内容を [window] セクションから復元する。
 * @param[in,out] pRect 対象となるRECT構造体。
 * @param[in] iniFilePath INIファイルのパス。
 *
 * 保存されていない場合は元データを書き換えない。
 */
void LoadRect(RECT* pRect, LPCTSTR iniFilePath)
{
    pRect->left = (long)GetPrivateProfileInt(
        _T("window"), _T("left"), pRect->left, iniFilePath);
    pRect->top = (long)GetPrivateProfileInt(
        _T("window"), _T("top"), pRect->top, iniFilePath);
    pRect->right = (long)GetPrivateProfileInt(
        _T("window"), _T("right"), pRect->right, iniFilePath);
    pRect->bottom = (long)GetPrivateProfileInt(
        _T("window"), _T("bottom"), pRect->bottom, iniFilePath);
}
}}

以下の説明において、ウィンドウ位置の保存と復元にはこれらの関数を用います。

*正しい位置取得 [#position]

**よくある間違い [#position-mistake]

次の実装を見てみて下さい。なお、 @code{#include}; は省略してあります(以下同様)。
次の実装を見てみて下さい。~
なお、 @code{#include}; および関数の説明コメントは省略してあります(以下同様)。

#code(c){{
void SaveWindowState(HWND hWnd, LPCTSTR iniFilePath)
{
    // ウィンドウ位置取得
    RECT rcWnd;
    GetWindowRect(hWnd, &rcWnd);

    // ウィンドウ位置保存
    SaveRect(&rcWnd, iniFilePath);

    // ウィンドウ最大化状態保存
    if (IsZoomed(hWnd))
    {
        WritePrivateProfileString(
            _T("window"), _T("zoomed"), _T("1"), iniFilePath);
    }
    else
    {
        WritePrivateProfileString(
            _T("window"), _T("zoomed"), _T("0"), iniFilePath);
    }
}

void LoadWindowState(HWND hWnd, LPCTSTR iniFilePath)
{
    // 既定のウィンドウ位置取得
    RECT rcWnd;
    GetWindowRect(hWnd, &rcWnd);

    // ウィンドウ位置読み込み
    LoadRect(&rcWnd, iniFilePath);

    // ウィンドウ位置復元
    SetWindowPos(
        hWnd, NULL, rcWnd.left, rcWnd.top,
        rcWnd.right - rcWnd.left, rcWnd.bottom - rcWnd.top,
        SWP_NOZORDER);

    // ウィンドウ最大化状態復元
    UINT isZoomed = GetPrivateProfileInt(
        _T("window"), _T("zoomed"), 0, iniFilePath);
    if (isZoomed != 0)
    {
        ShowWindow(hWnd, SW_MAXIMIZE);
    }
}
}}

初心者が現在の知識を総動員して実装したウィンドウ状態保存・復元はこんな感じになるんじゃないでしょうか。~
実際、このコードの過ちに気付かない方もいるのでは?

このコードを実装したアプリケーションの起動と終了を繰り返すと、確かにウィンドウの位置と最大化状態が保存されていることがわかります。~
しかしここで満足してしまうと、ユーザの使い方一つで恐ろしいことになってしまいます。

まず、最大化した状態で終了し、再び起動した後、最大化を解いて(通常状態にして)みて下さい。~
やってみればわかりますが、最大化した時のウィンドウサイズが保存されてしまっており、通常状態にしてもウィンドウが小さくなりません。

更に、最小化した状態でタスクバーの右クリックメニューから終了させてみて下さい。~
環境にもよりますが、INIファイルになかなか恐ろしい値が書き込まれたのではないでしょうか。~
私の環境では @code{-32000}; などという値が書き込まれました。~
そして次の起動時には、タスクバーに姿はあれどウィンドウは見えなくなってしまいました。

もうわかったと思いますが、 @code{GetWindowRect}; 関数は現在の状態におけるウィンドウの位置を返します。~
最大化されていれば最大化された位置とサイズを返しますし、最小化されていればとてつもなく小さい値を返します。~
これでは正しい位置保存とはいえません。

**正しい処理 [#position-truth]

ではどうすればいいのかというと、 @code{GetWindowPlacement}; 関数を使います。~
次のコードを見て下さい。

#code(c){{
void SaveWindowState(HWND hWnd, LPCTSTR iniFilePath)
{
    // ウィンドウ位置・状態取得
    WINDOWPLACEMENT wndPlace;
    wndPlace.length = sizeof(WINDOWPLACEMENT);
    GetWindowPlacement(hWnd, &wndPlace);

    // ウィンドウ位置保存
    SaveRect(&wndPlace.rcNormalPosition, iniFilePath);

    // ウィンドウ最大化状態保存
    if (wndPlace.showCmd == SW_SHOWMAXIMIZED)
    {
        WritePrivateProfileString(
            _T("window"), _T("zoomed"), _T("1"), iniFilePath);
    }
    else
    {
        WritePrivateProfileString(
            _T("window"), _T("zoomed"), _T("0"), iniFilePath);
    }
}

void LoadWindowState(HWND hWnd, LPCTSTR iniFilePath)
{
    // 既定のウィンドウ位置取得
    WINDOWPLACEMENT wndPlace;
    wndPlace.length = sizeof(WINDOWPLACEMENT);
    GetWindowPlacement(hWnd, &wndPlace);
    RECT rcWnd = wndPlace.rcNormalPosition;

    // ウィンドウ位置読み込み
    LoadRect(&rcWnd, iniFilePath);

    // ウィンドウ位置復元
    SetWindowPos(
        hWnd, NULL, rcWnd.left, rcWnd.top,
        rcWnd.right - rcWnd.left, rcWnd.bottom - rcWnd.top,
        SWP_NOZORDER);

    // ウィンドウ最大化状態復元
    UINT isZoomed = GetPrivateProfileInt(
        _T("window"), _T("zoomed"), 0, iniFilePath);
    if (isZoomed != 0)
    {
        ShowWindow(hWnd, SW_MAXIMIZE);
    }
}
}}

@code{GetWindowPlacement}; 関数は、 [[@code{WINDOWPLACEMENT};>http://msdn.microsoft.com/ja-jp/library/kb89946z.aspx]] 構造体に値を設定します。~
具体的にどんな値が設定されるかについてはMSDNライブラリを参照して下さい。

この構造体の @code{rcNormalPosition}; メンバには、現在の状態に関係なく常に通常状態でのウィンドウ位置が格納されます。~
これにより、どんな状態で終了したとしても正しいウィンドウ位置を書き出すことができます。

*解像度変更への対応 [#resolution]

**よくある間違い [#resolution-mistake]

実は先に述べたコードではまだ問題があります。~
それは、アプリケーション終了後に解像度が変更された場合です。~
例えば1280x1024の環境でウィンドウを左端へ持っていき終了した後、解像度を1024x768などに下げると、これまたウィンドウは画面の外へ行ってしまいます。

そこで、ウィンドウ位置の復元時に現在の画面領域内に収まっていなかったら位置をずらす必要があります。~
それを実装してみたのが次のコードです。~
なお、 @code{SaveWindowState}; 関数は内容が変わらないため省略します。

#code(c){{
void LoadWindowState(HWND hWnd, LPCTSTR iniFilePath)
{
    // 既定のウィンドウ位置取得
    WINDOWPLACEMENT wndPlace;
    wndPlace.length = sizeof(WINDOWPLACEMENT);
    GetWindowPlacement(hWnd, &wndPlace);
    RECT rcWnd = wndPlace.rcNormalPosition;

    // ウィンドウ位置読み込み
    LoadRect(&rcWnd, iniFilePath);

    // ディスプレイサイズの取得
    long dispX = GetSystemMetrics(SM_CXSCREEN);
    long dispY = GetSystemMetrics(SM_CYSCREEN);

    // 位置補正
    if (rcWnd.right > dispX)
    {
        rcWnd.left -= rcWnd.right - dispX;
        rcWnd.right = dispX;
    }
    if (rcWnd.left < 0)
    {
        rcWnd.right -= rcWnd.left;
        rcWnd.left = 0;
    }
    if (rcWnd.bottom > dispY)
    {
        rcWnd.top -= rcWnd.bottom - dispY;
        rcWnd.bottom = dispY;
    }
    if (rcWnd.top < 0)
    {
        rcWnd.bottom -= rcWnd.top;
        rcWnd.top = 0;
    }

    // ウィンドウ位置復元
    SetWindowPos(
        hWnd, NULL, rcWnd.left, rcWnd.top,
        rcWnd.right - rcWnd.left, rcWnd.bottom - rcWnd.top,
        SWP_NOZORDER);

    // ウィンドウ最大化状態復元
    UINT isZoomed = GetPrivateProfileInt(
        _T("window"), _T("zoomed"), 0, iniFilePath);
    if (isZoomed != 0)
    {
        ShowWindow(hWnd, SW_MAXIMIZE);
    }
}
}}

@code{GetSystemMetrics}; 関数で画面のサイズを取得し、ウィンドウが画面からはみ出ていた場合は画面内に収まるように位置を補正します。~
ただし、ウィンドウの方が画面よりも大きい場合はウィンドウを左端及び上端を画面の端に合わせます。

とまぁ、これで完璧に見えるでしょう。~
実際、こういった感じで紹介されているサイトも探せばあると思います。~
ですが、これでは間違い、というか不十分なのです。

画面が一つの環境では気付かないかもしれません。~
そう、このコードはマルチモニタ環境に対応していないのです。

@code{GetSystemMetrics}; 関数で取得できる画面のサイズは、プライマリモニタの画面サイズです。~
よってこのアプリケーションをセカンダリモニタ上で使いたいといった場合、位置補正処理がかえって邪魔をしてしまいます。~
上のコードでは、セカンダリモニタ上は画面外と見なされ、プライマリモニタ上へ位置補正されてしまうのです。

これを考慮せずにリリースされているソフトウェアは多く、市販のソフトウェアですら考慮されていないこともあります。

**正しい処理 [#resolution-truth]

マルチモニタ対応の処理方法はいくつか考えられますが、ここでは @code{MonitorFromRect}; 関数を使ってみたいと思います。~
この関数は、指定した長方形領域との交差部分が最も広いモニタのハンドルを返します。~
この関数を用いた実装方法を次に示します。

#code(c){{
void LoadWindowState(HWND hWnd, LPCTSTR iniFilePath)
{
    // 既定のウィンドウ位置取得
    WINDOWPLACEMENT wndPlace;
    wndPlace.length = sizeof(WINDOWPLACEMENT);
    GetWindowPlacement(hWnd, &wndPlace);
    RECT rcWnd = wndPlace.rcNormalPosition;

    // ウィンドウ位置読み込み
    LoadRect(&rcWnd, iniFilePath);

    // 対象モニタの情報を取得
    HMONITOR hMonitor = MonitorFromRect(
        &rcWnd, MONITOR_DEFAULTTONEAREST);
    MONITORINFO mi;
    mi.cbSize = sizeof(MONITORINFO);
    GetMonitorInfo(hMonitor, &mi);

    // 位置補正
    if (rcWnd.right > mi.rcMonitor.right)
    {
        rcWnd.left -= rcWnd.right - mi.rcMonitor.right;
        rcWnd.right = mi.rcMonitor.right;
    }
    if (rcWnd.left < mi.rcMonitor.left)
    {
        rcWnd.right += mi.rcMonitor.left - rcWnd.left;
        rcWnd.left = mi.rcMonitor.left;
    }
    if (rcWnd.bottom > mi.rcMonitor.bottom)
    {
        rcWnd.top -= rcWnd.bottom - mi.rcMonitor.bottom;
        rcWnd.bottom = mi.rcMonitor.bottom;
    }
    if (rcWnd.top < mi.rcMonitor.top)
    {
        rcWnd.bottom += mi.rcMonitor.top - rcWnd.top;
        rcWnd.top = mi.rcMonitor.top;
    }

    // ウィンドウ位置復元
    SetWindowPos(
        hWnd, NULL, rcWnd.left, rcWnd.top,
        rcWnd.right - rcWnd.left, rcWnd.bottom - rcWnd.top,
        SWP_NOZORDER);

    // ウィンドウ最大化状態復元
    UINT isZoomed = GetPrivateProfileInt(
        _T("window"), _T("zoomed"), 0, iniFilePath);
    if (isZoomed != 0)
    {
        ShowWindow(hWnd, SW_MAXIMIZE);
    }
}
}}

対象となるモニタの範囲内に収まるように位置を補正しています。~
マルチモニタ環境の方は実際に動作確認をしてみると良いでしょう。

このコードでは、ウィンドウが複数のモニタにまたがっていた場合、より広く交差しているモニタの方へ移動されます。~
複数のモニタをまたぐことを許可する場合は処理を考え直す必要があります。

複数のモニタをまたぐ処理を行う際に注意すべきことは、''複数画面全体での左上端座標を(0, 0)としないこと''です。~
座標(0, 0)はあくまでプライマリモニタの左上端座標です。~
例えばプライマリモニタの左側にセカンダリモニタを置いている環境の場合、全体での左端のX座標は''マイナスになります。''~
このことを考慮しているプログラムはフリーソフトレベルでは滅多に無いですが、完璧を目指すならば是非考慮してみて下さい。