Oisix ra daichi Creator's Blog(オイシックス・ラ・大地クリエイターズブログ)

オイシックス・ラ・大地株式会社のエンジニア・デザイナーが執筆している公式ブログです。

ボタンが押せない!透明な壁の正体を追う

はじめに

こんにちは。Oisixエンジニアリング部モバイルセクションの吉村です。

明けましておめでとうございます。年末年始、皆様はいかがお過ごしでしたか。私は、大晦日になるとテレビでカウントダウンを見なければそわそわしてしまうので珍しくテレビをつけました。年末はそわそわしていた反面、年が明けるとノスタルジックな気分になり去年の仕事を振り返っていました。その中でもトップレベルの根深い不具合があったので、今回はそれについて書きます。

Android開発の話ですが、クライアントサイドのアプリケーション開発に携わるエンジニアの方にも参考になる内容です。なお、Jetpack ComposeなどAndroid特有のキーワードの説明は割愛しています。最終的にはComposeのFirst Party APIの不具合発見に至る顛末です。お付き合いください。

謎の現象

ある日、アプリのテスト中、奇妙な現象に遭遇しました。

WebViewの画面左上にある「閉じるボタン」が、なぜか押せないのです。

close_button

ボタンは確かにそこに表示されています。見た目には何も問題がありません。しかし、何度タップしても無反応。まるで透明な壁があるかのように、指のタッチが届かないのです。

さらに厄介なことに、毎回起きるわけではありません。

  • 冒頭に挙げた、透明な壁によって閉じるボタンを押せなくなる
  • 閉じるボタンが押せるものの、本来表示されないはずのTooltipが意図しない位置に表示されてしまう

という2種類の不具合がランダムで発生することに気付きました。

※本来、下記画面ではTooltipを非表示にしているはずでした。「プライスバー」と呼ばれる画面下部の合計金額等を表示するAndroid viewのコンポーネントをアンカー(基準点)としていました。

unexpected_tooltip

2つの不具合を整理すると次のようになります。

不具合A 不具合B
Tooltip 表示されない なぜか表示される
閉じるボタン なぜか押せない 押せる

「タイミングによって動作が変わる」、これはソフトウェア開発において最も厄介な種類のバグの兆候でした。いわゆるレースコンディション(競合状態)の可能性が頭をよぎります。早速調査を始めました。

原因の特定

どうやらTooltipが悪さしていそうだとアタリをつけて、検証のためこのTooltipをコード上から消してみると問題が発生しなくなりました。原因はこのTooltipで間違いありません。しかしTooltip自体は見えていないのに、なぜタッチを妨げるのか。

答えを見つけるには、Tooltipの内部実装を理解する必要がありました。

Popupの仕組み

Jetpack ComposeのTooltipは、内部的にPopupというComposable関数を使っています。

通常、Androidアプリの画面はViewと呼ばれる部品を階層的に組み合わせて構成されます。親のViewが非表示になれば、その中にある子のViewも一緒に消える——これが通常の動作です。しかし、Popupにはこの常識が通用しません。

PopupはWindowManagerというシステムを使い、通常のView階層とは完全に独立したウィンドウを作成します。これはOSレベルで別のウィンドウとして管理されます。「親が消えたら子も消える」という直感が通用しない世界。ここに問題の糸口がありました。

透明な壁の正体

さらに調査を進めると、新しい事実が判明しました。

Tooltipは表示位置を決めるために「アンカー」と呼ばれる基準点を必要とします。「このボタンの近くに表示する」といった具合に、何かを基準にして位置を計算するのです。この位置計算は、onGloballyPositionedというコールバックを通じて行われます。「画面上のどこに配置されたか」が確定したタイミングで呼び出され、その情報をもとにTooltipの位置が決まります。

ここで重要なのが、プライスバー(アンカー)のvisibilityの設定です。

設定 動作
VISIBLE 表示される
INVISIBLE 見えないが、レイアウト上のスペースは確保される
GONE 見えず、レイアウト上にも存在しない

アンカーはGONEに設定されていました。GONEの要素は「レイアウト上に存在しない」ため、onGloballyPositionedが呼び出されません。するとアンカーの位置が不明なため、Popupの表示位置も計算できない状況になります。

Composeのソースコードを確認すると、位置計算ができないとき、Popupは2つの不思議な挙動をとることに気付きました。

不思議な挙動1: alphaが0になる

1つ目は、位置計算できないときalphaが0になることです。*1

val canCalculatePosition by derivedStateOf {
    parentLayoutCoordinates != null && popupContentSize != null
}

SimpleStack(
    Modifier.alpha(if (canCalculatePosition) 1f else 0f),  // ← ここ
    content
)

つまり「完全に透明」になります。

不思議な挙動2: 表示位置が(0, 0)になる

2つ目は、位置計算できないときPopupの表示位置が(0, 0)になることです。*2

以上2つから、透明なTooltipが(0, 0)の位置に表示されていたことが原因であるとわかりました。これが「透明な壁」の正体でした。

検証:GONEとINVISIBLEの実験

理論上の原因はわかりました。では、本当にそうなのか。実験で確かめます。

プライスバー(アンカー)の表示状態をGONEからINVISIBLEに変更してみました。どちらも「見えなくなる」点では同じですが、決定的な違いがあります。

  • GONE: レイアウト計算から完全に除外される
  • INVISIBLE: 見えないが、レイアウト上には存在する

INVISIBLEであれば、onGloballyPositionedが呼び出されるはず。つまり位置計算が成功し、alphaが0ではなく1になるはずです。

実験結果は次の通りです。

設定 Tooltipの状態 ボタン
GONE 見えない(alpha=0) 押せない
INVISIBLE 見える(正しい位置に表示) 押せる

予想通りINVISIBLEに変更すると、Tooltipが画面上に表示されました。

そして重要なのは、ボタンが正常に押せるようになったことです。

結論:レースコンディション

ここで、最初の謎に戻ります。透明な壁の正体はわかりました。しかし、もう1つの「Tooltipを非表示にしている画面にも関わらずなぜか表示されてしまう」という不具合の謎がまだ解けていません。

答えはアンカーのVisibilityが変化するタイミングにありました。

不具合A: Tooltipが透明な壁になるケース

  1. 画面が表示される
  2. アンカーが先にGONEへ変更される
  3. Tooltipが表示される
  4. アンカーのonGloballyPositioned呼ばれない(GONEのため)
  5. 位置計算が失敗し、alpha=0(透明)で(0, 0)の位置に表示される
  6. 画面左上の閉じるボタンを覆ってしまいタップイベントを奪う

不具合B: 本来表示されないはずのTooltipがなぜか表示されるケース

  1. 画面が表示される
  2. Tooltipが作成される
  3. アンカーのonGloballyPositionedが呼ばれ、位置計算が成功
  4. Tooltipが正しい位置に表示される
  5. その後、アンカーのVisibilityがGONEに変更される

この2つのパターンは、処理の実行順序というミリ秒単位のタイミングで決まります。ユーザーの操作速度、端末の処理速度、その他さまざまな要因で結果が変わるのです。

これこそがレースコンディション(競合状態)——複数の処理が「競争」し、どちらが先に実行されるかで結果が変わってしまう現象です。

delayを入れてみると不具合A, B、どちらも100%再現できました。

学びと対策

この調査から得られた教訓をまとめます。

「見えない」と「存在しない」は違う

alpha=0(透明)は「見えない」だけです。ウィンドウは存在し、タッチイベントを受け取ります。

GONEINVISIBLEも同様に、見た目は同じでもシステム的な扱いが異なります。この違いを理解していないと、今回のような問題に遭遇します。

アンカーがGONEやINVISIBLEになってもPopupは消えない

Popupのレイアウト自体は残りますがalphaが0になって一見消えたように見えて「ヨシ」としてしまうととてもつらいことになります。


おわりに

「ボタンが押せない」という単純に見える問題の裏には、フレームワークの内部実装、OSレベルのウィンドウ管理、そしてタイミングに依存するレースコンディションが潜んでいました。

とてもつらかったです。すぐ技術ブログに書く予定だったのですが、しばらく体が拒否反応を起こしていて一ヶ月くらい間を空けての投稿になります。

追伸

これを直した数日後にComposeのBoMをアプデしたらTooltipの表示位置がバグりました。

泣きながらIssueTrackerにissueを立てました。

https://issuetracker.google.com/issues/467185150

不具合を再現するためのミニマムなサンプルも共有したのですが、悲しいことにPriority: P2, Severity: S2でした。

https://github.com/tyoshimura-o/PopUpBugSample/tree/main#readme

2つの選択で悩みました。

  • 修正されるまでComposeのアプデを控える
  • パッチを当ててComposeのアプデを継続する

チームで話し合った結果、次の理由でパッチを当てました。

  • すぐには修正されなさそう
  • パッチを当てる範囲が限定的(TooltipにカスタムのPopupPositionProviderを差し込みました。根治したらカスタムのものを消すだけで済む)
  • Composeのアプデを長期間止めてしまう方がリスクとして高い

追伸2

今IssueTracker見たら早くも直してくれたようでした。

androidx.compose.ui:ui-*:1.11.0-alpha03 で直ったよとのことなのですが、 1.11.0-alpha01 以降で直っている様子です。

TooltipとPopupとはしばらく距離を置きたいです。

Oisix ra daichi Creator's Blogはオイシックス・ラ・大地株式会社のエンジニア・デザイナーが執筆している公式ブログです。

オイシックス・ラ・大地株式会社では一緒に働く仲間を募集しています