React Native Tech Blog

supported by maricuru (旧maricuru tech blogです)

Native Moduleの作り方

決済サービスPAY.JP でモバイルエンジニアをしている tatsuyakit といいます。PAY.JPでは先日 React Native向けプラグイン をリリースしたのですが、本稿ではその中で出会ったNative Moduleを開発する際に気をつけたいポイントについて解説します。

Native Moduleとは

React Native開発において、React NativeのAPIに存在しないネイティブの機能を利用したい場合に、JavaScriptから各プラットフォームのコードにアクセスするための仕組みがNative Modulesです。

https://facebook.github.io/react-native/docs/native-modules-setup

プロジェクト構成とツール

cli

React Nativeのライブラリプロジェクトを作成するツール群はいくつかあり、たとえば以下のようなものがあります。

React Nativeの公式ドキュメントには create-react-native-module を使う記載があります。

To get set up with the basic project structure for a native module we will use a third party tool create-react-native-module. https://facebook.github.io/react-native/docs/native-modules-setup

react-native-create-library と react-native-create-bridge はともにGitHubリポジトリのスター数も多いですがmasterブランチの最後のコミットが2018年となっており、最近はあまり活発ではありません。

react-native-community/bobはcreate-react-native-moduleとよく似ていますが、デフォルトでESLintやprettierといったツールやビルド時のワークアラウンドが含まれていて、 react-native-community/react-native-device-info などで採用されています。

プロジェクト構成

実際に create-react-native-module を使ってプロジェクトを作成してみます。

npx create-react-native-module MyLibrary

すると以下の構成のプロジェクトが作成されます。このプロジェクトを雛形として開発を進めていきます。

react-native-my-library/
├── README.md
├── android
│   ├── README.md
│   ├── build.gradle
│   └── src
│       └── main
│           ├── AndroidManifest.xml
│           └── java
│               └── com
│                   └── reactlibrary
│                       ├── MyLibraryModule.java
│                       └── MyLibraryPackage.java
├── index.js
├── ios
│   ├── MyLibrary.h
│   ├── MyLibrary.m
│   ├── MyLibrary.xcodeproj
│   │   └── project.pbxproj
│   └── MyLibrary.xcworkspace
│       └── contents.xcworkspacedata
├── package.json
└── react-native-my-library.podspec

この構成は最低限のプロジェクト構成ですので、TypeScriptやESLintなど必要に応じて追加していきます。

作成されたプロジェクトを見てみると、 package.jsonindex.js の他に各Native Moduleのための構成が androidios 以下にそれぞれ生成されています。また、プロジェクト直下に react-native-my-library.podspec が生成されているのがわかります。

Autolinking

React Native 0.60より導入されたAutolinkingは従来Native Moduleが含まれるパッケージをプロジェクトに追加する際に必要だった react-native link に相当するステップをビルド時に自動的に行ってくれます。

iOSのビルドにおいては、このときパッケージに含まれているpodspecを参照するのですが、その際のデフォルトのロケーションがプロジェクトルートとなっているため、 react-native-my-library.podspec はプロジェクト直下にあるのです。

cli/autolinking.md at master · react-native-community/cli

この設定は react-native.config.js をプロジェクトに追加することで変更できます。以下のようにpodspecのロケーションを変更します。

const path = require('path');

module.exports = {
  dependency: {
    platforms: {
      ios: {
        podspecPath: path.join(__dirname, 'ios', 'react-native-my-library.podspec')
      }
    }
  }
};

exampleアプリ

次に公式のドキュメントに従い、yarn install したのちにプロジェクト内にReact Nativeアプリを作成します。

cd react-native-my-library
react-native init example

開発するライブラリパッケージをexampleに追加します。

cd example
yarn add file:../

npmパッケージを開発する際に npm linkyarn link によってシンボリックリンクを作成する手法がありますが、React Nativeではこの方法はうまくいきません。1

開発するディレクトリ

Native Moduleを開発する際は、exampleプロジェクト上で開発するとReact Native本体への依存がネイティブ上で解決できるため便利です。

iOSの場合

iOSの場合は、まずCocoaPodsでexampleプロジェクトで依存するライブラリをインストールします。

cd ios
pod install

Xcodeで example/ios/example.xcworkspace を開きます。 左側のProject Navigatorで Pods というプロジェクトを開くと、Development Pods の中に react-native-my-library を確認できます。

f:id:tatsuyakit:20200219162600p:plain

このファイルはどこにあるのかというと、 react-native-my-library/example/node_modules/react-native-my-library/ios/ です。 react-native-my-library/ios に書いたコードは yarn installreact-native-my-library/example/node_modules/ に追加され、次に pod install でAutolinkingによってnode_modules配下のライブラリをPodsに追加していきます。

Development Podsの中ではReact Nativeのクラスが名前解決されるので、ここでNative Moduleを実装していきます。

Androidの場合

AndroidのNative Moduleを開発する際は、Android Studioで example/android/build.gradle を開きます。Gradleのビルドが完了すると左側のProject Navigatorに react-native-my-library のモジュールが表示されるようになります。

f:id:tatsuyakit:20200219162606p:plain

iOS同様、表示されているのは react-native-my-library/example/node_modules/react-native-my-library/android/ です。ここでNative Moduleの開発をしていきます。

コードの変更を反映させる

上記のようなプロジェクト構成の場合、exampleで挙動を確認するためには以下のステップが必要になります。

  1. iOSまたはAndroidのコードを変更する
  2. example に移動し yarn add file:../ し直す

このようなステップを何度も繰り返すのは手間です。

そこで react-native-my-library/package.json に以下のようなスクリプトを追加します。

"dev-sync": "cp -r *podspec *.js android ios example/node_modules/react-native-my-library/",

コードの変更を反映したければ npm run dev-sync することで対象のコードだけ差し替え、不要なnode_moduleの再取得を回避できます。 これは react-native-community/react-native-share などで使われているテクニックです(オリジナルかはわかりませんでした)。

ローカルパッケージのnode_modulesを取り除く

上記のように作成したプロジェクト構成では問題が発生します。 exampleアプリにローカルの react-native-my-library を追加しますが、このとき react-native-my-library/node_modules 配下もコピーされ実行時エラーの原因となります。 この問題にはいくつかの解決方法があるので紹介します。

1. postinstallで指定のファイルを取り除く

exampleプロジェクトの postinstall で、本来node_modules配下にコピーされる必要のない不要な依存を削除する方法です。

これは、react-native-create-library や react-native-create-module で採用されている方法です。ただし、react-native-create-moduleの場合、プロジェクトを生成する際にexampleプロジェクトを同時に生成する場合のみ適用されるようになっています。デフォルトでexampleプロジェクトを生成するオプションはfalseとなっているため、先に説明したような手順で作成したプロジェクトでは有効になりません。

# プロジェクト作成時にexampleプロジェクトも生成する
npx create-react-native-module MyLibrary --generate-example

2. metroのBlackListを使う

これは react-native-community/bob が採用している方法です。以下のようにプロジェクトルートのnode_modulesを重複して読み込まないようにしています。

resolver: {
  blacklistRE: blacklist([
    new RegExp(`^${escape(path.join(root, 'node_modules'))}\\/.*$`),
  ]),

  extraNodeModules: modules.reduce((acc, name) => {
    acc[name] = path.join(__dirname, 'node_modules', name);
    return acc;
  }, {}),
},

3. exampleとプロジェクトを分けない

react-native-community/react-native-webview などではexampleディレクトリに package.json はなく、プロジェクトルートの package.json に以下のようにexampleアプリを起動するスクリプトが追加されています。

  "scripts": {
    "start": "node node_modules/react-native/local-cli/cli.js start",
    "start:android": "react-native run-android --root example/",
    "start:ios": "react-native run-ios --project-path example/ios --scheme example",

まとめ

React NativeのNative Moduleライブラリの開発手法について説明しました。 現時点の個人的なCLIツールの選定としては、最小限の構成を作りたいのであれば create-react-native-module を、LinterやFormatterなどの初期設定まで任せるのであれば react-native-community/bob を検討するのが良いと思います。

React Nativeは開発者によって多くのライブラリが公開されていますが、特にreact-native-communityには多くのNative Moduleライブラリがあり開発ノウハウが詰まっているので、それらも参考にすると良いと思います。

Author
tatsuyakit


  1. 以前からissueとして存在し、またcreate-react-native-moduleではPRが作られているので将来的には解決するかもしれません。

ReactNavigation v.5 とReduxでタブのバッジ数を管理する

この記事でやること

Reduxで管理する通知バッジ数をReactNativeのボトムタブに表示させる記事です。
通知バッジ数はスクリーンをまたぐ変数なので、Reduxで管理するのが良いかと思います。

こんな感じのやつです ↓↓ (この記事ではこれの簡易版を作ります)
S__31916034.jpg

ReactNavigation v.4では、ボトムタブのレンダリングのタイミングに癖があった(?)ようです。
Reduxで状態を更新してもボトムタブに即時反映はされませんでした(僕の周りでも何人か言ってましたが、間違っていたら教えてください!)。
僕は無理やりテキトーな変数を入れたNavigationActionsをボトムタブにdispatchすることで、無理やり再レンダリングさせて、即時反映させていました(本当はこの記事はそれを書く予定だった)。

しかしなんと、v.5ではそんな必要がなくなってました…!
ありがてぇ…!

 やりたいこと

画面から通知バッジ数を変更してボトムタブの数字に即時反映

 主な環境

  • Expo 36.0.0
  • react 16.9.0
  • @react-navigation/bottom-tabs 5.0.5
  • @react-navigation/native 5.0.5
  • @react-navigation/stack 5.0.5
  • react-redux 7.1.3
  • redux 4.0.5

ReactNavigationはモジュールの移動が激しいですね。
公式ドキュメントを読んで、必要なライブラリをインストールしていってください。

 画面構成

ホームスクリーンをStackにして、それをひとつのタブに対応させる単純な画面構成です。

 コード

 Redux

初期状態とreducerの定義をします

src/reducers/index.js
const INITIAL_STATE = {
    badgeNumber:0,
}

const reducer = (state=INITIAL_STATE, action) => {
    switch (action.type){
        case "SET_BADGE_NUMBER":
            return {...state, badgeNumber:action.badgeNumber}
        default:
            return state;
    }
}

export default reducer

actionの定義をします

src/actions/index.js
export const setBadgeNumber = badgeNumber => ({
    type:"SET_BADGE_NUMBER",
    badgeNumber
})

storeを作ります

src/store.js
import { createStore } from 'redux'
import reducers from './reducers'

export default createStore(reducers)

 スクリーン

HomeScreenを作り、Reduxと繋げます。badgeNumber+1というテキストをタッチするとバッジ数がインクリメントされる仕様に。

src/screens/Home.js
import React from 'react';
import { Text, View, TouchableOpacity } from 'react-native';
import { setBadgeNumber } from '../../src/actions'
import { connect } from 'react-redux'

const HomeScreen =({ badgeNumber,setBadgeNumber})=>{
  return(
    <View>
      <TouchableOpacity onPress={()=>setBadgeNumber(badgeNumber+1)}>
        <Text>badgeNumber + 1</Text>
      </TouchableOpacity>
    </View>
  )
}
const mapStateToProps = state => ({
  badgeNumber: state.badgeNumber
})

const mapDispatchToProps = {
  setBadgeNumber
}

 const Home = connect(
    mapStateToProps,
    mapDispatchToProps
  )(HomeScreen)

  export default Home

 ナビゲーション

ReactNavigationでナビゲーションを作ります。
StackとTabでHomeScreenをラップしていきます。

App.js
import React from 'react';
import { Text } from 'react-native';
import { createStackNavigator } from '@react-navigation/stack';
import { NavigationContainer } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { Provider, connect } from 'react-redux'
import { setBadgeNumber } from './src/actions'
import store from './src/store'
import Home from './src/screens/Home'

// ここでStackをつくります()今回は1画面だけだけど
const Stack = createStackNavigator();

const HomeStack=()=>{
  return (
    <Stack.Navigator>
      <Stack.Screen name="Home" component={Home} />
    </Stack.Navigator>
  );
}


// ここでBottomTabを作ります(今回は上で作ったStack1つだけ)
const BottomTab = createBottomTabNavigator();

const MyBottomTab=()=>{
  return (
    <BottomTab.Navigator>
      <BottomTab.Screen 
        name="Home" component={HomeStack}
        options={{
          tabBarLabel: 'Home',
          tabBarIcon: () => (
            <Text>{store.getState().badgeNumber}</Text>
          ),
        }} />
    </BottomTab.Navigator>
  );
}


const Main=()=>{
  return(
    <NavigationContainer>
      <MyBottomTab>
      </MyBottomTab>
    </NavigationContainer>
  )
}

const mapStateToProps = state => ({
  badgeNumber: state.badgeNumber
})

const mapDispatchToProps = {
  setBadgeNumber
}

const ConnectedMain = connect(
  mapStateToProps,
  mapDispatchToProps
)(Main)

const App=()=>{
    return (
        <Provider store={store}>
            <ConnectedMain />
        </Provider>
    )
}
export default App

これで、HomeScreenのbadgeNumber+1をタッチすれば、ボトムタブの数字も更新されていくと思います。

 まとめ

基本に忠実なReduxの使い方、構成です。
上記のコードだけでタブに即時反映してくれるようになってとてもありがたいですね。
ReactNavigation v.5はv.4に比べて見通し良くなったと思います(前はcreateなんとかnavigatorみたいなのが何をやっているかわかりづらかった)。

今回は、HomeScreenから通知バッジ数を変更する仕様でしたが、プッシュ通知が来たらバッジ数をインクリメントすることも考えられるかなと。
僕も、プロジェクトにおいてWebsocket対応の優先順位はまだ低いと感じたときには、プッシュ通知が来たのをトリガーにバッジ数をインクリメントしています。
その場合、Focusされているのがどの画面か問わず、storeの状態を書き換えなければいけませんが、上記で言うMainコンポーネントの中でNotifications.addListenerを使ってハンドリングすると上手くいきます。

参考になれば幸いです!

 

Author: はらだ

Twitter @program_diary  

SnapmartにおけるCameraRollから写真の複数枚アップロードの実装

こんにちは。スナップマート株式会社でCTOをしているNaoshiHoshiです。
普段はReact Nativeを使ってアプリを作ったり、Railsでバックエンドの処理を書いたり、AWSでサーバー構成管理をしていたりします。

概要

自分が作っているプロダクトであるSnapmartは、スマホのアプリから誰でも手軽に写真を売り買いできるサービスです。
スマホアプリでは、カメラロールの中から写真をアップロードをする機能があります。
この写真をアップロードする機能ですが、カメラロールから写真を複数枚選択する処理の実装に非常に苦労しました。
この記事では、ReactNativeでカメラロールから写真を複数枚選択する処理をどのように実装しているのか / 実装の前に何を比較検討したのかを紹介します。

仕様の説明

まずSnapmartアプリにおける、写真の選択画面の仕様について、説明をします。
大まかな流れは以下の通りです。

  1. アップロードしたい写真があるアルバムを選択
  2. アルバムの中からアップロードしたい写真を複数選択

アルバム選択画面

f:id:watasihasitujidesu:20200213184903j:plain

こちらのアルバム選択画面の仕様は、以下の通りです。

  1. BottomNavigationの出品アイコンをタップするとスクリーンの表示
  2. カメラロール内の以下の項目を取得し表示(iOSの場合)
  3. 最近の項目
  4. お気に入り
  5. 存在するアルバム全て

写真選択画面

f:id:watasihasitujidesu:20200213184918j:plain

こちらの写真選択画面は以下の通りです。

  1. アルバム選択画面で選択したアルバム内の全ての写真を表示
  2. 表示される写真は撮影日の降順
  3. 複数枚選択し、アップロード

仕様だけなら非常にシンプルですね。

試してみたこと & 問題点

先述の仕様を実装するために、以下のことを試しました(実際にリリースもしました)。

  • アルバム選択画面を実装せず、直近1万枚の写真を表示し選択してもらう画面のみ実装
  • アルバム選択画面を実装せず、直近10万枚の写真を表示し選択してもらう画面のみ実装
  • ImagePickerを使用する

アルバム選択画面を実装せず、直近1万枚の写真を表示し選択してもらう画面のみ実装

元々のアプリはSwiftで実装されていたのですが、これをReactNativeに刷新しました。

blog.naoshihoshi.com

当初いち早くリリースするために、軽微(だと錯覚していた)機能は実装せずにリリースしてしまいました。
既存のユーザーさんからすると以下のような状態になってしまいました。

  • SwiftだろうがReactNativeだろうが、見た目は変わらない
  • それなのに、これまで使っていた機能が無くなってしまい不便になった

また、直近1万枚の写真しか表示されないので、1万件を超える写真が端末に保存されていた場合に画面に表示されない問題も発生しました。 これはテスト端末機に写真が1万枚も保存されていなかったため、事前に見抜くことができませんでした。

その結果、お問い合わせが急増してしまったり、アップロード率が低下してしまいました。

アルバム選択画面を実装せず、直近10万枚の写真を表示し選択してもらう画面のみ実装

お問い合わせが急増してしまったので、急いで修正する必要があります。 まず、1万枚の制限を10万枚に緩和してリリースしました。

しかし、10万枚を表示させたことによって、10万件の写真のソート処理に時間がかかってしまう問題に引っかかってしまいました。
基本的には、React NativeのCameraRoll APIを使用して実装をしています。 ところが、CameraRoll APIには、写真の並び順を指定するオプションが存在しないため、自前で実装する必要があります。 以下のようなイメージで実装します。

CameraRoll.getPhotos(options)
  .then((obj) => {
    let images = [];
    obj.edges.forEach(asset => {                                                                                 
      images.push(asset.node);
    })
    images.sort(function(a, b) {                                                      
      return b.timestamp - a.timestamp;
    })
  })

最大10万ループするので、写真を多く持つユーザーさんほど処理が遅くなってしまいます。
こちらでリリースした結果、以下のような問題点がありました。

  • ソートしている間は画面が真っ白になる
  • ソートにリソースが持っていかれるため
    • アプリがクラッシュしてしまう
    • 充電の減りが早い

ImagePickerを使用する

自前で実装しない方法を模索していたときに発見したのが、Expoが提供するImagePickerです。

ImagePicker - Expo Documentation

上記のページにある通り、このように実装するだけで、カメラロールからの写真選択が実装できます。
非常に素晴らしいAPIです。

ImagePicker.launchImageLibraryAsync

ただ、このAPIは写真を一枚ずつ選択しなければならないため、Snapmartにおける複数枚アップロードしたいという要望は満たすことができません。 試しにImageaPickerバージョンで社内メンバーに触ってもらったところ、見事にお蔵入りになりました。

悩んだ末の実装

結局、CameraRollで処理を頑張ることにしました。
また、CameraRoll.getPhotosにはオプションでgroupNameを指定することができます。
このgroupNameは写真が属しているアルバム名です。
そのため、Snapmartアプリにアルバム機能を実装して、CameraRoll.getPhotosに渡すオプションで、選択されたアルバム名をgroupNameで渡すことで、 ソートする写真を減らすというアプローチにしました。

ただ、アルバム名を取得しようにも、カメラロールに保存されている最後の1枚までアルバム名を取得しなければならず、結局ループする回数は減らすことはできませんでした。
苦肉の策として、以下を実装しています。

  • 一度取得したアルバム名(groupName)はAsyncStorageに格納する
  • AsyncStorageにデータがあった場合はそれを表示
  • アルバムを作成/削除した場合のために再読み込みボタンを設置
let folderNames = [];
CameraRoll.getPhotos(options)
  .then((obj) => {
    obj.edges.forEach(asset => {
      if(!folderNames.includes(asset.node.group_name)){
        folderNames.push(asset.node.group_name);
      }
    })
    AsyncStorage.setItem("hogehoge", folderNames.join(","));
  })

また、アルバム名(asset.node.group_name)で絞り込む場合はCameraRoll.getPhotosのオプションで指定します。

const options = {first: 99999, assetType: "Photos", groupTypes: "Album", groupName: "アルバム名"}
CameraRoll.getPhotos(options)

ReactNativeでカメラロールから写真を複数枚選択する場合のベストエフォート

  • カメラロールから写真を複数枚選択する
  • 写真の並び順を変更する

という要件は、簡単に実装できるように見えて、ReactNativeにおいては自前で実装が必要です。 また、CameraRoll APIは写真の並び順の指定ができないため、全ての写真をループで回し、timestampの降順/昇順、ファイル名の昇順/降順を決定する必要があります。

この問題点を少しでも緩和するために、写真選択画面の前に、アルバム選択画面を置くのが、今のところ最善策のように思えます。 理由は、アルバム選択をしてもらった場合、写真のループ処理の母集団が少なくなる(=それだけ処理が早く終わる)ためです。

Author
NaoshiHoshi

Future of React Native

この記事は Meguro.es #25 で登壇した内容です。

はじめに

最近 React Native Community の Github org の一員となりました。去年の Advent Calendar で目標を立てていたのですが、新年入って1週間足らずで達成できました。Community の一員になれた経緯はまた別の記事で書こうと思います。Community の一員として Contributors の Discord channel に招待されて、色々な知見が手に入るので、今回はその一部をシェアします。

The New React Native

2020年は React Native が大きく変わる年になります。現在、React Native は内部設計を徐々に変更しています。この内部設計の変化については、React Native Re-architecture として2018年からいくつかの技術カンファレンスにて発表がありました。

この記事ではその Re-architecture について、自分の理解を共有します。

いままでの設計

今まで内部的にどう動いていたのかについて、説明します。React Native はその名の通り、React で View の部分を作成するクロスプラットフォームのフレームワークです。ですが、JavaScript で書かれている React を iOS や Android にそのまま表示させることはできません。また、iOS や Android のモバイル端末には Bluetooth や位置情報機能など、端末に備わっているネイティブ機能があります。JavaScript だけでモバイルアプリがかけるプラットフォームである以上、ネイティブ機能も JavaScript から呼び出せる必要があります。React Native では、React で書かれた見た目や JavaScript で書かれたメソッド呼び出しをネイティブ側とつなぐために Bridge と呼ばれる中間層がありました。

f:id:daiki-sato:20200218224156p:plain

Bridge 間の通信は JSON を通じて行われます。JavaScript のコードもネイティブ側のコードも JSON を通じて非同期的に通信を行います。 ネイティブ側では UIModuleManager というモジュールが UI を描画します。React Native の View は FlexBox レイアウトで描かれますが、ネイティブ側には FlexBox レイアウトの知識がありません。そこで、Yoga と言う Facebook 製のレイアウトエンジンを使って受け取った Style の情報を処理して、その情報を UIModuleManager に渡します。ネイティブ機能の呼び出しもまた JavaScript から Bridge を通して非同期的にネイティブコードを呼び出す作りになっています。

React Native は長い間この設計で動いていました。JavaScript と ネイティブ側のコードが Bridge を挟んでお互いへの関心なく動作していました。しかし、この設計には問題がありました。Bridge 間のすべての通信は非同期で行われるため、ネイティブ側のコードはいつコードが呼ばれても良いように、すべての Module を起動時に読み込む必要があります。また、Bridge は一つしか無いので、非常に多くの処理を行っている時、ボトルネックになり得ます。View の部分も、一度 UIModuleManager が JSON を受け取ってからそれを Yoga に持っていき、処理されたものを扱う必要があったため、コストがかかっていました。

これらの問題を解決するために、React Native Re-architecture がはじまりました。

React Native Re-architecture

React Native の新しい設計は5つの段階に分けて行われています。

  • JSI
  • TurboModule (Native Moduleの改善)
  • React Native Fabric (View Renderの改善)
  • CodeGen
  • Removal of Bridge (アプリ起動時間の改善)

JSI

JSI とは JavaScript Interface の略で、Facebook が作成している JavaScript Engine をラップするレイヤー層です。React Native は従来 WebKit で使われている JSC という JavaScript Engine が使われてきました。他の JavaScript Engine の例としては Chrome や Node で JS 動かすのに使われている Google の V8 が挙げられます。JSI の誕生によって、React Native では内部で使用する JavaScript Engine を選べるようになりました。

React Native を V8 で動かす react-native-v8 というライブラリもあります。

また、Facebook はより軽量でスペックの低い端末でも快適に動かすための Hermes という新しい JavaScript Engine を独自で開発しています。

JSI はすでに大部分が実装されていて、React Native のコアリポジトリの中に実装があります。

また、JSI のもう一つの大きな特徴は、C++ のホストオブジェクトへの参照を持っており、C++ のメソッドをそのまま実行できると言うところにあります。これは、Bridge を使っていた時に問題になっていたすべての実行が非同期になってしまうことを解決するものとなります。

また、C++ は iOS と Android がそのまま使用できる数少ない言語となります。Android の Java のコードは JNI (Java Native Interface) を通して C++ に変換されて実行されています。iOS の Objective-C はその名の通り C++ と緩和性の良い言語なので、そのまま使うことができます。

C++ は他の多くのプラットフォームもサポートしている言語なので、iOS や Android にとどまらず、色んなプラットフォームに適応出来るようになります。Nintendo Switch のゲームが React Native で書ける未来もあるかもしれません。

Turbo Modules

Turbo Modules は上記の JSI を使って、Bluetooth や位置情報などの端末のネイティブ機能の呼び出しを同期的に行っていく改善です。同期的に呼び出しが行えると言うことは、必要な時に呼び出しを行えば良いので、起動時にすべての modules を読み込む必要はなくなります。これでアプリの起動は劇的に早くなります。また、同期的に呼ばれることによって、その機能のパフォーマンスも改善します。

Turbo Modules はすでに React Native のコア部分に実装されているネイティブ機能においては実装されてています。 react-native-ble-manager など、サードパーティが作成している Native Module に関してはまだ適応されていませんが、Turbo Modules の部分が OSS として切り離されたら、サードパーティの Native Modules も Turbo Modules が適応できるようになります。

React Native Fabric

React Native Fabric は同じく JSI を使って、View の部分を更新を部分的に同期的に行う仕組みとなります。 この実装は、描画の優先順位を操作できる React Concurrent Mode によって実現可能となります。多くのアイテムがあるFlatListを高速でスクロールした時など、描画が優先されるべき状態において、適切な描画を行うようになります。 また、レイアウト部分を司る Yoga は C++ 製なので、C++ の呼び出しを直接行える JSI が間にあることによって、無駄な通信を減らし、パフォーマンスが改善されます。

React Native Fabric は React Concurrent Mode が本格的に使えるようになったら組み込まれるようになります。 実装は 2020年中旬を予定しています。

CodeGen

Turbo Modules と React Native Fabric は両方 JSI を使うことが前提となっています。JSI は前述したとおり、C++ を直接呼び出せることによって高いパフォーマンスを出すことが出来るものです。C++ は静的型言語なので、これを実現するには型が必須となります。TypeScript や FlowType を使って、JS に型を付けて、C++ にも型を付けるとなると手間が大きいため、JS の型情報から C++ の型情報を作成する CodeGen と言うツールが作成されています。React Native は内部で FlowType を使っていますが、CodeGen は TypeScript の型にも適用可能となっています。FlowType の変換には FlowType の Parser、TypeScript の型には TypeScript の Parser を使って型を変換します。ただ、曖昧な型は C++ では許されないので、必然的に any を排除する必要が出てきます。

CodeGen のもう一つの使い方として、型の Diff を取ることによってアプリのアップデートが CodePush 可能かどうかを判別することができます。JS のコードの変化によって、Native の呼び出しへの型の変更が無ければ、CodePush のみでアップデートをかけられると判断することができます。

CodeGen のコードも今は React Native のコア部分に実装されています。これもまた、ツールとして切り出されると認識しています。

Removal of the Bridge

内部の設計が Bridge から JSI に切り替わったら、いよいよ Bridge は取り除かれます。前述の通り、Bridge がある状態では、すべての Native Modules を事前に読み込む必要があるため、React Native のアプリの起動はネイティブアプリよりも時間がかかります。Bridge が取り除かれることによって、アプリの起動は劇的に早くなるでしょう。とはいえ、後方互換性も保つ為に、いきなり Bridge が消えるようなことはありません。コア部分の実装がすべて Bridge 無しで起動出来るように、そしてサードパーティの対応の時間も考慮すると、しばらく Bridge は残るでしょう。

Bridge の撤去作業は2020年後半に開始され、まだ少し時間が掛かりそうです。

まとめ

2018年から言われ続けていた React Native の Re-architecture ですが、React の Concurrent Mode も実装されてくる中、2020年には大きな動きを見せてきています。JSI の登場によって、iOS や Android のより深いところに直接触りに行けるので、従来の iOS や Android アプリよりも一部パフォーマンスが出るようになることも夢では無いかもしれません。最近は Microsoft も React Native Windows に力を入れていたり React Native 自体の改善も積極的に行っています。Turbo Modules への移行などは自分も積極的に Contribute していくつもりです。React Native の今後の改善を楽しみにしていてください。

Auther
じぇしー
Naturalclar(Jesse K.) (@natural_clar) | Twitter

React Native + FirebaseのSNSログイン機能の実装(GoogleとFacebook)

どうも、サトウダイキです。 前回の続きです。React Native + Firebaseのログイン実装について記載してきます。

前回はメールアドレスと電話番号の実装をしました。

tech.maricuru.com

今回はFacebookアカウントとGoogleアカウントでのログインについです。

Facebookアカウントでのログイン

FacebookログインはFirebaseの公式サイト通りやっていけば簡単に実装できます。公式サイトも合わせて確認しながら実装してみてください。

Authenticate Using Facebook Login with JavaScript  |  Firebase

Facebook for Developersの登録

まずは、Facebook for Developers サイトに登録して、アプリのアプリ ID とアプリシークレットを取得します。

Facebook for Developers

f:id:daiki-sato:20200207063245p:plain
topページ

f:id:daiki-sato:20200207063405p:plain
アプリの登録

f:id:daiki-sato:20200207063426p:plain
設定

Firebaseの設定

続いて、Fireabaseの設定です。

コンソールに移動して [Authentication] セクションを開きます。
[ログイン方法] タブで [Facebook] を有効にし、Facebook から取得した [アプリ ID] と [app secret] を指定します。

次に、Facebook for Developers サイトの [Product Settings] > [Facebook Login] 構成にある Facebook アプリ設定ページで、OAuth リダイレクト URI(my-app-12345.firebaseapp.com/__/auth/handler など)が [OAuth redirect URIs] のうちの 1 つとしてリストされていることを確認します。

パッケージのインストール

ターミナルを立ち上げ、今回必要なパッケージをインストールします。

yarn add expo-facebook

ロジックの実装

ここからは実装です。ロジック部分から作成していきます。

firebase.tsに追記

export const facebookConfig = { ApplicationKey: '-----' };

export const { FacebookAuthProvider } = firebase.auth;

※ApplicationKey: '-----' のところは自身の値を入力してください。

auth.tsに追記

import firebase, {
  facebookConfig,
  FacebookAuthProvider,
} from '../configs/firebase';

// Facebookでのユーザ登録
export const facebookSignUp = async () => {
  await Facebook.initializeAsync(facebookConfig.ApplicationKey, '-----');
  const { type, token } = await Facebook.logInWithReadPermissionsAsync(
    facebookConfig.ApplicationKey,
    {
      permissions: ['public_profile'],
    }
  ).catch(e => {
    console.log(e);
    throw e;
  });

  if (type === 'success') {
    try {
      const credential = FacebookAuthProvider.credential(token);
      const user = await firebase.auth().signInWithCredential(credential);
      return user;
    } catch (error) {
      console.log(error);
    }
  }
  return null;
};

※ApplicationKey: '-----' のところは自身の値を入力してください。

大きな流れとしては、initializeAsyncでログインして、signInWithCredentialでサインインします。ExpoとFirebse が関数を用意してくれているので非常に助かります。

UIの実装

続いてUI部分の実装です。

SignUpScreen.tsxに追記

import {
  emailSignUp,
  phoneSignUp,
  * facebookSignUp, *
} from '../utils/auth';

  ---- 中略 ---- 

  const onPressFacebookSignIn = async (): Promise<void> => {
    const user = await facebookSignUp();
    console.log(user);
  };

  ---- 中略 ---- 

<Button title="Facebook Login" onPress={onPressFacebookSignIn} />

ボタンを押した時に、先ほど作成したfacebookSignUpを呼び出します。

完成

f:id:daiki-sato:20200207062810p:plain
Facebookログインボタン追加

f:id:daiki-sato:20200207062852p:plain
ボタン押下後

f:id:daiki-sato:20200207062918p:plain
ログイン画面

これでFacebookログインは完成です。

Googleアカウントログイン

最後にGoogleアカウントでのログインについてです。こちらはFacebookログインほど簡単にはできません。実装量が多いです。

Google Developに登録

まずはGoogle Developコンソールにログインして、必要な作業をしていきます。

Google Cloud Platform

Create Credentialsをクリック

f:id:daiki-sato:20200207064948p:plain
Credentials

Credentialsを選択。 こちらの方法はFirebaseでは完結せず、Developpers Consoleでプロジェクトを作成し、認証情報を追加する必要があります。 [認証情報]タブ→[認証情報を作成]→[OAuthクライアントID]を選択しクライアントIDを作成します。Androidの場合はパッケージ名を、iOSの場合はバンドルIDをhost.exp.exponentと入力します。

f:id:daiki-sato:20200207063518p:plain

f:id:daiki-sato:20200207063535p:plain

f:id:daiki-sato:20200207063554p:plain

f:id:daiki-sato:20200207063614p:plain

パッケージのインストール

今回の実装に必要はパッケージをインストールします。

yarn add expo-google-app-auth

 ロジックの実装

firabase.tsの追記

export const { FacebookAuthProvider, GoogleAuthProvider } = firebase.auth;

auth.tsの追記

import * as Google from 'expo-google-app-auth';

import firebase, {
  facebookConfig,
  FacebookAuthProvider,
  * GoogleAuthProvider,*
} from '../configs/firebase';

// Googelでのユーザ登録
const isUserEqual = (
  googleUser: Google.LogInResult,
  firebaseUser: firebase.User
): boolean => {
  if (firebaseUser) {
    const { providerData } = firebaseUser;
    // eslint-disable-next-line no-plusplus
    for (let i = 0; i < providerData.length; i++) {
      if (
        providerData[i].providerId ===
          firebase.auth.GoogleAuthProvider.PROVIDER_ID &&
        providerData[i].uid === googleUser.getBasicProfile().getId()
      ) {
        return true;
      }
    }
  }
  return false;
};

const onSignInGoolge = (googleUser: Google.LogInResult): void => {
  const unsubscribe = firebase.auth().onAuthStateChanged(firebaseUser => {
    unsubscribe();
    if (!isUserEqual(googleUser, firebaseUser)) {
      const credential = firebase.auth.GoogleAuthProvider.credential(
        googleUser.idToken,
        googleUser.accessToken
      );
      firebase
        .auth()
        .signInWithCredential(credential)
        .then(() => {
          console.log('success');
        })
        .catch(({ massage }) => alert('messgage', massage));
    } else {
      console.log('User already signed-in Firebase.');
    }
  });
};

export const goolgeSignUp = async () => {
  try {
    const result = await Google.logInAsync({
      behavior: 'web',
      androidClientId:
        '--------',
      iosClientId:
        '--------',
      scopes: ['profile', 'email'],
    });

    if (result.type === 'success') {
      onSignInGoolge(result);
      return result.accessToken;
    }
    return { cancelled: true };
  } catch (e) {
    return { error: true };
  }
};

※ --------の箇所は先ほ取得した自身の値を設定してください。
処理の大きな流れは、Googleアカウントにログインして、成功したらそのままFirebaseに登録しております。

 UIの実装

今作成したロジックを画面から呼び出せるようにします。

SignUpScreen.tsxの追記

import {
  emailSignUp,
  goolgeSignUp,
  phoneSignUp,
  facebookSignUp,
} from '../utils/auth';


  ---中略 ----

   const onPressGoolgeSignUp = async (): Promise<void> => {
    goolgeSignUp();
  };

  ---中略 ----

      <Button title="GoogleSignUp" onPress={onPressGoolgeSignUp} />

これでGoogleログインも完了です。

無事完了しました。最後に最終的コードを貼っておきます。

最終的なコード

configs/firebase.ts

import firebase from 'firebase';

export const firebaseConfig = {
  apiKey: '----',
  authDomain: '----',
  databaseURL: '----',
  projectId:'----',
  storageBucket: '----',
  messagingSenderId: '----',
  appId: '----',
  measurementId: '----',
};
export const facebookConfig = { ApplicationKey: '----' };
export const googleConfig = {
  androidClientId:
    '----',
  iosClientId:
    '----',
};

export const { FacebookAuthProvider, GoogleAuthProvider } = firebase.auth;

export default firebase;

utils/auth.ts

import * as Facebook from 'expo-facebook';
import { Linking } from 'expo';
import * as WebBrowser from 'expo-web-browser';
import * as Google from 'expo-google-app-auth';
import firebase, {
  facebookConfig,
  googleConfig,
  FacebookAuthProvider,
  GoogleAuthProvider,
} from '../configs/firebase';

// メールでのユーザ登録
export const emailSignUp = (email: string, password: string): void => {
  firebase
    .auth()
    .createUserWithEmailAndPassword(email, password)
    .then(user => {
      if (user) {
        console.log('Success to Signup');
      }
    })
    .catch(error => {
      console.log(error);
    });
};

// 電話番号で登録
export const phoneSignUp = async (
  phoneNumber: string
): Promise<firebase.auth.ConfirmationResult | null> => {
  const captchaUrl = `https://------/captcha.html?appurl=${Linking.makeUrl(
    ''
  )}`;

  let token: string | null = null;
  const listener = ({ url }): void => {
    WebBrowser.dismissBrowser();
    const tokenEncoded = Linking.parse(url).queryParams.token;
    if (tokenEncoded) token = decodeURIComponent(tokenEncoded);
  };

  Linking.addEventListener('url', listener);
  await WebBrowser.openBrowserAsync(captchaUrl);
  Linking.removeEventListener('url', listener);
  if (token) {
    // fake firebase.auth.ApplicationVerifier
    const captchaVerifier = {
      type: 'recaptcha',
      verify: () => Promise.resolve(token),
    };
    try {
      const confirmationResult = await firebase
        .auth()
        .signInWithPhoneNumber(phoneNumber, captchaVerifier);
      return confirmationResult;
    } catch (e) {
      console.warn(e);
      return null;
    }
  }
  return null;
};

// Facebookでのユーザ登録
export const facebookSignUp = async () => {
  await Facebook.initializeAsync(facebookConfig.ApplicationKey, '------');
  const { type, token } = await Facebook.logInWithReadPermissionsAsync(
    facebookConfig.ApplicationKey,
    {
      permissions: ['public_profile'],
    }
  ).catch(e => {
    console.log(e);
    throw e;
  });

  if (type === 'success') {
    try {
      const credential = FacebookAuthProvider.credential(token);
      const user = await firebase.auth().signInWithCredential(credential);
      return user;
    } catch (error) {
      console.log(error);
    }
  }
  return null;
};

// Googelでのユーザ登録
const isUserEqual = (
  googleUser: Google.LogInResult,
  firebaseUser: firebase.User
): boolean => {
  if (firebaseUser) {
    const { providerData } = firebaseUser;
    // eslint-disable-next-line no-plusplus
    for (let i = 0; i < providerData.length; i++) {
      if (
        providerData[i].providerId === GoogleAuthProvider.PROVIDER_ID &&
        providerData[i].uid === googleUser.getBasicProfile().getId()
      ) {
        return true;
      }
    }
  }
  return false;
};

const onSignInGoolge = (googleUser: Google.LogInResult): void => {
  const unsubscribe = firebase.auth().onAuthStateChanged(firebaseUser => {
    unsubscribe();
    if (!isUserEqual(googleUser, firebaseUser)) {
      const credential = GoogleAuthProvider.credential(
        googleUser.idToken,
        googleUser.accessToken
      );
      firebase
        .auth()
        .signInWithCredential(credential)
        .then(() => {
          console.log('success');
        })
        .catch(e => console.log('e', e));
    } else {
      console.log('User already signed-in Firebase.');
    }
  });
};

export const goolgeSignUp = async () => {
  try {
    const result = await Google.logInAsync({
      behavior: 'web',
      androidClientId: googleConfig.androidClientId,
      iosClientId: googleConfig.iosClientId,
      scopes: ['profile', 'email'],
    });

    if (result.type === 'success') {
      onSignInGoolge(result);
      return result.accessToken;
    }
    return { cancelled: true };
  } catch (e) {
    return { error: true };
  }
};

export default firebase;

screens/SignUpScreen.tsx

import React, { useState } from 'react';
import { View, StyleSheet, TextInput, Button } from 'react-native';
import { NavigationStackProp } from 'react-navigation-stack';
import {
  emailSignUp,
  goolgeSignUp,
  phoneSignUp,
  facebookSignUp,
} from '../utils/auth';

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    padding: 16,
  },
  textInput: {
    fontSize: 14,
    color: 'black',
    width: '100%',
    borderBottomWidth: 1,
    borderBottomColor: 'black',
    margin: 16,
  },
});

const SignUpScreen: React.FC<{ navigation: NavigationStackProp }> = ({
  navigation,
}): JSX.Element => {
  const [email, setEmail] = useState();
  const [password, setPassword] = useState();
  const [phoneNumber, setPhoneNumber] = useState('');
  const [confirmResult, setConfirmResult] = useState(null);

  const onPressGoolgeSignUp = async (): Promise<void> => {
    await goolgeSignUp();
  };

  const onPressPhoneSignUp = async (): Promise<void> => {
    const result = await phoneSignUp(phoneNumber);
    if (result) {
      setConfirmResult(result);
      navigation.navigate('VerificationCode', { confirmResult: result });
    } else {
      console.log('error');
    }
  };

  const onPressFacebookSignIn = async (): Promise<void> => {
    const user = await facebookSignUp();
    console.log(user);
  };

  return (
    <View style={styles.container}>
      <TextInput
        style={styles.textInput}
        value={email}
        onChangeText={(text: string): void => setEmail(text)}
        editable
        maxLength={50}
        placeholder="Email"
        autoCapitalize="none"
        keyboardType="email-address"
        returnKeyType="done"
      />
      <TextInput
        style={styles.textInput}
        value={password}
        onChangeText={(text: string): void => setPassword(text)}
        editable
        maxLength={20}
        placeholder="Password"
        autoCapitalize="none"
        secureTextEntry
        returnKeyType="done"
      />
      <Button title="登録" onPress={(): void => emailSignUp(email, password)} />

      <TextInput
        style={styles.textInput}
        value={phoneNumber}
        onChangeText={(text: string): void => setPhoneNumber(text)}
        editable
        maxLength={50}
        placeholder="Phone"
        autoCapitalize="none"
        keyboardType="phone-pad"
        returnKeyType="done"
      />
      <Button title="PhoneSignUp" onPress={onPressPhoneSignUp} />
      <Button title="Facebook Login" onPress={onPressFacebookSignIn} />
      <Button title="GoogleSignUp" onPress={onPressGoolgeSignUp} />
    </View>
  );
};

export default SignUpScreen;

src/VerificationCodeScreen.tsx

import React, { useState } from 'react';
import { View, Text, StyleSheet, TextInput, Button } from 'react-native';
import { NavigationStackProp } from 'react-navigation-stack';

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    padding: 16,
  },
  textInput: {
    fontSize: 14,
    color: 'black',
    width: '100%',
    borderBottomWidth: 1,
    borderBottomColor: 'black',
    margin: 16,
  },
});

const VerificationCodeScreen: React.FC<{ navigation: NavigationStackProp }> = ({
  navigation,
}): JSX.Element => {
  const [code, setCode] = useState('');

  const onPressConfirmCode = (): void => {
    if (code.length) {
      const { params = {} } = navigation.state;

      params.confirmResult
        .confirm(code)
        .then(user => {
          console.log('Code Confirmed!');
        })
        .catch(error => console.log(`Code Confirm Error: ${error.message}`));
    }
  };

  return (
    <View style={styles.container}>
      <Text>Enter verification code below:</Text>
      <TextInput
        style={styles.textInput}
        value={code}
        onChangeText={(value): void => setCode(value)}
        editable
        maxLength={50}
        placeholder="Code"
        autoCapitalize="none"
        keyboardType="numeric"
        returnKeyType="done"
      />
      <Button
        title="Confirm Code"
        color="#841584"
        onPress={onPressConfirmCode}
      />
    </View>
  );
};

export default VerificationCodeScreen;

public/captcha.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Welcome to Firebase Hosting</title>

  <!-- update the version number as needed -->
  <script defer src="/__/firebase/7.8.0/firebase-app.js"></script>
  <!-- include only the Firebase features as you need -->
  <script defer src="/__/firebase/7.8.0/firebase-auth.js"></script>
  <script defer src="/__/firebase/7.8.0/firebase-database.js"></script>
  <script defer src="/__/firebase/7.8.0/firebase-messaging.js"></script>
  <script defer src="/__/firebase/7.8.0/firebase-storage.js"></script>
  <!-- initialize the SDK after all desired features are loaded -->
  <script defer src="/__/firebase/init.js"></script>

  <style media="screen">
    body { background: #ECEFF1; color: rgba(0,0,0,0.87); font-family: Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 0; }
  </style>
</head>
<body>
<p>Please, enter captcha for continue<p/>
<button id="continue-btn" style="display:none">Continue to app</button>
<script>
  function getToken(callback) {
    const containerId = 'captcha';
    const container = document.createElement('div');
    container.id = containerId;
    document.body.appendChild(container);
    var captcha = new firebase.auth.RecaptchaVerifier(containerId, {
      'size': 'normal',
      'callback': function(token) {
        callback(token);
      },
      'expired-callback': function() {
        callback('');
      }
    });
    captcha.render().then(function() {
      captcha.verify();
    });
  }

  function sendTokenToApp(token) {
    const baseUri = decodeURIComponent(location.search.replace(/^\?appurl\=/, ''));
    const finalUrl = location.href = baseUri + '/?token=' + encodeURIComponent(token);
    const continueBtn = document.querySelector('#continue-btn');
    continueBtn.onclick = function() {
      window.open(finalUrl, '_blank');
    };
    continueBtn.style.display = 'block';
  }

  document.addEventListener('DOMContentLoaded', function() {
    getToken(sendTokenToApp);
  });
</script>
</body>
</html>  

※ ---- ----の箇所は全て自身の値に書き換えてください。

まとめ

いかがだったでしょうか。複雑な実装も多かったと思いますが、これで一通りのログイン制御はできると思います。ではまた次回。

React Native + Firebaseのログイン機能の実装(メールアドレスと電話番号)

どうも、サトウダイキです。

今日はReact NativeでのFirebaseのログインの実装について書いていきます。4つの方法を実装していきます。

  • メールアドレス
  • 電話番号
  • Facebookアカウント
  • Googleアカウント

この記事ではメールアドレスと電話番号について書きます。続きはこちらで。

https://blog.hatena.ne.jp/ducklingsinc/ducklings.hateblo.jp/edit

tech.maricuru.com

※Expoを利用しています。Versionは36です。2020年2月の時の実装です。

メールアドレス

まずはメールアドレスとパスワードのログインから実装していきます。

Firebaseの設定

Firebaseのプロジェクを作成します。

下記URLからFirebaseプロジェクトを作成します。
ログイン - Google アカウント

f:id:daiki-sato:20200203055201p:plain
Fiebaseプロジェクトの作成

画面の指示通り進めていけば作成できます。

f:id:daiki-sato:20200203061424p:plain
完了後の画面

f:id:daiki-sato:20200203061522p:plain
ログイン方法の設定

[Authentication]→[ログイン方法]→[メール / パスワード] の順でクリック。 有効にするにタブを移動します。メールリンク(パスワードなしでログイン)も有効にしてもOKです。

続いて、アプリの作成を行います。

[Project Overview] → [アプリを追加] → [Web]をクリック

f:id:daiki-sato:20200203070637p:plain
Webアプリの登録

アプリ名を入力して「アプリを登録」をクリック

f:id:daiki-sato:20200203070952p:plain
config画面

firebaseConfigを値をコピーしておきます。これでWebブラウザでのFirebase側の設定は終わりです。

コードの実装

ここからはコーディングです。

ターミナルを立ち上げて、firebaseをinstallします。

色々な記事を読むと、react-native-firebaseを使っているコードと、firebaseを使っているコードがあります。どちらでも実装できますが、今回はfirebaseを使います。

react-native-firebase - npm
firebase - npm

yarn add firebase

configファイルの実装

次にconfigファイルを作成します。

src/configs/firebase.js

import firebase from 'firebase';

export const firebaseConfig = {
  apiKey: '----',
  authDomain: ----',
  databaseURL: ----',
  projectId: ----',
  storageBucket: ----',
  messagingSenderId: ----',
  appId: ----',
  measurementId: ----',
};

export default firebase;

※ ----の部分はfirebaseは自身のfirebaseアプリの設定をしてください。 先ほどコピーしたfirebaseConfigの値を貼り付けます。

ロジックの実装

ロジック部分のコードです。

src/utils/auth.ts

import firebase from '../configs/firebase'; 

// メールアドレスでユーザ登録
export const emailSignUp = (email: string, password: string): void => {
  firebase
    .auth()
    .createUserWithEmailAndPassword(email, password)
    .then(user => {
      if (user) {
        console.log('Success to Signup');
      }
    })
    .catch(error => {
      console.log(error);
    });
};

export default firebase;  

firebaseが予め用意している、createUserWithEmailAndPasswordにemailとpasswordを渡すだけです。

UIの実装

src/utils/auth.ts

import React, { useState } from 'react';
import { View, StyleSheet, TextInput, Button } from 'react-native';
import {
  emailSignUp,
} from '../utils/auth';

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    padding: 16,
  },
  textInput: {
    fontSize: 14,
    color: 'black',
    width: '100%',
    borderBottomWidth: 1,
    borderBottomColor: 'black',
    margin: 16,
  },
});

const SignUpScreen: React.FC = (): JSX.Element => {
  const [email, setEmail] = useState();
  const [password, setPassword] = useState();
  
  return (
    <View style={styles.container}>
      <TextInput
        style={styles.textInput}
        value={email}
        onChangeText={(text: string): void => setEmail(text)}
        editable
        maxLength={50}
        placeholder="Email"
        autoCapitalize="none"
        keyboardType="email-address"
        returnKeyType="done"
      />
      <TextInput
        style={styles.textInput}
        value={password}
        onChangeText={(text: string): void => setPassword(text)}
        editable
        maxLength={20}
        placeholder="Password"
        autoCapitalize="none"
        secureTextEntry
        returnKeyType="done"
      />
      <Button title="登録" onPress={(): void => emailSignUp(email, password)} />
    </View>
  );
};

export default SignUpScreen;

特別、難しい処理は何もしていないです。これで終わりです。

確認

f:id:daiki-sato:20200203073856p:plain
アカウント作成画面

シミュレータを立ち上げ、メールアドレスとパスワードを入力して、登録ボタンを押します。

f:id:daiki-sato:20200203074032p:plain
firebaseで確認

正しく設定されると、firebaseにユーザが登録されています。これでメールアドレスでの登録は完了です。

電話番号登録

続いて電話番号を使ったログイン方法についてです。

Webブラウザのfirebaseに移動して、Authenticationから電話番号を有効にします。

f:id:daiki-sato:20200205061255p:plain
電話番号ログインの設定

テスト用の電話番号はまだ登録しないで大丈夫です。これは、1度成功した後にログインの動作を省くために使います。

承認済みドメインの追加

そのまま画面を下にスクロールしていき、[認証済ドメイン]の設定を行います。これ設定忘れるとハマりますので、注意してください。設定する値は、IPアドレスです。

f:id:daiki-sato:20200208214641p:plain
ipアドレスの追加

reCAPTYACの設定

電話番号のログインの実装にはreCAPTYACが必要です。これはfirebaseへの不正のログインを防ぐためです。

reCAPTYACとは、「私はロボットではありません」といったあれです。

f:id:daiki-sato:20200208222316p:plain
reCAPTYAC

ただ、2020年2月時点では、React NativeとFirebaseでのreCAPTYACをうまい具合にやってくれるものが用意されていないので、自分で実装するしかありません。ということで、実際に作っていきます。

まずはターミナルを立ち上げfirebase-toolsをインストールします。

npm install -g firebase-tools  
firebase login  

続いて、実装中のアプリのカレントディレクトに移動して、下記を実行。

firebase init   

f:id:daiki-sato:20200205062558p:plain
firebase initの後の動作

initiを押した後、上下方向キーを使い、hostingを選択し、スペースを押下

? What do you want to use as your public directory? (public)  と聞かれたので、(これは皆さんの好きな場所でokです)

? Which project do you want to add? 対処のプロジェクト

? Configure as a single-page app (rewrite all urls to /index.html)? (y/N) これを一旦y

すると下記が自動で生成されます。

  • firebase.json
  • .firebaserc
  • public/index.html

captchaの実装

public/captcha.htmlを新規作成します。

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Welcome to Firebase Hosting</title>

  <!-- update the version number as needed -->
  <script defer src="/__/firebase/7.8.0/firebase-app.js"></script>
  <!-- include only the Firebase features as you need -->
  <script defer src="/__/firebase/7.8.0/firebase-auth.js"></script>
  <script defer src="/__/firebase/7.8.0/firebase-database.js"></script>
  <script defer src="/__/firebase/7.8.0/firebase-messaging.js"></script>
  <script defer src="/__/firebase/7.8.0/firebase-storage.js"></script>
  <!-- initialize the SDK after all desired features are loaded -->
  <script defer src="/__/firebase/init.js"></script>

  <style media="screen">
    body { background: #ECEFF1; color: rgba(0,0,0,0.87); font-family: Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 0; }
  </style>
</head>
<body>
<p>Please, enter captcha for continue<p/>
<button id="continue-btn" style="display:none">Continue to app</button>
<script>
  function getToken(callback) {
    const containerId = 'captcha';
    const container = document.createElement('div');
    container.id = containerId;
    document.body.appendChild(container);
    var captcha = new firebase.auth.RecaptchaVerifier(containerId, {
      'size': 'normal',
      'callback': function(token) {
        callback(token);
      },
      'expired-callback': function() {
        callback('');
      }
    });
    captcha.render().then(function() {
      captcha.verify();
    });
  }

  function sendTokenToApp(token) {
    const baseUri = decodeURIComponent(location.search.replace(/^\?appurl\=/, ''));
    const finalUrl = location.href = baseUri + '/?token=' + encodeURIComponent(token);
    const continueBtn = document.querySelector('#continue-btn');
    continueBtn.onclick = function() {
      window.open(finalUrl, '_blank');
    };
    continueBtn.style.display = 'block';
  }

  document.addEventListener('DOMContentLoaded', function() {
    getToken(sendTokenToApp);
  });
</script>
</body>
</html>  

よりfirebaseのバージョン部分はindex.htmlと同一のを使ってください。

ターミナルを立ち上げてデプロイします。

firebase deploy

成功するとfirebaseのコンソールでこんな感じになります。

f:id:daiki-sato:20200205064136p:plain
firebasehosting成功

パッケージのインストール

さて、ここからは実装に入ります。まずは必要なものをインストールします。

yarn add expo
yarn add expo-web-browser

ロジックの実装

auth.tsに電話番号ログイン処理を追記します。

import { Linking } from 'expo';
import * as WebBrowser from 'expo-web-browser';

// 電話番号で登録
export const phoneSignUp = async (
  phoneNumber: string
): Promise<firebase.auth.ConfirmationResult | null> => {
  const captchaUrl = `https://APPNAME.firebaseapp.com/captcha.html?appurl=${Linking.makeUrl(
    ''
  )}`;

  let token: string | null = null;
  const listener = ({ url }): void => {
    WebBrowser.dismissBrowser();
    const tokenEncoded = Linking.parse(url).queryParams.token;
    if (tokenEncoded) token = decodeURIComponent(tokenEncoded);
  };

  Linking.addEventListener('url', listener);
  await WebBrowser.openBrowserAsync(captchaUrl);
  Linking.removeEventListener('url', listener);
  if (token) {
    // fake firebase.auth.ApplicationVerifier
    const captchaVerifier = {
      type: 'recaptcha',
      verify: () => Promise.resolve(token),
    };
    try {
      const confirmationResult = await firebase
        .auth()
        .signInWithPhoneNumber(phoneNumber, captchaVerifier);
      return confirmationResult;
    } catch (e) {
      console.warn(e);
      return null;
    }
  }
  return null;
};

※APPNAMEの箇所は書き換えが必要です。

UIの実装

SignUpScreen.tsxを追記します。

import {
  emailSignUp,
  *phoneSignUp*
} from '../utils/auth';

---中略---  

 const [phoneNumber, setPhoneNumber] = useState('');
 const [confirmResult, setConfirmResult] = useState(null);
 const onPressPhoneSignUp = async (): Promise<void> => {
    const result = await phoneSignUp(phoneNumber);
    if (result) {
      setConfirmResult(result);
      navigation.navigate('VerificationCode', { confirmResult: result });
    } else {
      console.log('error');
    }
  };

---中略---  
    <TextInput
        style={styles.textInput}
        value={phoneNumber}
        onChangeText={(text: string): void => setPhoneNumber(text)}
        editable
        maxLength={50}
        placeholder="Phone"
        autoCapitalize="none"
        keyboardType="phone-pad"
        returnKeyType="done"
      />
      <Button title="PhoneSignUp" onPress={onPressPhoneSignUp} /></b>

VerificationCodeScreen

import React, { useState } from 'react';
import { View, Text, StyleSheet, TextInput, Button } from 'react-native';
import { NavigationStackProp } from 'react-navigation-stack';

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    padding: 16,
  },
  textInput: {
    fontSize: 14,
    color: 'black',
    width: '100%',
    borderBottomWidth: 1,
    borderBottomColor: 'black',
    margin: 16,
  },
});

const VerificationCodeScreen: React.FC<{ navigation: NavigationStackProp }> = ({
  navigation,
}): JSX.Element => {
  const [code, setCode] = useState('');

  const onPressConfirmCode = (): void => {
    if (code.length) {
      const { params = {} } = navigation.state;

      params.confirmResult
        .confirm(code)
        .then(user => {
          console.log('Code Confirmed!');
        })
        .catch(error => console.log(`Code Confirm Error: ${error.message}`));
    }
  };

  return (
    <View style={styles.container}>
      <Text>Enter verification code below:</Text>
      <TextInput
        style={styles.textInput}
        value={code}
        onChangeText={(value): void => setCode(value)}
        editable
        maxLength={50}
        placeholder="Code"
        autoCapitalize="none"
        keyboardType="numeric"
        returnKeyType="done"
      />
      <Button
        title="Confirm Code"
        color="#841584"
        onPress={onPressConfirmCode}
      />
    </View>
  );
};

export default VerificationCodeScreen;

電話番号を入力して、登録を押す→入力した電話番号に確認コードが飛ぶ。確認コードを入力。こういった流れになります。

電話番号は国際コードから入れなければいけません。また先頭の0も消します。080-1234-5678だとしたら、+818012345678と入力します。

今回はここまで。次回はFacebookログインと、Googleログインについて書いていきます。

続きはこちら

https://tech.maricuru.com/entry/2020/02/09/212813 tech.maricuru.com

ReactNativeのフォルダ構成はどうするべきか?

f:id:daiki-sato:20200126121229j:plain

どうも、佐藤大生です。マリクルのReactNativeエンジニアです。

React Nativeで開発を始める時、フォルダ構成について悩むと思います。

これが正解!といったものは残念ながらありません。試行錯誤の繰り返しです。しかし、一度フォルダ構成を決めてしまうと、後から直すのは大変です。開発を始める前にしっかり決めてから実装しなければなりません。ググって見てもなかなか情報がない。。。

ということで、これがベストではないか!?というものを今日は書いていきます。

条件

下記を導入している時のフォルダ構成です。
・Expo
・TypeScriptで書く
・状態管理はRedux
・ナビゲーションはReact Navigation

フォルダ構成

まずは結論から

  • src
    • components
      • atoms
        • LikeButton.tsx
      • molecules
        • UserHeader.tsx
      • organisms
        • UserInfo.tsx
    • containers
      • atoms
      • molecules
        • UserHeaderContainer.tsx
      • organisms
        • UserInfoContainer.tsx
      • SecondeScreeenContainer.tsx
    • navigations // ナビゲーション
    • screens // ページ
      • FirstScreeen.tsx
      • SecondeScreeen.tsx
    • stores // Redux関連
      • actions
      • reducers
    • styles // 共通のStyle
    • types // TypeScriptの型
    • utlis // 汎用処理
    • App.tsx
  • images // アプリ内で使う画像
  • App.tsx // 初めに参照されるファイル

ポイント① Atomic Designを使う

f:id:daiki-sato:20200126104731p:plain
引用元:https://bradfrost.com/blog/post/atomic-web-design/

Atomic Designとは簡単にいうと、「要素の大きいもの、中くらいのもの、小さいものをフォルダごとに分けていく」というやり方です。

  • Atoms・・・・これ以上細かく分けれない粒子レベル
  • Molecules・・・中くらいのサイズ
  • Organisms・・・大きいサイズ
  • Templates
  • Pages・・・Screen

5つのフォルダに分けて書くことを推奨されています。AtomsとPagesだけ採用しているプロジェクトや、Atoms、Molecules、Organisms、Pagesの4つを使うプロジェクトなどいろいろ見てきました。ここは、プロジェクトごとに異なっている印象があります。

弊社では、Pageはscreensの中に入れております。それ以外はcomponentsの下に、atoms、molecules、organismsと3つに分けています。実際に運用する上で、MoleculesとOrganismsの分け方は難しいです。明確なルール分けがなく直感に頼っているところがあります。

再利用をしやすくするという観点で実装すると、うまく分けれるようになるかと思います。

参考サイト bradfrost.com

ポイント② ComponentとContainerは分ける

弊社では、最初はComponentとContainerを分けずに実装していました。1つのComponentファイルにまとめて書いていましたが、途中で全て切り分けて書き直しました。

コードが読みやすくなるのと、メンテナンスが楽になります。最初から分けておいた方が無難です。

フォルダ構成は、 - containersの下にpagesのものをおく
- containersの下にatoms、molecules、organismsフォルダを作り、componentのものをおく
としていくと分かりやすいです。

またファイル名ですが、Component/Screen名 + Containerとしています。

ポイント③ TypeScriptは絶対に導入する

こちらも弊社では最初導入していませんでした。全てJSで作成していました。現在では型も全て定義した形で書き直しました。まだ完全にはTypeScript化できていませんが。。。

引数に間違った型を設定していると、ワーニングが出るので、「エラー検知がしやすくなること、コードが追いやすくなる」といったメリットがあります。

型の定義はtypes配下にまとめて置いております。

まとめ

私自身個人アプリも作っておりますし、4つプロジェクトにジョインした経験があります。

今のところこのフォルダ構成が一番実装しやすいです。アプリを作り始める前にぜひ検討してしてみてください。ではまた。