stand.fmアプリのパフォーマンス改善話「推測するな、計測せよ」
(このnoteはstand.fm engineeringマガジンの記事です)
stand.fm エンジニアの和田(@takahi5)です。
今回はReact Nativeアプリののパフォーマンス改善について書きたいと思います。主にレンダリング周りの改善です。
アプリを開発していてユーザーや社内のメンバーから「アプリが重い!」と言われたことはないでしょうか?僕はよくあります笑
今回はstand.fmのライブ配信機能で実施したパフォーマンス改善について、ボトルネックの特定からその改善まで、実例に触れながらご紹介したいと思います。
アプリが重い!?
ただこの「アプリが重い!」にもいろいろパターンがあります。
- ボタンをタップしたときの反応が遅い
- アニメーションがカクカクする
- ローディングがなかなか終わらない
- 起動に時間がかかる
などなど、一言で「重い」と言っても、詳しく聞いてみると色々な現象があるかと思います。
その中でも
- ボタンをタップしたときの反応が遅い
- アニメーションがカクカクする
のような症状がReact Native製アプリで発生したときは、レンダリング周りを疑ってみる価値ありです。今回の記事では、レンダリング周りの改善についてご紹介します。
あとは、端末が熱くなる、バッテリー消費が異様に速い、といった現象もレンダリング周りの修正で改善する場合があります。
定性的な情報から仮説を立てる
実際にコードを修正する前にボトルネックがどこにあるか?を特定する必要があります。
その第一歩として、ユーザーや社内のメンバーからの定性的な情報が参考になることもあります。
今回の場合は、以下のような情報がありました。
- 人気タレントのライブ配信でアプリが重くなる
- 重くなると画面が固まったり、ボタンが反応しにくくなる
- そのライブでは大量のコメントが送信される
- またコメントの投稿頻度も高い
画面が固まったり、ボタンの反応が悪くなるのはいかにもレンダリング周りの気配がします。
またReactのレンダリング負荷は「状態更新頻度 ✕ 更新対象のコンポーネント数」の掛け算になるので高頻度で大量のコメントが投稿されると、いかにも負荷になりそうです。
React NativeのUI描画の仕組み
さてここでReact NativeのUI描画の仕組みを簡単にご紹介します。
React Nativeでは主に2つのスレッド
- UI(ネイティブ)スレッド
- JSスレッド
が走っています。そして、それぞれのスレッドはブリッジを通じて情報をやり取りしています。
それぞれのスレッドは60FPSで動くことが可能です。つまり1/60msec毎に画面を再描画することが可能です。この頻度で画面が再描画されれば、十分にスムーズな体感を実現できます。
この仕組を頭に入れた上で、フレーム落ちやボタンの反応が悪いといった現象がなぜ発生するかを見てみましょう。
フレーム落ちの原因
JavaScript側でレイアウトを計算し、その情報をブリッジを通じてネイティブ側に送ります。そしてネイティブ側ではその情報を元に、ネイティブのUIコンポーネントを生成し画面に表示しています。
この命令は1秒間に60回の頻度で送ることができます。この頻度で送ることができれば十分に滑らかなUIを表示できます。
しかしReact Nativeでアプリを開発すると往々にしてJavaScript側に多くのロジックを書くため、JSスレッドが忙しくなります。そしてJS側で処理に時間がかかると、この1/60秒間隔での送信が間に合わなくなります。それがフレーム落ちになります。
多少のフレーム落ちであればユーザーは気付かないでしょう。一般的には100msecほど遅れると人は違和感を感じるようです。
ボタンの反応が悪い、の原因
ボタンのタップの検出はその逆方向で、UIスレッド(Native)で検知したタップイベントは、UIスレッド→JSスレッドにブリッジを通じて渡されます。
しかしJSスレッドが忙しくなると、Nativeから送られてきたイベントに即座に反応できなくなります。その結果、ボタンをタップしたのに反応がなく、ユーザーは画面が固まったような印象を受けてしまいます。
という感じで、往々にしてJavaScript側での処理がボトルネックになってきます。そしてJavaScript側の負荷としてよくあるのがReactのレンダリングに関する処理です。以降ではレンダリング負荷を改善するフローを具体的に見ていきます。
現象を再現させる
では本題に戻って、実際のパフォーマンス改善のフローを見ていきましょう。
ボトルネックがどこにあるかを突き止めるために計測を行いたいのですが、そのためにはまず問題の状況を再現させる必要があります。
stand.fmではライブ配信での負荷を擬似的に再現するために、Harvestという社内ツールを開発して現象を再現させました。
詳細はこちらをご覧下さい↓
計測する ~ React Devtoolsなど ~
「推測するな、計測せよ」という言葉があります。推測で手当り次第にパフォーマンスチューニングするのではなく、本当にボトルネックになっている部分に絞ってチューニングするべきだからでしょう。パフォーマンスの最適化によってコードは複雑性を増すこともあります。Reactの具体例でいうと、useMemoやuseCallbackでパフォーマンス改善した際に、うっかり依存配列の値が漏れると思わぬバグを引き起こしてしまいますよね…そしてこの手のバグは見つけにくいという…
なのでパフォーマンス最適化は初めから推測で行わず、問題が出たときにちゃんと計測しながら取り組むべきだと思います。
さて今回の改善では、performance monitorとReact Devtoolsを用いて計測を行いました。公式で出しているツールなので使ったことがある人も多いかと思います。その活用方法をご紹介します。
performance monitorでざっくり計測
React Nativeアプリをを開発モードで起動している場合、performance monitorにてざっくりとした計測ができます。(シミュレータの場合cmd + dで起動)
このようなウィンドウが表示され、UI(Native)スレッドとJSスレッドのFPSが表示されます。アプリが重くなるような操作をして、このFPSを見てみましょう。
もしJSスレッドのFPSが落ちていたら、JS側でなにかの負荷がかかっています。経験上、多くの場合はレンダリングが負荷になっています。次のステップでよりReact Devtoolsを用いてより詳細な計測と、ボトルネックの特定を行いましょう。
React Devtoolsによる計測と分析
●ボタンをクリックすると計測が始まります。負荷がかかってそうな操作をして、おわったら■で計測を終了します。
すると計測の結果が表示されます。
ではこの計測結果の見方をご紹介します。
まず右上にレンダリング時間が棒グラフで時系列に表示されています。
縦の高さがそのレンダリングにかかった時間を表しています。なので高い棒がレンダリング負荷のかかってるタイミングと考えて良いでしょう。
適当に高めの棒をクリックします。
すると下側に、どのコンポーネントでレンダリングが走ったのか、またそれに要した時間を表示してくれます。
(💡React Devtools おすすめの設定)
Record why each component rendered while profiling
コンポーネントがなぜレンダリングされたか、理由を表示してくれます。デバッグする際に非常に便利なので有効にしましょう
Hide commits below x (ms)
それほど時間のかかっていないコミット(右上の棒グラフに相当)も表示するとグラフが見にくくなるので、この設定で無視すると良いです。
React Devtoolsを使ってボトルネックを探す
ではReact Devtoolsを使ってボトルネックになっているコンポーネントを探しましょう。
今回は以下のようなカウンターとFlatListから構成されるサンプルアプリを用意しました。ちなみにここでカウンターの値とFlatListは全く関連がありません。
さてこの画面でカウンターを増やしたときのレンダリングを解析してみましょう。
このような結果が出ました。
なんとカウンターと全く関係のないFlatListのアイテムが全部再レンダリングされています。全体のレンダリングは309msですが、そのうち307msをFlatListが占めています。全く見た目は変わっていないにも関わらず…!
(※FlatList内のアイテムListItemは人為的に重くしてあります)
レンダリングの改善
Reactでは状態が更新されると更新対象のコンポーネントが再レンダリングされます。つまりレンダリングの負荷は
更新対象のコンポーネント数 ✕ 状態の更新頻度
と言えるでしょう。なので
・更新対象のコンポーネントをなるべく減らす
・状態の更新頻度をなるべく下げる
といったアプローチが必要になります。
更新対象のコンポーネントを減らす(memo化)
さてなぜ再レンダリングされちゃっているのでしょうか?FlatListにカーソルを合わせるとそのヒントが見えます。
Why did this render?
・Props changed (keyExtractor)
keyExtractorというpropsが変更されたから、とのことです。
ご存知Reactはpropsが変更されると再レンダリングされます。またその子コンポーネントも再レンダリングされます。
ではコードを見てみます。
<FlatList
data={items}
renderItem={renderItem}
keyExtractor={item => item.id}
/>
keyExtractorにアロー関数を渡しています。アロー関数は実行のたびに新しいオブジェクトとして生成される性質を持っているため、実質的な意味は同じでもReact的にはPropsが変わったと判断します。
そこでこの関数をメモ化するわけですが、そのためにuseCallbackを使います。
const renderItem = useCallback(({item}: {item: Item}) => {
return <ListItem text={item.text} />
}, [])
const keyExtractor = useCallback((item) => item.id, [])
return (
<SafeAreaView style={styles.container}>
<Button onPress={onPressCounterButton} title="Add Count" />
<Text>{count}</Text>
<FlatList
data={items}
renderItem={renderItem}
keyExtractor={keyExtractor}
/>
</SafeAreaView>
)
そして再度計測してみます。
FlatListがDid not renderになりました!グレーの網掛けで表示されてるコンポーネントは再レンダリングされていないことを意味します。useCallbackが効いてFlatListに渡すpropsに変化がなくなたため、FlatListが無駄に再レンダリングされなくなりました。
全体としてもレンダリング時間が309ms→1msに短縮されました。
これはちょっと極端な例かもしれませんが、実際の現場でもこれに似た状況は意外と起こっています。とくにFlatListなどのリストは表示するアイテムが多くなればなるほど影響が大きくなるので、知らずしらずのうちにボトルネックになっていることが多いです。
上記はサンプルアプリの例ですが、stand.fmのアプリでも似たような理由でmemo化が効いていない箇所があり、それらを見直すことでレンダリングの負荷がぐっと下がりました。
状態の更新頻度を下げる
あるコンポーネントがどの程度の頻度で更新されているのか?も先程のReact Devtoolsにて確認できます。
左の画面で特定のコンポーネントをクリックすると、右側にそのコンポーネントが更新されたタイミングが一覧表示されます。
FlatListを選ぶと以下のように表示されました。
これは
計測開始から1.6秒後にレンダリングされて307msかかった。
計測開始から1.9秒後にレンダリングされて306msかかった。
計測開始から2.2秒後にレンダリングされて307msかかった。
....
という意味になります。約0.3秒間隔の頻度でレンダリングされているのがわかります。もしこの頻度を下げることができれば、負荷軽減にかなり効きそうですね。
stand.fmのライブ機能の場合、視聴者数によってはかなりの高頻度でコメントが投稿されます。そしてそのコメント投稿の度にReactの状態が更新されると、再描画の負荷がとても高くなります。
そこでstand.fmのアプリでは、コメントがどれだけ高頻度で投稿されても、Reactの状態更新頻度は一定に保つような仕組みがあります。
詳細はこちら↓
改善の結果!!
問題がよく発生していたユーザーさんの配信はエンジニアでも注意して聴いていたのですが、改善後の配信でそのユーザーさんが
「なんか今日、アプリの調子よかったね!」
と話してくれていました。ときは、よっしゃ!と心のなかでガッツポーズをしました。
ユーザーさんの声が直接が聞けるのはエンジニア冥利に尽きますが、文字通り”声”で聞けるのは音声サービスのstand.fmならではですね!
・・・
株式会社 stand.fm では、絶賛エンジニアを募集しております!募集職種はこちらから!
Twitter で stand.fm の技術情報や note の更新をしています。
ぜひフォローしてみてください!