ガワネイティブアプリ(Creator)を、React Nativeで置き換えてみての一年間戦いの記録

Native Application Groupの大木です。BASEでは、購入者向けのショッピングアプリ「BASE」、「BASEライブ」、ショップオーナー向けのショップ運営管理アプリ「BASE Creator」の3つのスマホアプリをリリースしております。今回は、その中の一つBASE Creatorを、React Nativeで置き換え、リリースしてみての話を、お伝え出来ればと思います。

課題と動機

https://help.thebase.in/hc/ja/articles/206417201-BASE-Creator-とはなんですか-

BASE Creatorは、基本的にはWebViewで画面を表示するいわゆるガワネイティブアプリというものです。Webアプリとの違いは何処にあるかといいますと、商品が売れたり、購入者からメッセージが届くと、Push通知でお知らせ出来る機能があるところです。

アプリを運用していて出て来た課題としては、下記の点です。

  • 現状、問い合わせによる不具合報告に対応するために、iOS/Androidのコードベースそれぞれを確認する必要がある
  • スマホアプリは合計6つあり、チームメンバーも少数のため、WebViewベースのこのアプリは極力共通化したい。

これらの課題を解決するために、クロスプラットフォーム開発フレームワークの検討をしました。

React Native か Flutter か

開発プラットフォームとして上がったのは、React NativeとFlutterでした。どちらを選んでも問題は無さそうですが、今回はReact Nativeを採用しました。

重要視するポイント

  • 既存技術を流用出来るか(社内、一般的なNative/Web技術含め)
  • クロスプラットフォーム開発のノウハウが蓄積されているか

特に重要視していないポイント

  • 統一されたガイドラインのようなものがある
  • 新しい技術で開発

Flutterの方が後発で、挑戦してみたい気持ちもありました。しかし、Reactやnpmの技術を流用出来て、場合によってはWebのフロントエンドエンジニアでも対応できる可能性があるため、React Nativeを選択しました。

置き換えてみてどうなったか

結論から言うと、色々な良い影響をもたらすことが出来ました。

アプリケーションロジックを共通化出来た

最大の課題であった、iOS/Androidそれぞれのコードベースを一つにすることが出来、アプリケーションロジックを全てTypeScriptで記述することに成功しました。ネイティブ側のObjective-CやJavaのコードは、起動時に必要なコードやFirebase SDK用の初期化時コードくらいです。

CodeBase

別々のスキルセットを持つエンジニアとのコミュニケーションが増えた

React Nativeで開発するとなると様々なスキルセットを必要とするため、Pull-Requestベースでレビューをもらいながら開発していきました。

Colabo1 Corabo2

メンテナンス可能なReact Nativeアプリを開発し続けるための構成を模索し構築できた

Expoで管理するJavaScript/TypeScriptのみ動作させる環境を含むワークフローは"Managed workflow"と呼ばれます。対して、それを使わずNativeのIDE/SDKを使い、自分たちでReact Nativeの環境を整えるのを、Expoでは"Bare workflow"と読んでます。今回はFirebaseのNative SDKを含んだReact Nativeのモジュールを使うため、最終的にはこの"Bare workflow"でリリースする必要がありました。ただ、Expoの"Managed workflow"も素早く動作確認できる検証用環境として捨てがたいです。

幸いなことに、2019年はReact Native Re-Architecture実践の年で様々な改善をしていました。その動きに呼応するようにExpoもSDKに同梱されていたモジュールを分割するようになり、Expoベースの"Managed workflow"を使わなくても、Expoの便利モジュールを使えるようになりました。

両方の動作環境を一つのプロジェクトで共存させるために、monorepoという仕組みを導入しました。この仕組みは、BabelReactでも採用されています。

リポジトリのディレクトリ構成は、最終的に次のようになりました。

.
├── @types
│   ├── metro-config
│   └── react-web-vector-icons
├── docker
│   └── android
├── packages
│   ├── app
│   ├── cli
│   ├── components
│   ├── core
│   ├── expo-starter
│   ├── functions
│   ├── native-starter
│   ├── platform
│   ├── rn-metro-configurator
│   └── tsconfig-paths-transformer
└── tools
    ├── bin
    └── docker-bin

また、分割したモジュールをどのように使うかの簡易的な概念図は次の通りです。

Component Diagram

図の native-starter というモジュールが、通常のネイティブのIDE/SDKの設定と実装が必要な"Bare workflow"で動作させるためのエントリポイントとなっております。このモジュールからDebugビルドのアプリを起動したり、ストアリリースのためにReleaseビルドを作成したりします。また、expo-starter というモジュールが、検証用にExpoの"Managed workflow"で動作させるためのエントリポイントとなっています。

monorepo構成で、React Nativeアプリを動作させるためには、色々なテクニックがいるのですが、長くなりそうなため、機会があれば別途解説記事を書ければと思っています。

Storybookの導入

React Nativeのコンポーネントを作成するに当たって、アプリケーションの特定の状態でしか発生しないUIの状態があり、その状況を再現したUIを素早く実装するためにStorybookを導入しました。

Storybook

CodePushの導入

CodePushは、ユーザー端末に直接アプリの更新を配信できるMicrosoftのApp Centerのサービスです。リリース後、特定の古いOSでアプリが動作しない不具合が発生した時に、これを導入していたおかげで緊急アップデートをすることができました。

iOS 12.1以下で動作しないメソッドを使ってしまったため、CodePushで修正アップデート

iOS 12.2以上でないと、Object.fromEntires というメソッドは使えません。次のように修正し、リリースした当日に緊急アップデートで対処することができました。

@@ -42,14 +42,23 @@ export class FirebaseEventTracker implements EventTracker {
     }
 
     eventParams<T extends { [x: string]: string | number } = {}>(params: T) {
-        return Object.fromEntries(
-            Object.entries(params).map(([k, v]) => {
-                if (typeof v === 'string' && v.length > 0) {
-                    return [k, v.substr(0, 100)];
-                }
-                return [k, v];
-            })
-        );
+        const values = Object.entries(params).map(([k, v]) => {
+            if (typeof v === 'string' && v.length > 0) {
+                return [k, v.substr(0, 100)];
+            }
+            return [k, v];
+        });
+
+        if (Object.fromEntries) {
+            return Object.fromEntries(values);
+        }
+        return values.reduce<{
+            [x: string]: string | number;
+            [x: number]: string | number;
+        }>((acc, [k, v]) => {
+            acc[k] = v;
+            return acc;
+        }, {});
     }

React Native環境で利用できるJavaScript/npmの機能はできる限り試した

これはチーム開発関係なく、個人的な興味になるのですが、ブラウザ環境でもNode環境でもないReact Native環境でどの程度最新技術をサポートしているのか、限界はどこかということを知っておきたいと考え、色々な検証をしました。

TypeScriptのDecorators導入

JavaのAnnotationのようなものが使いたくなり、TypeScriptのDecoratorsを導入しました。API Clientで、pathを定義したり、HTTP MethodがGETなのかPOSTなのかを定義したりで、次のような感じで使っております。

export default class StableVersionRequestService {
    private requestBuilder: RequestBuilder;

    constructor(requestBuilder: RequestBuilder) {
        this.requestBuilder = requestBuilder;
    }

    @post
    @path('/path/to/check-api')
    checkVersion(parameters: ServiceRequest<Data>) {
// [...]

IoCコンテナ(スプラッシュ画面の例)

複数のReact Native動作環境に対応するのであれば、当然使えるモジュールも違ってくることがありうると思いました。実際、アプリのスプラッシュ画面表示は、Expoの"Managed workflow"と"Bare workflow"とでは、利用できるモジュール異なることもあります。そこで、I/Fと実装を紐付けるためにIoCコンテナを導入しました。

まず、実装すべき共通のインタフェースを定義します。

export default interface Splash {
    preventAutoHide(): void;
    hide(): void;
}
export const SplashSymbol = Symbol.for('Splash');

expo-starterでは、ExpoのSplashScreenが使えるので、次のように実装します。

import { SplashScreen } from 'expo';
import { Splash } from '@universal-webcreator/app';

export const splash: Splash = {
    preventAutoHide: () => SplashScreen.preventAutoHide(),
    hide: () => SplashScreen.hide(),
};

native-starterでは、react-native-splash-screen を使うように実装します。

import SplashScreen from 'react-native-splash-screen';
import { Splash } from '@universal-webcreator/app';

export const splash: Splash = {
    preventAutoHide: () => {
        // ネイティブ側のコード(Objective-C, Java)でスプラッシュを表示するため何もしない
    },
    hide: () => SplashScreen.hide(),
};

そして、IoC Containerを使い、I/Fと実装を紐づけます。

export const dependencies = new ContainerModule(bind => {
    // [...]
    bind<Splash>(SplashSymbol).toConstantValue(splash);
    // [...]
});

その他

その他、試して導入した機能は、主に次の通りです。

  • TypeScript 3.7
  • Redux/Redux-Saga
  • React Hooks
  • TypeScript Compiler API
  • TypeScript Generics

React Native開発の一年

この1年でReact Nativeならではの辛さや問題にも遭遇しました。

React Native環境構築 (2019年3月)

他のタスクがひと段落したため、本格的に環境構築を開始しました。公式ドキュメントを参考にしながら、ほとんどつまづくことなく開発することができました。Jestを使ったスナップショットテストを導入したり、CircleCIでlintやスペルチェックを回して、少しづつ環境を整えていました。

この時に使用した技術は次の通りです。

  • TypeScript v3.3.3333
  • Expo v32.0.5
  • Node v11.10.1
  • Jest v24.1.0

WebViewベースのアプリを開発するのに致命的な不具合を発見(2019年3月後半)

初回起動画面の実装やある程度開発環境も整え、要のWebViewを使った実装を行っていこうと考え、React Native本体のWebViewコンポーネントを使って動作確認しました。予想に反して、このコンポーネントには深刻な不具合があり、それにより、プロジェクトの存続すら怪しくなっていました。

完成度が低いReact Native本体のコンポーネントが、Communityに移管され難を逃れる

開発開始直後は、React Native関連の状況を理解していなかったのですが、2018年のLean Coreという提案により、WebViewの開発は、React Native Communityに移管されることに決定していたようです。

今のReact Native動作環境は、Expoの"Managed workflow"を利用しているため、すぐには移管されたWebViewは使えません。そのため、この環境を諦めるかどうかの選択に迫られました。そんな時、Expo DevelopersのSlackにJoinしてみると、次のExpoのアップデートで、React Native Community版のWebViewに差し替えるという話があったため、他の開発をしながら、リリースを待つことにしました。

Expoのアップデートを待つ間に、他の機能を開発(2019年4月-2019年6月)

要のWebViewは不具合で開発を進められなかったため、他の機能を開発していくことにしました。待っている間に実装していた昨日は次の通りです。

  • IoCコンテナの導入
  • Core/Platformモデル・ユースケースの定義
  • Redux/Redux-Saga導入
  • CI用Node環境の整備
  • APIクライアント実装
  • 文言の管理のため、i18next導入
  • TypeScriptのpathsによるモジュールパス解決に試行錯誤

一番最後のモジュールパス解決は試行錯誤していましたが、調べてみても、React Nativeのバージョンが古い時の方法であったり、パス解決の仕組みがよく分かっておらず、この時は断念しました。後日、最終的にMetroやBabelのモジュールパス解決の方法を組み合わせることで解決することができ、monorepo構成に移行するまでうまく動作しておりました。

Expo v33リリース!(2019年6月)

待てど暮らせど次期バージョンアップデートのアナウンスがなく、Expo DevelopersのSlackを定期的に覗く日々でした。様々な人が次のリリースはいつ?のような発言をしていた印象です。

いつものようにSlackをのぞいていると、いくつかのIssueを片付けたらリリースできるよと発言がありました。GitHubのリポジトリを確認してみると、すでにExpo v33があり、インストール可能な状態になっていました。

公式アナウンスを待ちきれず試してみると、普通に動くWebViewと対面することができました。

Expov33

未知との遭遇(2019年6月)

基本的には動作していたWebViewですが、2つほど問題がありました。

  • target=_blank の挙動に準拠せず、勝手に同一WebViewで開いてしまう。
  • React Native側からWebViewでJavaScriptを実行しても実行結果を受け取れない。

これらの挙動のため、HTMLの何処に target=_blank のリンクが存在するか自分で確かめなければなりません。WebViewのJavaScript環境は、ユーザーの端末に依存するため、出来るだけレガシーなJavaScriptを書いてWebViewで動くようにしました。(ブラウザをアップデートしていないユーザーは古い機能しか利用できない)。また、BASEの管理画面は、Vue + CakePHPで構築されており、機能内のRoutingはVue Routerによる画面遷移で react-native-webview では検知できません。

そこで、JavaScript : コールバックがないならDOMの変更を監視するを参考に、MutationObserverを使ったDOM変更監視で、Vue Routerでの画面遷移時に、target=_blank のリンク取得するようにしました。

目的のリンクは取得できるようにはなりました。しかし、WebViewでJavaScriptを実行しても、実行結果を返すことはありません。一応window.ReactNativeWebView.postMessageを使うことで解決が可能です。しかし、window.ReactNativeWebViewとなっているので、window オブジェクトにいつこれが設定されるかが問題で、ユーザーがリンクをタップするのが早いか、React Native側に実行結果を送信するのが早いかわからないという問題には、完全に対処できませんでした。

Expoの"Managed workflow"と"Bare worflow"を共存させる試みの失敗(2019年6月-7月)

結論から言うとこの試みは失敗しました。最初次の構成にすればうまくいくと考えました。

  • 共通の部分は"Managed workflow"ベースで実装
  • それぞれの実行環境で必要な部分は共通のinterfaceを定義し、それに準拠させる実装をする
  • 上の定義と実装を、ejsのテンプレートにし、コードジェネレーターやスクリプトで切り替える

しばらく進めてみたのですが、ふと冷静に考えてみて、今後のReact Nativeのアップデートに追従していくためには、こんな複雑な仕組みメンテナンスできないと思い、考え直すことにしました。

Code Generator

monorepo構成の導入(2019年7月)

しばらく色々試して迷走していましたが、1つのプロジェクトで複数のモジュールを管理できるような仕組みがあれば良いのではと考え、調査したところmonorepoについて知りました。monorepoを実現するツールの一つであるLernaを導入し、試してみました。この構成は現在も使っており導入してよかったと思いました。

実行環境の共存も、エントリポイントとなるモジュールを分割することにより別々に実行できるようになりました。

実行環境共通で使えないモジュールの解決は、共通interfaceを定義しそれぞれの実行環境ごとに実装したものを、IoCコンテナを通じてアクセスすることによって利用できるようになりました。この辺は前述のIoCコンテナ(スプラッシュ画面の例)に例を載せています

monorepo構成への完全移行とネイティブIDE/SDKの設定(2019年7月-8月)

monorepoへ移行してからも、TypeScriptのpathsを使ったモジュール解決を使っていましたが、エディター上でモジュールをインポートしようとすると、ローカルPCのフルパスでインポートしたり、importの重複が発生したり何かと問題が発生していました。

pathsを使ってはいたのですが、そもそも機能ごとにグループ化し擬似モジュールのように扱っていたに過ぎません。モジュールのように扱っていたのであれば、monorepoの中でモジュールとして分割した方が管理しやすくなると考え、pathsで管理していたディレクトリーをそのままモジュールとして切り出すことにしました。これによってモジュールのインポート問題は解決することができました。

monorepoでJavaScriptレイヤーの問題は解決することはできましたが、ネイティブ側の設定はnpmのエコシステムに自動的に対応している訳ではありません。最終的に解決はできましたが、様々な問題が発生しました。それに関しては長くなるためここでは省略します。

毎回アプリを起動してUI実装するの面倒なためStorybookを導入(2019年8月-9月)

基本機能はほぼ出来上がっていましたが、次はUIの微調整が待っていました。毎回アプリを削除して、目的の画面まで遷移して特定の状態にして確認という作業を、繰り返していたため、フラストレーションが溜まっていました。

そこで、UIの状態を固定で確認できれば、アプリの実行環境で確認しなくてもいいのでは?と考えStoryboardを導入しました。結果的に、ブラウザ上で素早く確認できるようになりました。ちなみに、Storybook for React Nativeは使っておらず、Storybook for Reactをベースに react-native-web を利用しました。

リリースビルドをCircleCIで成功させるのに四苦八苦(2019年9月-11月)

アプリのテスト配信には、MicrosoftのApp Centerを選びました。React Nativeアプリの場合、CodePushを使えるのが採用した大きな理由です。

さて、配信サービスが決まったので、CircleCIでリリースビルドを作成してみるとなかなか上手くいきません。しかも一回のビルド時間が40分以上かかり確認にも時間がかかります。ここをクリアしないとリリースもできません。絶対にローカルPCでリリースビルドを作るという作業はしたくありません。

この作業では、次のような問題が発生しました。

  • Androidビルドで、Javaメモリーエラーが発生する
  • ビルドは作成できたが、署名が異なるため、端末にインストールできない
  • アイコンフォント対応ライブラリで、リリースビルドのみアイコンが表示されない
  • ビルド時間が長い

またこの時期、iOS 13へのアップデート対応で、他アプリの対応もしなくてはならず、なかなか時間が取れませんでしたが、一つ一つ解決していきました。

QA(2019年11月後半)

iOS 13へのアップデートもひと段落し、やっとリリース準備に漕ぎ着けました。- Done is better than perfect - のようにまずは終わらせろの気持ちでいた私ですが、全体を通しての動作確認はしておりません。そう、QAをしなければなりません。

QAフェースでも色々問題が発生しました。これは主にAndroidの知識不足が原因でのことです。

  • Androidのバックボタンでホームに戻って、アプリを再度開くと、React Nativeのコンポーネントが再生成されるのに、UI周りの再初期化できてない
  • 不要な権限をAndroidManifestで消せてない

また、QAを行なっていると、利用しているライブラリに関しても色々と気づくこともあり、修正Pull-Requestを送ったりしました。

リリース(2019年11月末)

まだまだ、改善するところはあったのですが、紆余曲折を経て、リリースすることができました。

2019/11/27 Play StoreでAndroid版リリース 2019/12/2 App StoreでiOS版リリース

リリース後の色々(2019年12月-)

リリース後、Play StoreやApp Storeに再申請するほどの深刻な不具合が発生しなかったことにまずは安心しました。ただし、ユーザーに影響のあるものや運用開発環境の考慮漏れなど、いくつかの問題は発生してしまいました。確認不足やAndroidの知識不足に起因するものが多かったです。いくつか上げると次のようなことです。

  • 古いOSで動作しないJavaScriptの機能を使ってしまっていた
  • Android Crashlyticsの設定ミス
  • Android CodePushの設定ミス

まとめ

今回のプロジェクトでは、本当に多種多様な技術に触れ、大変なことも多かったのですが、様々な人の助言もありなんとか終わらせることができました。また、長期的な運用やチームでの開発を考えた実装を深く考えられた良い機会でした。React Nativeだからネイティブのことを考えなくてよい訳ではなく、ネイティブ起因の様々な問題が発生しました。その時には同じチームのAndroidエンジニアにも度々助けられました。

今、React NativeやExpoを取り巻く環境は、2019年の一年間で大幅に変化しそして改善しています。先日、Shopifyの記事にReact Nativeのことが書かれていました。2020年の今なら、React Nativeを採用するのも悪くない選択肢かなと思います。