SkeletonScreenのUIコンポーネントの実装

ここからはこれらのアーキテクチャに対して、どのようにSkeletonScreenを組み込んだかについてお話をしていきます。まずは、SkeletonScreenのUIコンポーネントの実装についてです。SkeletonScreenのコンポーネントは、タブのコンポーネントをラップして配置するような仕組みを目指しました。

実際には、2つのコンポーネントに分けて実装しました。1つ目はSkeletonScreenのデザイン部分を担うSkeletonScreenコンポーネントです。このコンポーネントは、ビジネスロジックを含まずHTMLとCSSスタイルだけを適用したコンポーネントになります。Reactでいうところのプレゼンテーショナルコンポーネントにあたります。ステートは一切持たず、onTransitionEndといったDOMのイベントのハンドリングに必要な最低限のpropsを持つ仕様にしています。

2つ目のコンポーネントは先ほど説明したSkeletonScreenコンポーネントの出し分けを行う、SkeletonOverlapコンポーネントです。出し分けに加えて、フェードアウトのアニメーション管理も行っています。

実装はこのようになっています。propsから表示・非表示のフラグを受け取って、その値をアニメーション用のカスタムフックに渡しています。このカスタムフックは表示・非表示の真理値からアニメーションの状態を加味したステートに変換します。

そのステートをもとにクラス名を付与してフェードアウトアニメーションを適用したり、SkeletonScreenのコンポーネントを出し分けたりしています。

これでpropsを渡すことで、SkeletonScreenを出し分けられるようになったので、次は表示・非表示を決定するロジックに関する2つの高さについて説明していきます。

表示・非表示を決定するロジックに関する2つの高さ

ここで、もう一度SkeletonScreenの解除条件についておさらいします。画面全体を覆う1つのSkeletonScreenを解除する条件は、viewport内に描画されるコンポーネントの高さがすべて確定した時でした。つまりviewportの高さとSkeletonScreenの裏にある描画済みのコンポーネントの高さの合計を比較して、後者のほうが大きいとレイアウトシフトは起きず、SkeletonScreenを解除できるようになります。

それらの2つの高さはStoreに格納します。Storeが更新されるたびにSkeletonOverlapコンポーネントに状態をpropsとして反映させ、SkeletonScreenの出し分けができます。StoreはSkeletonScreen専用に新規で用意しています。その構造は、SkeletonScreenの解除条件で必要な2つの高さを持つように、SkeletonOverlapStateとして定義しています。

ステート1つ目のプロパティは、viewportの高さです。画面の高さは端末が縦向きか横向きかで異なるので、向きの変更を監視し、Web APIを使って取得します。ステートの2つ目のプロパティは、タブごとのコンテンツの高さを保持するSkeletonOverlapTabオブジェクトを格納する配列です。

SkeletonOverlapTabオブジェクトは、タブ内のコンポーネントごとの高さ、それらの合計値を保持しています。

そしてその合計値とviewportの高さを比較して決定される、SkeletonScreenを表示するか否かの状態を持っています。このreadyToHideの値が、先ほど紹介したSkeletonOverlapコンポーネントにpropsとして渡されることで、SkeletonScreenの出し分けが実現できます。

コンテンツの高さがどのように決まっていくか

ここまでで、高さを格納するStoreについて説明してきたので、ここからはコンテンツの高さがどのように決まっていくかをコードと合わせて紹介します。

各コンテンツが高さを確定するためには、コンテンツが実際に描画される必要があります。そして描画に必要なデータは、コンテンツごとに個別のStoreにステートとして格納されています。各コンテンツのStoreはAPIを通じてデータが保存されるため非同期的にデータがStoreに格納されます。

つまりコンポーネントが描画し、高さが格納できるかどうかはコンテンツのStoreのデータが確定しているかどうかに依存します。このデータが確定しているかどうかの状態をData statusと呼び、各コンテンツのStoreに新たに持たせることにしました。

Data statusには、例えば次のような状態があります。データの有無が未確定な状態を表すNone、データをAPIなどで取得中の場合はPending、APIの取得が成功しデータの存在が確定した場合はSuccessとします。そして先ほどお伝えしたように、Data statusがSuccessであればそのコンテンツを描画し、高さが確定されることを指します。

またData statusには、Success以外にも状態があります。例えばLINE NEWSではAPIによるデータ取得に失敗した場合は、そのコンテンツを非表示にしています。つまり、高さを0として確定できます。このようにコンテンツのデータがStoreに格納されているかどうかではなく、データの有無が確定しているかどうかが重要なポイントとなります。

他にも、ローカルストレージなどをキャッシュとして利用している場合も、コンテンツを描画できます。ちなみにコンテンツが描画できるかどうかはTrueかFalseかの真理値であるため、シンプルにBoolean型で管理することもできますが、今後Data statusを別の箇所でも利用したり、デバッグ時により正確に状態を把握するために、このように細かく管理をしています。

実際にコンテンツのStoreにデータの状態を格納している部分について見ていきます。画面に表示しているコードはSkeletonScreenの導入前、データの状態を格納する前のニュースタブのコードを簡略化したものです。内容としては、コンテンツの描画に必要なAPI呼び出しをしていて、そのAPIの結果を、Dispatcherを通じてStoreに反映させています。

このActionsに手を加えて、Data statusを更新する仕組みを実装していきます。そしてその状態をStoreに持たせるために行った変更は、緑色の箇所になります。Storeには初期値としてNoneのデータを持ち、API呼び出し直前にPending状態に変更します。ACTION_TYPEにてPendingを表現しています。

そして、API呼び出しが完了するとペイロードに取得したコンテンツのデータを含め、SuccessのACTION_TYPEをディスパッチします。またAPI呼び出しが失敗した場合は、FailedをStoreに反映させるようにしています。ここまでが、コンテンツの高さを確定するまでの基本的な流れになります。

複数のStoreに依存する場合

ここからは少し応用的な内容になります。コンテンツを描画するのに必要なデータは1つだけとは限りません。複数のデータが揃って初めて描画できるコンポーネントもあります。例えば記事を表示するためのデータが必要であるのに加えて、A/Bテストのグループ結果によって出し分けする場合などです。

そのため、複数のStoreに依存する場合のSkeletonScreenの解除条件についても考慮する必要があります。コンテンツが複数のStoreに依存する場合、すべてのStoreのData statusをAND条件として確認します。例えばData Bのように1つでもデータが確定していない場合は、コンテンツはまだ描画できないと判断できます。

そしてすべての依存するデータが確定している状態であれば、そのコンテンツは描画の準備が整った、つまり高さが整ったと判断できます。こうして各コンポーネントはそれぞれのデータとData statusを受け取り描画をして、その高さをSkeletonScreenの解除をするStoreに格納できます。

この時、コンポーネントの高さに加えて、コンポーネントの配置が上から何番目かを示すIndexも合わせて格納しています。その理由は、高さの合計を求める仕組みにあります。

例を挙げると上からA、B、Cという3つのコンポーネントで構成するレイアウトの場合、AとCのコンポーネントの高さが確定したとします。この時コンポーネントAとCの高さの合計がviewportの高さを超えている場合、SkeletonScreenを解除しても問題ないのでしょうか? 

これには問題があります。コンポーネントBが遅れて描画された場合に、コンポーネントCの位置を押し下げてしまい、レイアウトシフトが発生してしまいます。この例では、コンポーネントを押し下げないであろう高さとviewportとで比較すべき高さは、コンポーネントのAの高さまでとなります。

他の例でも見てみます。コンポーネントAとBが表示され、高さが確定した場合はどうでしょうか。その場合は、AとBの合計の高さまでとなります。つまりは各要素を押し下げないためには高さが確定したコンポーネントが連続している必要があります。

話を戻すと、各コンポーネントの高さが確定した時、高さに加えてIndexをStoreに格納する必要があると話しましたが、高さが確定したコンポーネントがどこまで連続しているかを確認するために必要だったということになります。

開発効率を上げるためのユーティリティ

最後に、開発効率を上げるためのユーティリティを実装したので紹介をいたします。コンポーネントの高さとIndexを受け渡す仕組みとしてuseRenderReadyとRenderReadyWrapperという2つのユーティリティを用意しました。この2つは、ともに同じ機能でhooks用とclass components用で分かれています。

基本的にはhooksの利用を優先し、利用できない場合はコンポーネントのユーティリティを利用することにしています。今回はhooksを掘り下げて紹介いたします。

このhooksは、コンテンツのData statusとコールバック関数を受け取り、Refオブジェクトを返す関数です。このhooksはコンテンツのコンポーネントから呼び出しています。hooks内部で生成されたRefオブジェクトをコンテンツのDOMに紐づけます。これによりhooksは、コンテンツの高さを取得できます。

引数で受け取ったData statusから、コンテンツの高さが確定したかどうかを判断できます。そしてコンテンツの高さが確定した時、登録しているコールバック関数を呼び出します。このコールバック関数には、SkeletonScreenのStoreの更新を行うactionを登録しておきます。このactionにはトップや国内など、どのタブかを表すtabIndexと、そのタブの中のどのコンポーネントかを表すcontentIndex、そしてコンテンツの高さを引数に渡しています。

以上が、コンポーネントの高さとIndexをStoreに格納する実装になります。これらのユーティリティは既存のコンポーネントの実装に適用したり、今後新規のコンポーネントで使用することでSkeletonScreenの解除に関する実装コストを減らすことができます。

SkeletonScreen実装時に頭を悩ませた問題と対策

ここまでで私たちのSkeletonScreenがどういうコンセプトでどのように実装されたか、そのすべてをお見せいたしました。現行のニュースタブに適用し得る最善の方法を追求したつもりですが、問題がまったくないわけではありませんでした。ここからはSkeletonScreen実装時に頭を悩ませた問題と、その対策について紹介いたします。

今回LINE NEWSで導入したSkeletonScreenというのは画面全体を1つで覆うSkeletonScreenですが、一般的なSkeletonScreenは各コンポーネントに対してSkeletonScreenを適用させる分、各コンポーネントが独立してSkeletonScreenの動作を解除させることができます。そのためもしSkeletonScreenが解除されない不具合が発生した場合でも、不具合が起こる範囲を限定できます。また、どのコンポーネントに対して不具合が起きているかについても明確であるため、不具合の原因が特定しやすいメリットがありました。

一方、今回導入するSkeletonScreenの方法では、裏側の複数のコンポーネントから動作を管理し、1つのSkeletonScreenを解除する仕組みであるため、いずれかのコンポーネントで不具合が発生するとSkeletonScreen全体が解除されなくなってしまい、ユーザーがニュースタブを操作不可能になってしまいます。また、原因となったコンポーネントの特定が困難になります。

つまり私たちの独自のSkeletonScreenではSkeletonScreenが解除されない想定外の問題が起きた時に、ユーザーにとって致命的であること。同じく問題が起きた時に開発者が原因を特定しにくいことという2つの問題がありました。

まず1つ目のユーザーが操作不能になってしまう問題については、フォールバックとしてSkeletonScreenにタイムアウトを設けました。タイムアウト時間に到達すると解除条件を満たしていなくてもSkeletonScreenが解除されます。

また、タイムアウト処理は開発中にSkeletonScreenの解除に不具合が出たことを明確化するためにプロダクション環境と一部のステージング環境のみ適用しています。こうすることで、開発中に問題が起きた場合はSkeletonScreenが解除されなくなり、必ず問題が発生していると認知できます。

次に不具合時に原因が特定しにくい問題のアプローチですが、こちらはニュースタブに以前から導入している開発者向けのデバッグツールにSkeletonScreenに関するデバッグ機能を追加し対策しました。私たちがDev Supportと呼んでいるデバッグツールはランタイムで動作し、端末単体でローカルストレージやAPI呼び出しの状況などを確認できるものです。サービスのコードに影響しないbundle.jsとして、開発時のみ読み込ませています。

そしてこちらがDev Supportに新たに追加したスケルトンスクリーンモニターというデバッグ機能です。できることはいくつかあるのですが、主に各コンポーネントの高さが確定したかどうかと、どのコンポーネントが解除条件になっているのかを確認する際に役立ちます。ちょっと小さいですが、緑色のテーブル部分を見ていただくと各コンポーネントのIndexと高さが表示されていて、赤字になっている0から2番目のコンポーネントが解除条件になっていることがわかります。

QAで利用する実機にてスケルトンスクリーンモニターを利用できるので、QA確認時に不具合が見つかった際にスケルトンスクリーンモニターのスクショをBugチケットに添付してもらうことでデバッグのコストを減らすことができました。

SkeletonScreen導入後の変化と今後の展望

最後に、SkeletonScreen導入後の変化と今後の展望をお話して締めくくろうと思います。まずはそもそもの目的だったレイアウトシフトの問題は解消され、ユーザーが誤タップするなどの関連する問題もなくなりました。またレイアウトシフトを数値として表すCore Web Vitalsの指標でもあるCLSも大きく改善されました。

CLSは優れたユーザーエクスペリエンスを提供するためにページのCLSを0.1以下に維持する必要があり、もともと0.348だった値が0.002と改善しています。この0.002という値も意図的に発生させているローディングアニメーションが反応して起きた値であり、ほとんど0となっています。レイアウトシフトのような一見改善の効果を数値化しにくそうな問題でもこういった指標を利用して定量的に評価できるのは非常に良いものだと思います。

一方でCLS以外の指標、例えばLCPなどのその他のパフォーマンス改善の余地は残っています。特に今回のSkeletonScreenでは画面全体を覆う1つのSkeletonScreenを導入しているため、どこかのコンテンツの読み込み速度が低下すると、SkeletonScreen全体の解除条件に影響してしまいます。

そのため今後は、SkeletonScreenの解除までの時間を減らすようなパフォーマンスチューニングが必要です。例えば今回導入した1つのSkeletonScreenは、段階的に解除する一般的なSkeletonScreenとハイブリッドにできます。あまりに表示に時間がかかるコンテンツは、全体のSkeletonScreenを解除条件から外し、そのコンテンツ独自にSkeletonScreenを組み込むことも可能になります。

ただしLINE NEWSでは現状遅いとわかっているコンテンツは広告であり、広告はレイアウトのサイズがAPI結果によって変更されるものではあるので、一筋縄に広告をSkeletonScreenにするというわけにはいきませんが、今後私たちはさまざまな工夫でファーストビューの表示速度に対する改善を行っていこうと思っています。

発表は以上です。ご清聴ありがとうございました。