ReactNativeアプリの作り方::Props&State

このページの目標

  • [ ] Reactコンポーネントの状態管理の仕組みを理解する
  • [ ] PropsとStateの特性を理解する
  • [ ] 画面を越えた状態の受け渡し方を理解する(ReactNavigation)

Props

PropsはReactコンポーネントに渡す引数を指します

渡す側

class App extends React.Component{
  render(){
    //JSXリテラルで指定されるものがProps(name&iconUrl)
    <Person  name={name} iconUrl={icon.url} />
    {Person(name:name, iconUrl:icon.url)} //上と等価の表現
  }
}

受け取り側

Propsはconstrucorの引数かそれ以外の関数ではthis.propsで参照できます。
自分自身のPropsを更新することは出来ませんが、親コンポーネントが新しいPropsを再度受け渡すことがあります。その場合componentDidUpdateが呼ばれます。componentDidUpdateは初期化時には呼ばれないので注意が必要です。

interface Props {
 name: string;
 iconUrl: string;
}

class Person extends React.Component<Props> {
  constructor(props: Props){
    super(props);
    const name = props.name;
  }
  componentDidUpdate(prevProps: Props){
     if(this.props.name !== prevProps.name) { 
        // do something.
     }
  }
}

State

Stateは名前の通りコンポーネントの状態を管理するオブジェクトです。コンポーネントはStateの変更を検知すると再描画する仕組みになっています。

interface Props {
  id: number;
}
interface State {
 isLoading: boolean;
 name?: string;
 iconUrl?: string;
}

class Person extends React.Component<Props> {
  constructor(props: Props){
    super(props);
    this.state = { isLoading: true }; //初期化はコンストラクタで直接代入する
  }
  async componentDidMount() {
     const response = await fetch(`https:exmaple.com/users/${this.props.id}`);
     const {name, iconUrl} = await response.json();
     this.setState({name, iconUrl, isLoading: false}); //状態の更新はthis.setState経由で行う
  }
  render() {
    if(this.state.isLoading) { // Stateを参照する場合は直接アクセス出来る
      return <LoadingView>
    } else {
      const {name, iconUrl} = this.state;
      return <ProfileRow name={name} iconUrl={iconUrl} />
    }
  }
}

Navigation.State

画面間で値を受け渡したい時はNavigation.Stateの仕組みを利用します。これはReactNavigationの提供する機能です。 navigation.navigate する際にparamプロパティを渡すと遷移先のコンポーネントではprops.navigation.state.paramsからアクセスが可能です。

const params = {userName: this.state.userName}
this.props.navigation.navigate({ routeName: HomeScreen.routeName, params }); // 受け渡し側
type Navigation = NavigationScreenProp<NavigationRoute<any>, any>; //Props型が複雑なためaliasを作成
interface Props {
  navigation: Navigation; 
}

class HomeScreen extends React.Component<Props> {
  constructor(props: Props) {
    super(props);
    const userName = props.navigation.state.params.userName; //受け取り側
  }
}

ReactNativeアプリの作り方::画面遷移

このページの目標

  • [ ] StackNavigatorを使いこなせるようになる
  • [ ] StackActionに使って変則的な画面遷移も出来るようになる

画面遷移の実装

ReactNavigation

フルスクラッチでの画面遷移実装は大変なのでReactNavigationを利用します。

セットアップ

yarn add react-navigation
yarn add @types/react-navigation -D
yarn start

提供されているNavigator

ReactNavigationはiOSAndroidでよく使われるいくつかの画面切り替えのためのUIコンポーネントを提供しています。今回は一番シンプルなStackNavigatorについて説明します。

  • StackNavigator
  • SwitchNavigator
  • DrawerNavigator
  • TabNavigator
  • BottomTabNavigator
  • MaterialBottomTabNavigator
  • MaterialTopTabNavigator

余談ですが、上記のNavigatorの動作見たいときはExpoで配信されているのでそちらでチェックすると便利です。 https://expo.io/@react-navigation/NavigationPlayground

StackNavigatorについて

StackNavigatorは基本となるもので、画面切り替えと遷移スタックの管理を提供します。何も設定しなくてもヘッダーなどはそれっぽい見た目に合わせてくれます。

実装サンプル

"Hello world!"を表示するだけのアプリがあると仮定して、これをStackNavigator経由で起動します。

Before

//App.tsx ここがルートコンポーネントとする
export default class App extends React.Component<{}, {}> {
  render() {
    return (
      <View style={styles.container}>
        <Text style={styles.text}>Hello world!!</Text>
      </View>
    );
  }
}

After

// App.tsx
import { createStackNavigator, createDrawerNavigator, NavigationScreenProp, NavigationRoute } from 'react-navigation';
import HomeScreen from './HomeScreen';

// ルートコンポーネントをStackNavigatorに切り替える
export default createStackNavigator({
  [HomeScreen.routeName]: { screen: HomeScreen }
});

各画面に1:1対応するrouteNameというstatic stringを定義します。

// HomeScreen.tsx
export default class HomeScreen extends React.Component<{}, {}> {
  static routeName = '/HomeScreen';
  render() {
    return (
      <View style={styles.container}>
        <Text style={styles.text}>Hello world!!</Text>
      </View>
    );
  }
}

画面遷移

ReactNavigationを経由してマウントされたコンポーネントPropsnavigationが入っています。これを利用して他画面への遷移操作ができます。

基本の画面遷移:navigate

createStackNavigatorに登録したスクリーンであればnavigation.navigate(routeName: "hoge")で遷移することができます。

const navigation = this.props.navigation
const params = {userName: "ReactNative太郎"} // 遷移先の画面に値が渡す
navigation.navigate({ routeName: HomeScreen.routeName, params });

複雑な画面遷移: dispatch+StackAction

例えば アプリを起動->ログイン画面->(ログイン成功)->ホーム画面 という画面遷移があった場合、ホーム画面でエッジスワイプや戻るボタン押された際はアプリは終了するべきだと思われます。しかしnavigateを使ってログイン画面からホーム画面に遷移してしまうと戻ることが出来てしまいます。
このような特殊なケースはStackActionを使って詳細な制御する方法があるので紹介します。下記には画面遷移はするがスタックに積まない場合のStackActionの設定を載せました。

https://reactnavigation.org/docs/en/stack-actions.html

const resetAction = StackActions.reset({
  index: 0,
  actions: [NavigationActions.navigate({ routeName: HomeScreen.routeName })],
});
this.props.navigation.dispatch(resetAction);

ReactNativeアプリの作り方::Fetch APIとFlatList

このページの目標

  • [ ] Fetch APIを使ってHttpリクエストが扱えるようになる
  • [ ] FlatListを使ってリスト表示が出来るようになる
  • [ ] APIレスポンスをFlatListでリストを表示する実装が出来るようになる

Fetch API

Fetch APIとは

  • 現代のXMLHttpRequestのようなもの
  • WHATWGによって標準化され、ほとんどのモダンブラウザで利用可能
  • ReactNativeでも同様のインターフェースで使える

https://developer.mozilla.org/ja/docs/Web/API/Fetch_API/Using_Fetch

GET

const response = await fetch('https://example.com/images/1');

POST

const request = {
  method: 'POST',
  headers: {
    Accept: 'application/json',
    'content-type': 'application/json',
  },
  body: JSON.stringify({image: base64String}),
};
const response = await fetch('https://example.com/images', request)

Responseを扱う

エラー時(4xx,5xx)も例外を吐かないので正常なやり取りが出来たか始めに確認する必要があります。

if(response.ok) { //200系が返ってきていることを確認
  return await response.json(); //レスポンス内容は非同期で受け取る
}

https://developer.mozilla.org/ja/docs/Web/API/Response

TypeScriptで型をつけてwrapする

FetchAPIはそのまま使うとanyを扱う必要があったり、クエリパラメータのエンコード機能がなかったりするので簡易的にWrapしたAPIClientを経由して利用します。

const API_ENDPOINT = 'https://exmaple.com'
type Params = { [key: string]: string | number };

class ApiClient {
  get = async <T>(path: string, params: Params = {}): Promise<T> => {
    const url = this.createUrl(path, params);
    const response = await fetch(url);
    if (response.ok) { 
      return (await response.json()) as T; 
    } else {
      const status = response.status;
      const body = await response.text();
      throw new Error(`status: ${status}, body: ${body}`);
    }
  };

  private createUrl(path: string, params: Params = {}): string {
    let url: string;
    if (Object.keys(params).length == 0) {
      const encoded_params = this.encoded_params(params);
      url = `${API_ENDPOINT}/${path}?${encoded_params.join('&')}`;
    } else {
      url = `${API_ENDPOINT}/${path}`;
    }
    return url;
  }

  private encoded_params(params: Params): string[] {
    return Object.keys(params).map(
      key => `${encodeURIComponent(key)}=${encodeURIComponent(String(params[key]))}`,
    );
  }
}
export default new ApiClient();

型を指定してGET

intreface Image {
  id: number;
  url: string;
  width: number;
  height: number;
}
const image = await = ApiClient.get<Image>(`images/${imageId}`);
return image.url;

FlatList

FlatListとは

  • ReactNative公式のリストを表示するのコンポーネント
  • 多機能でかつインターフェースが扱いやすい
  • FlatListのためにReactNativeが使いたくなるレベル

https://facebook.github.io/react-native/docs/flatlist

最小実装

<FlatList
  data={[{key: 'a'}, {key: 'b'}]} //データセットの提供
  renderItem={({item}) => <Text>{item.key}</Text>} //描画方法の指定
/>

APIからデータを受け取り表示

架空のAPIから過去のメッセージの一覧を取得する実装例です。ポイントは3点あります。

  • componentDidMountAPIリクエストする
  • 結果をStateに保存する
  • FlatListのデータソースはStateを参照する
interface Props {}
interface State {
  messages: Message[];
}

interface Message {
  id: number;
  text: string;
}

class App extends React.Component<Props, State> {
  //コンポーネントが描画されるタイミングで呼ばれるイベント
  async componentDidMount() { 
    const messages = await ApiClient.get<Message[]>('messages/1');
    this.setState({ messages }); // コンポーネントの状態を差分更新
  }
  render() {
    return (
      <FlatList
        keyExtractor={item => { return String(item.id);}}
        data={this.state.messages}
        renderItem={({ item }: { item: Message }) => <Text>{item.text}</Text>}
      />
    );
  }
}

FlatListの便利機能

ネイティブ実装だとかなり苦労するPull-to-Refreshや無限スクロールも数行で実装できます。今回は省略しますがヘッダー,フッター,複数カラムや横向きのスクロールなどで手軽に作成できます。

Pull-to-Refresh

      <FlatList
        keyExtractor={item => { return String(item.id);}}
        data={this.state.messages}
        renderItem={({ item }: { item: Message }) => <Text>{item.text}</Text>}
+       onRefresh={() => { handleOnRefresh(); }} //再読込処理を呼び出す
+       refreshing={this.state.refreshing} //リフレッシュ処理が走っているか状態をセット
      />

無限スクロール

      <FlatList
        keyExtractor={item => { return String(item.id);}}
        data={this.state.messages}
        renderItem={({ item }: { item: Message }) => <Text>{item.text}</Text>}
+       onEndReached ={() => { handleonEndReached(); }} //次ページのデータを読み込む
      />

ReactNativeアプリの作り方::HelloWorld

このページの目標

クライアントアプリケーションの作り方

一般的にクライアントアプリケーションはUIコンポーネントの集合体なので、「コンポーネントの作り方」と「レイアウトの組み方」が理解すると静的なアプリケーションが作成出来ます。 前半はReactNativeでのコンポーネントの作り方とレイアウトの方法を説明していきます。

コンポーネントとは?

Cookpadアプリを例にすると、"ヘッダー"と"メイン"と"フッター"の大きく3つのコンポーネントに分類できます。 それぞれのコンポーネントは更に小さいコンポーネントに分類できます。例えば"ヘッダー"を作るなら下記のような手順になるでしょう。

他のコンポーネントも複雑度は違いますが手順が増えますが大枠は同じです。

f:id:kazy1991:20180724044849p:plain

HelloWorld!

ReactNativeではReactの作法でコンポーネントを作成します。

  • React.Component を継承したクラスを作る
  • render メソッドに表示したい内容を書く

WebのReactと異なる点はrender内で<div>要素などの要素を利用するかreact-nativeが用意したコンポーネントを利用するかの違いのみです。

公式ドキュメント上でReactNativeの振る舞いを試す事が出来るので、このページでHelloWorldを表示させましょう。 https://facebook.github.io/react-native/docs/tutorial.html#hello-world

import React from 'react';
import { StyleSheet, Text, View } from 'react-native';

export default class App extends React.Component {
  render() {
    return (
      <View style={styles.container}>
        <Text style={styles.text}>Hello world!!</Text>
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
  text: {
    color: 'gray',
    fontSize: 32,
  },
});

カスタムコンポーネントを描画する

View, Text などと同様に自作コンポーネントも簡単に描画する事が出来ます。BuleTextComponentという青い文字列の"HelloWold!"を表示するコンポーネントを作って先程のコードに追加して自作コンポーネントが描画されることを確認しましょう。
ReactNativeの開発ではあるまとまったUIパーツを一つのコンポーネントに纏めることで見通しを良くしたり、再利用性を高めたりします。

import React from 'react';
import { StyleSheet, Text, View } from 'react-native';

export default class App extends React.Component {
  render() {
    return (
      <View style={styles.container}>
        <Text style={styles.text}>Hello world!!</Text>
        <BlueTextComponent />
      </View>
    );
  }
}

class BlueTextComponent extends React.Component {
  render() {
    return (
      <Text style={{color:'blue'}}>Hello world!!</Text>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
  text: {
    color: 'gray',
    fontSize: 32,
  },
});

Peril: GithubAppとして動くDanger

これは下記のブログざっと読んだメモです。

artsy.github.io

概要

PerilはDangerをホストするアプリケーションです。Dangerは通常CIサーバーで動かしますが、Perilを導入すると自らwebhookを受け取りPerilサーバー上でDangerスクリプトが実行されます。
PerilプロジェクトはCIサーバーからDangerの実行環境を独立させることを目的としています。これによってissueの更新などのイベントフックが可能になります。 PerilはGithubAppとして動きます。Herokuでの動作手順は下記に紹介されています。

peril/setup_for_org.md at master · danger/peril · GitHub

なぜPerilをつくったのか

作者のブログに色々と書いてありますが昔から構想はあったそうです。結局の所CIサーバー上で動かしている限り制約が多いので、独立したサーバーとして動かして多くの作業をDangerに任せたいからだと理解しました。 CocoaPods orgではissueとPRの管理で実際に運用されています。

github.com

Perilの設定ファイル

Artsy社で実際に使われているPerilの設定ファイルは下記のようです。Org単位で設定を書く形になります。任意のwebhookに任意のscriptを手軽に動かせるのがPerilの魅力のようです。

{
  "settings": {
    "modules": [
      "danger-plugin-spellcheck",
      "danger-plugin-yarn",
      "@slack/client"
    ],
    "env_vars": ["SLACK_RFC_WEBHOOK_URL"]
  },
  "rules": {
    "pull_request": "artsy/artsy-danger@org/all-prs.ts"
  },
  "repos" : {
    "artsy/reaction": {
      "pull_request": "danger/pr.ts"
    },
    "artsy/positron": {
      "pull_request": "dangerfile.ts"
    },
    "artsy/artsy-danger": {
      "issues.opened": "artsy/artsy-danger@danger/new_rfc.ts"
    }
  }
}

おわり

Perilはまだ開発途中で問題をいくつか抱えています。またPerilはDanger.jsで動くのでGithub以外に対応していないほかDaner(Ruby)と比べると安定性に欠けます。それでもこういうものを求めていたという人はぜひ導入してみると良さそうです。

現代のフォローリスト

数日前の話、同僚からサポートissueの調査について相談を受けた。内容は「フォローリストが正しく表示されない(数人足りない)」というものだった。

最初にうちの会社の環境を紹介しておくと、メイン事業はモノリシックで巨大なrailsで書かれている。最近は機能や開発を担当するチームごとに切り崩してマイクロサービス化される事例も増えてきた。今回調査したフォローシステムを提供するアプリケーションもメインのrailsアプリケーションとは別に小さなrailsとして動いていた。

事前調査していた同僚から「DB上のソーシャルグラフは正常だった事」と「名前のないユーザーはフォローリストに表示されない事」を教えてもらった。今回のサポートissueに挙がってきた事例もユーザー名が無いことが原因の様で、それはシステム上意図してない挙動だった。

調査の手始めにユーザー名の管理について調べた。DBはアプリケーション毎に別のものを参照していた。ユーザー名はメインのrailsとフォローシステムのrailsで二重管理されていて、メインのUserモデルに変更があると内製のPub/Subシステムを経由して変更内容が同期されるようになっていた。

Pub/SubシステムはQueueを持っていて、イベントがpublishされるとQueueにどんどん積んでいく。積まれたイベントは非同期にデキューされ、紐付けられたDockerイメージが立ち上がり事前に設定したスクリプトがその中で実行される。

デキュー後の処理はごく稀に失敗することを把握していたので今回もそこが原因だろうと考えた。

ところが影響範囲を調べてみると数十万人(直近数日でも数百人)の規模で起こっていることが判明して読みが外れていることに気づいた。デキュー後の処理が失敗するのは一日数件程度なので、もっと根本的なバグがありそうだった。

調査を再開してUserモデル内の該当イベントがpublishされている箇所を調べた。最近入った何らかの変更でイベントが飛ばなくなったみたいな話はいかにもありそうだ。そこで該当のイベント名でgrepした見たところやはり見当たらなかった。

「おっエンバグの線で当たりかな..」と思ったがこれも間違いだった。Userレコードが書き換わったときに該当イベントのpublishをチェックするユニットテストが見つかった。 もしイベントがpublishされなくなっていたらこのテストケースで気付くことができる。しかしmaster CIはずっとgreenのままだったし、当たり前だけれど手元でテストを実行してももちろん成功した。ここでエンバグによってpublishイベントが消えたという線はなくなった。

やや脇道に逸れるけど一体どんな仕組みで該当イベントをpublishしているのかどうしても気になったので該当モデルのコードをもう少し読んでみた。すると何かのイベントをpublishしている処理を見つけた。注意深くコードを追うとイベント名がメタプログラミングチックに組み立てられていることに気づいた。「なるほどだからさっきのgrepでは引っかからなかったのか」と納得が入った。一方で「なんてgrepビリティが低いコードなんだ。こんなに処理を汎化する意味はあるんだろうか...これだからrubyは..」と悪態をつきたくなった。(もちろんほとんど冗談の文脈。 rubyは別に悪くないし grepしさすさを重視するかは個人の好みに分かれそう)

イベントのpublishまでは正しく行われていることがわかった。次に疑うべきはイベントが流れる経路とsubscribe側の処理なのだけどここは疑う余地がほとんど無かった。他のPub/Subシステムを利用したサービスは正常に動いていたし、さらに言えば内製のPub/SubシステムはほとんどAWSのサービスのラッパーみたいなものでAWS側で障害が起こったりしない限り原因になりえそうに無かったからだ。仮に障害が起こっていたら技術基盤かインフラチームの人がいち早く気づいて共有してくれると思うのでこのあたりを疑うのはすぐにやめた。

こうなってくると手詰まりで「申し訳ないけどちょっと原因がわからないな ちゃんと時間を確保して調べてみないとダメそうですね」という話をした。

他の作業も残っているので一旦調査をやめようかと思っていたのだけど、ふと昨年関わった仕事のことを思い出した。 そういえばユーザー登録基盤はメインのrailsから引き剥がされてマイクロサービス化されていたのだ。

「ユーザー登録基盤のrailsはメインのrailsとDBを共有していて、ユーザー登録基盤のrailsからDBを書き換えるとイベントが発火しないのでは」という仮説が偶然思いついた。結論だけ言うと大体合っていた。正確にはほとんどのテーブルは共有してなかったがusersテーブルのみ直接参照/書き込みしていた。 (あまり詳しくないので間違っているかもしれない)

railsチュートリアルに登場するフォローリストなら中間テーブルから一覧を引いてきて、ユーザー情報を列挙するだけのシンプルな実装で十分だけど、現実のフォローリストには無名のユーザーが表示されないロジックがあったり、ユーザー名はpub/subで渡ってきて登録されていたり、とても複雑な構成になっていた。

たまたま思いついた雑な推測から原因が特定できて問題は解決したのだけど、現代のフォローリストは複雑なんだなーと思ったのが印象的でブログに調査ログを書いてみた。

今回の調査でアプリケーション間でDBの共有すると状況を把握するのが大変になるので、(しょうがないケースも多いけど) 出来るだけ避けたいですね。という学びがあった。アンチパターンなことは知ってはいたけれど、どういうケースで困るのか理解できていなかった。

独学プログラミングとペアプロ

今日のお昼の話。所属部署のとても優秀な学生アルバイトの人とお昼ごはんを食べながら色々と話をしていた。流れは忘れてしまったのだけど彼は今までペアプロを経験したことがないらしいということがわかった。そこからペアプロって具体的に何するんですか?とか色々一般的な説明をして、機会があればぜひペアプロやろうみたいな話をした。

僕はペアプロに良い印象を持っていて、その要因の一つに些末だけど便利なテクニックを同僚から盗める点がある。 覚えている実体験を話すと昔巨大なrailsアプリのスタイルを直している時にちょっとscss変えただけなのに毎回リロードのために5秒待っていたりしたのだけど、ある時デザイナーの人とペアプロしたら彼は秘伝のブックマークレットを持っていて、それを使うと瞬時にスタイルだけ反映することができた。(今でも不思議なんだけどそのブックマークレットを消してしまったので一体どんな仕組みだったのかもう知る術はない)
他にはシェル上でコマンド間違えたりするとバックスペース連打で消したりしていたんだけど、ある時同僚の人とペアプロした時に「cntrl+u でカーソルの前の文字列全部消えるよ」と教えてもらってそれ以来かなり重宝してたりする。(今日ランチしたバイト氏はcntrl+uの存在を知らなかった) なんかそういう細かいけど便利なハックが盗めるので定期的にペアプロしたいなって思っている。

独学プログラミングについても思うところがあったんだけど長くなってしまったのでまた別の機会に書こうかな :P