【テクニカル・上級編】Windows Formsにおけるアクセシビリティ対応:スクリーンリーダーやキーボードナビゲーションを考慮したUI設計 – Visual Basic (VB / VB.NET)解析バイブル

スポンサーリンク

Windows Formsの要塞を破る:アクセシビリティの極限実装とUIオートメーションの深淵

企業の基幹システムにおいて、Visual Basic .NETによるWindows Forms(WinForms)はいまだに現役の主力要塞である。しかし、現代のシステム要件において、視覚障害者や上肢障害を持つユーザーを排除したUIは、法的なコンプライアンス違反のみならず、アーキテクチャとしての致命傷になり得る。

「ボタンにラベルがついていればいい」「Tabキーで移動できればいい」――そのような甘い認識でアクセシビリティ対応を語る時代は終わった。
本稿では、スクリーンリーダー(JAIS, NVDA, Microsoft Narrator等)の内部動作メカニズムから、WinFormsのHWND(ウィンドウハンドル)ライフサイクル、さらにはUI Automationプロバイダの直接制御に至るまで、シニアエンジニアが知るべき極限の知見を叩き込む。

—

1. WinFormsアクセシビリティの裏側:AccessibleObjectの寿命管理

WinFormsのコントロールは、内部でWin32 APIのウィンドウハンドルを抱えており、COMベースのアクセシビリティインターフェイス(`IAccessible`)をラップしている。ここで最も重要なのは、「GC(ガベージコレクション)とCOMオブジェクトの参照カウントの不一致」が生むメモリリークとクラッシュの恐怖だ。

カスタムコントロールを自作する際、`CreateAccessibleObject`をオーバーライドすることがある。この時、マネージドヒープとネイティブCOMクライアント(スクリーンリーダー)のライフサイクルの同期を誤ると、アクセス違反(Access Violation)による致命的なクラッシュを引き起こす。

以下に、メモリ最適化と安全性を極限まで高めたカスタムコントロールのアクセシビリティ実装を示す。

.net
Imports System.Windows.Forms
Imports System.Runtime.InteropServices

Public Class SecureAccessibleButton
Inherits Button

‘ カスタムAccessibleObjectのインスタンスを保持し、GCによる早期回収を防ぐ
Private _accessibleObj As ButtonAccessibleObject

Protected Overrides Function CreateAccessibilityInstance() As AccessibleObject
If _accessibleObj Is Nothing Then
_accessibleObj = New ButtonAccessibleObject(Me)
End If
Return _accessibleObj
End Function

”’

”’ メモリ最適化とCOM参照のライフサイクルを厳密に制御するAccessibleObject
”’

Private Class ButtonAccessibleObject
Inherits Control.ControlAccessibleObject

Private _owner As SecureAccessibleButton

Public Sub New(ByVal owner As SecureAccessibleButton)
MyBase.New(owner)
_owner = owner
End Sub

‘ スクリーンリーダーが読み上げる名前を動的に構築
Public Overrides ReadOnly Property Name() As String
Get
If _owner Is Nothing OrElse _owner.IsDisposed Then
Return String.Empty
End If
‘ 業務システム特有の文脈(エラー状態や必須マーク)を付加して返す
Dim baseName As String = If(Not String.IsNullOrEmpty(_owner.Text), _owner.Text, “処理ボタン”)
Return baseName & If(_owner.Enabled, “, 有効”, “, 無効”)
End Get
End Property

‘ スクリーンリーダーにコントロールの役割を正確に伝える
Public Overrides ReadOnly Property Role() As AccessibleRole
Get
Return AccessibleRole.PushButton
End Get
End Property

‘ 障害物競走のようなフォーカス移動を防ぐためのステート制御
Public Overrides ReadOnly Property State() As AccessibleStates
Get
Dim states As AccessibleStates = MyBase.State
If _owner IsNot Nothing AndAlso Not _owner.Enabled Then
states = states Or AccessibleStates.Unavailable
End If
Return states
End Get
End Property
End Class

‘ デストラクタ/Disposeパターンにおける明示的なCOM参照解放
Protected Overrides Sub Dispose(ByVal disposing As Boolean)
Try
If disposing Then
‘ マネージド資源の解放
If _accessibleObj IsNot Nothing Then
‘ 必要に応じてCOMオブジェクトの解放を明示
Marshal.ReleaseComObject(_accessibleObj)
_accessibleObj = Nothing
End If
End If
Finally
MyBase.Dispose(disposing)
End Try
End Sub
End Class

—

2. キーボードナビゲーションの完全統御:TABオーダーの罠

視覚障害を持つユーザーや、マウス操作が不可能なパワーユーザーにとって、キーボードナビゲーションは生命線である。しかし、多くのWinForms製基幹システムは、パネルやグループボックス、カスタムコンテナが複雑に入れ子構造になっており、Tabキーの巡回順序(TabIndex)が崩壊している。

さらに厄介なのは、「フォーカスを受け取ってはいけない装飾用パネル」や、「動的に生成されるグリッドセル」の存在だ。

TabStopとCausesValidationの極意

フォーム上のコントロールが意図通りにフォーカスを受け取るよう、プログラム側でTabStopとTabOrderを動的に制御するアーキテクチャを構築する。特に、非同期通信(バックグラウンドワーカーなど)の実行中は、誤操作を防ぐためにUI全体のキーボード入力を遮断しつつ、スクリーンリーダーには「処理中」の状態を通知し続けなければならない。

以下のコードは、フォーム全体のキーボードナビゲーションを安全に一元管理するユーティリティモジュールの断片である。

.net
Public NotInheritable Class AccessibilityNavigator

Private Sub New()
‘ 静的クラスのインスタンス化を防止
End Sub

”’

”’ 指定されたコンテナ内のコントロール群に対して、論理的なタブオーダーを強制適用する
”’

Public Shared Sub EnforceLogicalTabOrder(ByVal container As Control)
If container Is Nothing Then Return

Dim tabIndexCounter As Integer = 0

‘ 再帰的にコントロールを走査し、視覚的な上から下、左から右へTabIndexを再割り当て
Dim orderedControls = container.Controls.Cast(Of Control)().
Where(Function(c) c.Visible).
OrderBy(Function(c) c.Top).
ThenBy(Function(c) c.Left)

For Each ctrl As Control In orderedControls
ctrl.TabIndex = tabIndexCounter
tabIndexCounter += 1

‘ コンテナコントロールの場合は再帰処理
If ctrl.HasChildren Then
EnforceLogicalTabOrder(ctrl)
End If
Next
End Sub

”’

”’ スクリーンリーダー向けにフォーカス強制的移動とアナウンスを行う
”’

Public Shared Sub AnnounceAndFocus(ByVal targetControl As Control, ByVal announcement As String)
If targetControl Is Nothing OrElse Not targetControl.CanFocus Then Return

targetControl.Focus()

‘ Windows APIを利用してアクセシビリティ通知(Live Region相当の動作)を強制発火
‘ Win32のAccessibility通知イベントをフックする場合、AccessibleLocationChange等を利用
AccessibilityExtensions.NotifyClient(AccessibleEvents.Focus, targetControl.Handle, 0, 0)

If Not String.IsNullOrEmpty(announcement) Then
System.Windows.Forms.AccessibleObject.NotifyClients(
AccessibleEvents.SystemAlert,
targetControl.AccessibilityObject
)
End If
End Sub
End Class

—

3. レガシー環境とWindows APIの連携:UIA(UI Automation)の強制介入

古いVB.NET(.NET Framework 2.0/4.0など)で構築されたレガシーなサードパーティ製グリッドや、OwnerDraw(自前描画)されたカスタムリストビューは、デフォルトではスクリーンリーダーが内部のテキストを一切読み取れない。

この壁を突破するには、Windows API(User32.dll)とUI Automation(UIA)APIを直接叩き、アクセシビリティツリーをコードから強制的に構築・修正する必要がある。

.net
Imports System.Runtime.InteropServices

Public Module NativeAccessibilityInterop

‘ Win32 APIのインポート

Public Function GetWindowLong(ByVal hWnd As IntPtr, ByVal nIndex As Integer) As Integer
End Function


Public Function SetWindowLong(ByVal hWnd As IntPtr, ByVal nIndex As Integer, ByVal dwNewLong As Integer) As Integer
End Function

Public Const GWL_STYLE As Integer = -16
Public Const WS_TABSTOP As Integer = &H10000
Public Const WS_VISIBLE As Integer = &H10000000

”’

”’ APIレベルでウィンドウのスタイルを強制書き換えし、アクセシビリティツリーに強制参加させる
”’

Public Sub ForceEnableAccessibility(ByVal hWnd As IntPtr)
Dim style As Integer = GetWindowLong(hWnd, GWL_STYLE)

‘ 必要なスタイルが付与されていない場合、強制的に付加して再描画
If (style And WS_TABSTOP) = 0 Then
SetWindowLong(hWnd, GWL_STYLE, style Or WS_TABSTOP)
End If
End Sub

End Module

レガシーなシステム保守において、ソースコードが失われたコントロールに遭遇することは珍しくない。そのような場合でも、上記のように `HWND` を直接キャプチャし、ウィンドウメッセージを操作することで、最低限のキーボードナビゲーションとスクリーンリーダー対応を後付けで強行することが可能だ。

—

4. チーフアーキテクトからの提言

アクセシビリティ対応は、単なる「おまけの機能」でもなければ、役所向けの「形式的な免罪符」でもない。それは、「どのような極限環境や制約下においても、アプリケーションの操作権をユーザーに委譲する」という、システムアーキテクトの矜持そのものである。

VB.NETとWindows Formsという長寿の技術プラットフォームの上であっても、メモリのライフサイクルを正確に把握し、ネイティブAPIとマネージドコードの境界線を完全にコントロールすれば、現代の厳格なアクセシビリティ基準を満たす要塞を築き上げることは十分に可能だ。

妥協なきコードを書け。ユーザーの操作権を奪うバグは、アーキテクチャ上の最大の悪である。

タイトルとURLをコピーしました