🫠

React Native のバージョンアップが辛かった話

2024/10/03に公開

こんにちは!アルダグラムでエンジニアをしている渡邊です。

先日 React Native と使用しているライブラリのバージョンアップを行いました。この対応に苦労したので対応した内容を知見として共有したいと思います。
色々と私見を述べていますが、私自身が Android、iOS のネイティブアプリ開発経験があるという点も踏まえた意見であることにご留意ください。

弊社のアプリについて

弊社のアプリ KANNA は React Native で作られています。(ただし一部の機能は Kotlin や Swift といったネイティブの言語で実装されています
KANNA で使用している主なライブラリと今回のアップデート対応の前後のバージョンは以下になっています。

ライブラリ アップデート前のバージョン アップデート後のバージョン
React Native 0.69.12 0.73.8
TypeScript 4.4.4 5.4.5
React 17.0.2 18.2.0
apollo client 3.3.21 3.7.17
formik 2.2.9 2.4.6
yup 0.28.0 1.4.0
react-native-screens 2.9.0 2.9.0(バージョン変更なし)
react-navigation/bottom-tabs 5.11.15 5.11.15(バージョン変更なし)
react-navigation/material-top-tabs 5.3.19 5.3.19(バージョン変更なし)
react-navigation/stack 5.14.9 5.14.9(バージョン変更なし)
react-native-reanimated 2.9.1 3.6.2
react-native-gifted-chat 0.16.3 2.4.0

なぜアップデートしたのか?

私が入社した2年前の 2022年9月時点では React Native のバージョンは 0.67.2 でした。 そこからずっと React Native のライブラリのアップデートは行なっていませんでした。 ただ iOS の Privacy Manifest の対応のために React Native のバージョンアップが必要になり、2024年4月に 0.69.12 までアップデートを行いました。

この対応後にアプリチームのメンバーで話し合い、今後同様のことが起こる可能性があることを考えたら React Native のバージョンは最新版にアップデートしておいた方がいいのではないか、という結論になりました。

また React Native だけではなく、React Native で使っているライブラリについても今回を機にアップデートしようという方針に決まりました。 ライブラリは4年前のリリース当初からアップデートされていないものが多くあったためです。

アップデート対応の手順

アップデート対応は以下の手順を考えて、この方向で進めました。

  1. devDependencies 系のライブラリのアップデート
    • TypeScript やリンターなど
  2. React Native に依存しないライブラリのアップデート
    • Apollo Client や formik、yup など
  3. React Native に依存するライブラリのアップデート
    • 主に React Native の UI 系のライブラリ
  4. React Native や React のアップデート

ただし 3 と 4 については、ライブラリによっては順番が前後することもありました。

アップデート対応で発生した問題

アップデート対応していく中で多くの問題が発生しました。 その中からいくつか共有したいと思います。

React のアップデート

React に関しては 17.0.2 から 18.2.0 へアップデートしました。 アップデートした際に TypeScript の型周りでエラーが発生したので、主に以下の対応を行いました。

  • React.FC の props から children がなくなったので、children の型定義を追加
// 型定義の例
+type HyperlinkProps = {
+  children: ReactNode
+}

-const Hyperlink: FC = () => {
+const Hyperlink: FC<HyperlinkProps> = ({ children }) => {
  • useCallback に指定する関数の引数に型の指定が必要になった
  const handleChange = useCallback(
-   value => {
+   (value: string) => {
      setFieldValue(name, value)
      if (onChange) {
        onChange(value)
      }
    },
    [name, onChange, setFieldValue]
  )

react-navigation のアップデート

react-navigation は React Native で画面遷移を行うためのライブラリです。 react-navigation は react-native-screensreact-native-reanimated というライブラリを参照しているので、それぞれ以下のようにバージョンアップを試みました。

  • react-navigation: 5.x 系 → 6.x 系
  • react-native-screens: 2.9.0 → 3.31.1
  • react-native-reanimated: 2.9.1 → 3.12.0

これらのライブラリのアップデートする上でも色々と問題はあったのですが、それらについては割愛します。

結果としては react-native-reanimated だけアップデートし、それ以外はアップデートをしないという方向で対応しました。

この対応を行なった理由、また対応の際に発生した問題やその解決方法は以下の通りです。

  • react-native-screens を 3.x 系にアップデートすると、ネイティブでのビューの階層構造が変わってしまう

    • ビューの階層構造が変わることにより、Android の特定の画面で何も描画されないという不具合が発生したが、簡単には解決できそうになかったので 3.x 系へのアップデートを断念した
    • 詳細

      react-navigation のスタックの機能を使った画面遷移に関して、react-native-screens 3.x 未満とそれ以降とでビューの階層構造の挙動が変わっていました。

      以下のキャプチャは弊社の Android アプリにおいて、設定画面(SettingsView)からオフラインモード設定画面(OfflineModeSettingsView)へ画面遷移した際のビューの階層構造です。

      左側が 2.9.0、右側が 3.x のものになっています。

      上記の階層構造を見ていただくとわかりますが、2.9.0 では設定画面が残ったままですが 3.x では画面がリプレイスされています。 バックキー押下などで前の画面に戻ることができますが、3.x ではこの時に Android の特定の画面で何も描画されないという不具合が発生しました。

      この特定の画面というのが、Android で Jetpack Compose を使っている画面でした。(弊社では Android アプリの一部の画面に Jetpack Compose を採用しています) おそらく理由は こちらのコメント に記載されているものと同じと推測しています。

      こちらの解決方法が何かないか調べてみましたが簡単に解決する方法が見つからなかったため、react-native-screens のバージョンアップを断念しました。

  • react-navigation の 6.x 系は react-native-screens の 3.x 系に依存するため、react-navigation を 6.x 系にアップデートすることができない。 そのため react-navigation は 5.x 系のバージョンのままとした

  • react-native-reanimated をアップデートしたことによりタブを表示している画面でエラーが発生

    • エラー内容
      ERROR  TypeError: Cannot read property 'prototype' of undefined
      
      This error is located at:
      in Pager (created by TabView)
      in RCTView (created by View)
      in View
      in GestureHandlerRootView (created by TabView)
      in TabView (created by MaterialTopTabView)
      in MaterialTopTabView (created by MaterialTopTabNavigator)
      in MaterialTopTabNavigator (created by CmTabs)
      in RCTView (created by View)
      in View (created by CmTabs)
      in CmTabs (created by SceneView)
      
    • 原因としては react-native-reanimated をアップデートしたことにより、react-native-tab-view というライブラリでコードが参照できなくなってしまった模様

      • 該当箇所

        node_modules/react-native-tab-view/lib/module/Pager.js の以下の行でエラーになっていた。

         _defineProperty(this, "clock", new Clock());
        

        どうやら Clock というクラスか何かが参照できなくなってしまった模様

    • 解決方法としては react-native-tab-view の 2.x 系のバージョンでは react-native-reanimated が使われているが、3.x 系のバージョンでは react-native-reanimated が使われなくなるので、react-native-tab-view のバージョンを更新することで解決した

      • 詳細
        npm install react-native-tab-view@3.5.2
        
        # https://reactnavigation.org/docs/tab-view
        # react-native-tab-view の 3.x 系移行のバージョンでは
        # react-native-pager-view が必要になるので別途インストール
        npm install react-native-pager-view@6.3.3
        

React Native のアップデート

React Native は React Native Upgrade Helper を参考に 0.69.12 から 0.73.8 へアップデートしました。

特に Android は React Native の Gradle Plugin が提供されるようになり、build.gradle の設定関係を変更する必要がありました。

アップデート後 npm run type で TypeScript の型エラーが発生するようになったので、該当の箇所を修正しました。

主に対応したエラーとしては以下になります。

  • Linking.removeEventListener が Deprecated になった
  • AppState.removeEventListener が Deprecated になった
    • 対応内容
      useEffect(() => {
        const listener = ...
        
      - AppState.addEventListener('change', listener)
      - return () => AppState.removeEventListener('change', listener)
      + const subscription = AppState.addEventListener('change', listener)
      + return () => subscription.remove()
      }, [])
      

その後 React Native が 0.74.2 までリリースされていたので、こちらのバージョンへアップデートを試みました。

アップデートをしてみた結果、iOS ではアプリを起動することはできたのですが、画面をタッチしても何も反応しない状態になってしまいました。

また Android ではアプリを起動するとすぐにクラッシュしてしまいました。

Android のクラッシュ時のエラーログの抜粋
runtime.cc:691] Pending exception java.lang.NoSuchMethodError: no static or non-static method "Lcom/facebook/react/devsupport/CxxInspectorPackagerConnection$WebSocketDelegate;.didFailWithError(Ljava/util/OptionalInt;Ljava/lang/String;)V"
runtime.cc:691] (Throwable with no stack trace)
runtime.cc:691]
runtime.cc:699] JNI DETECTED ERROR IN APPLICATION: JNI NewGlobalRef called with pending exception java.lang.NoSuchMethodError: no static or non-static method "Lcom/facebook/react/devsupport/CxxInspectorPackagerConnection$WebSocketDelegate;.didFailWithError(Ljava/util/OptionalInt;Ljava/lang/String;)V"
runtime.cc:699] (Throwable with no stack trace)
runtime.cc:699]
runtime.cc:699]     in call to NewGlobalRef
runtime.cc:699]     from java.lang.String java.lang.Runtime.nativeLoad(java.lang.String, java.lang.ClassLoader, java.lang.Class)

このエラーログから、クラッシュは以下の箇所が関係してそうです。

この件に関しては iOS、Android とも修正に時間がかかってしまいそうだったので、結局 0.74.2 へのアップデートは諦め、React Native のバージョンは 0.73.8 のままとしました。

また、react-native-reanimated のバージョンが 3.12.0 だと一部の画面でエラーが発生してしまいました。

エラー内容

react-native-reanimated は React Native のバージョンに依存しているため、React Native 0.73 以降のバージョンでは react-native-reanimated は 3.6.0 以降が必要となります。バージョンを 3.6.2 にダウングレードすることでエラーが回避できたため、こちらのバージョンを使うようにしました。

react-native-gifted-chat のアップデート

react-native-gifted-chat はチャットの UI のためのライブラリです。 KANNA ではチャット機能があり、その部分の UI で使用しています。

このライブラリは 0.16.3 から 2.4.0 までアップデートしました。

TypeScript の型エラーがいくつか発生したので修正したのですが、動作確認するとチャット画面が真っ白になってしまいました。

原因を調査してみたところ 2.1.0 からこのライブラリで提供される GiftedChat というコンポーネントがクラスベースのコンポーネントから関数ベースのコンポーネントに変更されていたことが影響していました。

GiftedChat のコードを読み、以下のように patch-package でパッチを当てることで、正常に描画されるようになりました。

GiftedChat に patch-package を適用
diff --git a/node_modules/react-native-gifted-chat/lib/GiftedChat.js b/node_modules/react-native-gifted-chat/lib/GiftedChat.js
index 9951cd8..af80779 100644
--- a/node_modules/react-native-gifted-chat/lib/GiftedChat.js
+++ b/node_modules/react-native-gifted-chat/lib/GiftedChat.js
@@ -38,13 +38,15 @@ function GiftedChat(props) {
     const actionSheetRef = useRef(null);
     let _isTextInputWasFocused = false;
     const [state, setState] = useState({
-        isInitialized: false,
         composerHeight: minComposerHeight,
         messagesContainerHeight: undefined,
         typingDisabled: false,
         text: undefined,
         messages: undefined,
     });
+    // NOTE: 元々のコードでは、この変数は上記の state に含まれていたが、isInitialized を true にした後に
+    // 別の箇所で setState で更新処理が実行されて isInitialized が false に戻ってしまうことがあり、別の変数で管理するように変更した.
+    const [isInitialized, setIsInitialized] = useState(false);
     useEffect(() => {
         isMountedRef.current = true;
         setState({
@@ -279,9 +281,9 @@ function GiftedChat(props) {
         notifyInputTextReset();
         maxHeightRef.current = layout.height;
         const newMessagesContainerHeight = getMessagesContainerHeightWithKeyboard(minComposerHeight);
+        setIsInitialized(true);
         setState({
             ...state,
-            isInitialized: true,
             text: getTextFromProp(initialText),
             composerHeight: minComposerHeight,
             messagesContainerHeight: newMessagesContainerHeight,
@@ -339,7 +341,7 @@ function GiftedChat(props) {
         actionSheet: actionSheet || (() => { var _a; return (_a = actionSheetRef.current) === null || _a === void 0 ? void 0 : _a.getContext(); }),
         getLocale: () => locale,
     }), [actionSheet, locale]);
-    if (state.isInitialized === true) {
+    if (isInitialized === true) {
         return (<GiftedChatContext.Provider value={contextValues}>
         <View testID={TEST_ID.WRAPPER} style={styles.wrapper}>
           <ActionSheetProvider ref={actionSheetRef}>

yup のアップデート

yup はバリデーションのために使っています。 0.28.0 から 1.4.0 へのアップデートを行いましたが、1.0.0 で BREAKING CHANGE があったため記法が変わりました。

そのため yup のバリデーションのコードに対してまずはテストコードを書き、その後 yup をアップデートしてテストコードが通ることを確認する方針で進めました。

その中でいくつか問題があった箇所を紹介します。

特定のバリデーションが常にエラーになってしまった

元々以下のようなバリデーションが設定されており、 values のフィールドには yup.string() が期待されていました。

return yup.array().of(
  yup.object().shape({
    values: yup
          .string() // values のバリデーションのスキーマが string になっているが、
                    // values の実際の値は string[] 型になっていた
           ...

しかし実際の values の値は string[] 型になっていたため、この部分でバリデーションがエラーになってしまっていました。(以前のバージョンではこれでも問題なく動作していた)

これは以下のようにバリデーションも string[] 型にすることで解決しました。

return yup.array().of(
  yup.object().shape({
    values: yup
      .array()
      .of(
        yup
          .string()
      )
      ...

特定のバリデーションが正常に機能しなくなった

schema.concat に仕様変更があり、以前は deep merge だったものが shallow merge になっていました。(BREAKING CHANGE に記載がありました)

  • concat works shallowly now. Previously concat functioned like a deep merge for object, which produced confusing behavior with incompatible concat'ed schema. Now concat for objects works similar to how it works for other types, the provided schema is applied on top of the existing schema, producing a new schema that is the same as calling each builder method in order

そのため例えば以下のようなバリデーションのスキーマが定義されていたとします。

const projectSchema = yup.object().shape({
  project: yup.object({
    client: yup.object({
      ...
    })
  })
})  
return schema.concat(projectSchema)

今まではネストしていたオブジェクトのバリデーションスキーマも自動的にマージされるようになっていましたが、1.0.0 からマージが行われなくなってしまい、バリデーションが正常に動作しなくなってしまいました。

例えば上記の schema にも project.client のバリデーションのスキーマが含まれていた場合、今までは問題なく動作していたのですがアップデート後は正常に動作しなくなっています。

そのためネストしているオブジェクトのバリデーションスキーマをマージさせるために、以下のようにネストしているオブジェクトのバリデーションスキーマをそれぞれ個々にマージするような対応を行いました。

// schema に project のバリデーションスキーマが含まれていなければ undefined
const projectSchema = schema.fields.project as
  | ObjectSchema<Record<string, unknown>>
  | undefined
// schema に project.client のバリデーションスキーマが含まれていなければ undefined
const clientSchema = projectSchema?.fields?.client as
  | ObjectSchema<Record<string, unknown>>
  | undefined

const customProjectSchema = yup.object({
  client: ...
})
// ネストしたフィールドのバリデーションスキーマを設定する
const mergedProjectSchema = projectSchema
  ? projectSchema.concat(customProjectSchema)
  : customProjectSchema

return schema.concat(
  yup.object({
    project: mergedProjectSchema
  })
)

リリース後に発生した問題

アップデート後にテストも行い先日リリースしましたが、リリースした後に問題が発生しました。

その問題というのが iOS で iPhone SE などの画面サイズが小さい端末だと、ログイン画面でキーボードを表示するとフリーズしてしまい操作が全くできなくなってしまう、というものでした。

調べてみたところ React Native にこれと同じ問題のような Issue がありました。

元々ログイン画面では以下のようにコンポーネントを実装していました。

<ScrollView>
  <KeyboardAvoidingView>
	...
  </KeyboardAvoidingView>
</ScrollView>

Issue のコメント を参考にすると、もしかしたらレイアウト処理が無限ループしてしまっている可能性がありそうでした。

別の Issue コメントKeyboardAvoidingView の中に ScrollView を置くと正常に動作するとあったので、以下のように修正してみたところ、フリーズが発生しなくなり問題なく操作できるようになりました。

<KeyboardAvoidingView>
  <ScrollView>
		...
  </ScrollView>
</KeyboardAvoidingView>

React Native 0.69.12 では問題なかったコードが、0.73.8 にバージョンアップしたことによって不具合が発生するようになってしまった、ということでした。

この辺りのコンポーネントの順番の定義については公式ドキュメントにも記載が見当たらなかったので、ハマってしまうポイントになるかと思いました。

アップデート対応後の所感

アップデート対応を行ってみて、正直辛いことが多かったです。今回紹介した内容は一部で、ほかにも色々と問題が発生しました。 特に UI 系のライブラリをアップデートした際に動作がおかしくなってしまうことが多く、対応に苦慮しました。

react-navigation などはプロジェクトの色々な箇所で使われていたりするため代替が難しいですし、その他の UI 系のライブラリについても他に代替があるかというとそうではありません。 これは React Native のコミュニティがあまり盛んではないというのが私の率直な感想です。

ですので今後も React Native のアップデートを行う場合に対応のコストがかかってしまう可能性が考えられそうです。

React Native は React が使えるフロントエンドエンジニアのような方々にとっては、1つのコードで iOS、Android などのマルチプラットフォーム向けに開発できるのでアプリ開発の一つの選択肢です。

ただあくまで個人的な意見になりますが、もし今後 React Native を新規プロジェクトに採用する際には以下の点を考慮した上で採用するかどうかを検討するのがよいかと思います。

継続的な開発が必要か?

継続的に開発を行う場合、React Native のアップデートが必要になるような場面が出てくるかもしれません。

その際に今まで採用していたライブラリが動作しなくなったりする可能性があるかもしれません。 この場合、今まで使っていたライブラリが正常に動作するように修正できれば問題ないですが、正常に動作させることが難しかった場合には代替のライブラリを採用したり、最悪独自にライブラリを実装する必要に迫られるかもしれません。

代替のライブラリを探すにしても、React Native のライブラリは数があまり多くなかったり、メンテが止まってしまっていたりするものがよく見受けられるので注意が必要です。

React Native だけでは実装が難しい機能を実装する可能性はあるか?

この場合、何かしらのライブラリを採用するか、もしくは iOS や Android のネイティブ側で機能を実装して React Native と連携する必要があります。

この場合 iOS、Android に精通していないと実装が難しいかもしれません。

React Native エンジニアの採用

継続的に開発していく場合、プロジェクトをグロースしていくためにエンジニアの採用が必要になる可能性があります。 例えば iOS や Android に詳しいエンジニアを採用しようと思っても、React Native を使っているということでアプリエンジニアから敬遠されてしまう可能性は充分にあると思っています。

弊社の今後のアプリの方針

参考までに、弊社のアプリは今 React Native を取り除く方針で対応を行なっています。

具体的には Kotlin Multiplatform と Compose Multiplatform を使って、一つの Kotlin のコードで Android、iOS のそれぞれのプラットフォームに対して画面を置き換え始めたところです。

この方針に決めた経緯や、Kotlin Multiplatform や Compose Multiplatform を導入した際にハマったところがあったので、こちらについては別の機会に共有できたらと思います。

最後に

以上、React Native のアップデートが辛かった話でした。

もちろん所属している組織の状況によって React Native を採用した方が良いケースはあると思っていますし、この記事は React Native をプロジェクトに採用することに対して否定するものではありません。

また今回の対応で業務委託の方含めチームメンバーに色々と助けていただきましたので、この場で感謝を申し上げたいと思います。
ありがとうございました!!

もっとアルダグラムエンジニア組織を知りたい人、ぜひ下記の情報をチェックしてみてください!

アルダグラム Tech Blog

Discussion