React Native ファーストインプレッション

  • 671
    いいね
  • 1
    コメント
この記事は最終更新日から1年以上が経過しています。

React Native 概要

React.js とだいたい同じ作法で、JavaScript で書いたコードが iOS ネイティブアプリとして一応動く。そのフレームワークと開発環境を提供する。

React.js と同じ React を謳っているとおり、JSX で UI コンポーネントを定義するとか、Props や State で View のデータフローを整えるとか、setState() によるデータバインディングとか、諸々が一緒。従って React.js でアプリケーションを作ったことがあれば、学習コストをほとんどかけずにアプリが作れる・・・かも

例えば以下のように、入力値をそのまま画面にエコーするアプリケーション。

これはこんな感じのコードになる。

var React = require('react-native');
var {
  AppRegistry,
  StyleSheet,
  Text,
  View,
  TextInput
} = React;

var Playground = React.createClass({
  getInitialState: function() {
    return {
      text: ""
    };
  },

  _onChangeText: function(text) {
    this.setState({ text: text });
  },

  render: function() {
    return (
      <View style={styles.container}>
        <Text style={styles.welcome}>
          {this.state.text}
        </Text>
        <TextInput
          style={styles.textInput}
          onChangeText={this._onChangeText} />
      </View>
    );
  }
});

var styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#F5FCFF',
  },
  welcome: {
    fontSize: 20,
    textAlign: 'center',
    margin: 10,
  },
  instructions: {
    textAlign: 'center',
    color: '#333333',
  },
  textInput: {
    height: 40,
    borderColor: 'gray',
    borderWidth: 1
  }
});

AppRegistry.registerComponent('Playground', () => Playground);

State や JSX など見覚えのある API で書く。

みてのとおり、UI 構造は JSX で定義して、見た目はスタイルシートっぽいもので宣言的に定義する。

コードは特に別のトランスパイラなどを必要とせず ES6 前提で書くことができる。Arrow Functions や Destructuring Assignment を結構使う。

JavaScript 実行回りのアーキテクチャ

React Native はその名前が「ネイティブ」を謳っているが、JavaScript のコードがコンパイルされてネイティブコードになるわけではなく、内部に JavaScript ランタイム (JavaScriptCore) があって JS はそのランタイム上で動く。

内部には Objective-C で書かれた、JS <=> ネイティブブリッジの実装があり JavaScript ランタイムからはそのブリッジが呼ばれて、UIKit の各種コンポーネントや iOS ネイティブの API が呼ばれるようになっている。

JavaScript ランタイムの実行とネイティブコードは、ドキュメントをみる限り非同期で実行されていて各種ネイティブモジュール群も別スレッドで動いており、パフォーマンス的な劣化はそれほど大きくないと謳っている (ほんとかどうかはわからない)。

この JavaScript ランタイムを持っていてネイティブとブリッジで UIKit を操作する的なアーキテクチャは Titanium Mobile のそれに似ているというのが第一印象だったが、それは表面的にというだけで既存のそれ系アーキテクチャの欠点は解消しているとのこと。詳しくはまだわからない。

少し中身を見る

例えば <WebView /> で作れる組み込みの WebView コンポーネントは WebView.ios.js というファイルに定義されていて、その render() は以下のように <RCTWebView /> コンポーネントを子に持っていて、実質 RCTWebView がその実態。

    var webView =
      <RCTWebView
        ref={RCT_WEBVIEW_REF}
        key="webViewKey"
        style={webViewStyles}
        url={this.props.url}
        shouldInjectAJAXHandler={this.props.shouldInjectAJAXHandler}
        contentInset={this.props.contentInset}
        automaticallyAdjustContentInsets={this.props.automaticallyAdjustContentInsets}
        onLoadingStart={this.onLoadingStart}
        onLoadingFinish={this.onLoadingFinish}
        onLoadingError={this.onLoadingError}
      />;

    return (
      <View style={styles.container}>
        {webView}
        {otherView}
      </View>
    );

そしてその RCTWebView は、

var RCTWebView = createReactIOSNativeComponentClass({
  validAttributes: merge(ReactIOSViewAttributes.UIView, {
    url: true,
    contentInset: {diff: insetsDiffer},
    automaticallyAdjustContentInsets: true,
    shouldInjectAJAXHandler: true
  }),
  uiViewClassName: 'RCTWebView',
});

このように iOS ネイティブコンポーネントのクラスを作る API によって作り出されており、その実装は RCTWebView.m という Objective-C の実装である。

RCTWebView.m 内では

@implementation RCTWebView
{
  RCTEventDispatcher *_eventDispatcher;
  UIWebView *_webView;
}

- (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher
{
  if ((self = [super initWithFrame:CGRectZero])) {
    _automaticallyAdjustContentInsets = YES;
    _contentInset = UIEdgeInsetsZero;
    _eventDispatcher = eventDispatcher;
    _webView = [[UIWebView alloc] initWithFrame:self.bounds];
    _webView.delegate = self;
    [self addSubview:_webView];
  }
  return self;
}

こうして UIWebView をインスタンス化しており、このクラスには UIWebViewDelegate プロトコルが実装されている。

このようにして JSX で参照される React コンポーネントと、ネイティブ側の実装が対応づけられており、実行時には JavaScriptCore でその両者をブリッジするモジュール (おそらく RCTBridge とかその辺) が働く。

開発環境

Xcode は初回のビルドだけに使う。それ以降のコードの変更はシミュレータ上で Cmd + R することで再読込できる。したがって、ビルド待ちがない。ネイティブアプリではあるが Web アプリケーションを作っているようなラピッドプロトタイピング的な感覚で開発できる。このあたりは JavaScript ランタイムでコードを動かしてるからこそできる芸当。

基本は好きなエディタで書く。React Packager という node ベースのサーバーが立ち上がって、エディタと通信してくれる。例えば React のエラー画面からスタックトレースを選択すると Emacs でそれが開いたりする。若干余計なお世話感もあるが。

また、シミュレータで Cmd + D を押すと Chrome がたちあがり Chrome Developer Tool でデバッグできる。localhost:8081/debugger-ui でデバッグ用の Web アプリが動いている。Dev Tool では、React Dev Tools を入れているとコンポーネントの構造をみたりもできる。

このデバッガがどのぐらい便利かは、まだ本格的にデバッグする機会には至ってないので、不明。

サンプル実装

試しに HBFav の基本部分を React Native で実装してみる。

サーバーから JSON over HTTP でフィードを取得し、TableView にそれを表示する。React のチュートリアルにある実装を参考に、もう少し React の作法に従って書いた。簡単ながら、コンポーネント分割、State、Props など React の基本的な要素が詰まっている実装ではある。

https://github.com/naoya/React-HBFav

'use strict';

var React = require('react-native');
var {
  AppRegistry,
  StyleSheet,
  Text,
  View,
  Image,
  ListView,
  NavigatorIOS,
  ActivityIndicatorIOS,
  TouchableHighlight,
  WebView
} = React;

var RCTFav = React.createClass({
  render: function() {
    // FIXME: tintColor does not work
    return (
      <NavigatorIOS
         style={styles.navigator}
         initialRoute={{component: BookmarkListView, title: 'HBFav'}}
         tintColor="#4A90C7"
      />
    );
  }
});

var BookmarkListView = React.createClass({
  getInitialState: function() {
    return {
      bookmarks: null,
      loaded: false
    };
  },

  componentDidMount: function() {
    this.fetchData();
  },

  fetchData: function() {
    fetch('http://feed.hbfav.com/naoya')
    .then((response) => response.json())
    .then((responseData) => {
      this.setState({
        bookmarks: responseData.bookmarks,
        loaded: true
      });
    })
    .done();
  },

  openBookmark: function(rowData) {
    this.props.navigator.push({
      title: rowData.title,
      component: WebView,
      passProps: {url: rowData.link}
    });
  },

  renderLoadingView: function() {
    return (
      <View style={styles.container}>
        <ActivityIndicatorIOS animating={true} size='small' />
      </View>
    );
  },

  render: function() {
    if (!this.state.loaded) {
      return this.renderLoadingView();
    }

    return (
      <BookmarkList
        bookmarks={this.state.bookmarks}
        onPressBookmark={this.openBookmark}
      />
    );
  }
});

var BookmarkList = React.createClass({
  propTypes: {
    bookmarks: React.PropTypes.array.isRequired,
    onPressBookmark: React.PropTypes.func.isRequired
  },

  componentWillMount: function() {
    this.dataSource = new ListView.DataSource({
      rowHasChanged: (row1, row2) => row1 !== row2
    });
  },

  render: function() {
    var dataSource = this.dataSource.cloneWithRows(this.props.bookmarks);
    return (
      <ListView
        dataSource={dataSource}
        renderRow={(rowData) =>
          <Bookmark
             bookmark={rowData}
             onPress={() => this.props.onPressBookmark(rowData)}
          />
        }
        style={styles.listView}
      />
    );
  }
});

var Bookmark = React.createClass({
  propTypes: {
    bookmark: React.PropTypes.object.isRequired,
    onPress: React.PropTypes.func.isRequired
  },

  _onPress: function() {
    this.props.onPress();
  },

  render: function() {
    var bookmark = this.props.bookmark;
    return (
      <TouchableHighlight onPress={this._onPress}>
        <View style={styles.container}>
          <Image
            source={{uri: bookmark.user.profile_image_url}}
            style={styles.profileImage}
          />
          <View style={styles.rightContainer}>
            <Text style={styles.userName}>{bookmark.user.name}</Text>
            <Image
              source={{uri: bookmark.favicon_url}}
              style={styles.favicon}
            />
            <Text style={styles.title}>{bookmark.title}</Text>
          </View>
        </View>
      </TouchableHighlight>
    );
  }
});

var styles = StyleSheet.create({
  navigator: {
    flex: 1,
  },

  container: {
    flex: 1,
    flexDirection: 'row',
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#F5FCFF'
  },

  rightContainer: {
    flex: 1,
    marginLeft: 10
  },

  favicon: {
    width: 16,
    height: 16
  },

  title: {
    fontSize: 15,
    marginBottom: 8,
    textAlign: 'left',
  },

  userName: {
    fontSize: 16,
    fontWeight: 'bold',
    textAlign: 'left',
  },

  profileImage: {
    width: 48,
    height: 48,
    marginLeft: 10,
  },

  listView: {
  }
});

AppRegistry.registerComponent('RCTFav', () => RCTFav);

スタイルシートでレイアウティングするのなんか癖があってうまくない。

感想

ぶっちゃけると、おもちゃとしては面白いなーとおもいつつちゃんと使おうとするとなんか微妙だなという印象も受けていて、ただ、まだ1日2日触った程度だしあの Facebook 様によるものなので、それを現時点で言い切るのも自信がない! そんな気持ちです。

良いところ

Learn Once, Write anyware ということで、React でアプリケーションを作ったことがあればすぐに書けるのは本当。フレームワークの使い方やアプリケーションモデルを新しく学習しなくてもすぐに作れるのは脳内コンテキストスイッチも少なく思いのほか良い。

また、React の根本のところである、VirtualDOM とデータバインディングにより、プログラマは View の状態管理を考えなくてよい・・・というモデルもそもまま踏襲されている。これも良い。

JavaScript ブリッジとは言え、パフォーマンスに関しては昨今の端末の性能向上もあっておそらくそこは問題にならなそう。

開発環境そのほかの作り込みもあって、ラピッドプロトタイピング的に作れるし、また React による抽象化により Web アプリケーションのようなメンタルモデルで開発が進められるようになってる。これは結構大きなパラダイムかもしれない。というわけで、一見すると確かに生産性は高い。

誤解しないほうがいいところ

ただし React Native は JavaScript のコードを書く限り UIKit をはじめとするネイティブのフレームワークを抽象化するが、iOS のネイティブのコンポーネントや API がどういうものでどういう癖があるのかをある程度把握してないとそこまでスムーズには作れないという印象。

iOS プログラミングを知らないで使えるかということはそんなことはない気がする。

Facebook がその辺りの欠点に目をつぶってこれを作るということは考えにくいし、公式にもそれは謳ってないようだから、このフレームワークは決して iOS の知識を要求しないものではなくてそれを知ってる前提で使うものだろう。

(いまのところ) 微妙だと感じてるところ

気になっているのはコンポーネントにより抽象化されているが故、細かい挙動をそれら UIKit やさらにその下のレイヤの API を使って調整するというようなところに手が届かない点。

じゃあ、コンポーネントを自分で Objective-C や Swift で作るのが前提になってるのかと言えば、確かにそのための API はあるものの、組み込みのコンポーネント実装をみる限りそれほどカジュアルに実装する類のものでもなさそうだ。帯に短したすきに長し、である。


実際 Facebook は Facebook Messenger (だっけ?) を React Native で作っているわけだし、細かな調整がきかないというのは単に経験値の問題なのかもしれないが、いまのところは自分にはちょっとネガティブに写っている。

また、組み込みコンポーネントにないネイティブ実装は自分でブリッジさせる実装を書くことが前提になっているが、その辺もめんどくさい。

もちろん、既存のフレームワーク on フレームワーク実装とは異なり、先にも述べた通り React には、View の状態管理を放棄できるというパラダイムがあるわけで長く保守していくアプリケーションにおいてはそここそが重要なんだほかの部分はたいしたコストではない!という見解に将来達する可能性もあるかもしれないが、いまのところはどうでしょう。そんなに大胆なことは言えない。

ほか

  • npm モジュールは Polyfill があって、Polyfill が橋渡ししてくれるものに関しては使えるようです
  • そもそも require とかも (ランタイムは node じゃなくて JavaScriptCore だし実装されてないわけで) Polyfill で提供されてる
  • やはり React Native だけだと心もとないので Flux したいし、するのが良さそう。ただ、 fluxxor をロードするのは Polyfill 的にまだ無理なようです。他の実装を使おう