React NativeでiOSアプリを作ってストアでリリースしてみた

  • 125
    いいね
  • 2
    コメント

はじめに

React Nativeでの開発が全体的にどんな感じか掴みたかったので、小さなアプリを開発し、App Storeでリリースしてみました。
プロジェクト作成からストアでのリリースまでの間、開発中に出てきたテーマを振り返ってみます。(React Nativeの概要や特徴の解説については、他の方の記事をご覧ください。)
今回の一連の開発を通して、現段階ではネイティブ側(Xcode)を触らないといけない場面がちらほらあるということが分かりました。

※記事執筆時点のReact Nativeのバージョンは0.43です
※筆者はiOSアプリの開発経験者です(Swift, Objective-C)

アプリの主な仕様

非常にシンプルなアプリです。

  • 画面は3つ(メイン画面、タイマー設定画面、設定画面)
  • バンドルした音源の再生機能
  • ヘッドフォンプラグ関連の制御 -> React Nativeとネイティブの連携
  • タイマー機能(時間が来たら音源の再生を止める)
  • SNSに投稿
  • 開発者ページを開く
  • アプリレビュー依頼 -> ※結局ネイティブ側で実装
  • 多言語表示(英・日)

以下の機能は付けませんでした。

  • push-popによる画面遷移(今回は単純なモーダル画面表示のみ)
  • 通信(外部から情報をfetchするなど)

やったこと

プロジェクトの作成

公式のGetting Startedの手順に従い、アプリを作成しました。

brew install node
brew install watchman
npm install -g react-native-cli
react-native init ProjectName
cd ProjectName

モジュール

利用したモジュールは以下。

index.ios.js
import React, { Component } from 'react';

import {
  AppRegistry,
  StyleSheet,
  Text,
  View,
  Modal,
  Image,
  TouchableHighlight,
  TouchableOpacity,
  TouchableWithoutFeedback,
  findNodeHandle,
  Animated,
  Easing,
  Picker,
  Linking,
} from 'react-native';

import { NativeEventEmitter, NativeModules } from 'react-native';

import RNSimpleShare from 'react-native-simple-share';

import MusicControl from 'react-native-music-control';
import resolveAssetSource from 'react-native/Libraries/Image/resolveAssetSource';

import I18n from 'react-native-i18n'

ビューの構築

公式のStyleStyleSheetを参照。HTMLっぽく書いていくことができました。
flexbox、StyleSheet.absoluteFillなどを使いました。

画面遷移

モーダル

公式のModalに従って実装しました。animationTypeを指定することで、iOS標準っぽい動きも実装できました。

ステータスバー

今回は画面毎のステータスバーの動的制御が無かったので、Xcode側で設定しました。
公式ドキュメントはStatusBarを参照。

画像表示

公式のImagesを参考に、画像ファイルはトップディレクトリのimgに置きました。

ボタン画像

各画像について、@2x.png@3x.pngのファイルを用意しました。React NativeでシングルベクターのPDFを扱う方法は分からず。
なおボタンについては、公式のHandling TouchesではTouchableHighlightが最初に紹介されていますが、TouchableOpacityだとタッチした際の挙動がUIButtonっぽくなりました。

メイン画面の背景画像

resizeModeはstyleの外に書くので注意。Imageを参照。

index.ios.js
<Image source={require('./img/image.jpg')} resizeMode='cover' style={{flex: 1, marginTop: '-90%'}} />

再生ボタン・一時停止ボタンの画像

今回は再生・一時停止ボタンを1つのコンポーネントとして実装しました。

index.ios.js
var icon = this.props.isPlaying ? require('./img/pause.png') : require('./img/play.png');
<Image source={icon} />

// 以下はBAD
// var icon = this.props.isPlaying ? 'pause' : 'play';
// <Image source={require('./' + icon + '.png')} />

アプリアイコン

Xcode側で設定しました。

Launch Screen(スプラッシュ画面)

Xcode側で用意しました。

タイマー機能

picker

公式のPickerを利用しました。PickerIOSも用意されています(PickerAndroidはなし)。

index.ios.js
<PickerTimer ref="pickerTimer" styles={styles.picker}/>
index.ios.js

const styles = StyleSheet.create({
  pickerItem: {color: 'white'},
});
index.ios.js
class PickerTimer extends Component {
  static title = '<Picker>';
  static description = 'Provides multiple options to choose from, using either a dropdown menu or a dialog.';

  state = {
    selected: 'OFF',
  };

  render() {
    return (
          <Picker
            style={styles.picker}
            itemStyle={styles.pickerItem}
            selectedValue={this.state.selected}
            onValueChange={this.onValueChange.bind(this, 'selected')}>
            <Item label={I18n.t('OFF')} value='0' />
            <Item label={"5 " + I18n.t('mins')} value={(60 * 5).toString()} />
            // 中略
            <Item label={"9 " + I18n.t('hours')} value={(3600 * 9).toString()} />
          </Picker>
    );
  }

  onValueChange = (key: string, value: string) => {
    const newState = {};
    newState[key] = value;
    this.setState(newState);
  };
}

Timer制御

今回は簡易な実装なのでコードは割愛。
ネイティブ側との関係については、公式のTimersを参照。

残時間表示

stateが更新されたら、UIの表示も自動で更新されます。

index.ios.js
<Text style={styles.timerLabel}>{this.props.remainingTime}</Text>

アニメーション

公式のAnimationsによると、AnimatedLayoutAnimationという2つのAPIがあります。LayoutAnimationは、flexboxや、親子関係のある複数のコンポーネントを扱う際に便利なようです。
今回はAnimatedを使いました。

フェードアニメーション

ユーザーが画面のどこかをタップしたら、操作ボタンをフェードイン・フェードアウトさせます。

今回はstateのbuttonsFadeAnimにアニメーションの値を持たせます。

index.ios.js
state = {
  buttonsFadeAnim: new Animated.Value(1),
}

Animated.timingでbuttonsFadeAnimを操作します。

index.ios.js
  toggleButtonVisiblility(isButtonVisible) {
    if (isButtonVisible) {
      Animated.timing(this.state.buttonsFadeAnim, {toValue: 1, duration: 500, easing: Easing.ease}).start();
    } else {
      Animated.timing(this.state.buttonsFadeAnim, {toValue: 0, duration: 500, easing: Easing.ease}).start();
    }
  }

AnimatedによりbuttonsFadeAnimの値が更新されると、それがAnimated.Viewに伝播します。

index.ios.js
<Animated.View style={{opacity: this.props.buttonsFadeAnim}}>
  {this.props.children}
</Animated.View>

音源関連

音源再生

zmxv/react-native-soundというモジュールを利用しました。npmで簡単にインストールできます。
音源ファイルについてはXcode側でリソースとして登録する必要があります。

Sound.setCategoryでAVAudioSession categoryを設定できます。

index.ios.js
const Sound = require('react-native-sound');
index.ios.js
export default class ProjectName extends Component {
  constructor(props) {
    super(props);

    Sound.setCategory('Playback'); // Enable playback in silence mode (iOS only)
    const s = new Sound('file.wav', Sound.MAIN_BUNDLE, (e) => {
      this.playSoundBundle = () => {
          if (e) {
            console.log('error', e);
          } else {
            s.setNumberOfLoops(-1); // Loop indefinitely until stop() is called
            s.setSpeed(1);
            s.play();
          }
      };
      this.pauseSoundBundle = () => {
        if (e) {
          console.log('error', e);
        } else {
          s.pause();
        }
      };
    });
  }

Now playing Info表示

tanguyantoine/react-native-music-controlというモジュールを利用しました。
Issueに挙がっていますが、README通りのコードで画像が読み込めなかったので、resolveAssetSourceの記述を加えました。

index.ios.js
// import resolveAssetSource from 'react-native/Libraries/Image/resolveAssetSource';

  setNowPlaying() {
    MusicControl.setNowPlaying({
      title: 'Your Title',
      artwork: resolveAssetSource(require('./img/image.jpg')).uri, // URL or RN's image require()
      // artist: 'Michael Jackson',
      // album: 'Thriller',
      // genre: 'Post-disco, Rhythm and Blues, Funk, Dance-pop',
      // duration: 294, // (Seconds)
      description: '', // Android Only
      color: 0xFFFFFF, // Notification Color - Android Only
      // date: '1983-01-02T00:00:00Z', // Release Date (RFC 3339) - Android Only
      rating: 84 // Android Only (Boolean or Number depending on the type)
    })
  }

ヘッドフォンプラグ対応

ユーザーがヘッドフォンを抜いたら、音源再生を一時停止する機能。

公式のCommunication between native and React Nativeを参考に、React Nativeとネイティブとのブリッジを書きました。
NativeEventEmitterという仕組みを使うことで、ネイティブ側からの通知をReact Native側で購読し、通知を受け取ったら任意の処理を実行できます。

js.index.ios.js
import { NativeEventEmitter, NativeModules } from 'react-native';
const { EventEmitter } = NativeModules;
let eventEmitter = new NativeEventEmitter(EventEmitter);
let subscriptionHeadphone;
// 略
  componentWillUnmount() {
    subscriptionHeadphone.remove();
  }

  componentDidMount() {
    subscriptionHeadphone = eventEmitter.addListener('HeadphoneStatus', (result) => {
      if (this.state.isPlaying) {
        this.togglePlayPause(false);
      }
    });
  }

ネイティブからのイベントをReact Nativeに通知するには、Xcode側でRCTEventEmitterのサブクラスを記述します(以下、EventEmitter)。
ここでハマったのですが、EventEmitterは自分でインスタンス化してもnilになるようです。
今回は以下の情報を参考にして実装しました。

EventEmitterのクラスメソッドでNSNotificationを送信してEventEmitter自身に通知を行い、sendEventWithNameでReact Native側に任意の情報を送っています。

EventEmtter.h
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>

@interface EventEmitter : RCTEventEmitter <RCTBridgeModule>

+ (void)emitEventWithName:(NSString *)name andPayload:(NSDictionary *)payload;
+ 
@end
EventEmtter.m
#import "EventEmitter.h"

@implementation EventEmitter

RCT_EXPORT_MODULE();

- (NSArray<NSString *> *)supportedEvents
{
  return @[@"HeadphoneStatus"];
}

- (void)stopObserving
{
  [[NSNotificationCenter defaultCenter] removeObserver:self];
}

- (void)startObserving
{
  [[NSNotificationCenter defaultCenter] addObserver:self
                                           selector:@selector(emitEventInternal:)
                                               name:@"headphoneUnplugged"
                                             object:nil];
}

- (void)emitEventInternal:(NSNotification *)notification
{
  [self sendEventWithName:@"HeadphoneStatus" body:notification.userInfo];
}


+ (void)emitEventWithName:(NSString *)name andPayload:(NSDictionary *)payload
{
  [[NSNotificationCenter defaultCenter] postNotificationName:name
                                                      object:self
                                                    userInfo:payload];
}

@end

なお公式チュートリアル全般についてですが、ネイティブ部分のコードは基本的にObjective-Cで書かれています。今回はそれに合わせてObjective-Cを使いました。

バックグラウンド再生

Xcode側で設定しました。

SNSシェア

jasonnoahchoi/react-native-simple-shareというモジュールを利用しました。バグありかもしれませんが未調査です。。

index.ios.js
  shareOnSNS() {
    RNSimpleShare.share({
      title: I18n.t('appName'), // FIXME: 表示されず
      description: '#hashtag',  // FIXME: 表示されず
      url: I18n.t('appURL'),
      // imageUrl: 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png',
      // imageBase64: 'Raw base64 encoded image data',
      // image: '', //'Name of the image in the app bundle'
      // file: 'Path to file you want to share',
      // subject: 'subject',
      // excludedActivityTypes: [],
      anchor: findNodeHandle(this.refs.modalScreenSettings.refs.rowShare), // iPad only
    }, (failure) => { console.log(failure);
    }, (success) => { console.log(success);
    });
  }

App Storeレビュー依頼

Appiraterのラッパーモジュールはありましたが、上手く実装できなかったので結局ネイティブの方でAppiraterを入れました。
ちなみにiRateのラッパーモジュールはまだ無いようです。

App Storeページ表示

公式のLinkingを参照。

index.ios.js
  openOtherApps() {
    var link = developerURLIOS;
    Linking.canOpenURL(link).then(supported => {
     supported && Linking.openURL(link);
    }, (err) => console.log(err));
  }

多言語対応

UI

AlexanderZaytsev/react-native-i18nというモジュールを利用しました。

index.ios.js
import I18n from 'react-native-i18n'
I18n.fallbacks = true;
I18n.translations = {
  'en': {
    Share:  "Share",
    RateAndReview: "Rate, Review",
  },
  'ja-JP': {
    Share:  "シェア",
    RateAndReview: "評価・レビュー",
  }

I18n.jsと同様に、簡潔に書けます。

index.ios.js
<Text style={styles.menuRow}>{I18n.t('RateAndReview')}</Text>

アプリ表示名

今回はXcode側でInfo.plistの多言語対応を行いました。

ネイティブライブラリの文言

今回はAppiraterというライブラリを使いましたが、Xcode側で導入したので、Xcode側で多言語対応の設定を行いました。(npmでネイティブのライブラリを導入した場合は、多言語化はXcode側で行う?)

その他

アナリティクス・広告系では、idehub/react-native-google-analytics-bridgeという、JavaScriptを書くとネイティブのtrackerを叩いてくれるものや、sbugert/react-native-admobといったモジュールなどがあるようです。

iTunes Connect申請

リリースビルド作成

Xcode側で最初から用意されているスキームの設定を編集し、Run->Build ConfigurationをReleaseにしました(スキームを追加すると上手くいかず)。
後はいつも通り実機を接続して、Archiveを行い、iTunes Connectにアプリをアップロードしました。
なおRelease設定でビルドすると、アプリを実行した時にReact Nativeの開発者向け情報が表示されなくなります。

公式のRunning On Deviceでリリースビルドに関する記述がありますが、今回はreact-nativeのコマンドを使用せず、Xcodeでリリース関連の作業を行いました。

開発中に困ったところ

  • iOSシミュレータでCommand+Rが効かない
    Command+K でソフトウェアキーボードをトグルさせる必要がありました(ここでハマった方の記事をちらほら見かけます)。
    react native - Reload app in iOS simulator using Command-R not working - Stack Overflow

  • Unable to resolve moduleエラー
    公式チュートリアルで遭遇しました。何のモジュールだったか失念しましたが、Reactの方のバージョンを下げて対処しました。(今は大丈夫かも)
    Error loading up after upgrade to 0.43 · Issue #13314 · facebook/react-native

  • UIに関するネイティブAPI(でできる表現)を使いたいが...
    iOSネイティブのUIViewAnimationTransitionFlipUIMotionEffectっぽい動きを実装したかったのですが、React NativeのAPIやライブラリが見つけられなかったので、自前での実装を試みました。

所感

機能は大体実装できたし、思ったより早く作れた

開発中の多くの場面でアプリをビルドし直す必要がなく、開発中に待たされる時間がかなり減ったと感じました。UIを1pxずつ動かして調整したいシーン、JS部分のコードを少しだけ変えて試したいといった場面では恩恵が大きいです。アプリの要件次第では、フルネイティブ開発よりもReact Nativeで開発した方が早く作れる、ということもあるかもしれません。

ネイティブ開発に比べて資産がまだ少ない

React Nativeはライブラリが結構あるという印象ですが、意図通りに動かない機能があったり、本家のバージョンアップへの対応が間に合っていないものにいくつか遭遇しました。やりたいことによってはXcodeで設定を行ったり、Swift/Objective-Cでコードを書いたりする必要があります。

おわりに

今回は小規模なiOSアプリではありますが、React Nativeでプロジェクトを新規作成し、ストアでのリリースを行ってみました。
次はナビゲーションや通信機能をもったアプリを作ってみたいと思います。