【React Native】表示速度が遅い問題のカイゼン

はじめに

こんにちは、株式会社カミナシのエンジニア @MuiKnskです。

今回は「React Nativeの表示速度が遅い問題のカイゼン」について書きたいと思います。
『カミナシ』は新しい機能を最速でリリースして、ビジネスの速度を上げる期間がありました。 そのときの技術的な負債が溜まってしまい、あるときお客様から表示速度が遅くて困っているという声をいただきました。
そこで表示速度が遅い原因の調査と実際に対応したことを書いてみたいと思います。
React Nativeの表示速度が遅くて困っている、どうにかしたいと思っている方の手助けになれば嬉しいです!

尚、React.memoのHooksは既に実装済みの状態での話になります。

開発環境

  • React Native (0.63.2)
  • native-base (2.13.14)
  • Expo SDK 39
  • TypeScript (3.9.6)

課題

  • 記録する画面で表示項目が大量にある場合、表示速度が遅くなってしまう
不要なレンダリングがされていないか確認しましょう

React Native(Web)だと、why-did-you-render を使って確認することが出来ますが、Mobile(Expo)だと利用できません。 そのため、Hooksを使った useWhyDidYouUpdate を使って確認することが出来ます!

useWhyDidYouUpdateにあるサンプルを元に、どのように確認できるか試してみます。

import React, { useState, useEffect, useRef } from 'react'
import { Text, View, Button, StyleSheet, StyleProp, TextStyle } from 'react-native'

interface CounterProps {
  count: number
  style: StyleProp<TextStyle>
}

const styles = StyleSheet.create({
  view: {
    flex: 1,
    justifyContent: 'center',
    alignSelf: 'center'
  },
  counter: {
    alignSelf: 'center',
    color: 'red'
  }
})

const Counter = React.memo((props: CounterProps) => {
  useWhyDidYouUpdate('Counter', props)
  return <Text style={props.style}>{props.count}</Text>
})

const useWhyDidYouUpdate = (name: string, props: any) => {
  const previousProps = useRef<any>()
  useEffect(() => {
    if (previousProps.current) {
      const allKeys = Object.keys({ ...previousProps.current, ...props })
      const changesObj = {}
      allKeys.forEach(key => {
        if (previousProps.current[key] !== props[key]) {
          changesObj[key] = {
            from: previousProps.current[key],
            to: props[key]
          }
        }
      })
      if (Object.keys(changesObj).length) {
        console.log('[why-did-you-update]', name, changesObj)
      }
    }
    previousProps.current = props
  })
}

export default function App() {
  const [count, setCount] = useState(0)
  return (
    <View style={styles.view}>
      <Counter count={count} style={styles.counter} />
      <Button onPress={() => setCount(count + 1)} title="Increment"></Button>
    </View>
  )
}

gyazo.com

簡単なサンプルではありますが、なぜCounter Componentが更新されたのかが視覚的に分かると思います。 これを遅いComponentに入れ込むことで、不要なレンダリングがされていないかを確認することができるので是非試してもらいたいです!

この動画はコンソールログで確認していますが、ExpoClientの「Debug Remote JS」でも確認ができます。 f:id:kaminashi-developer:20201122231058p:plain

不要なレンダリングがされていないのに何故遅い?

理由は表示項目が画面外でも描画されているため、すべてレンダリングされた後に入力が開始できるようになっていたため、表示速度が遅くなっていました。

FlatListの「initialNumToRender」を使って初回のレンダリング数を決めて、遅延レンダリングしてみました。

import React, { useEffect, useState } from 'react'
import { Content, Text, View, Button } from 'native-base'
import { FlatList } from 'react-native'

export default function App() {
  const [sample, setSample] = useState<number[]>([])

  useEffect(() => {
    let lens = []
    for (let i = 1; i <= 100; i++) {
      lens = lens.concat(i)
    }
    setSample(lens)
  }, [])

  return (
    <Content>
      <View>
        <FlatList
          data={sample}
          initialNumToRender={23}
          renderItem={({ item, index }) => (
            <Button key={index}>
              <Text key={index}>{item}</Text>
            </Button>
          )}
        />
      </View>
    </Content>
  )
}

一番簡単ですぐに解決出来ましたが、 VirtualizedLists should never be nested inside plain ScrollViews with the same orientation - use another VirtualizedList-backed container instead. のWarningが発生してしまい、Warningの状態で使い続けるのはダメだと思いやめました。

  • React.lazy, React.Suspenseを使ったComponentのロードを遅延する

初回はロードしている感があって良かったけど、2回目以降は同じ問題になってしまうため、根本的に違うなと思いやめました。

最終的にどうしたのか?

CSSを直接書いている箇所をStyleSheetに書き出した(多少なりカイゼンされるようです)

import React from 'react'
import { Text, View } from 'native-base'

export default function App() {
  return (
    <View style={{ backgroundColor: 'red' }}>
      <Text>sample</Text>
    </View>
  )
}import React from 'react'
import { Text, View } from 'native-base'
import { StyleSheet } from 'react-native'

const styles = StyleSheet.create({
  view: {
    backgroundColor: 'red'
  }
})

export default function App() {
  return (
    <View style={styles.view}>
      <Text>sample</Text>
    </View>
  )
}

②各Component単位のレンダリングは遅くないが表示項目が大量にある場合、画面外まで描画してしまうのをカイゼンしないといけないと思い、遅延レンダリングを独自実装しました。 (Mobile、Webで利用できます)

import React, { useEffect, ReactNode } from 'react'
import useSafeState from './useSafeState'
import useUnmountRef from './useUnmountRef'

interface DelayedProps {
  delayCount: number
  delayThreshold?: number
  children: ReactNode
}

export const Delayed: React.FC<DelayedProps> = props => {
  const unmountRef = useUnmountRef()
  const [visible, setVisible] = useSafeState(unmountRef, false)

  useEffect(() => {
    if (props.delayThreshold && props.delayCount < props.delayThreshold) {
      typeof setVisible === 'function' && setVisible(true)
    } else {
      setTimeout(() => {
        if (!unmountRef.current) {
          typeof setVisible === 'function' && setVisible(true)
        }
      }, (props.delayCount - props.delayThreshold) * 50)
    }
    return () => {
      typeof setVisible === 'function' && setVisible(false)
    }
  }, [])

  return <>{visible && props.children}</>
}
  • useUnmountRef.tsx
import { useRef, useEffect } from 'react'

const useUnmountRef = () => {
  const unmountRef = useRef(false)
  useEffect(
    () => () => {
      unmountRef.current = true
    },
    []
  )
  return unmountRef
}

export default useUnmountRef
  • useSafeState.tsx
import { useState, useCallback, MutableRefObject } from 'react'

const useSafeState = (unmountRef: unknown, defaultValue: unknown) => {
  const [state, changeState] = useState(defaultValue)
  const wrapChangeState = useCallback(
    (v: boolean) => {
      const ref = unmountRef as MutableRefObject<boolean>
      if (!ref.current) {
        changeState(v)
      }
    },
    [changeState, unmountRef]
  )

  return [state, wrapChangeState]
}

export default useSafeState
  • 使い方
import React, { useState, useEffect } from 'react'
import { Content, Text, View, Button } from 'native-base'
import { Delayed } from 'shared/src/views/components/Delayed'

export default function App() {
  let delayCount = 0
  const [sample, setSample] = useState<number[]>([])

  useEffect(() => {
    let lens = []
    for (let i = 1; i <= 10; i++) {
      lens = lens.concat(i)
    }
    setSample(lens)
  }, [])

  return (
    <Content>
      <View>
        {sample.map((data: number, index: number) => {
          delayCount++
          return (
            <Delayed
              key={index}
              delayCount={delayCount}
              delayThreshold={5} // 5回目以降は遅延させてレンダリングする
              children={
                <Button key={index}>
                  <Text key={index}>{data}</Text>
                </Button>
              }
            />
          )
        })}
      </View>
    </Content>
  )
}
どれくらい変わるかをサンプルデータで見てみましょう
  • 1-200の数値を配列に格納した後、開始ボタンを押して表示速度を確認します。
  • カイゼン前はmapを使ってボタンを200個表示します。
  • カイゼン後はmapを使い、独自の遅延レンダリングComponentを使ってボタンを200個表示します。
カイゼン前の表示速度
import React, { useState, useEffect } from 'react'
import { Content, Text, View, Button } from 'native-base'
import { StyleSheet } from 'react-native'

const styles = StyleSheet.create({
  view: {
    flex: 1,
    justifyContent: 'center',
    alignSelf: 'center'
  },
  button: {
    margin: 12
  },
  text: {
    width: 100,
    textAlign: 'center'
  }
})

export default function App() {
  const [start, setStart] = useState<boolean>(false)
  const [sample, setSample] = useState<number[]>([])

  useEffect(() => {
    let lens = []
    for (let i = 1; i <= 200; i++) {
      lens = lens.concat(i)
    }
    setSample(lens)
  }, [])

  if (!start) {
    return (
      <View style={styles.view}>
        <Button style={styles.button} onPress={() => setStart(true)}>
          <Text style={styles.text}>開始</Text>
        </Button>
      </View>
    )
  }

  return (
    <Content>
      <View style={styles.view}>
        {sample.map((data: number, index: number) => {
          return (
            <Button style={styles.button} key={index}>
              <Text style={styles.text} key={index}>
                {data}
              </Text>
            </Button>
          )
        })}
      </View>
    </Content>
  )
}

gyazo.com

カイゼン後の表示速度
import React, { useState, useEffect } from 'react'
import { Content, Text, View, Button } from 'native-base'
import { StyleSheet } from 'react-native'
import { Delayed } from './Delayed'

const styles = StyleSheet.create({
  view: {
    flex: 1,
    justifyContent: 'center',
    alignSelf: 'center'
  },
  button: {
    margin: 12
  },
  text: {
    width: 100,
    textAlign: 'center'
  }
})

export default function App() {
  const [start, setStart] = useState<boolean>(false)
  const [sample, setSample] = useState<number[]>([])

  useEffect(() => {
    let lens = []
    for (let i = 1; i <= 200; i++) {
      lens = lens.concat(i)
    }
    setSample(lens)
  }, [])

  if (!start) {
    return (
      <View style={styles.view}>
        <Button style={styles.button} onPress={() => setStart(true)}>
          <Text style={styles.text}>開始</Text>
        </Button>
      </View>
    )
  }

  let delayCount = 0
  return (
    <Content>
      <View style={styles.view}>
        {sample.map((data: number, index: number) => {
          delayCount++
          return (
            <Delayed
              key={index}
              delayCount={delayCount}
              delayThreshold={16}
              children={
                <Button style={styles.button} key={index}>
                  <Text style={styles.text} key={index}>
                    {data}
                  </Text>
                </Button>
              }
            />
          )
        })}
      </View>
    </Content>
  )
}

gyazo.com

ただ、動画を見てもらうと分かる通り、意図的に遅延レンダリングしているため、高速でスクロールすると描画が追いつかなくて、若干引っかかるような感じになるので注意してください。(Delayed ComponentのsetTimeoutの時間調整したり、visibleがfalseの場合にローディングアイコンを挟むと良いかも知れません)

おわりに

いかがだったでしょうか。 表示速度のカイゼンって意外と単純そうで難しいなと個人的には感じています。きちんと問題に対して調査をした上で、どういったカイゼンのアプローチができるのかを模索して解決していくのはエンジニアとしての醍醐味でもありますし、それがお客様の体験を良くしていくとなると一段とやりがいを感じる部分だと思っています。

今回は独自のComponentを作成してカイゼンをしましたが、もっとこういう方法があるよ!という方が居れば、ぜひコメントください!よろしくおねがいします。

また、カミナシに少しでも興味を持っていただけて、話を聞いてみたい、応募したいという方はwantedlyからお待ちしております!