iOS Safariでページ内ジャンプ直後に画面が一瞬真っ白になる問題と解決策
縦に長いLPで、ページ内リンクを使って離れた位置へジャンプすると、iOS Safariで画面全体が一瞬真っ白になる問題に遭遇しました。正確には、画面内のコンテンツが消え、背景色だけが表示される状態です。
スクロール処理のタイミングを調整しても症状は変わらず、最終的には画面外のCSSアニメーションをanimation: noneで解除することで解消しました。ページ内に多数配置していた、無限ループのtransformアニメーションが関係していたようです。
この結果から疑っているのが、アニメーションに伴うコンポジットレイヤーの増加と、それによるメモリの圧迫です。なぜ画面外のアニメーションが関係するのか、調べた過程も含めて書いておきます。
発生した症状
scrollIntoView()やURLの#idによるページ内移動で、スクロール位置を一気に変えた直後に発生しました。検証した範囲では、次のような挙動でした。
- iOS Safariでは、画面全体が2〜3フレームだけ背景色になる
- macOSや手持ちのAndroid(Pixel 8a)では再現しない
- ページ最上部付近へのジャンプでは再現せず、先頭から離れた位置へのジャンプで発生する
一瞬の出来事なので、目視では「ちらついた気がする」程度にしか分かりません。何が起きているのかを確かめるため、まずは画面録画を使うことにしました。
調査の過程
画面録画をコマ送りする
iOS実機で60fpsの画面録画を行い、コマ送りしてみると、ジャンプ直後の2〜3フレームで画面内のコンテンツが消えていました。
こうした一瞬の描画の不具合は、目視だけだと修正できたかどうかの判断も曖昧になりがちです。個人的には、まず症状が再現する端末で録画してみるようにしています。コマ送りで見ると、案外「なるほど……これはあれ系かな?」と見当が付くこともあるものです。
スクロール処理のタイミングを変える
最初に疑ったのは、JavaScriptによるスクロール処理のタイミングです。次のような調整を試しましたが、症状は変わりませんでした。
preventDefault()でリンクの既定のページ内移動を止めるrequestAnimationFrame()のコールバックでscrollIntoView()を呼ぶ
タイミングの調整では変化がなかったので、今度はページ内の要素やCSSに目を向けることにしました。
要素を減らして切り分ける
ページ内のコンテンツを半分ずつ外し、症状が残る側をさらに絞り込んでいきました。いわゆるBisect手法です。地道ですが、こういうときは要素を減らしていくのが手堅いと思います。
こうして絞り込むと、無限ループするCSSアニメーションを持つ要素群が関係していることが分かりました。特に見直したのが、画面外にある要素のアニメーションです。
原因の考察:レイヤーとメモリ
今回の症状は、画面外の要素に適用していた無限ループのCSSアニメーションを解除すると解消しました。この結果から、アニメーションが維持していたコンポジットレイヤーがメモリを圧迫し、ジャンプ時の描画に影響していたのではないかと考えています。
コンポジットレイヤーは、ブラウザがページの一部を分けて描画し、最後に合成するための描画単位です。transformのアニメーションが独立したレイヤーとして扱われると、描画済みの内容を動かすことで、毎フレーム描き直す処理を減らせます。一方、その描画内容を保持するためのメモリは必要です。レイヤーの数だけでなく、描画領域の大きさもメモリ消費に関係します。この仕組みはWebKitのレイヤーに関する解説でも紹介されています。
transformなら軽い、と考えて使うことは多いと思います。ただ、個々のアニメーションで再描画を減らせても、ページ全体で多数のレイヤーを保持すれば別の負荷になります。今回のような縦長のLPでは、画面外にある多数の要素が保持するレイヤーも、メモリを圧迫する要因として考えられます。
見落としやすいのは、画面外でアニメーションを一時停止していても、レイヤーの負荷までなくなるとは限らないことです。animation-play-state: pausedは進行を止め、再開時に同じ位置から動かすための指定です。アニメーション自体は残っているので、描画用のレイヤーも保持される場合があります。「見えていないし、動きも止めたから大丈夫」と考えたくなりますが、この違いは案外盲点だと思います。
こうしたレイヤーの保持でメモリの余裕が少なくなり、離れた位置へジャンプした際に、移動先の描画が間に合わなくなったのではないか、というのが今回の見立てです。ただ、Safari内部の処理やメモリ使用量の変化までは追えていません。解除すると直った、という結果からの推測なので、メモリ不足が原因だとまでは言い切れないところです。
解決策:画面外のアニメーションを解除する
この見立てをもとに、画面外ではアニメーションの指定自体を外し、レイヤーを保持する要因を減らすことにしました。最初から再生しても支障のない装飾なら、見えている間だけ動かす形にするのがシンプルだと思います。
変更したのは、画面外の要素をpausedにする指定を、animation: noneに置き換える部分です。CSSの差分としては小さいですが、アニメーションを残したまま止めるか、指定自体を外すかが変わります。
/* 変更前:画面外では一時停止する */.icon { animation: spin 15s linear infinite; animation-play-state: paused;}
.icon.is-inview { animation-play-state: running;}
画面内にある間だけis-inviewクラスを付ける仕組みはそのままに、次のように変更しました。
/* 変更後:画面外ではアニメーションを解除する */.icon { animation: none;}
.icon.is-inview { animation: spin 15s linear infinite;}
ほかのCSS指定によってレイヤーが残る場合はありますが、これでアニメーションを理由に保持する必要はなくなります。
画面内かどうかの判定にはIntersectionObserverを使いました。変更点はこの程度ですが、今回のページではこれでちらつきが収まりました。
再表示時は最初から再生される
ただ、この方法では画面内に戻るたびにアニメーションが最初から始まります。animation-delayの待ち時間もやり直しです。
途中の状態を保持したい演出には使いにくいと思います。解除時には通常のCSS指定に戻るので、画面の端で急に向きや位置が変わって見える可能性もあります。
レイヤーのメモリ使用量も確認する
修正後も、先頭から離れた位置へのジャンプで確認しました。ページ最上部付近ではもともと再現しなかったので、そこだけを見ても直ったかどうか分からないためです。
まだ確かめたいのは、pausedとanimation: noneで、レイヤーのメモリ使用量が実際にどれだけ変わるかです。SafariのWebインスペクタにある「レイヤー」タブでは、各レイヤーのメモリ使用量や、レイヤーが作られた理由を確認できます。
気になるのは、画面外に大きな描画領域を持つレイヤーが残っていないか、解除によってそのメモリ使用量が減るか、という点です。これだけでSafari全体のメモリ不足を証明できるわけではありませんが、今回の見立てを確かめる手がかりにはなると思います。
描画の問題だと、ついwill-changeも試したくなります。ただ、事前の最適化を促すこの指定は、使いすぎるとメモリ消費を増やす可能性があります。MDNでも過剰な使用を避けるよう説明されています。今回のようにレイヤーの負荷を疑っていると、追加するのは少しためらいます。
そもそも今回の症状は、実機で触って初めて気付いたものでした。シミュレーターでは出ない挙動もありますし、特にメモリ周りは実際の端末で動かしてみないと分からないことが多いと感じます。
まとめ
今回のiOS Safariでのちらつきは、画面外のCSSアニメーションをanimation: noneで解除することで解消しました。コンポジットレイヤーによるメモリ圧迫を疑っていますが、メモリ不足が直接の原因だったかどうかは未特定です。
transformによる再描画の削減にも、レイヤーを保持するメモリのコストはあります。また、animation-play-state: pausedで動きを止めても、レイヤーが解放されるとは限りません。ループアニメーションの多いページでは、画面外のアニメーションを解除することも、負荷を減らす選択肢になります。
今回の症状に気付いたのも実機での確認がきっかけでした。シミュレーターでは再現しない挙動もあるため、特にメモリ周りが関係する描画の問題は、最終的に実機で確認することが大事です。