🍇

React Nativeで裏画面の不要な再レンダリングを解消する話

に公開

WebアプリケーションのReactはそこそこ詳しいけれど、React Nativeは初めて触るという方は多いのではないでしょうか。今回は、リリース済みのアプリで発生していたパフォーマンス課題とその解決までのプロセスを紹介します。

対象読者

  • Reactはある程度知っているがNativeは初めて
  • React Native開発に携わることになった

直面していた深刻な課題

リリース済みのアプリで以下のような問題が発生していました。

  • 画面の切り替えがAndroidで3秒以上かかることがあった
  • ユーザーからのパフォーマンスに関するストアレビューが増えていた(なかなか辛辣)

使用技術スタック

  • React Native
  • React Navigation
  • Redux

WebとNativeの根本的な違いを理解する

いきなり横道に逸れますが、これだけは覚えて帰ってほしいと言う項目だったので最初に書かせて下さい。

前提としてWeb開発者がNativeアプリを開発する際は両者の根本的な違いを理解することが大切です。
今回は画面のライフサイクルを理解できていないことが原因でした。

Webアプリケーションの場合

ページ遷移時に前のページは基本的にアンロードされ、新しいページが読み込まれます。ブラウザのメモリ管理により、不要になったDOMは自動的に解放されるため、パフォーマンスの問題は比較的起こりにくい構造です。

Nativeアプリケーションの場合

ユーザー体験を向上させるために画面を事前に読み込んでメモリに保持し続けます。これにより素早い画面切り替えを実現していますが、同時にメモリ効率とレンダリング最適化が開発者の責任となります。

イメージ

画面コンポーネントはアンマウントされない

React NativeのルーティングライブラリであるReact Navigationは、上記Nativeアプリの特性を活かした設計となっております。そのため、一度表示された画面コンポーネントはメモリに保持され続けます。

しかし、この仕様を理解せずにWeb開発の感覚で実装すると、今回のようなパフォーマンス問題が発生してしまいます。

これはHomeタブとExploreタブを持つ一般的なアプリケーションの例です。

https://github.com/morimorig3/react-native-performance-improved

一般的にReactではStateによってコンポーネントの表示を出し分けることが多いため、タブをHomeからExploreに切り替えるとHomeタブの画面はメモリから解放されてアンマウントされそうに思えます。

実際に検証してみる

useEffectを使って動作確認してみましょう。

export default function HomeScreen() {
  useEffect(() => {
    console.log("mounted!! HomeScreen");
    return () => {
      console.log("unmounted!! HomeScreen");
    };
  }, []);
  return (
    // ...

何度かタブを切り替えた結果がこちらです。

LOG  mounted!! HomeScreen
LOG  mounted!! ExploreScreen

マウントのログは出力されますが、アンマウントのログは出力されません。タブに指定されている画面コンポーネントは基本的にアンマウントされないということがわかります。

一見、これの何が問題なのか?と感じると思いますが、Reactが持つStateの変更を検知して再レンダリングを行うという特性と相性が悪いと感じています。(気をつけて実装しなければいけない)

(事例)裏画面で不要な再レンダリングが発生していた

ここからは実際に発生していた事例を通して、課題となっていた実装を紹介しつつ解消方法を追っていきながら解説したいと思います。

アプリの実装方針

まずは、アプリの実装方針はこのようになっていました。

  • Stateの管理にReduxを使用
  • 画面表示用や計測用の値など、あらゆる値をReduxで一元管理
  • 画面切り替え時に現在のページ名をReduxにセット

具体的な実装

ここでは簡略化するために、Homeとリスト表示を持つExploreの2つの画面を持つアプリとします。

  1. 画面表示時に画面名をReduxにセットする
  2. Exploreタブのリスト要素をタップしたときに現在の画面名を計測値として発火する

画面切り替え時に現在のページ名をReduxにセットする実装です。

export default function HomeScreen() {
  const dispatch = useAppDispatch();
  useFocusEffect(
    useCallback(() => {
      dispatch(setScreenName("Home"));
    }, [dispatch])
  );
  return (
    // ...
  );
}

export default function ExploreScreen() {
  const dispatch = useAppDispatch();
  useFocusEffect(
    useCallback(() => {
      dispatch(setScreenName("Explore"));
    }, [dispatch])
  );
  return (
    // ...
  );
}

Explore画面でのリスト表示と計測処理はこんな感じです。
FlatListを用いたシンプルなリストで、画面名はscreenNameとしてセレクターで取得します。

export default function ExploreScreen() {
  const dispatch = useAppDispatch();
  const data = useMemo(() => {
    return Array.from({ length: 5 }, (_, i) => ({
      id: `item-${i}`,
      title: `Item ${i + 1}`,
    }));
  }, []);

  const screenName = useAppSelector(screenNameSelector);

  useFocusEffect(
    useCallback(() => {
      dispatch(setScreenName("Explore"));
    }, [])
  );

  return (
    <SafeAreaView>
      <FlatList
        data={data}
        renderItem={({ item }) => (
          <Item title={item.title} screenName={screenName} />
        )}
        keyExtractor={(item) => item.id}
      />
    </SafeAreaView>
  );
}
const Item = ({ title, screenName }: { title: string, screenName: string }) => {
  const onPress = () =>
    console.log(
      `計測値: ${title}, ScreenName: ${screenName}` // 何かしらの計測
    );
  return (
    <View style={styles.stepContainer}>
      <Pressable onPress={onPress}>
        <Text>{title}</Text>
      </Pressable>
    </View>
  );
};

発生していた問題

ExploreからHomeタブに切り替える動作をReactのパフォーマンス計測ツールであるReact Profilerでプロファイリングしたものが以下になります。

これをみると、画面の切り替えごとにFlatListの要素が毎回再レンダリングされていることが判明しました。

Homeタブに切り替えるので、表示されなくなるはずのExploreタブのFlatListは本来再レンダリング不要なはずです。

再レンダリング原因を究明していく

React Profilerを用いることで、コンポーネントが再レンダリングされることになったトリガーを調べることが出来ます。

FlatListをクリックすると、「Why did this render?」と言う項目を確認できます。これをみることでFlatListがレンダリングされるトリガーになっているPropsがわかります。

どうやらFlatListに渡しているrenderItemkeyExtractorが即時関数であるため、Propsに変化ありとみなされて再レンダリングされているようです。

<FlatList
  data={data}
  renderItem={({ item }) => (
    <Item title={item.title} screenName={screenName} />
  )}
  keyExtractor={(item) => item.id}
/>

FlatListのrenderItemkeyExtractorについては公式ドキュメントでも言及されています。
https://reactnative.dev/docs/optimizing-flatlist-configuration#avoid-anonymous-function-on-renderitem

まず、公式ドキュメントに従ってメモ化を試してみます。

const renderItem: ListRenderItem<{
  id: string;
  title: string;
}> = useCallback(
  ({ item }) => <Item title={item.title} screenName={screenName} />,
  [screenName]
);

const keyExtractor = useCallback((item: { id: string }) => item.id, []);

部分的な改善のみ

React Profilerで再度確認すると、こんな感じでした。

keyExtractorについては最適化されましたが、renderItemがまだ再レンダリングのトリガーになっていました。

screenNameの取得タイミングを変更

renderItemはメモ化していますが、依存配列にscreenNameが指定されています。画面を切り替えるとscreenNameが変わるため、再レンダリングされて当然の実装でした。

しかし、重ね重ねになりますがExploreからHomeタブに切り替える際に、裏の画面であるExploreで再レンダリングが発生するのは不要です。

解決策として、screenNameの取得をstore.getState()を通して、Itemコンポーネント内で行うように変更しました。

const Item = ({ title }: ItemProps) => {
  const onPress = () =>
    console.log(
      `計測値: Pressed ${title} with Screen Name: ${screenNameSelector(
        store.getState()
      )}`
    );
  return (
    <ThemedView style={styles.stepContainer}>
      <Pressable onPress={onPress}>
        <ThemedText type="subtitle">{title}</ThemedText>
        <ThemedText>
          {`Tap the Explore tab to learn more about what's included in this starter app.`}
        </ThemedText>
      </Pressable>
    </ThemedView>
  );
};

解決の効果

React Profilerで確認すると、FlatListがグレーアウトされ、不要な再レンダリングが抑制されたことがわかります。

解決結果

この最適化により、以下の大幅な改善を実現できました。

  • 画面の切り替えがAndroidでも1.5秒以内に短縮(従来の3秒以上から大幅改善)
  • iOSでも体感できるほどのパフォーマンス改善効果

特にAndroid端末での改善効果は顕著で、ユーザー体験が劇的に向上しました。
リリースしているアプリでは、1つの画面にリスト要素を複数表示しており、パフォーマンスへの影響がかなり大きかったようです。

おわりに

今回の問題は、ボトムタブの画面コンポーネントがアンマウントされないというReact Navigationの仕様を理解せずに設計してしまったことが原因でした。

React Nativeは、Nativeを意識せずに実装できてしまうのですが
やはりパフォーマンスの課題やバグの発生を防ぐためにはWebとNativeの違いを理解しておくことが大事だと改めて実感した記録でした。

少しでも、皆様のプロダクト改善に繋げられれば幸いです。

参考

Discussion