2週間半のReact Nativeアプリ開発を振り返る
React Native+Expoではじめるスマホアプリ開発 ~JavaScriptによるアプリ構築の実際~
- 作者: 松澤太郎
- 出版社/メーカー: マイナビ出版
- 発売日: 2018/08/29
- メディア: 単行本(ソフトカバー)
- この商品を含むブログを見る
ここ2週間ちょっとReact NativeでiOSアプリ書いてました。 サーバサイドをメインでやってきた自分にとって面白い技術で、今後も趣味で使ってみたいなと思えているのでTipsや所感を残しておきます。
目次
選定理由
3人でとあるアプリを開発しているのですが、自分も含めてサーバエンジニア2人、iOSエンジニア(Webフロントとかもできる)1人の状態でした。 開発期間が短いわりに画面数の多いアプリケーションの開発だったので、JS経験のある自分もネイティブに加われるようReact Nativeを検討。苦労しそうな部分は早い段階で、検証しておきました。
- カメラ撮影
- iOSライブラリとReact Nativeのブリッジが問題なく作れるか確認
- 画像のフィルタ処理(iOSのCIFilter)用ライブラリをReact Nativeから呼び出した
- 動画再生
- APIクライアント(fetch apiが使える)
- Firebase JavaScript SDK(Firebase Authenticationを使用)
開発を始めて強く思ったのは、やっぱりネイティブの知識があって気軽に質問できる人が近くにいないと大変だったなと思います。 幸いiOSに詳しいチームメイトがReact NativeとiOSのブリッジを書いてくれて助かった。
一方で、アプリケーションのほとんどの画面はAPIを叩いてレスポンスを表示するだけだったりサーバに何か文字列を送るだけだったり、意外とJavascriptの範囲で出来ることが多くてサーバサイドばかりやってきた自分もがっつりiOSのアプリ開発が手伝えます。
普段あんまりネイティブアプリケーションの実装とかを意識することはなかったんですが、スマホのアプリを立ち上げるとコンポーネントの組み方を考えるようになったり、細かいアニメーションに意識がいくようになりました。
Tips
エラーハンドリング・状態管理
React Nativeのコンポーネントや状態管理の設計は、ReactでSPAを作るのと感覚的にかなり似ているのかなと思っています。 今回は次のような手順で管理しました。
コンポーネントの状態を管理するStoreを用意。 このStoreでは、各ComponentIDに対して以下の4つの状態を持つフィールドとエラーメッセージを持つFieldが存在する。
BEFORE_LOADING
: データをフェッチする前(コンポーネント生成時に、この状態に設定するActionを発火)LOADING
: データをフェッチ中 (ぐるぐるのローディングアイコン表示)COMPLETE
: 無事にデータを読み込みできている状態ERROR
: データのフェッチ中に何かしらのエラーが発生している。コンポーネントはこの場合エラーを表示
各コンポーネントは次の手順で表示を変える。
- constructorで自分自身を示すIDを生成
- 各Componentに上のStoreの情報を伝搬させる
componentWillMount
で、自分自身のIDをstoreに登録(StoreではBEFORE_LOADING
にセット)componentDidMount
で、データのフェッチをするActionを発火。自分の状態をLOADING
に設定するActionも発火- 200番のレスポンスが帰ってきたら
COMPLETE
に設定するActionを発火
- 何か問題があったときは
ERROR
に設定するActionを発火- エラーメッセージを登録する
- エラー情報を表示するModalアニメーションを表示するActionを発火
- コンポーネントは自分自身の状態が
ERROR
ならエラーメッセージを表示、COMPLETE
なら受け取ったデータを表示
今回選んだ、エラーハンドリングや状態の管理方法はかなりうまく機能していた気がするので今後も積極的に使っていきたい。
入力欄をキーボードの出現に合わせて動かすアニメーション
書くか悩んだのですが、結構多くのアプリケーションで必要になる処理かなと思ったので載せておきます。 必要になった際は参考にしてください。
次のようにkeyboardの入力に応じて高さの変わるコンポーネントを用意
import React, { Component } from "react"; import { Animated, Keyboard } from 'react-native'; const defaultHeight = 48; export class AvoidableKeyboard extends Component { constructor(props, context) { super(props, context); this.state = { height: new Animated.Value(defaultHeight) }; this.keyboardWillShow = this.keyboardWillShow.bind(this); this.keyboardWillHide = this.keyboardWillHide.bind(this); } componentDidMount() { Keyboard.addListener("keyboardWillShow", (e) => this.keyboardWillShow(e)); Keyboard.addListener("keyboardWillHide", (e) => this.keyboardWillHide(e)); } keyboardWillShow(e) { Animated.timing(this.state.height, { toValue: e.endCoordinates ? e.endCoordinates.height : e.end.height, duration: e.duration }).start(); } keyboardWillHide(e) { Animated.timing(this.state.height, { toValue: defaultHeight, duration: e.duration }).start(); } render() { return ( <Animated.View style={{ flexBasis: this.state.height }}></Animated.View> ); } }
あとは flexGrow
と flexBasis
プロパティをうまく使って表示します。
(省略) export class CommentListComponent extends Component { render() { (中略) return ( <View style={{ flex: 1, backgroundColor: "#FFFFFF" }}> <ScrollView style={{ flexGrow: 2 }}> {comments.map((c) => <CommentItemComponent key={c.id} comment={c} />)} </ScrollView> <View style={{ flexBasis: 50, flexDirection: "row", alignItems: "center", borderBottomColor: "#ddd", borderTopColor: "#ddd", borderBottomWidth: 1, borderTopWidth: 1 }}> <TextInput style={{ height: 50, flexGrow: 1, fontSize: 15, paddingLeft: 10 }} onChangeText={(text) => this.setState({ text })} value={this.state.text} /> <TouchableWithoutFeedback onPress={this.postComment} disabled={this.state.text.length === 0}> <View> <Text style={[{ fontSize: 15, fontWeight: "bold", paddingRight: 10 }, postTextStyle]}>投稿する</Text> </View> </TouchableWithoutFeedback> </View> <KeyboardSpace /> </View> ) } }
今回使ったライブラリ
使ったライブラリも載せておきます。 ライブラリ選定の際に参考にどうぞ。
react-native-focus-scroll
ScrollViewの中で今ディスプレイに表示されているアイテムを知る方法がなかったので作成・先ほど公開しました。
Vertical Scrolling | Horizontal Scrolling |
---|---|
react-navigation
今回はiOSアプリのみの開発だったので、公式の NavigatorIOS を最初つかっていたのですが、navigate時に渡したpropsが変化しても値が更新されないようです(NavigatorIOS optimizations break functional components · Issue #13539 · facebook/react-native · GitHub)。 これだと困るシーンがあったのでreact-navigationを使用しました。react-navigaitonのscreenPropsでstoreの情報とかを伝播させています。
見た目もカスタマイズ出来る範囲がNavigatorIOSより多そうです。
react-native-vector-icons
TabBarIOSのItemに画像を入れようとすると、うまくリサイズできなくて困っていました。 このライブラリはTabBarIOSに組み込むためのComponentも提供してくれています。あとSVGからttf生成して追加してあげれば自作のアイコンとかも使うことが出来ます。
react-native-camera
カメラで使用。 ここは全部チームメイトがやってくれて、自分は使い勝手がわからないのですが無事動いてくれています。
react-native-video
動画の再生で使用。 解説は特にないのですが、muteやpauseもpropsで指定できて使いやすかったです。
firebase
認証はFireabase Authenticationを使用しました。 JS SDKが問題なく動きます。
Firebaseから取得したJWTは、サーバ側で検証するのですが、SDKはJavascriptとかPython用が提供されています。
Go用のSDKはないので、 こちらの説明 に従って自分で検証処理を書く必要があります。
検証用のコードを公開したので、ご活用ください。
現在は公式でサポートされたので、そちらをご利用ください。
react-native-user-defaults
iOS限定ですが、JWTの保管はUser defaultsで行いました。
苦労したこと
スタックトレースについて
苦労した点はいくつかありますが、スタックトレースはReact Nativeを採用するか悩んでいる人の多くが心配しているかもしれません。 実際スタックトレースから情報がなかなか読み取れずデバッグが大変なシーンはいくつかありました
実際に発生したエラーのスタックトレースを3つほど載せておきます。
エラー1 | エラー2 | エラー3 |
---|---|---|
エラー1は、バンドル後のJSファイル jsBundle.js
内での行数が表示されてしまっているケース。
エラー2とエラー3は、Objective-Cのブリッジ等の部分でエラーが起きてそちらのスタックトレースが表示されてしまうケースです。
ここからちゃんと問題を把握・デバッグできるぐらい理解を深めるのであれば、React Nativeを使う理由が減っていくだろうなぁという思いもあり、トライ&エラーで乗り切りました。ある程度はくじけない気持ちが必要かもしれません。
再現性のないバグ
React Nativeに限らないかもしれませんが、再現性のないバグが比較的多く発生してしまた気がします。 自分の実装がまずかった場合も多いかと思いますが、決して成熟しきった技術ではないので、フレームワークやライブラリのバグを疑う回数も多いです。
再現性の無いバグについては自分も理解できていないのであまり多くは紹介しませんが、文字通り時間が解決してしまったケースもあります。 ほとんど参考にならないかとも思いますが、おかしな挙動に遭遇したときに自分がとりあえずやっていることを残しておきます。
- git cleanでgitの管理対象外のファイルを消して、ビルドし直す
git clean -x -d -f -e node_modules -e .idea
- 何か変更を加えてエラーが出たとき、戻してもまだビルドできないとかがたまにあります。開発序盤はとりあえずこれで乗り越えました
- 1週間もしてくるとだいたいどの辺に問題があるのか感覚的にわかってきて、git cleanを使う回数が自然と減ってきた気がします
- 自分が加えた変更が反映されずReloadしてもずっとエラーを吐き続ける場合 (実機Buildはうまくいくけど、Simulatorで動かない場合もある):
- iPhoneのSimulatorのCacheを消す。git cleanも実行
- Websocketサーバを一度止めてから
react-native run-ios
- どこにキャッシュが残っているのか、これでも何故か以前のコードがSimulator上で実行されてしまうときがありましたが、気づいたら直っていてもう再現できないので自分もよくわかっていません
サーバサイドのアプリケーションを開発していると、多くのバグは掘り下げることで突き止めることができる気分になれますが、iOSは自分には知らないことが多すぎてとにかく色々手を動かして条件を変えてうまく動くことを祈ることが多かったです。
開発をやってみて
冒頭でも書きましたが、カメラやフィルター処理といったネイティブの機能をガリガリ使うところ以外は自分でもかなり開発に参加できます。 また普段使っているスマートフォンのネイティブアプリケーションもネイティブの機能は使わずAPIの通信と表示のみで成り立っている画面も多いなと感じました。 スマートフォンの細かいアニメーションにも意識がいくようになり、色々とこれまで感じることのなかった面白さがあったかなとおもいます。
また趣味で使ってみたいですね。 終わり。
Protocol BuffersをJSONに変換する
React Native + GoのProobuf API Serverの構成でアプリケーションを作っているのですが、次の条件でdeserializeができないことがわかってしまった。
- [javascript] Failed to deserialize protobuf message which has other message types. · Issue #3098 · google/protobuf · GitHub
- protobuf/reader.js at master · google/protobuf · GitHub
decodeIO/protobuf.js の方も一応試してみたけど、うまく動かないので急遽Protobufの使用を諦めてjsonに切り替えることにします。 こちらも詳細は↓にまとめました。あとでGithubの方にもIssue建てようかと思います
protobuf/jsonpb
でJSONに変換する
開発期間も短いので今からサーバの実装を変えていくのはできればさけたい。 モックサーバとかはすでにProtobufで書いていたので、それをJSONに変換する処理を書いていく方法を検討してみた。
今回使っている型はそんなに多くないので自前でさっと書けないかなとか考えつつ調べてみたら、protobufの中に jsonpb
という便利そうなモジュールがあった。
ProtobufのモックAPIをJSONのAPIに書き換えてみる。
func confirmProfile(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) p := &pb.ConfirmProfileResponse{ HasProfile: true, Profile: &pb.ProfileResponse{ Uid: "uid0001", Username: "c_bata_", IconUrl: "http://example.com/hoge.png", WebsiteUrl: "http://example.com", CreatedAt: 1000, UpdatedAt: 1000, }, } m := jsonpb.Marshaler{ EmitDefaults: true, Indent: " ", OrigName: true, } m.Marshal(w, p) return }
curlで確認。
$ curl localhost:8080 { "has_profile": true, "profile": { "uid": "uid0001", "username": "c_bata_", "icon_url": "http://example.com/hoge.png", "website_url": "http://example.com", "created_at": "1000", "updated_at": "1000" } }
実装はreflectを多用することになるので、パフォーマンスには課題があるかもしれませんが簡単にJSONのAPIに変換できた。 ただ、数値(int64)で欲しかったものがstringになってしまっていて、整数値として出力してほしい。 もしかするとバグかなと思い、ResponseWriterの代わりに次のLoggerBufferを仕込みつつ原因となっている行を探してみた。
type LoggerBuffer struct { bytes.Buffer } func (b *LoggerBuffer) Write(p []byte) (n int, err error) { log.Printf("LoggerBuffer: %s\n", p) return b.Buffer.Write(p) }
次の行が原因らしく、どうやらint64とuint64の型の場合はあえて、ダブルクオーテーションで囲っている。 こうしている理由は、JavaScriptの仕様に何か関係しているのかもしれないと思い調べてみた。
https://github.com/golang/protobuf/blob/master/jsonpb/jsonpb.go#L504-L511
EcmaScriptのNumber型
調べてみると次のStackoverflowの回答が見つかった。
Number型で表現可能なのは +/- 253 の範囲らしい。 あとbit演算は32bitの範囲で利用できる。 実際にChromeのConsoleで挙動をみてみた。
> Math.pow(2, 53) 9007199254740992 > Math.pow(2, 64) 18446744073709552000 > Math.pow(2, 53) - 1 9007199254740991 > Math.pow(2, 53) + 1 9007199254740992
値としては持つことができているが、253を超える範囲では数値演算が正しく動作しないことが確認できる。 負の数も同様。
> - Math.pow(2, 53) -9007199254740992 > - Math.pow(2, 53) -1 -9007199254740992 > - Math.pow(2, 53) + 1 -9007199254740991
そもそも何故 int64を使ったのか
GoのAPIサーバとJS間で、文字列よりも言語間で統一されてそうというイメージで時刻はUnixTimeで表現していた。
golangでは time.Time
型に Unix()
メソッドが提供されている。
このメソッドは int64 を返していたので、その値をProtocol Buffersでも使用していた。
https://godoc.org/time#Time.Unix
ただし、先程のEcmaScriptのNumber型の仕様上、JSでint64を扱うのはやめておいたほうがよさそう。 関連する問題に、「2038年問題」というのがあるみたい。
こういった問題がある以上、API上での時間の扱いとしてUnixTimeを使うのはあまりいい方法ように思えてきた。
ISO8601とRFC3339
ISO8601形式がよさそう。文字列なので先程のような問題は解決できる。 今後はこれを使おうかと思う。
- javascript - The "right" JSON date format - Stack Overflow
- ISO 8601 - Wikipedia
- RFC 3339 - Date and Time on the Internet: Timestamps
golang で time.Time
をISO8601形式に変換する
time.Now().UTC().Format(time.RFC3339)
あとは、go-sql-driver/mysqlのlocの設定もつけておいた ( ?parseTime=true&loc=Local
)。
- GitHub - go-sql-driver/mysql: Go MySQL Driver is a MySQL driver for Go's (golang) database/sql package
- time - The Go Programming Language
おわりに
常識だったのかもしれない。この機会に知ることができてよかった。
プログラミング言語Go (ADDISON-WESLEY PROFESSIONAL COMPUTING SERIES)
- 作者: Alan A.A. Donovan,Brian W. Kernighan,柴田芳樹
- 出版社/メーカー: 丸善出版
- 発売日: 2016/06/20
- メディア: 単行本(ソフトカバー)
- この商品を含むブログ (2件) を見る
asyncioがPOSIXスレッドを使っている原因を調べる
tokibito先生 (id:nullpobug) がオフィスに遊びにおいでと声かけてくれたので、オープンコレクターさんに遊びに行ってました。 aodag先生 (id:aodag) と3人で雑談してたんですが、ふと以前気になっていたことを思い出したので聞いてみた。
気になっていたこと
とある勉強会の発表資料 を作っている時に、 asyncioとaiohttpを使ってとあるサーバにHTTPのリクエストを送るコード例を用意した。
import aiohttp import asyncio async def fetch(l, url): async with aiohttp.ClientSession(loop=l) as session: async with session.get(url) as response: return await response.text() async def main(l, url, num): tasks = [fetch(l, url) for _ in range(num)] return await asyncio.gather(*tasks) if __name__ == '__main__': loop = asyncio.get_event_loop() results = loop.run_until_complete(main(loop, 'http://localhost:8000', 3)) for r in results: print(r)
PyCharmでは、「Concurrency Diagram」を表示する機能があり、スレッドやプロセスの動きを確認できるのですが、 このコードを実行したときのスレッドの動きは次のようになる。
なぜか、 concurrent.futures.ThreadPoolExecutor
が現れている。
ドキュメントに何か書いてあるのか調べてみたのですが、それらしい記述が見つからず諦めていたのでaodag先生とtokibito先生に聞いてみた。
socket.getaddrinfo
数十分で原因を見つけてくれた。
socket.getaddrinfo
が同期的に実行されてしまうため、cpythonの実装としてはこれを非同期に実行できるように変えるのではなく、ひとまず concurrent.futures.ThreadPoolExecutor
により複数のスレッドで実行するようにしているらしい。
- https://github.com/python/cpython/blob/6f0eb93183519024cb360162bdd81b9faec97ba6/Lib/asyncio/base_events.py#L666-L673
- https://github.com/python/cpython/blob/6f0eb93183519024cb360162bdd81b9faec97ba6/Lib/asyncio/base_events.py#L627-L636
試しにgetaddrinfoが使われないコードサンプルとして、 aioredisを使ったサンプルを用意した。 ローカルに建てたRedisのサーバにUNIXドメインソケットで繋いでみる。
import asyncio import aioredis async def connection_example(key): conn = await aioredis.create_connection('/tmp/redis.sock') return await conn.execute('GET', key) async def main(num): tasks = [connection_example('my-key') for _ in range(num)] return await asyncio.gather(*tasks) if __name__ == '__main__': loop = asyncio.get_event_loop() results = loop.run_until_complete(main(3)) for r in results: print(r)
ちなみにredisのconfigは↓。
daemonize no pidfile /var/run/redis.pid unixsocket /tmp/redis.sock unixsocketperm 700 logfile "" databases 1
この時のConcurrency Diagramを見ると、
たしかにスレッドが生成されていない。
外部のRedisサーバへのアクセス
一方でRedisのサーバを外部に用意して繋いでみると (今回は arukas.io を使わせていただきました)、
import asyncio import aioredis async def connection_example(key): conn = await aioredis.create_connection( ('seaof-xxx-xxx.arukascloud.io', 311390), db=0, password='xxxxxxxxxxxxxxx') return await conn.execute('GET', key) async def main(num): tasks = [connection_example('my-key') for _ in range(num)] return await asyncio.gather(*tasks) if __name__ == '__main__': loop = asyncio.get_event_loop() results = loop.run_until_complete(main(3)) for r in results: print(r)
実行すると次の通り。
おー やっぱりワーカースレッドが生成されてしまうらしい。
ThreadPoolExecutorのワーカースレッドはいくつまで生成されるのか。
Semaphoreで同時に実行される数を3つに制限した時のConcurrency Diagramは次のようになる。
どうやらThreadPoolExecutor内のThreadが再利用されていない。 どこまで生成されるのかは、ドキュメントのThreadPoolExecutorのところに書いてあった。
max_workers が None か指定されない場合のデフォルト値はマシンのプロセッサの数に 5 を掛けたものになります
試しに30個ぐらいリクエストを送ってみる(Semaphoreは3)。
20個まで生成され、それ以降は再利用されているのを確認できた。 実装上ワーカースレッドの上限は変更できないけど、たしかに別に困るケースもなさそう。
uvloop
uvloopの方は、POSIXスレッド使わずに頑張ってるかもという話が出たので確認。
import uvloop # 中略 loop = uvloop.new_event_loop() asyncio.set_event_loop(loop)
実行すると、
おー ほんとだ。
おわりに
aodag先生とtokibito先生すごい… 自分はドキュメントを読みつつモヤモヤしたまま放置してたのですが、数十分で原因を見つけて教えてくれた。 自分もいよいよ明日から社会人なので、お二人目指して精進します。
aodag先生とtokibito先生のいるオープンコレクターさん、お仕事募集中だそうです(お世話になったので一言宣伝)。 ありがとうございました!