Help us understand the problem. What is going on with this article?

【2020年版】 React NativeでiOSアプリを作ってストアでリリースしてみた

この記事はReact Native Advent Calendar 2020の16日目の記事です。

以前に【2018年版】React NativeでiOSアプリを作ってストアでリリースしてみた という記事を書きました。
今年は小規模なメモアプリを開発してリリースしたので、その際に全体的にどのような技術を使ったのかについて書きます。
今回はネイティブモジュールやネイティブUIコンポーネントも開発してみました。

記事執筆時点のReact Native の最新バージョンは0.63.4 です。

作ったアプリ

シンプルなiOS アプリです。

  • メモが作成できる
  • メモをタグで管理できる
  • ダークモード対応
  • 多言語対応 (日本語・英語)

ちなみにMacOS 版とWEB 版も途中まで開発しましたが、未リリースです。

全体感

開発方針

当初はクロスプラットフォーム開発を想定していたのですが、途中から仕様変更を重ね続けた末にiOS アプリ中心の開発に変更しました (受託案件ではないので、自分の興味を元にして決めました) 。

  • TypeScript を使う
  • Function Component, React Hooks API を積極的に使う
  • ネイティブモジュールを作り、使う (Android 版の開発は一旦考えない)
  • Expo を使わない (今回要件では使えない) が、react-native-unimodules は必要に応じて使う
  • UI コンポーネントの開発では react-native-elements が使える場面では使う

ディレクトリ構成

├── __tests__
├── android
├── app.json
├── assets
├── babel.config.js
├── index.js
├── ios
├── metro.config.js
├── node_modules
├── package-lock.json
├── package.json
├── src
├── translations
├── yarn-error.log
└── yarn.lock

プロジェクトの第一階層のディレクトリ・ファイル一覧です。公開されている他のプロジェクトを参考にしつつ、今回は src にソースコードを、 assets に画像ファイルを、 translations に翻訳ファイルを置きました。

ファイル数

src
├── App.tsx
├── Const.ts
├── components
├── models
├── screens
└── utils

コンポーネントファイルが13、モデルファイルが4、画面ファイルが7、ユーティリティファイルが11あるだけです。
なお今回はネイティブ側の実装もあり、他に ios ディレクトリに13ファイルあります。

package.json

シンプルなアプリなのでライブラリも少ないです。主要なものだけ載せます。

- @react-native-community/async-storage
- @react-native-firebase/analytics
- @react-native-firebase/app
- @react-native-firebase/auth
- @react-native-firebase/firestore
- @react-navigation/drawer
- @react-navigation/native
- @react-navigation/stack
- @sentry/react-native
- @types/react-native
- expo-local-authentication
- i18n-js
- react
- react-native
- react-native-dark-mode
- react-native-device-info
- react-native-elements
- react-native-gesture-handler
- react-native-localize
- react-native-markdown-display
- react-native-reanimated
- react-native-safe-area-context
- react-native-screens
- react-native-splash-screen
- react-native-swipe-list-view
- react-native-unimodules
- react-native-vector-icons
- react-native-webview
- @typescript-eslint/parser
- @react-native-community/eslint-config
- eslint
- eslint-plugin-react-hooks
- metro-react-native-babel-preset
- typescript

開発のメモ

React Native のバージョン

v0.62.2 で開発を始め、Xcode 12 への対応が必要になったタイミングでv0.63.3 までアップグレードしました。
Upgrade React Native applications を参考にして地道にやりました。小規模なアプリなので作業は大変ではなかったです。

バージョンアップ作業をまとめてやるのはプロジェクトによっては大変です。年1回のOS のメジャーアップデートのタイミングの少し前くらいに、最新近くのReact Native バージョンまで上げておくとスムーズに対応できます。
今回はiOS 14 に本格対応するためにXcode 12 が必要で、Xcode 12 で起こる問題を解消するためにReact Native のバージョンアップをするか、パッチを当てるという対応が必要でした。
【React Native】 Xcode12でビルドするとiOS14で画像が表示されない問題に対処する - Qiita

リリース

シンプルにXcode でArchive してApp Store Connect にアップロードする方法で特に問題なくリリースできました。
今回はCI は使っていません。リリース頻度が低いので気にしなかったのですが、リリースビルドでアプリを作成してアップロードをする作業を自動化するためにFastlane くらい入れておいてもいいかもと思いました。

クラッシュレポーティング

Sentry for React Native を利用しました。ネイティブ層のクラッシュだけでなく、JavaScript 層の例外も捕捉してくれるので便利です。

ダークモード対応

codemotionapps/react-native-dark-mode: Detect dark mode in React Native を利用しました。
ただし記事執筆時点でdeprecated になっています。
(RN 界隈はこういう変化が速い印象です)

代替として以下のライブラリが紹介されています。
codemotionapps/react-native-dynamic: Helper APIs to work with dark mode in React Native

多言語対応

react-native-localizei18n-js を使いつつ、 useTranslate なるカスタムフックを自作しました。
(最近主流のやり方があったら知りたいです)

画面遷移

React Navigation | React Navigation v5 を利用しました。
v4 と react-navigation-hooks を使う手もありますが、新しく作るアプリならv5 にすると開発しやすいと感じました。

例えば、従来のstatic navigationOptions よりも直感的にnavigation option が設定できるようになっています。params を触らずに、props, state, context に基づいて変更ができます。

  useLayoutEffect(() => {
    navigation.setOptions({
      animationEnabled: navAnimationEnabled,
      headerBackTitle: ' ',
      headerTintColor: isDarkMode ? Color.keyDark : Color.keyLight,
    });
  }, [navigation, navAnimationEnabled, isDarkMode]);

UI コンポーネント設計

基本的にFunction Component + React Hooks で実装するようにしました。
Context / Provider パターンやカスタムフックは使いました。アニメーションも公式ドキュメントを参考にして実装できました。

UI コンポーネント

React Native 純正のコンポーネントの他には、シンプルで導入しやすいReact Native Elements | React Native Elements を利用しました。
実際には Text, Button, ButtonGroup, SearchBar, Icon, colors などをimport しました。

アイコンにはoblador/react-native-vector-icons: Customizable Icons for React Native with support for NavBar/TabBar, image source and full styling. を利用しました。

アプリ起動直後の画面表示

crazycodeboy/react-native-splash-screen: A splash screen for react-native, hide when application loaded ,it works on iOS and Android. を使いました。
アプリ起動直後に LaunchScreen.storyboard の内容を表示し、任意のタイミングで非表示にするという事が出来ます。

// ios/AppDelegate.m
#import "RNSplashScreen.h"

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    [RNSplashScreen show];
// src/App.tsx
import SplashScreen from 'react-native-splash-screen';

const App: React.FC = () => {
  useEffect(() => {
    // TODO: your logic
    SplashScreen.hide();
  }, []);

これはダークモードに対応した LaunchScreen.storyboard を活用したい時に便利そうです。

また、JavaScript (React Native側) のコードで最初に表示する画面の表示は画像の読み込みが出来てから行いたい、という場面でも重宝しました。具体的には、アプリが起動したのに特定の画像だけがなかなか読み込まれず、一連のアニメーションが破綻してしまうという場面です。
Image コンポーネントや、 (今回使っていませんが) ライブラリのFastImage コンポーネントには onLoadEnd のようなprop があり、画像の読み込みが完了した後の処理を記述することができます。

<Image
  onLoadEnd={() => SplashScreen.hide()}

広告表示

最終的には実装しなかったのですが、sbugert/react-native-admob: A react-native component for Google AdMob banners というライブラリを使うコードを書きました。
バナーだけでなくインタースティシャルやリワード動画にも対応しています。

フィードバック

ユーザーに匿名で投稿をしてもらうための機能です。今回はCloud Firestore | React Native FirebaseAuthentication | React Native Firebase を使って実装しました。

匿名ユーザー認証のコードはこんな感じのカスタムフックにしました。

import {useState, useEffect} from 'react';

import auth from '@react-native-firebase/auth';

export const useSignInAnonymousUser = () => {
  const [user, setUser] = useState(null);
  const [isError, setIsError] = useState(false);

  useEffect(() => {
    (async () => {
      try {
        const credential = await auth().signInAnonymously();
        setUser(credential.user);
      } catch (error) {
        console.log(error.message);
        setIsError(true);
      }
    })();
  }, []);

  return [user, isError];
};

スワイプできるリストアイテムコンポーネント

横にスワイプすると、編集ボタンやら削除ボタンが出現するリストアイテムコンポーネントの実装には、jemise111/react-native-swipe-list-view: A React Native ListView component with rows that swipe open and closed を利用しました。

マークダウンプレビュー表示

いくつか候補となるライブラリが見つかります。
今回はiamacup/react-native-markdown-display: React Native 100% compatible CommonMark renderer を利用しました。
見栄えはカスタマイズできるものの、いい感じにするのが大変という印象でした。

顔認証・指紋認証

iOS のデバイスによって搭載されている、Face ID やTouch ID による認証を行う機能です。
LocalAuthentication - Expo Documentation (expo-local-authentication) を使って実現しました。

このライブラリの注意点として、error の内容によって適切な処理を実装する必要があります。 エラーの定義はexpo-local-authentication のコード に書かれています。

import * as LocalAuthentication from 'expo-local-authentication';

try {
  const results = await LocalAuthentication.authenticateAsync();
  if (results.success) {
    // 認証成功
  } else {
    const error: string = (results as any).error;
    if (error === 'user_cancel') {
      return;
    }
    if (error === 'passcode_not_set') {
      alert('OSの設定でパスコードを登録してください');
      return;
    } 

react-native-unimodules について

React Native プロジェクトを開始する時にExpo を選択しなかった場合でも、 react-native-unimodules を導入することでExpo のモジュールが使えるようになります。
Installing react-native-unimodules - Expo Documentation
このアプリでは未導入ですが、Sign in with Apple の対応や、セキュアストレージの対応などでExpo のモジュールを利用したことがあります。分野によってはOSS ライブラリがほとんど存在しないことがあり、React Native アプリを開発していたらどこかでunimodules のお世話になるのではと思います。

デバイス種類判定 (ネイティブモジュール)

react-native-device-info パッケージあたりに関数がありそうな気がしますが、今回は動作するネイティブモジュールを1つ自分で作っておきたかったので、ノッチあり端末かどうかを判定するためのネイティブモジュールを作成しました。

// src/utils/Device.ts
import {useState} from 'react';
import {Dimensions, Platform, NativeModules} from 'react-native';
const nativeDevice = NativeModules.Device;
const {width, height} = Dimensions.get('window');

export const useHasNotch = () => {
  const [hasNotch, setHasNotch] = useState(true);
  nativeDevice.hasNotch((result: boolean) => {
    setHasNotch(result);
  });
  return hasNotch;
};

ネイティブ側のObjective-C とSwift のコードはこんな感じになりました。数値のあたりは定数を使うなどしてもう少しちゃんと書けると思います...

// ios/DeviceBridge.m
#import <Foundation/Foundation.h>
#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(Device, NSObject)

RCT_EXTERN_METHOD(hasNotch:(RCTResponseSenderBlock)callback)

+ (BOOL)requiresMainQueueSetup
{
  return NO;
}

@end
// ios/Device.swift
import UIKit

@objc(Device)
class Device: NSObject {
  @objc static func requiresMainQueueSetup() -> Bool {
    return true
  }

  @objc
  func hasNotch(_ callback: @escaping RCTResponseSenderBlock) {
    DispatchQueue.main.async {
      guard let rootVC = UIApplication.shared.delegate?.window??.rootViewController else {
        callback([false])
        return
      }
      if #available(iOS 11.0, *) {
        let hasSafeAreaInsetsTop = 20 < rootVC.view.safeAreaInsets.top
        callback([hasSafeAreaInsetsTop])
      } else {
        callback([false])
      }
    }
  }
}

このようなネイティブモジュールの作り方は、公式ドキュメントに記載されています。
iOS Native Modules · React Native

データ永続化 (ネイティブモジュールあり)

クライアント・アプリ側での検索機能などを付けようとすると、DB やデータ管理のためのフレームワークを使いたくなるはずです。今回はメインのデータ管理にRealm Cloud と Cloud FireStore を検討したうえで、最終的にCoreData を選択しました。
React Native で広く使われるアプリを作るならCoreData はまず選択肢に上がらないと思うので、実装のコードは割愛します。

確かめられたこととしては、ネイティブブリッジのコードを地道に書くことで、React Native アプリからもCoreData の機能を使うことができました。 (非常に面倒だということが分かったので、強い理由が無い限りはおすすめはしません)

なお、設定値のような単純な用途のデータはreact-native-async-storage/async-storage: An asynchronous, persistent, key-value storage system for React Native. で永続化するようにしました。
データの内容によってはSecureStore - Expo Documentation (expo-secure-store) や oblador/react-native-keychain: Keychain Access for React Native も候補になるでしょう。

テキストビュー (ネイティブUI コンポーネント)

React Native のTextInput コンポーネントではフォーカスなどの細かい挙動がどうしても意図通りにならなかったので、複数行テキストを扱うUI コンポーネントを自作しました。
今回はネイティブ側コンポーネントのフォーカス状態を直接操作するための nativeFocusnativeBlur というprop を持たせました。(他にテキスト値を扱う実装も必要ですが、長くなるので割愛します)

// src/components/TextView.tsx
import React from 'react';
import {requireNativeComponent} from 'react-native';
const NativeTextView = requireNativeComponent('NativeTextView');

export class TextView extends React.Component<Props> {
  render() {
    const {onFocus, onBlur, nativeFocus, nativeBlur} = this.props;
    return (
      <NativeTextView
        onFocus={onFocus}
        onBlur={onBlur}
        nativeFocus={nativeFocus}
        nativeBlur={nativeBlur}
        {...this.props}
      />
    );
  }
}

ここからはObjective-C です。
実際には他にもプロパティやデリゲートメソッドを追加したので、コード量はもう少し多いです。

// ios/NativeTextView.h
#import <UIKit/UITextView.h>
#import <React/RCTComponent.h>

@interface NativeTextView : UITextView

@property(nonatomic, copy) RCTBubblingEventBlock onFocus;
@property(nonatomic, copy) RCTBubblingEventBlock onBlur;

@end
// ios/NativeTextView.m
#import "NativeTextView.h"

@implementation NativeTextView

@end
// ios/NativeTextViewManager.h
#import <React/RCTViewManager.h>
#import "NativeTextView.h"
#import "RCTConvert+TextView.h"

@interface NativeTextViewManager : RCTViewManager <UITextViewDelegate>

@end
// ios/NativeTextViewManager.m
#import "NativeTextViewManager.h"

@implementation NativeTextViewManager

RCT_EXPORT_MODULE(NativeTextView)
RCT_EXPORT_VIEW_PROPERTY(onFocus, RCTBubblingEventBlock)
RCT_EXPORT_VIEW_PROPERTY(onBlur, RCTBubblingEventBlock)
RCT_CUSTOM_VIEW_PROPERTY(nativeFocus, APPResignFirstResponder, UITextView)
{
  [view becomeFirstResponder];
}
RCT_CUSTOM_VIEW_PROPERTY(nativeBlur, APPResignFirstResponder, UITextView)
{
  [view endEditing:YES];
}

- (UIView *)view
{
  NativeTextView *textView = [[NativeTextView alloc] init];
  textView.delegate = self;
  return textView;
}

@end

フォーカス・ブラーに関しては、これで細かく制御する足がかりが出来ました。

以下はJS 側でコンポーネントをrender するためのコードです。 nativeFocusnativeBlur にstate の値を渡して使っています。

<TextView
  onFocus={onFocus}
  onBlur={onBlur}
  nativeFocus={nativeFocus}
  nativeBlur={nativeBlur}

このようなネイティブUI コンポーネントの作り方は、公式ドキュメントに記載されています。
iOS Native UI Components · React Native

ネイティブモジュールもネイティブUI コンポーネントも、普段書かないコードが必要で最初の1つを作るのに時間がかかりました。
また運用面では、ネイティブ側の仕様が変わったりReact Native の実装が変わったりした時の対応が大変なのではという予感がしますね。

今回は実装したものの細かいところで挙動が意図通りにならない部分が残ってしまい、改修を試みるか、React Native の方でいい感じにコンポーネント修正・追加などがないか今後チェックするということになりそうです... (RN がネイティブのUI をどのように構築して描画するのかを理解する必要がありそう)

MacOS 版の開発について

React Native for Windows + macOS · Build native Windows & macOS apps with Javascript and React が発表されていました。これでmacOS アプリが作れるのでは?ということで試してみました。
iOS アプリ版から機能を削ったものを開発し、macOS 上でアプリとして動くことが確認できました。

開発中にはインストール・ビルドが上手くいかないライブラリが多く、ライブラリを使わずに開発しないといけないことが頻発しました。積極的に開発に使いたいという印象はこの段階では持てませんでした...
ちなみにmacOS 版の package.jsondependencies はこれだけになりました。

- @react-native-community/async-storage
- i18-js
- react
- react-native
- react-native-macos
- react-native-swipe-list-view

firebase 系がビルドできず、当然 @react-native-firebase/firestore が使えませんでした。また、react-navigation が使えないので、画面遷移設計はやり直す必要がありました。react-native-vector-icons は対応していそうな雰囲気だったのですが上手く導入できずでしたつらい...

※試したのは公開された直後のタイミングだったので、今では状況が変わっているのではと思います

余談ですが、既に発売されているApple Silicon (M1プロセッサ) 搭載Mac ではiOS アプリが動作するようになっており、macOS アプリの開発ニーズがこれからどう変化するのか気になりますね。

さいごに

自分のプロジェクトとして小規模なiOS アプリをReact Native で作ってみました。
翻訳のあたりはベストプラクティスが分かっていないので、よさげな仕組みがあったら知りたいです。
今回はちょっと極端な仕様にしましたが、ネイティブUI を開発するあたりでクロスプラットフォーム開発からネイティブ開発に切り替えるべきかどうか悩みました。 UI の開発に関しては、SwiftUI が登場してからネイティブ開発とReact Native 開発との垣根が下がったように感じています。開発途中でRN からネイティブに切り替える、もしくはネイティブからRN に切り替えるという事例も出てくるかもしれませんね。

17日目は @gaishimo さんの M1 Mac上でReact Nativeアプリを実行・検証してみる という記事です!

ariiyu
Woz Inc. 代表・開発者 / キッチハイク ASSOCIATE / React Native / SwiftUI
https://woz-inc.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away