はじめに
[React Native](React Native | A framework for building native apps using React)での開発が全体的にどんな感じか掴みたかったので、小さなアプリを開発し、App Storeでリリースしてみました。
プロジェクト作成からストアでのリリースまでの間、開発中に出てきたテーマを振り返ってみます。(React Nativeの概要や特徴の解説については、他の方の記事をご覧ください。)
今回の一連の開発を通して、現段階ではネイティブ側(Xcode)を触らないといけない場面がちらほらあるということが分かりました。
※記事執筆時点のReact Nativeのバージョンは0.43です
※2020年に別のアプリを作った記事を投稿しました【2020年版】 React NativeでiOSアプリを作ってストアでリリースしてみた - Qiita
※筆者はiOSアプリの開発経験者です(Swift, Objective-C)
ご注意
本記事の執筆時点に対して、最新のReact Nativeにおいては事情が変わっている可能性があります。
特定のトピックについては、React Nativeのドキュメントやバージョン履歴を参考にされることをおすすめします。
https://facebook.github.io/react-native/versions.html
アプリの主な仕様
非常にシンプルなアプリです。
- 画面は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
モジュール
利用したモジュールは以下。
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'
ビューの構築
公式のStyleやStyleSheetを参照。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を参照。
<Image source={require('./img/image.jpg')} resizeMode='cover' style={{flex: 1, marginTop: '-90%'}} />
再生ボタン・一時停止ボタンの画像
今回は再生・一時停止ボタンを1つのコンポーネントとして実装しました。
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はなし)。
<PickerTimer ref="pickerTimer" styles={styles.picker}/>
const styles = StyleSheet.create({
pickerItem: {color: 'white'},
});
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を参照。
【追記】
別の記事でライブラリを紹介しました。
[React Native] iOSアプリがバックグラウンドに入ってもタイマーを動かす - Qiita
残時間表示
stateが更新されたら、UIの表示も自動で更新されます。
<Text style={styles.timerLabel}>{this.props.remainingTime}</Text>
アニメーション
公式のAnimationsによると、Animated
とLayoutAnimation
という2つのAPIがあります。LayoutAnimation
は、flexboxや、親子関係のある複数のコンポーネントを扱う際に便利なようです。
今回はAnimated
を使いました。
フェードアニメーション
ユーザーが画面のどこかをタップしたら、操作ボタンをフェードイン・フェードアウトさせます。
今回はstateのbuttonsFadeAnim
にアニメーションの値を持たせます。
state = {
buttonsFadeAnim: new Animated.Value(1),
}
Animated.timingでbuttonsFadeAnim
を操作します。
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に伝播します。
<Animated.View style={{opacity: this.props.buttonsFadeAnim}}>
{this.props.children}
</Animated.View>
音源関連
音源再生
zmxv/react-native-soundというモジュールを利用しました。npmで簡単にインストールできます。
音源ファイルについてはXcode側でリソースとして登録する必要があります。
Sound.setCategory
でAVAudioSession categoryを設定できます。
const Sound = require('react-native-sound');
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
の記述を加えました。
// 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側で購読し、通知を受け取ったら任意の処理を実行できます。
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になるようです。
今回は以下の情報を参考にして実装しました。
- javascript - React-native Bridge is Nil when I call method from another method - Stack Overflow
- What is the method can be used to send an event from native module to JS? · Issue #8714 · facebook/react-native
EventEmitterのクラスメソッドでNSNotificationを送信してEventEmitter自身に通知を行い、sendEventWithName
でReact Native側に任意の情報を送っています。
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>
@interface EventEmitter : RCTEventEmitter <RCTBridgeModule>
+ (void)emitEventWithName:(NSString *)name andPayload:(NSDictionary *)payload;
+
@end
#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というモジュールを利用しました。バグありかもしれませんが未調査です。。
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を参照。
openOtherApps() {
var link = developerURLIOS;
Linking.canOpenURL(link).then(supported => {
supported && Linking.openURL(link);
}, (err) => console.log(err));
}
多言語対応
UI
AlexanderZaytsev/react-native-i18nというモジュールを利用しました。
import I18n from 'react-native-i18n'
I18n.fallbacks = true;
I18n.translations = {
'en': {
Share: "Share",
RateAndReview: "Rate, Review",
},
'ja-JP': {
Share: "シェア",
RateAndReview: "評価・レビュー",
}
I18n.jsと同様に、簡潔に書けます。
<Text style={styles.menuRow}>{I18n.t('RateAndReview')}</Text>
アプリ表示名
今回はXcode側でInfo.plistの多言語対応を行いました。
ネイティブライブラリの文言
今回は[Appirater](arashpayan/appirater: A utility that reminds your iPhone app's users to review the app.)というライブラリを使いましたが、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ネイティブのUIViewAnimationTransitionFlip
やUIMotionEffect
っぽい動きを実装したかったのですが、React NativeのAPIやライブラリが見つけられなかったので、自前での実装を試みました。
所感
機能は大体実装できたし、思ったより早く作れた
開発中の多くの場面でアプリをビルドし直す必要がなく、開発中に待たされる時間がかなり減ったと感じました。UIを1pxずつ動かして調整したいシーン、JS部分のコードを少しだけ変えて試したいといった場面では恩恵が大きいです。アプリの要件次第では、フルネイティブ開発よりもReact Nativeで開発した方が早く作れる、ということもあるかもしれません。
ネイティブ開発に比べて資産がまだ少ない
React Nativeはライブラリが結構あるという印象ですが、意図通りに動かない機能があったり、本家のバージョンアップへの対応が間に合っていないものにいくつか遭遇しました。やりたいことによってはXcodeで設定を行ったり、Swift/Objective-Cでコードを書いたりする必要があります。
おわりに
今回は小規模なiOSアプリではありますが、React Nativeでプロジェクトを新規作成し、ストアでのリリースを行ってみました。
次はナビゲーションや通信機能をもったアプリを作ってみたいと思います。