LoginSignup
15
10

More than 5 years have passed since last update.

「StackOverflow から突然 Qiita に移動」を、App Extensions と React Native で簡単プロトタイプ

Last updated at Posted at 2019-03-06

なんか実装でハマってしまい、うまくいかねーなって思いながら、StackOverflow を見ていたら、なんだか似たような質問 Qiita で見たし、やっぱり日本語で読みたいから Qiita に行こうかな、と思うことありませんか?
ありますよね!

それ、Action Extension でさくっと実装できます!
しかも、ほんの少しの Swift と Objective-C を書けば、あとは React Native で書けます。

ということでやってみました。

0. 本稿について

  • 対象者: iOS プログラミング初心者. Xcode といえば、xcode-select --installだと思っている人
  • 書いている人: iOS プログラミング初心者. Swift はprint("Hello, World")だけで卒業した
  • 必要な知識: JavaScript, React, React Native を触ったことがある
  • 動作環境: macOS X mojave. React Native 0.58.x. Xcode や Swift のバージョンはしらない
  • 内容: チュートリアル形式で Action Extension を React Native から使えるまでの実装方法を紹介する
  • 初公開日: 2019/03/06 更新日は上に書いてあるよ

1. React Native CLI のインストールとアプリのひな形の作成

では、さっそく元気に新プロジェクトつくってやっていきましょう。

$ yarn global add react-native-cli
$ cd /path/to/somewhere
$ react-native init So2Qiita

もし CamelCase なフォルダ名が気持ち悪ければ、ひな形作成後に、

$ mv SO2Qiita so2qiita

リネームしても大丈夫です。また、git にもコミットしておきます。

$ git init
$ git add -A
$ git ci -m "Create project via react native cli"

ここで一度実行してみる。
初めて起動する場合や、metro という React Native 用に Javascript の Bundle を行うプロセスが起動していない場合は cli から、

$ react-native run-ios

で起動。

ひとたび React-Native の画面のレンダリングがうまく実行できるようになれば、Objective-C や Swift を変更したとき、 Xcode からビルドすることも可能といえば可能です。
しかし、簡便のため、本記事で「実行する」と書いた場合は、CLI で、react-native run-iosしていると考えておいてください。

ひとたびビルドタスクがはじまると、マシンが熱をはらみ、CPU のほとんどを持っていかれるので、人間は大人しく wait 状態に入りましょう。

Simulator Screen Shot - iPhone 7 - 2019-03-04 at 19.47.12.png Simulator Screen Shot - iPhone X - 2019-03-04 at 19.54.00.png

さらに、App.jsxを編集して、Cmd+R すると、画面が変わることも確認できたでしょうか。

Simulator Screen Shot - iPhone X - 2019-03-04 at 19.55.09.png

2. Xcode を開いて、Action Extension を追加。

まずは言葉の定義から。

App Exntesions
  |- Share Extension
  |- Action Extension

App Extensions は総称。具体的な実装として、Share Extension と Action Extension がある。他にもあるかもしれないが知らない。

Share Extension は現在閲覧中の情報を他のアプリや SNS・サービスで共有するための拡張で、ActionExtension は現在閲覧中の情報に関連したタスクを実行するための拡張です。

このあたりの仕分けはけして自明ではなく、URL をブックマークするにしてもはてブを使うなら、Share Extension っぽいし、Safari のブックマークに残すなら、Action Extension っぽい。

実装としてはほぼ同じものになるので、Apple のガイドラインを読んだ上で、より自分の作る拡張に適した方を選んでください。

また、これらの拡張機能の設計指針についても、ガイドラインでしっかり触れられているので、軽く目を通しておいて損はない。
煩雑な UI にするなとか、単一の機能を持つように設計しろとか、まぁそういった内容。

今回は閲覧中の Stack Overflow のページからタグ情報を抜き出し、Qiita のページを開く、という動作で、Share じゃない感が強いし、Action Extension を選択します。

さて、Action Extension を作成すると決まったところで、いよいよ Xcode を開きます。
普段のお仕事がサーバーサイドだったら、Xcode を開くのは、年に数回あるかないかというところで、不慣れではありますが、怖がらないで、やっていきましょう。

open ios/So2Qiita.xcodeproj/

Xcode がおもむろにたちあがります。以下のメニューを選択して、

File > New > Target ...

Screen Shot 2019-03-04 at 19.33.06.png

ウィザードから、Action Extensionを選択。

Screen Shot 2019-03-04 at 19.33.26.png

Swift で書きたいんだけど、ここはいったん Objective-C を選んでおいてください。
名前は、So2QiitaExtとした。

Screen Shot 2019-03-04 at 19.34.06.png

Activateするか聞かれるので、当然Yes, "Activate"。

Screen Shot 2019-03-04 at 19.34.20.png

これで、空のActionExtensionができたことになる。

React Native を使えるようにするために、So2QiitaExtに依存関係として、JavaScriptCore.frameworklibRCTxxx.aが10個, それにlibReact.a というライブラリを追加します (tvOS 向けと間違いないようにしてください)。
あと、Qiita を WebView で開くので、libRNCWebView.aも追加しておいてください。

結果、こんな感じになる。

Screen Shot 2019-03-06 at 23.23.13.png

また、build phrasesの、Other Linker flags に、-ObjC -lc++を追加します。

Screen Shot 2019-03-06 at 23.24.57.png

謎めいた作業のように思われるかと思いますが、ここをきちんとしておかないと、後で詰むので、今がんばってください。

また、Deployment Info > Deployment Target が最新のものになっていると、Readt Native から run-ios したときの simulator から実行したときには deploy されないので、バージョンを合わせておきましょう。2019 年 3 月時点では、11.4 にする必要がありました。

Screen Shot 2019-03-04 at 19.59.07.png

ちなみに、asset 由来の color などが理由でビルドが失敗する場合、.xcassetsファイルは削除しても問題ありません。今回のチュートリアルでは使用しないです。

さて、ここでまた実行してみましょう。
実行後、Safari を起動して、Share ボタンをクリック。So2QiitaExt が無事に表示されていれば成功。

Simulator Screen Shot - iPhone 7 - 2019-03-04 at 20.00.48.png
Simulator Screen Shot - iPhone 7 - 2019-03-04 at 20.00.43.png

これからは毎回書かないけれど、section 毎に、git commitしておくと便利です。

3. Action Extension から React Native を呼び出す。

Xcode での作業がもう少し続きます。

いまSo2QiitaExtを開いて実行すると、画像を受け取り、そのまま表示するというデフォルトの動作になっています。
(ぜひ、Photos アプリから写真を選択して試してみてください)

Safari から実行しても画像が渡されないためなにも表示されず、空白のモーダルに、Done ボタンがあるだけです。
実はこの動作は、So2QiitaExt/ActionViewController.m内のviewDidLoadメソッドで定義されています。

このメソッドをごっそり削除して、代わりに

//  ActionViewController.m

#import <React/RCTBundleURLProvider.h>
#import <React/RCTRootView.h>

をヘッダに追加してから、

//  ActionViewController.m

- (void)loadView {
  NSURL *jsCodeLocation;

  jsCodeLocation = [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index" fallbackResource:nil];

  RCTRootView *rootView = [[RCTRootView alloc] initWithBundleURL:jsCodeLocation
                                                      moduleName:@"So2Qiita"
                                               initialProperties:nil
                                                   launchOptions:nil];
  rootView.backgroundColor = [[UIColor alloc] initWithRed:1.0f green:1.0f blue:1.0f alpha:1];
  self.view = rootView;
}

をというメソッドを追加してください。
viewDidLoadloadViewはなにが違うのか、気になる方は、

UIViewController のライフサイクル - Qiita

などを参照のこと。

さて、これで実行し、Safari から Share ボタン経由で、So2QiitaExtを立ち上げると空のモーダルが出現します。
ふむ、これはなにかおかしいですね。
本来なら、コンテナアプリの起動画面と同様に"Welcome to React Native"が出て欲しいところです。

実はセキュリティ保護の観点からデフォルトでは localhost も含めて http 通信はブロックされており、かつ React Native は開発環境下では Hot Reload やデバッグを有効にするために、http で js.bundle をダウンロードして実行しているのです。

ということで、So2QiitaExtInfo.plist に以下の項目を追加します。

Screen Shot 2019-03-06 at 23.30.13.png

このXMLをコピペしてもいいよ。

<key>NSExceptionDomains</key>
<dict>
  <key>localhost</key>
  <dict>
    <key>NSExceptionAllowsInsecureHTTPLoads</key>
    <true/>
  </dict>
</dict>

そして、再実行。
今回はウェルカムメッセージがモーダル内に表示されたのではないでしょうか。

Simulator Screen Shot - iPhone X - 2019-03-04 at 21.53.40.png

しかし、なんと、モーダルを消すすべがありません。
いまのところ、「上へスワイプ」から Safari を kill することでなんとか終了してください。

4. Action Extension から呼び出されたとき用の画面を作る

モーダルを消す方法は少し先延ばしにして、ここからは Action Extension 用の画面を作っていきましょう。
方策としては、

  1. initialPropertiesに Action Extension からのリクエストの場合のみ True になるフラグを設定して分岐する
  2. エントリーポイントとなるファイルをindex.jsではないなにかに変える

が考えられます。正直、どちらでもかまわないのですが、今回は特にファイルを分けるほどの理由もないので、方策 1 を採用しましょう。

loadView メソッドに

//  ActionViewController.m

NSDictionary *initialProps = [NSDictionary dictionaryWithObject:[NSNumber numberWithBool: TRUE] forKey:@"isExtension"];
RCTRootView *rootView = [[RCTRootView alloc] initWithBundleURL:jsCodeLocation
                                                    moduleName:@"So2Qiita"
                                             initialProperties:initialProps
                                                 launchOptions:nil];

を追加。
RCTRootView のイニシャライザに、 initialProperties として、{isExtension: true}を渡すようにします。

そして、React Native 側では、

// App.js

export default class App extends Component<Props> {
  render() {
    const { isExtension } = this.props;
    let message;
    if (isExtension) {
      message = "Welcome to So2QiitaExt on React Native!";
    } else {
      message = "Welcome to So2Qiita on React Native!";
    }

    return (
      <View style={styles.container}>
        <Text style={styles.welcome}>{message}</Text>
      </View>
    );
  }
}

isExtensionによって、表示するメッセージを変えてみます。実行。

Simulator Screen Shot - iPhone X - 2019-03-04 at 22.07.25.png

やりましたね。

5. モーダルを終了するためのボタンを作る

では、次に、モーダルを閉じるためのボタンを作りましょう。
今までは iOS からのアクションやメッセージを受け取るだけでしたが、今回はじめて React Native 側から、iOS へメッセージを送ることになります。
これを実現するためには、iOS 側でメッセージを受け取るためのインタフェースを用意する必要があります。

せっかくですので、Objective-C ではなくて、Swift でそのブリッジ部分を作ってみましょう。
ここがたぶん、このチュートリアルの一番難しいところ。
みなさん、無事に乗り切ってください。

まずは、Swift ファイルをSo2QiitaExtに追加します。New Fileから、Swift を選び、ActionExtensionという名前でファイルを作ります。

Screen Shot 2019-03-04 at 22.45.21.png

ブリッジファイルを作るかと聞かれるので、Create Bridging Header を選んで、作成してください。

Screen Shot 2019-03-04 at 22.45.32.png

まだファイルの中は変えなくていいです。
同名の Objective-C ファイル、ActionExtensionも作りましょう。

こういったファイルがSo2QiitaExt以下に追加されているはずです。

Screen Shot 2019-03-04 at 22.57.56.png

ここでブリッジ部分の実装を行いますが、Swift だけでは完結せず、Objective-C から、モジュールやメソッドを Extern 宣言、すなわち外部公開する必要があります。

ActionExtensionというクラスと、doneというメソッドを定義して、Javascript からアクセスできるようにしてみましょう。

まずはブリッジヘッダに必要なヘッダを追加

// So2QiitaExt-Bridging-Header.h
//
//  Use this file to import your target's public headers that you would like to expose to Swift.
//
#import "React/RCTBridgeModule.h"
#import "ActionViewController.h"

外部公開用の宣言を追加

//  ActionExtension.m
#import "React/RCTBridgeModule.h"

@interface RCT_EXTERN_MODULE(ActionExtension, NSObject)

RCT_EXTERN_METHOD(done)

@end

そして、Swiftで実装

// ActionExtension.swift

import Foundation
import os.log

let log = OSLog(subsystem: "com.o3c9.so2qiita", category: "ActionExtension")

@objc(ActionExtension)
class ActionExtension: NSObject {
  @objc
  func done() {
    os_log("done", log: log, type: .default)
  }
}

os_logを仕込むと、Console.appにログを吐き出すことができるようになります。
これが今後君の命綱となる。

そして、App.jsに、doneメソッドを呼び出すコードを追加しましょう。

// App.js

export default class App extends Component {
  _onPress() {
    NativeModules.ActionExtension.done();
  }

  render() {
    ...

    return (
      <View style={styles.container}>
        <Text style={styles.welcome}>{message}</Text>
        {isExtension && <Button onPress={this._onPress} title="Done" />}
      </View>
    );
  }
}

そんでもって実行〜。

Donwをタップすると、無事にSwiftのdoneメソッドが呼ばれ、ログに表示されていますね。地味ですが大きな意味を持つ一歩です。

Screen Shot 2019-03-05 at 21.35.12.png

では、doneの実装を行い、本当にモーダルを閉じることができるようにします。

まずは、ActionViewController.hactionViewControllerインスタンスの外部公開と、doneメソッドの宣言を追加します。

// ActionViewController.h

#import <UIKit/UIKit.h>

@interface ActionViewController : UIViewController

extern ActionViewController * actionViewController;

- (void) done;

@end

実装ファイルでは、actionViewControllerに値をセット。done メソッドは初めに作成したひな形にすでに実装されてあるので、このままこれを流用して Swift から呼ぶという算段です。

//  ActionViewController.m

ActionViewController * actionViewController = nil;

@implementation ActionViewController

- (void)loadView {
  ...

  rootView.backgroundColor = [[UIColor alloc] initWithRed:1.0f green:0.0f blue:0.0f alpha:0.3];
  self.view = rootView;
  actionViewController = self;
}

で、ActionExtension.swift。

// ActionExtension.swift

class ActionExtension: NSObject {
  @objc
  func done() {
    os_log("done", log: log, type: .default)
    actionViewController.done()
  }
}

はい、また、実行。

qiita_action.gif

よかったね、無事モーダルが隠れました。

6. App Extensions から現在の URL を受け取る

ずいぶん長くなりましたが、このセクションが本機能の肝心な部分。Action Extension が起動された元のアプリから URL を JavaScript で受け取ります。
今回の場合、Safari から StackOverflow の Question の URL が受け取りたい情報ですね。

まずは、JavaScript 側の実装のイメージ。関数のcallbackを使う場合。

NativeModules.ActionExteion.url( (error, url) => if(!error) this.setState({ url }));

Promiseとして受け取る場合.

NativeModules.ActionExteion.url()
  .then( (url) -> this.setState({ url }))
  .catch(e => console.log(e));

どっちでもいいんだけど、好みで、Promise でやってみましょう。
async awaitを使ってモダンにやるぞ。

まずはurlというメソッドを定義し、Promise を返すメソッドであるという宣言をブリッジ部分に書く。おまじないです。

// ActionExtension.m

RCT_EXTERN_METHOD(url: (RCTPromiseResolveBlock)resolve rejecter: (RCTPromiseRejectBlock)reject)

そして、Swift ファイルでの実装。

// ActionExtension.swift

@objc(ActionExtension)
class ActionExtension: NSObject {
  ...

  @objc
  func url(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) -> Void {
    guard
      let inputItem = actionViewController.extensionContext?.inputItems.first as? NSExtensionItem,
      let attachments = inputItem.attachments
      else {
        let error = NSError(domain: "", code: 400, userInfo: nil)
        reject("E_URL", "cannot obtain url", error)
        return
    }

    for provider in attachments {
      if provider.hasItemConformingToTypeIdentifier(kUTTypeURL as String) {
        provider.loadItem(forTypeIdentifier: kUTTypeURL as String, options: nil, completionHandler: { (target, error) in
          let url = target as! URL
          resolve(url.absoluteString)
        })
        break
      }
    }
  }
}

まぁ、こういうの、正直すべてを正しく理解するのにはたくさん寄り道をせなあかんので、全部は説明しきれないです。
ただ Swift や iOS プログラミングに関心があれば、ぜひ Apple の公式ドキュメントや有志の解説記事を探して読んでみてください。

React Native とのつなぎこみに関しては、React Native の Official のドキュメントか、Medium で人気 TeaBreak の記事、Swift in React Native - The Ultimate Guide Part 1: Modulesが詳しいです。

// App.js

async componentDidMount() {
  try {
    const url = await NativeModules.ActionExtension.url();
    this.setState({ url });
  catch(error) {
    console.log(error);
  }
}

また、実行。

Simulator Screen Shot - iPhone X - 2019-03-05 at 22.39.11.png

やった、URLが表示されてますね。これで、Action Extension から URL を React Native の JavaScript で受け取ることができるようになりました。
めでたし、めでたし。

Action Extension を React Native と組み合わせて使う、という書きたかった内容はここまででほとんどすべて。

  • doneメソッドで、JS から Swift 側へメッセージを送る。
  • urlメソッドで、JS から Swift 側へデータを要求し受け取る。

この 2 つができるようになったわけです。

あとは、機能を完成させるために React Native 側での実装をさくっと紹介して終わることにします。

7. StackOverflow のタグを使って Qiita で検索する JavaScript

Safari から送られてきた URL を使って、StackOverflow のタグを Qiita 検索に使うというロジックの実装に入ります。

まずは、Component での実装のイメージ。
(補足すると、今の時点ではあくまでイメージだったはずが、この後、このイメージに沿って実装が進むため、結局これがそのまま Component の実装となっていく)

async componentDidMount() {
  try {
    // URLをNativeModules経由で、上記のActionExtensionクラスから受け取る & Wait
    const url = await NativeModules.ActionExtension.url();
    // そのURLをStackOverflow APIを実装したクラスに渡して、タグを受け取る & Wait
    const tags = await new StackOverflow(url).getTags();
    // タグからQiitaの検索クエリを構築
    const query = encodeURIComponent(tags.map(t => `tag:${t}`).join(" "));
    const uri = `https://qiita.com/search?utf8=%E2%9C%93&q=${query}`;
    // stateにつっこむ
    this.setState({ isLoading: false, uri });
  } catch (error) {
    this.setState({ isLoading: false, error });
  }
}

StackOverflow.jsというクラスを作って、ダミーの実装をする.

export default class StackOverflow {
  constructor(url) {
    this.url = url;
  }

  getTags() {
    return Promise.resolve(["javascript", "reactjs"]);
  }
}
class Extension extends Component {
  render() {
    if (isLoading) {
      return (
        <SafeAreaView style={styles.container}>
          <ActivityIndicator size="large" color="#0000ff" />
        </SafeAreaView>
      );
    } else if (uri) {
      return (
        <SafeAreaView style={styles.extension}>
          <Text>{uri}</Text>
        </SafeAreaView>
      );
    } else {
      return (
        <SafeAreaView style={styles.container}>
          <Text style={styles.error}>{error}</Text>
        </SafeAreaView>
      );
    }
  }
}

次に、Qiitaの検索結果ページを、WebView で表示させてみる。
WebViewはReact Native本体から分離されて別パッケージになったようなので、yarnで追加。

yarn add react-native-webview
react-native link react-native-webview

Xcode のビルド設定を見て、So2QiitaExtlibRNCWebView.aが含まれているか確認しよう。
入ってないと、"Invariant Violation: requireNativeComponent: "RNCWebView" was not found" が襲いかかってくる。

あと、全面 WebView だとせっかくつくったdoneメソッドを呼べなくなるので、NavigationBarも追加しておこう。

yarn add react-native-navbar

stateuriがあるときの View はこんな感じになる。

<SafeAreaView style={styles.extension}>
  <NavigationBar
    title={{ title: "So2QiitaExt" }}
    leftButton={{ title: "Done", handler: this._onPress }}
  />
  <WebView style={styles.webview} source={{ uri }} />
</SafeAreaView>

うまくいけば、たぶん見慣れたサイトがモーダル上に表示される。

Simulator Screen Shot - iPhone X - 2019-03-06 at 23.44.34.png

最後に、StackOverflow.getTags()をちゃんとした実装にする。
StackAppsというところから、API Keyの登録をしなくちゃいけないと思っていたが、実はこのエンドポイントはpublicなようで、認証なしで呼べる。

APIの詳細は、この辺に転がってます。

constructor(url) {
  this.questionId = this._parseUrl(url);
}

getTags() {
  return new Promise(async (resolve, reject) => {
    if (this.questionId) {
      try {
        const response = await fetch(
          `https://api.stackexchange.com/2.2/questions/${this.questionId}?site=stackoverflow`
        );
        if (response.ok) {
          const json = await response.json();
          const tags = json.items[0].tags;
          return resolve(tags);
        } else {
          return reject(`http error: ${response.status}`);
        }
      } catch (error) {
        return reject(error.message);
      }
    } else {
      return reject("not a valid SO url");
    }
  });
}

URL_REGEX = /^https\:\/\/stackoverflow\.com\/questions\/(\d+)/;

_parseUrl(url) {
  const result = url.match(this.URL_REGEX);
  return result && result[1];
}

はい、これで完成 :sparkles:

so2qiita.gif

どうだ、このセクションでは、Js しか書いてないぞ!
Test だって、Jest で書けるぞ!

ということで、React Native から、Action Extension を使う方法を紹介しました。
ごくろうさまでした。

最終的な成果物は、

https://github.com/o3c9/so2qiita

ここで公開しています。
参考になったと思えば、ぜひStar :star: をよろしくです!

8. FAQ -結びにかえて

それ、全部 Swift で書いた方が早くない?

ほとんどの場合そうかもしれないが、コンテナアプリが React Native で書かれている場合、App Extensions でもその資産を使いたいことはあるはず。そういった場合には、なるべくすべて React Native にしておくほうがいいはずなので、ここで紹介したテクニックは使えると思う。

この機能、微妙じゃね?

タグ検索だけだと微妙だけど、タイトルから重要なキーワードを推測して適切な検索クエリを構築できるようになると、けっこう実用的だと思っている。それなりに自然言語処理がんばってやらないと使い物にならないだろうけどね。

エンジニアは全員英語で読み書きできるべきだし、日本語のQiita見るより英語のStack Overflowを参照するべきでは?

You Qiita2So 作るべき

Share Extension の例もほしい

表示されるカラムなどが違い、UI としては別物に見えるけど、実質ほぼ同じものなので、Action Extension が作れたら、Share Extension も作ることができる #はず #未確認 #誰かやってみて #コメント欲しい

チュートリアル通りやっても動かない

うん、それが現実。

https://github.com/o3c9/so2qiita に完成したコードあるから、これcloneして動かしてみてください。

途中経過の再現については Xcode の設定や、コードをにらめっこしながら、差分を見つけてみましょう。
簡単じゃないけど、難しくもないはず。時間はかかるけど、こういうのがいい勉強になるよ。

Xcode 上での作業が多い、もっとラクにできないの?

そう思ってた頃もあった。

React Native Share Extensionというものがあって、わざわざライブラリになっているにもかかわらず、README に書いてある Setup のプロセスがめっちゃ長くて大変そう。
つまり、ここに書いたくらいのステップがほぼ現在のところ最短だと思う。
一回やってみて要領つかめたら、次からは怖くない

Extension のデバッグつらくない?

うん、つらい。

NativeModules.ActionExtension.xxxが不要な場合には、Extension の画面をコンテナアプリのトップ画面にして開発すると、JS 由来の動作確認がしやすくなる。

Extension として実行しているときは、console.log の代わりに、自前の logger を作って、React Native の画面に出してしまうというのをやっていた。こんな感じ。

debug(message) {
  this.setState( { console: [...this.state.console, message] })
}

render() {
  return (
    <View>
      ...
      {this.state.console.map(m => <Text>{m}</Text>)}
    </View>
  )
}

iOS 側は、os_logを使えば、Console.app から見えるようになる。
他の大量のメッセージにかき消されるという苦難はあるけれど。
Process を限定してみれば、いちおう追えないことはない。

Android は?

また今度。
https://github.com/o3c9/so2qiita のStarの進捗次第かな

15
10
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
10