Stailerがスクリーンリーダーに対応するまでの道のり~Flutterでのスクリーンリーダー対応について、あるいはユーザビリティやユーザー獲得の話~

ブログのタイトル画像、Stailerがスクリーンリーダーに対応するまでの道のりとFlutterでのスクリーンリーダー対応について、あるいはユーザビリティやユーザー獲得の話

こんにちは、ソフトウェアエンジニアの@futaboooです。

先日スクリーンリーダーへ対応したプレスリリースを配信しました。今日はその裏側について紹介です。 10x.co.jp

はじめに

とあるパートナーのネットスーパーシステムをStailerへリプレイスして少しすると、お客様から「今まで使えていたのに使えなくなった!」という切実な声が届きました。この問い合わせを通じて、視覚障害者のお客様がスクリーンリーダーを使って買い物をしていたこと、そしてStailerがそのニーズに応えていないことに気づきました。 そこで、我々は視覚障害者のお客様へのヒアリングを開始し、どのような環境でアプリを使っているのか、使用しているデバイスやスクリーンリーダーソフトウェアの種類など、具体的な情報を収集しました。このプロセスを通じて、アプリがより多くの人々にとって使いやすいものになるための重要な改善点を発見しました。 スクリーンリーダー対応を進めることで、プロダクト面では全てのお客様にとって使いやすいアプリとは何かを考えるきっかけとなり、開発面では具体的な実装方法や解決策を探ることができました。この記事では、その詳細を共有したいと思います。

解像度を高める

お問い合わせを受けて視覚障害者のお客様は普段どのようにしてお買い物をしているのか解像度を高める活動を行いました。視覚障害者向けには様々な支援技術が存在しており、スクリーンリーダーもそのうちの一つです。他には画面拡大ツールや配色変更などがあります。OSやブラウザによって支援技術のソフトウェアも様々なものが存在しています。スクリーンリーダーを使用する環境においてはPC環境ではWindowsとChromeを使いサードパーティのPC-Talkerを使うパターンが多く、モバイル環境ではiOSとSafariを使いOS標準のVoiceOverを使うことが多いことがわかりました。

accessible-usable.net

また、実際に視覚障害者の方々へのユーザーインタビューを実施しました。このときのユーザーインタビューではお問い合わせをいただいたお客様がWeb版を使っていたのでWeb版ですることとしました。結果はほぼ読み上げが行なわれずにお買い物を完了することができない状態であることがわかりました。さらに、実店舗へお買い物に行く場合はスタッフに帯同してもらい、補助してもらいながらお買い物をするのである程度妥協するシーンもあり、ネットスーパーでお買い物ができれば気兼ねなく自分のペースで楽しめるということで読み上げへの対応が重要であることがわかりました。

対応方針を決める

ユーザーインタビューで普段買い物ではWebを使っていることや参加してくれた方がみなさんiPhoneだったこと、また先述した日本における支援技術の利用状況から、モバイルブラウザをメインの動作確認対象とし、特にiOS Safariをメインの動作ターゲットとしてお買い物完了に必須となる部分の読み上げに対応することとしました。また対応内容についても最低限とし、体験が良い状態はスコープ外として進めることを決めました。体験についてはスクリーンリーダー対応完了後に再度ユーザーインタビューを経てスコープを決めるのが良いという判断です。

開発は業務委託でスクリーンリーダー対応を依頼できる方を探し、@blendthink さんに副業でご協力いただくことができました。社内のメンバーだけではなく業務委託の方を採用したのは、スクリーンリーダーの対応はドメイン知識よりもFlutterの専門性やアクセシビリティへの理解が必要とされるためFlutterでのスクリーンリーダー対応を進めてもらいながらドキュメントも用意してもらい、一定知見が溜まったところで、対応を拡大するタイミングで社内メンバーも開発に参加し知見をインストールするという進め方で効率よく対応ができると考えたからです。(@blendthink さんにはFlutterの不具合で動かない場面でワークアラウンドを実装していただいたり、Semanticsの動作についてドキュメントを用意いただいたりしました。今回の対応が進められたのはblendthinkさんのおかげです。)

Flutterでのスクリーンリーダー対応

具体的にFlutterでどのようにスクリーンリーダー対応を行ったのかについてここでは紹介していきます。プロダクトとしてどうやって対応を進めたのかについて知りたい方は読み飛ばしてもらっても大丈夫です。

SemanticsTree、SemanticsNodeを理解する

SemanticsTree

FlutterはすべてがWidgetとしてレイアウト情報がTree構造になっていますが、セマンティクス情報もすべてがTree構造になっています。注意が必要なのはWidgetTreeとSemanticsTreeが必ずしも一致しないところです。例えば後述するSemantics Widgetでcontainer=trueを指定すると子どものWidgetのSemantics情報が親のSemantics情報にまとめられて一つのSemanticsNodeとして出力されます。

SemanticsNode

SemanticsTreeを構成する要素です。SemanticsNodeはSemantics Widgetで指定した様々なセマンティクス情報を保持しています。例えばSemantics.labelやSemantics.buttonや自身の子どものSemanticsNodeのリストなどです。他にも様々なセマンティクス情報がありますので公式ドキュメントのSemanticsPropertiesを参考にしてください。

SemanticsProperties class - semantics library - Dart API

スクリーンリーダー対応で知っておきたいWidget

Semantics

Semantics class - widgets library - Dart API

Semanticsを使わないパターン

WidgetとSemanticsのTreeが一致している

      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),

CenterをSemanticsでwrap、container=trueにしたパターン

Column WidgetがCenterに合成されている

      body: Semantics(
        container: true,
        child: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              const Text(
                'You have pushed the button this many times:',
              ),
              Text(
                '$_counter',
                style: Theme.of(context).textTheme.headlineMedium,
              ),
            ],
          ),
        ),
      ),

CenterをSemantics(container=true) 、子どものTextをSemantics(container=true) warpしたパターン

Column WidgetがCenterに合成されているが、Textは独立したSemanticsとして表示される

      body: Semantics(
        container: true,
        child: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Semantics(
                container: true,
                child: const Text(
                  'You have pushed the button this many times:',
                ),
              ),
              Text(
                '$_counter',
                style: Theme.of(context).textTheme.headlineMedium,
              ),
            ],
          ),
        ),
      ),

MergeSemantics

MergeSemantics class - widgets library - Dart API

子どものSemanticsを合成しひとつのSemanticsNodeとします。 例えば以下の例では2つのTextが合成されてYou have pushed the button this many times:$_counterという読み上げが一つのSemanticsNodeとして実行されます。他にも商品画像とタイトルやスライダーとラベルなど合成しても問題ない複数のWidgetをMergeSemanticsで合成することで無駄なSemanticsNodeを作らないことによるパフォーマンス向上やシンプルなSemanticsTreeの作成によってわかりやすい読み上げを実現することができます。

      body: Center(
        child: MergeSemantics(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              const Text(
                'You have pushed the button this many times:',
              ),
              Text(
                '$_counter',
                style: Theme.of(context).textTheme.headlineMedium,
              ),
            ],
          ),
        ),
      ),

ExcludeSemantics

ExcludeSemantics class - widgets library - Dart API

子どものSemanticsをSemanticsTreeから除外します。 ExcludeSemanticsでwrapしなくてもWidgetのpropertyにexcludeFromSemanticsのようなものがある場合もあります。これをtrueにすることでもSemanticsTreeからの除外を実現できます。

excludeFromSemantics property - GestureDetector class - widgets library - Dart API

      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            ExcludeSemantics(
              child: Text(
                '$_counter',
                style: Theme.of(context).textTheme.headlineMedium,
              ),
            ),
          ],
        ),
      ),

BlockSemantics

BlockSemantics class - widgets library - Dart API

z-indexというのが正しいのかわかりませんがBlockSemanticsでwrapするとそのWidgetよりも下に描画されているSemanticsNodeが非表示になります。公式ではDrawerなどがBlockSemanticsを使っています。

      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: BlockSemantics(
        child: FloatingActionButton(
          onPressed: _incrementCounter,
          tooltip: 'Increment',
          child: const Icon(Icons.add),
        ),
      ), 

デバッグ方法

視覚的にSemanticsNodeを確認する

MaterialAppにshowSemanticsDebugger=trueを追加するとSemanticsNodeを視覚的に確認することができます。 これまでのサンプルのキャプチャ画像はこの設定をtrueにして確認したものになります。スクリーンリーダーでのタップ可能領域や読み上げ内容が表示されるようになります。

  MaterialApp(
   showSemanticsDebugger: true,

SemanticsTreeをDumpする

Debug Flutter apps from code | Flutter

debugDumpSemanticsTree()を実行するとそのタイミングで生成されているSemanticsTreeをDumpすることができます。公式にもデバッグのサンプルがあるので参考にしてください。

明示的にSemantics Widgetを使わなかったときのSemanticsTree

I/flutter (21395): SemanticsNode#0
I/flutter (21395):  │ Rect.fromLTRB(0.0, 0.0, 1080.0, 2337.0)
I/flutter (21395):  │
I/flutter (21395):  └─SemanticsNode#1
I/flutter (21395):    │ Rect.fromLTRB(0.0, 0.0, 411.4, 890.3) scaled by 2.6x
I/flutter (21395):    │ textDirection: ltr
I/flutter (21395):    │
I/flutter (21395):    └─SemanticsNode#2
I/flutter (21395):      │ Rect.fromLTRB(0.0, 0.0, 411.4, 890.3)
I/flutter (21395):      │ sortKey: OrdinalSortKey#0e91d(order: 0.0)
I/flutter (21395):      │
I/flutter (21395):      └─SemanticsNode#3
I/flutter (21395):        │ Rect.fromLTRB(0.0, 0.0, 411.4, 890.3)
I/flutter (21395):        │ flags: scopesRoute
I/flutter (21395):        │
I/flutter (21395):        ├─SemanticsNode#6
I/flutter (21395):        │ │ Rect.fromLTRB(0.0, 0.0, 411.4, 80.0)
I/flutter (21395):        │ │
I/flutter (21395):        │ └─SemanticsNode#7
I/flutter (21395):        │     Rect.fromLTRB(16.0, 38.0, 262.4, 66.0)
I/flutter (21395):        │     flags: isHeader, namesRoute
I/flutter (21395):        │     label: "Flutter Demo Home Page"
I/flutter (21395):        │     textDirection: ltr
I/flutter (21395):        │
I/flutter (21395):        ├─SemanticsNode#4
I/flutter (21395):        │   Rect.fromLTRB(61.0, 457.1, 350.4, 477.1)
I/flutter (21395):        │   label: "You have pushed the button this many times:"
I/flutter (21395):        │   textDirection: ltr
I/flutter (21395):        │
I/flutter (21395):        ├─SemanticsNode#5
I/flutter (21395):        │   Rect.fromLTRB(197.8, 477.1, 213.6, 513.1)
I/flutter (21395):        │   label: "0"
I/flutter (21395):        │   textDirection: ltr
I/flutter (21395):        │
I/flutter (21395):        └─SemanticsNode#8
I/flutter (21395):          │ merge boundary ⛔️
I/flutter (21395):          │ Rect.fromLTRB(0.0, 0.0, 56.0, 56.0) with transform
I/flutter (21395):          │ [1.0,2.4492935982947064e-16,0.0,339.42857142857144;
I/flutter (21395):          │ -2.4492935982947064e-16,1.0,0.0,818.2857142857143;
I/flutter (21395):          │ 0.0,0.0,1.0,0.0; 0.0,0.0,0.0,1.0]
I/flutter (21395):          │ tooltip: "Increment"
I/flutter (21395):          │ textDirection: ltr
I/flutter (21395):          │
I/flutter (21395):          └─SemanticsNode#9
I/flutter (21395):              merged up ⬆️
I/flutter (21395):              Rect.fromLTRB(0.0, 0.0, 56.0, 56.0)
I/flutter (21395):              actions: tap
I/flutter (21395):              flags: isButton, hasEnabledState, isEnabled, isFocusable
I/flutter (21395):              thickness: 6.0

BlockSemanticsを使ったサンプルのときのSemanticsTree

I/flutter (20990): SemanticsNode#0
I/flutter (20990):  │ Rect.fromLTRB(0.0, 0.0, 1080.0, 2337.0)
I/flutter (20990):  │
I/flutter (20990):  └─SemanticsNode#1
I/flutter (20990):    │ Rect.fromLTRB(0.0, 0.0, 411.4, 890.3) scaled by 2.6x
I/flutter (20990):    │ textDirection: ltr
I/flutter (20990):    │
I/flutter (20990):    └─SemanticsNode#2
I/flutter (20990):      │ Rect.fromLTRB(0.0, 0.0, 411.4, 890.3)
I/flutter (20990):      │ sortKey: OrdinalSortKey#aa8f5(order: 0.0)
I/flutter (20990):      │
I/flutter (20990):      └─SemanticsNode#3
I/flutter (20990):        │ Rect.fromLTRB(0.0, 0.0, 411.4, 890.3)
I/flutter (20990):        │ flags: scopesRoute
I/flutter (20990):        │
I/flutter (20990):        └─SemanticsNode#4
I/flutter (20990):          │ merge boundary ⛔️
I/flutter (20990):          │ Rect.fromLTRB(0.0, 0.0, 56.0, 56.0) with transform
I/flutter (20990):          │ [1.0,2.4492935982947064e-16,0.0,339.42857142857144;
I/flutter (20990):          │ -2.4492935982947064e-16,1.0,0.0,818.2857142857143;
I/flutter (20990):          │ 0.0,0.0,1.0,0.0; 0.0,0.0,0.0,1.0]
I/flutter (20990):          │ tooltip: "Increment"
I/flutter (20990):          │ textDirection: ltr
I/flutter (20990):          │
I/flutter (20990):          └─SemanticsNode#5
I/flutter (20990):              merged up ⬆️
I/flutter (20990):              Rect.fromLTRB(0.0, 0.0, 56.0, 56.0)
I/flutter (20990):              actions: tap
I/flutter (20990):              flags: isButton, hasEnabledState, isEnabled, isFocusable
I/flutter (20990):              thickness: 6.0

Webの対応

SemanticsTreeの生成を強制する

AndroidアプリやiOSアプリの場合はTalkbakやVoiceOverのスクリーンリーダーソフトを立ち上げるとSemanticsTreeが生成されて読み上げ可能になりますがWebでは少し工夫が必要です。具体的には以下の1行を追加してあげる必要があります。

SemanticsBinding.instance.ensureSemantics();

実際の実装などは公式を参考にしてください。

flutter/dev/a11y_assessments/lib/main.dart at 778eaf6c1dcaba7a433a66cbce25385d9db7877d · flutter/flutter · GitHub

この1行を追加しないとWebでアクセスした時にenable accessibility buttonという読み上げがされる透明なボタンにまずフォーカスがあたり、これを押さないとSemanticsTreeが生成されずサイト内の読み上げされないという状況になってしまいます。このボタンの読み上げは日本語対応されていないので初見では何が起こるのかわからないため強制的にSemanticsTreeを生成する先程のコードを追加しました。

ただしこの設定をONにするとiOSアプリでは特定のOSバージョンでTextFiledがつぶれてしまうバグに遭遇したのでWebに限定するのを忘れないようにします。

Webだけ設定が必要なのは以下のイシューが関連していると思いますが裏取りできていないのでもし知っている方が居たらコメントお待ちしております。

[a11y][Web] MediaQuery.accessibleNavigation is not updating when a11y is enabled · Issue #134980 · flutter/flutter · GitHub

今後のスクリーンリーダー対応について

ガイドラインの導入

スクリーンリーダー対応ガイドラインを作成して実装上判断に迷う部分はガイドラインを参考にすることで迷わず実装が進められるようにしています。例えば・や/のように要素を分ける意図の記号は「、」(読点)として読み上げさせるというのをMUSTとしてガイドラインには定義しています。これはWindowsの読み上げソフトのPC-Talkerの場合、読点を分節の区切りとして読み上げ時に1拍置いてくれるので聞き取りやすくなるためこのように対応することをMUSTとしています。まだまだ実装の知見をガイドラインにしきれていないので今後も整備を続けていきたい思います。

Widget Testの導入

せっかくスクリーンリーダーに対応してもUIが変わるときに対応を忘れたり、新規画面では対応が漏れたりすることもあります。スクリーンリーダー対応が動いているかどうかは自動テストととても相性が良いのでWidget Testを導入し今回対応した読み上げを維持していけるようにしていきたいと思っています。

スクリーンリーダー対応開発後のユーザーインタビュー

しばらくの開発期間を経てもう一度ユーザーインタビューをしてもらっても大丈夫という段階で、前回ユーザーインタビューに参加してくれた方々へ改善後のStailerを使ってもらいました。 前回はほとんど読み上げがされずお買い物を完了するこができませんでしたが、今回は参加いただいたみなさんにお買い物が完了するところまで進めてもらうことができました。

また、今回のユーザーインタビューで嬉しい誤算もありました。スクリーンリーダー対応着手前にはWebでの利用を想定して対応を進めており、文字入力の部分にかなり苦労して対応を進めていました。しかしインタビューの中で「アプリはスクリーンリーダーに未対応のものが多くWebの方がまともに使えることが多いのでWebを使っていることが多い。アプリが使えるならそっちのほうが嬉しい」と言っていたのを聞き、アプリを触ってもらうと「これだけ読み上げられるならアプリを使う、知り合いにも紹介したい」という感想をいただくことができました。Flutter on the Webでの読み上げはアプリに比べると想定通り動かないことがあったり、入力に不備があったりするのでアプリを使ってもらえるのは我々としてもWebへ対応するよりも工数を減らすことができます。

おわりに

リプレイス後のお問い合わせを発端としてスクリーンリーダーの対応を行ってきました。最終的にユーザーインタビューでとてもうれしい言葉をいただけるところまで対応することができました。実際にお問い合わせいただいたお客様のもとにも届いて、またお買い物ができるようになっていたら良いなと思っています。

今回の対応は視覚障害者の方のアクセシビリティ向上の一面が大きいですが、ユーザーインタビューの最後にもあったように新たなお客様を獲得する活動ととらえることもできます。多くの人が使いやすいサービスを作るためにアクセシビリティを常に意識した開発をしていきたいと思っています。