なんか実装でハマってしまい、うまくいかねーなって思いながら、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 状態に入りましょう。
さらに、App.jsx
を編集して、Cmd+R
すると、画面が変わることも確認できたでしょうか。
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 ...
ウィザードから、Action Extension
を選択。
Swift で書きたいんだけど、ここはいったん Objective-C を選んでおいてください。
名前は、So2QiitaExt
とした。
Activateするか聞かれるので、当然Yes, "Activate"。
これで、空のActionExtension
ができたことになる。
React Native を使えるようにするために、So2QiitaExt
に依存関係として、JavaScriptCore.framework
と libRCTxxx.a
が10個, それにlibReact.a
というライブラリを追加します (tvOS 向けと間違いないようにしてください)。
あと、Qiita を WebView で開くので、libRNCWebView.a
も追加しておいてください。
結果、こんな感じになる。
また、build phrases
の、Other Linker flags
に、-ObjC
-lc++
を追加します。
謎めいた作業のように思われるかと思いますが、ここをきちんとしておかないと、後で詰むので、今がんばってください。
また、Deployment Info
> Deployment Target
が最新のものになっていると、Readt Native から run-ios
したときの simulator から実行したときには deploy されないので、バージョンを合わせておきましょう。2019 年 3 月時点では、11.4
にする必要がありました。
ちなみに、asset 由来の color などが理由でビルドが失敗する場合、.xcassets
ファイルは削除しても問題ありません。今回のチュートリアルでは使用しないです。
さて、ここでまた実行してみましょう。
実行後、Safari を起動して、Share ボタンをクリック。So2QiitaExt
が無事に表示されていれば成功。
これからは毎回書かないけれど、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;
}
をというメソッドを追加してください。
viewDidLoad
とloadView
はなにが違うのか、気になる方は、
UIViewController のライフサイクル - Qiita
などを参照のこと。
さて、これで実行し、Safari から Share ボタン経由で、So2QiitaExt
を立ち上げると空のモーダルが出現します。
ふむ、これはなにかおかしいですね。
本来なら、コンテナアプリの起動画面と同様に"Welcome to React Native"が出て欲しいところです。
実はセキュリティ保護の観点からデフォルトでは localhost
も含めて http 通信はブロックされており、かつ React Native は開発環境下では Hot Reload やデバッグを有効にするために、http で js.bundle
をダウンロードして実行しているのです。
ということで、So2QiitaExt
の Info.plist
に以下の項目を追加します。
このXMLをコピペしてもいいよ。
<key>NSExceptionDomains</key>
<dict>
<key>localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
そして、再実行。
今回はウェルカムメッセージがモーダル内に表示されたのではないでしょうか。
しかし、なんと、モーダルを消すすべがありません。
いまのところ、「上へスワイプ」から Safari を kill することでなんとか終了してください。
4. Action Extension から呼び出されたとき用の画面を作る
モーダルを消す方法は少し先延ばしにして、ここからは Action Extension 用の画面を作っていきましょう。
方策としては、
-
initialProperties
に Action Extension からのリクエストの場合のみ True になるフラグを設定して分岐する - エントリーポイントとなるファイルを
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
によって、表示するメッセージを変えてみます。実行。
やりましたね。
5. モーダルを終了するためのボタンを作る
では、次に、モーダルを閉じるためのボタンを作りましょう。
今までは iOS からのアクションやメッセージを受け取るだけでしたが、今回はじめて React Native 側から、iOS へメッセージを送ることになります。
これを実現するためには、iOS 側でメッセージを受け取るためのインタフェースを用意する必要があります。
せっかくですので、Objective-C ではなくて、Swift でそのブリッジ部分を作ってみましょう。
ここがたぶん、このチュートリアルの一番難しいところ。
みなさん、無事に乗り切ってください。
まずは、Swift ファイルをSo2QiitaExt
に追加します。New File
から、Swift を選び、ActionExtension
という名前でファイルを作ります。
ブリッジファイルを作るかと聞かれるので、Create Bridging Header
を選んで、作成してください。
まだファイルの中は変えなくていいです。
同名の Objective-C ファイル、ActionExtension
も作りましょう。
こういったファイルがSo2QiitaExt
以下に追加されているはずです。
ここでブリッジ部分の実装を行いますが、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メソッドが呼ばれ、ログに表示されていますね。地味ですが大きな意味を持つ一歩です。
では、done
の実装を行い、本当にモーダルを閉じることができるようにします。
まずは、ActionViewController.h
でactionViewController
インスタンスの外部公開と、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()
}
}
はい、また、実行。
よかったね、無事モーダルが隠れました。
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);
}
}
また、実行。
やった、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 のビルド設定を見て、So2QiitaExt
にlibRNCWebView.a
が含まれているか確認しよう。
入ってないと、"Invariant Violation: requireNativeComponent: "RNCWebView" was not found" が襲いかかってくる。
あと、全面 WebView だとせっかくつくったdone
メソッドを呼べなくなるので、NavigationBar
も追加しておこう。
yarn add react-native-navbar
state
にuri
があるときの View はこんな感じになる。
<SafeAreaView style={styles.extension}>
<NavigationBar
title={{ title: "So2QiitaExt" }}
leftButton={{ title: "Done", handler: this._onPress }}
/>
<WebView style={styles.webview} source={{ uri }} />
</SafeAreaView>
うまくいけば、たぶん見慣れたサイトがモーダル上に表示される。
最後に、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];
}
はい、これで完成
どうだ、このセクションでは、Js しか書いてないぞ!
Test だって、Jest で書けるぞ!
ということで、React Native から、Action Extension を使う方法を紹介しました。
ごくろうさまでした。
最終的な成果物は、
https://github.com/o3c9/so2qiita
ここで公開しています。
参考になったと思えば、ぜひ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の進捗次第かな