見出し画像

パフォーマンスによるユーザー体験の改善

この記事は、Goodpatch Advent Calendar 2020 22日目の記事です。

はじめに

現在Goodpatchが提供している「Strap」というクラウド型ワークスペースのフロントエンド開発を担当しています。最近特にパフォーマンス改善の部分にハマっています。Strapのような描画ツールのパフォーマンスは直接操作感につながるので、良い操作感を提供するために、うちのチームは一定のリソースを割いてパフォーマンス改善を継続的に行っています。

ソフトウェアエンジニアとして、良い操作感やユーザー体験の改善への関わり方はいくつかありますが、今回はパフォーマンスチューニングでユーザー体験を改善した経験をお話できたらと思います。

パフォーマンスはユーザー体験

ユーザー体験を形作るものは多くありますが、ユーザー体験に強い影響力がある非ビジュアル要素はよく無視されてしまうのではないでしょうか。実はエンジニアはさまざまな形で、ユーザー体験に対して大きな影響を与えています。個人的な経験では、パフォーマンスチューニングはエンジニアが持つ力の中で一番シンプルに直接ユーザー体験へ影響を与えるものだと思います。また、デザインプロセス上流工程などに関わらず、プログラミングだけでパフォーマンスを最適化し、ユーザー体験を向上させることができる方法です。

In many ways, developers have a greater impact on the user’s experience than we do as designers.

ーPaul Boag
UXのデザイナーでありデジタルトランスフォーメーションの専門家

パフォーマンスが低くなってしまうと、ファインダビリティに影響するだけではなく、アクセシビリティ、ユーザーの満足度、製品やサービスが十分に理解されないことなどにも大きな影響があります。つまり、パフォーマンス改善はSEOの改善のためだけではなく、ユーザー体験のためとも言えるでしょう。

パフォーマンスをUXの指標に

パフォーマンス改善の目標と聞くと、検索エンジンの結果を連想することが多いかもしれません。ただ、パフォーマンスチューニング本来の目的は、ユーザーの体験を向上させるためで、結果は検索エンジンに評価されるだけです。そこで、グーグルから「Web Vitals」という新しいUXの指標が提唱されています。今回の具体例もこの指標で、特にLCPとFIDの改善を目標に行ってきました。

スクリーンショット 2020-12-18 10.04.06

1. Largest Contentful Paint:最大コンテンツの描画

ユーザーがページで最も有意義なコンテンツをどのくらい速く見ることができるかを表します。ブラウザの表示範囲内に、一番大きな内容(そのページでメインとなるもの、画像、初期動画やテキストなどを含む)が表示されるまでの時間を測定します。

2. First Input Delay:初回入力遅延

入力がページに反映されるまでの遅延時間を表します。ユーザーが最初にページ内のアクション(クリック、ドラッグやドロップなど)をしてからブラウザが反応するまでにかかった時間を測定します。

3. Cumulative Layout Shift:累積レイアウト変更

ページがどのくらい安定しているように感じられるかを表します。内容が読み込まれ始めてから、表示されるコンテンツの予期しないレイアウトのズレの量を定量化します。例えば:広告が突然コンテンツの間に現れることなど。

具体的な改善事例

Strapの開発でいくつ改善を行ってきましたが、ここでまず一つの事例を取り挙げ、具体的な取り組みをご紹介します。

どういう課題なのか

Strapの様々な機能の中で一つの大きな特徴は無限の広さのボードです。その無限の空間にユーザーが自由にエレメントを制作し、ボードを縮小してコンテンツを俯瞰したり、拡大して細部を作業したりすることができます。ただ、無限の空間に大量なエレメントが存在すると、初期描画の表示に時間がかかってしまうという問題がありました。

StrapはWebGLを使っている描画ツールなので、GPU資源がかなり重要な部分です。大量のエレメントを描画する時は、Cullingという手法(例:React-Virtual)で表示範囲外のエレメントの描画しないという改善方法をとっていますが、(以前自作でPIXI用のロジックを作りましたが、)それでも大量のGPUの資源を使っていることから初期描画を表示させる時、以下のようなユーザー体験の課題がわかりました。

(1) LCP:読み込みが完全に終わらないと初期描画が表示できず、ボードが壊れているように見えてしまうという問題がありました。
(2)FID:初期描画の読み込みが完全に終わる前に、アプリがユーザー全てのアクションに反応できないという課題がありました。

どういう手法で改善したのか

表示範囲内と表示準備範囲内を含めての大量のエレメントを一度に描画するので、GPUに大きな負荷をかけてしまったという根本的な原因が計測によってわかりました。もちろんエレメント一つずつの利用資源を下げることも行いましたが、限界があります。そこで、私たちはゲームの手法を参考にし、大量のエレメントを大きく切り分けて描画します。描画のロジックを内部で管理しやすいように、、Render Propsという手法で行いました。(注:HooksとDOMのやり方まだ別です)

const CHUNK_SIZE = 240; //大きく切分けるサイズ
const LAZY_TIME = 80; //GPUが息抜きできる時間

export class ChunkLoader extends React.Component {
    ...
    componentDidMount = () => {
        this.startLoad();
    }
    
    startLoad = () => {
        const { elements } = this.props;
        const pixiContainer = this.ref.current;
        
        const waitingIds = elements.map(e => e.id);
        this.setState({ waitingIds });
        
        pixiContainer.on('childAdded', this.loading); //Loading method in PIXI
    }
    
    loading = (_, parent) => {
        const { elements } = this.props;
        const { waitingIds } = this.state;
        const remainNum = elements.length - parent.children.length;
        
        if (remainNum === 0) return this.completeLoad();
        if (remainNum <= waitingIds.length) {
            setTimeout(() => {
                this.setState({ waitingIds: waitingIds.slice(CHUNK_SIZE) });
            }, LAZY_TIME);
        }
    }
    
    completeLoad = () => { ... }
    
    render = () => {
        const { elements, children } = this.props; //children is a function
        const { waitingIds } = this.state;
        const loadedElements = elements.filter(e => !waitingIds.includes(e.id));
        
        <Container ref={this.ref}>{children(loadedElements)}</Container>
    }
}

// --------

export const Board = ({ elements }) => {
    return (
        <ChunkLoader elements={elements}>
            {(loadedElements) => 
                loadedElements.map(e => <Element {...e} />
            )}
        </ChunkLoader>
    );
}

この改善で、GPUが息抜きできるようになって、初期描画が完全に終わらなくてもエレメントが見られるし、ユーザーからのアクションにもすぐ反応できるようになりました。ただ、ユーザーが見ているボードの場所によっては、最初に描画した内容が見えない可能性があるので、LCP(最大コンテンツの描画)は改善されないままでした。

画像4

そこで、もう一つの改善の方法を加えて、ユーザーが見ているところから順番に描画したところ、上記二つの課題も改善できました。

export class ChunkLoader extends React.Component {
    ...
    startLoad = () => {
        const { elements } = this.props;
        const pixiContainer = this.ref.current;
        
        const waitingIds = this.calcLoadingOrder(elements);
        this.setState({ waitingIds });
        
        pixiContainer.on('childAdded', this.loading); //Loading method in PIXI
    }
    
    calcLoadingOrder = (elemenst) => {
        const { cameraArea } = this.props;
        
        // Loading from center of the camera
        const center = calcCenter(cameraArea);
        const distanceMap = {};
        
        elements.forEach(e => {
            distanceMap[e.id] = calcLength([center, calcCenter(e)]);
        });
        
        const Object.keys(distanceMap).sort((a, b) => distanceMap[a] - distanceMap[b]);
    }
    ...
}

画像3

ユーザー体験にどのような変化があったのか

まだチューニングの余地があるはずですが、この改善により、計測した結果に以下のような変化が見られました。

(1) LCP:初期描画の読み込みが完全に終わらなくても、ユーザーがメインコンテンツを見ることができるようになり、待ち時間が7秒から1.5秒に改善できました
(2)FID:アプリを起動してすぐユーザーからのアクションに反応できるよう改善できました

画像2

おわりに

ユーザー体験を改善するのに、ソフトウェアエンジニアの力は欠かせないと思います。パフォーマンスチューニングはエンジニアがユーザー体験に関わる方法の一つにすぎません。それ以外に、要件分析と体験設計のフェーズから関わりを持ち、実現性と提供価値の両方の責任を担うことでプロダクトを前進させたり、UXエンジニアとしてデザインとエンジニアリングを支援したりすることもあると思います。私はこれらの関わり方を完璧に実践できている訳ではないですが、エンジニアとして常にユーザーのことを考えることはすごく大事なことだと感じるので、もし同じような関心がある方がいらっしゃったら、お話できると嬉しいです。

最後にUXエンジニアについて興味がある方は、ぜひ同僚のOsumiさんの記事もご覧ください。

最後まで読んでいただき、ありがとうございました!


この記事が気に入ったらサポートをしてみませんか?