モバイルのアクセシビリティサービス
見た目の順序を変えても読み上げ順がズレたままなのはツリー構造の誤解が原因。アクセシビリティツリーの構築原理とDynamic Typeの仕組みを押さえれば防げる。
- 1.VoiceOver/TalkBackはUIツリーの隣に構築される「アクセシビリティツリー」を辿って読み上げる。見た目の描画順ではなくノードの意味的な階層とラベルが読み上げ順を決める。
- 2.Dynamic Type/フォントスケーリングはOSがユーザー設定のスケール係数をポイントサイズに掛けて配信し、アプリはレイアウトを固定値ではなく相対値・Auto Layoutで組む必要がある。
- 3.Android Accessibility ServiceはAccessibilityEvent購読とAccessibilityNodeInfoの操作でUIを疑似的に操作する仕組みで、スクリーンリーダーだけでなく自動化ツールの土台にもなる。
見た目の裏にある「もう一つの木」
画面には常に2つの構造があります。ピクセルとして描画されるビュー階層と、支援技術が読み取るアクセシビリティツリーです。ボタンが赤くて丸いという情報はスクリーンリーダーの利用者には意味を持ちません。重要なのは「これは何のロール(役割)を持つ要素で、何と呼ばれ、今どういう状態か」という意味情報だけです。VoiceOver(iOS)もTalkBack(Android)も、レンダリング結果を画像解析するのではなく、OSが並行して構築するこの意味木を辿って音声合成に渡しています。
アクセシビリティツリーの各ノードは概ね次の属性を持ちます。
| 属性 | 役割 | iOS(UIAccessibility) | Android(AccessibilityNodeInfo) |
|---|---|---|---|
| ロール | 要素の種類を伝える | accessibilityTraits | className / role |
| ラベル | 読み上げる名前 | accessibilityLabel | contentDescription / text |
| 値 | 現在の状態値 | accessibilityValue | text(編集可能値など) |
| ヒント | 操作方法の補足 | accessibilityHint | hintText |
| 階層 | 親子・兄弟関係 | accessibilityElements順序 | ツリー内のインデックス順 |
このツリーはネイティブのビュー階層と1対1とは限りません。装飾用の下位ビューを1つの意味ノードに統合したり(isAccessibilityElementをまとめて1つに)、逆に1つのビューから複数の意味ノードを切り出したりできます。つまりアクセシビリティツリーは、開発者が明示的に設計すべき別レイヤーの情報構造です。
読み上げ順序はどう決まるか
TalkBackもVoiceOverも、基本はツリーの深さ優先探索(DFS)順で次の要素へフォーカスを移します。スワイプ操作は「ツリー上で次の兄弟ノードへ」「子がなければ親の次の兄弟へ」という遷移に対応し、視覚的な左右上下の位置とは独立です。そのため、CSSやAuto Layoutで見た目上の順序を変えても、DOM/ビュー階層の実際の並びが変わらなければ読み上げ順序はズレたままになります。
Flexboxのorderプロパティや絶対配置で見た目だけ入れ替えると、視覚的には自然でもアクセシビリティツリー上の順序は元のソース順のままです。スクリーンリーダー利用者には「文脈が飛ぶ」体験になります。視覚順序を変えるなら、ビュー階層そのものの並びを変えるか、iOSならaccessibilityElementsで明示的な順序配列を与える必要があります。
両OSともフォーカス移動のたびに、対象ノードの矩形(フレーム)をハイライトし、ロールとラベルとヒントを合成して読み上げます。例えば「見出し、設定」のように、ロール名を先に読むかラベルを先に読むかはOSの言語設定やユーザー設定に依存しますが、いずれもノードが持つ属性の組み合わせから機械的に文章を合成している点は共通です。
アクセシビリティツリーの構築タイミング
このツリーは常時全体が保持されているわけではなく、多くの実装で遅延構築されます。Android ではView階層に変更があるたびにAccessibilityNodeProviderやView#createAccessibilityNodeInfoが呼ばれ、フォーカス移動やスクロールなどのイベント発生時にオンデマンドでノード情報が生成されます。iOSでも同様に、UIKit/SwiftUIのレンダーツリーからaccessibilityElementsが問い合わせ時に解決されます。これにより次の実務上の帰結が生まれます。
- 動的に生成される要素(無限スクロールのリストなど)は、生成が完了する前にフォーカスが来ると空のノードとして読み上げられる。
- カスタム描画(Canvas/
CustomPainter相当、独自ViewのonDraw)はデフォルトでは意味ノードを持たないため、開発者がAccessibilityDelegateやUIAccessibilityの各プロトコルを実装してノードを手動で公開しない限り、スクリーンリーダーには「何も存在しない領域」として扱われる。 - リスト内のオフスクリーン要素は仮想化されている場合、実際のビューが存在しないためノードも存在せず、スクロールをトリガーしてから初めてツリーに現れる。
Dynamic Type とフォントスケーリングの仕組み
Dynamic Type(iOS)とフォントスケール(Android)は、ユーザーがOS設定で選んだ相対的なテキストサイズ段階を、アプリのポイントサイズに反映する仕組みです。核となるのは倍率(スケールファクタ)の伝播です。
iOSでは、ユーザーが選んだカテゴリ(.body、.headlineなど11段階+補助アクセシビリティサイズ)に対応するポイントサイズ表をOSが保持し、UIFont.preferredFont(forTextStyle:)を呼ぶたびに現在の設定に応じたサイズを返します。固定のポイント数(UIFont.systemFont(ofSize: 14))で指定すると、この倍率が一切反映されません。レイアウト側もAuto Layoutで高さを固定せず、UIFontMetricsでスケーリング後の値に追従させる必要があります。
Androidではsp(scale-independent pixel)単位がこれに相当し、実ピクセルへの変換時にConfiguration.fontScaleを反映します。Android 13以前は次の単純な線形計算でした。
実際の描画ピクセル = 指定した sp 値 × フォントスケール係数 × 密度係数(dp→pxのdensity)
Android 14(API 34)以降は大きいスケール設定時にテキストが際限なく肥大化しないよう、FontScaleConverterによる非線形変換に変わっており、フォントスケール係数を単純に掛けるだけでは実際のpx値と一致しません。ただし「dpのみのレイアウトサイズはフォントスケールの影響を受けない」という原則自体は変わらないため、テキストをsp、余白やアイコンサイズをdpで書き分ける方針は引き続き有効です。フォントスケールを200%まで上げるとテキストがコンテナからあふれる設計は珍しくなく、固定高さのボタンにテキストを収める実装は上限設定時に文字が欠けます。
固定ピクセル高さのコンテナに動的サイズのテキストを詰め込むと、大きいフォントスケール設定時に切れます。iOSならAuto Layoutの制約を「最小高さ」にして内容に応じて伸縮させる、Androidならwrap_contentやConstraintLayoutの比率制約を使い、spで拡大されたテキストの実高さにビュー側が追従できるようにするのが原則です。
Android Accessibility Service:UIを外側から操作する仕組み
TalkBackを含むAndroidの支援技術は、いずれもAccessibilityServiceという特別な権限を持つバックグラウンドサービスとして実装されます。仕組みは大きく2段階です。
- イベント購読 — フォーカス移動、ウィンドウ内容の変化、クリック、スクロールなどが起きるたびに、システムが
AccessibilityEventをサービスへディスパッチします。サービスはAccessibilityServiceInfoで購読するイベント種別(TYPE_VIEW_CLICKED、TYPE_WINDOW_STATE_CHANGEDなど)を宣言し、必要なイベントだけを受け取ります。 - ノード操作 — イベントに応じて、サービスは現在のウィンドウの
AccessibilityNodeInfoツリーを取得し、performAction()でクリックや長押し、テキスト入力、スクロールといった操作を当該ノードに対して直接発行できます。これは実際のタップ座標を計算する必要がなく、意味ノード単位で「このボタンを押す」を実行できる点が特徴です。
Service宣言 → イベント購読(TYPE_VIEW_FOCUSED 等)
→ System が AccessibilityEvent をディスパッチ
→ Service が rootInActiveWindow からノード木を取得
→ 対象ノードに ACTION_CLICK 等を performAction()
→ System が実際の入力として反映
この「座標に依存せずノード単位でUIを操作できる」性質のため、Accessibility Serviceはスクリーンリーダーだけでなく、スイッチアクセス(外部スイッチでの操作)、自動入力サービス、そして一部のRPA/自動化ツールの基盤としても利用されます。同時にこの強力さゆえ、Google Playはこの権限を要求するアプリに対して用途の明示と審査を厳格化しており、スクリーンリーダー以外の目的での濫用(キーロガー的な悪用など)が繰り返し問題視されてきました。
iOSのVoiceOverはOS内蔵機能でサードパーティが同等の「他アプリのUIを操作する」APIには通常アクセスできません(Switch Controlなど専用APIの範囲に限定)。一方AndroidのAccessibilityServiceは公開APIとして提供され、宣言と権限許可さえあれば他アプリのノード木を横断的に読み書きできる、という設計思想の違いが試験・面接でよく問われます。
まとめ
スクリーンリーダーは画面を「見て」いるのではなく、ビュー階層と並行して構築される意味的なアクセシビリティツリーをDFS順に辿ってロール・ラベル・値を合成音声に変換しています。この木はカスタム描画や仮想化リストでは自動生成されないため、開発者側での明示的な公開が要ります。Dynamic Type/フォントスケーリングは、OSが保持するスケール係数をspやテキストスタイルの取得時に反映する仕組み(Android 14以降は非線形変換)であり、固定ピクセル値に依存したレイアウトはスケール変更で破綻します。AndroidのAccessibilityServiceはイベント購読とノード操作という2段構えでUIを外側から意味単位で操作でき、TalkBackの土台であると同時に自動化ツールの基盤にもなっています。周辺の描画パイプラインは /graphics/、OS側のイベントディスパッチの基礎は /os/ と合わせて理解すると見通しが良くなります。
モバイル開発 Article
モバイルのアクセシビリティサービスを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
アクセシビリティ
比較で見る軸
難易度: advanced / カテゴリ: モバイル開発 / タグ数: 5
導入後に効く点
Dynamic Type/フォントスケーリングはOSがユーザー設定のスケール係数をポイントサイズに掛けて配信し、アプリはレイアウトを固定値ではなく相対値・Auto Layoutで組む必要がある。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- モバイル開発
- タグ数
- 5
判断チェックリスト
- 自社の用途が「アクセシビリティ / iOS」に近いか確認する。
- 強みである「VoiceOver/TalkBackはUIツリーの隣に構築される「アクセシビリティツリー」を辿って読み上げる。見た目の描画順ではなくノードの意味的な階層とラベルが読み上げ順を決める。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。