読者です 読者をやめる 読者になる 読者になる

MacからiPhoneに遷移させよう

こんにちは。モバイルファースト室の中村(@_nkmrh)です。

突然ですが、Mac上で探したレシピをすぐiPhoneで見られると便利だと思いませんか?

先日リリースしたiOSクックパッドアプリではそれが出来るようになりました。

とても便利なのでぜひ活用してください。

※ 実はこの便利機能、次のバージョンで一旦取り下げ、問題を解決したあとで再度導入することになりました。以降の記事で事情を説明します。

  • Mac OS X YosemiteがインストールされたMac、iOS 8がインストールされたiPhone 5以降、iPad 第4世代、iPad Air、iPad mini、iPad mini Retinaディスプレイモデル、iPod touch 第5世代でご利用いただけます。
  • MacとiPhoneに同じiCloudアカウントを設定して下さい。

これがその様子...。

Mac上のSafariでクックパッドサイトを開いてレシピを探します。

すると...、iPhoneのロック画面の左下にクックパッドのアイコンが表示されます。

左下にクックパッドアイコンが...。

アイコンをスワイプすると...。

 

なんと...、Macで表示していたレシピがクックパッドアプリで表示されました...。 便利っ!!

このように便利なのですが、アプリリリース後、この機能には問題があることがわかりました。

Handoffの問題点

アプリがリリースされた翌日、12/05 の 0:00 に サーバーへのアクセスが急増し、捌き切れない状態になりました。 調査したところ、その1秒くらいの間に "/apple-app-site-association" というファイルに対して、普段の40倍くらいのアクセスがありました。

apple-app-site-association へのアクセスはインストール時に行われます。 インストールというのは 初回インストール時だけではなく、アップデートも含みます。 このタイミングはインストール or アップデートが終わったタイミングで、OS のデーモン (swcd) によってリクエストされます。

ところで、iOS にはアプリの自動アップデート機能があります。 というわけで、この自動アップデートが 0:00 に動き、一斉に /apple-app-site-association にアクセスが来ているのでは、と原因を推測しました。 アクセスは Apple 経由ではなく iOS 端末それぞれから来ているため、アプリのリリース後、毎回 0:00 にこのファイルに対して大量のアクセスが来る状況になりました。 (もちろん1端末1リクエストしかないので、トラフィックとしては大したことないのですが)

アップルのテクニカルサポートへこの件について問い合わせしたところ次のような返答がありました。

返答を要訳すると、アプリのインストール、再インストール、アップデート時に/apple-app-site-associationにアクセスします。自動アップデートの時間は決まってないけど、グラフを見ればリクエスト数予想できますね?アプリをアップデートするスケジュールに応じて、サーバーにどれくらいの負荷がかかるか予測して下さい。という旨のキビシい返答でした...

これに対して、リクエストの時間を分散させる等の対策をお願いしたところ、https://developer.apple.com/bug-reporting/ にBug Reportを送ってほしいとの回答だったため、Bug Reportを送りました。

このような経緯のため、iOSクックパッドアプリの次のバージョンでは一度Handoff機能を取り下げ、大量リクエストに備えたチューニングをした上で来年再度導入することにしました。

ここまで長くなりましたが、それでもHandoff実装したい。という方へ、以降Handoffの具体的な実装方法等を紹介します。

iOS 8の新機能Handoff

HandoffはiOS 8とMac OS X Yosemiteからつかえるようになった機能です。Bluetooth Low Energy(以下、BLE)技術をつかい、iPhoneやiPad, Mac間でデータの送受信を実現しています。

※ BLEで一度に送受信できるデータサイズは、3KB以下です。それ以上のデータはストリーミング転送させることができます。

デバイスの設定

  • Handoffに対応するOSがインストールされたiPhoneやiPad, MacのiCloudアカウントを、同一のアカウントに設定します。
  • iOSでは設定.app > 一般 > Handoffと候補のAppからHandoffをONにします。
  • Macではシステム環境設定 > 一般からHandoffを有効にするチェックボックスをONにします。

実装

今回は上記で紹介したように、MacのSafariで開いているWebサイトからiPhoneアプリに遷移させる方法を紹介します。iOS間の実装も基本的には同じです。

apple-app-site-association

apple-app-site-associationファイルを作成します。 このファイルは、Webサイトのルートに配置しておくもので、連携するアプリのApp Idを記述したファイルをAppleが認可する証明書で署名したものです。

  • handoff.jsonファイルを作成し、以下の内容を記述します。

{"activitycontinuation":{"apps":["XXXXXXXXXX.com.example.myapp"]}} (XXXXXXXXXXの部分はApp Id Prefixを指定します。)

  • .p12ファイルをKeychain Access.appから書き出します。iPhone Developerの証明書を右クリックで選択し書き出しを選択します。

Certificates > iPhone Distribution: \<name\> xxx > Certificates.p12

  • opensslコマンドで証明書を作成します。

openssl pkcs12 -in Certificates.p12 -clcerts -nokeys -out output_crt.pem

  • 秘密鍵を作成します。

openssl pkcs12 -in Certificates.p12 -nocerts -nodes -out output_key.pem

  • 中間証明書を作成します。

openssl pkcs12 -in Certificates.p12 -cacerts -nokeys -out sample.ca-bundle

  • handoff.jsonファイルを下記のコマンドで署名します。

cat handoff.json | openssl smime -sign -inkey output_key.pem -signer output_crt.pem -certfile sample.ca-bundle -noattr -nodetach -outform DER > apple-app-site-association

  • apple-app-site-associationをWebサイトのルートに配置します。

Xcodeの設定

  • Info.plistにNSUserActivityTypesプロパティを追加します。TypeにArrayを指定、値をcom.example.${PRODUCT_NAME:rfc1034identifier}.activityTypeに設定します。(※ ActivityTypeは任意の文字列です。)
  • Target > Capabilities > Entitlements Associated Domainsを有効ONに、値をactivitycontinuation:example.comに設定します。

アプリの実装

iOS SDK 8.0からapplication delegate protocolとUIResponderクラスにHandoff用のメソッドが追加されています。

optional func application(_ application: NSApplication,willContinueUserActivityWithType userActivityType: String) -> Bool

application delegate protocol に追加されたメソッドです。引数のNSUserActivityオブジェクトの値を見て、Handoffの応答に応じるか無視するかを返すようにします。

optional func application(_ application: NSApplication, continueUserActivity userActivity:NSUserActivity, restorationHandler restorationHandler: ([AnyObject]!) -> Void) -> Bool

application delegate protocol に追加されたメソッドです。Handoffから起動した際に呼ばれます。引数で渡ってくるblockの引数にViewControllerのArrayを渡すことでViewControllerの-restoreUserActivityState:メソッドが呼ばれます。Handoffから起動した際の処理はこのメソッドに実装します。

func restoreUserActivityState(_ activity: NSUserActivity)

UIResponderクラスに追加されたメソッドです。このメソッドをViewControllerに実装します。Handoffで起動した際にNSUserActivityオブジェクトを受け取ることができます。

実装例

// application delegate

func application(application: UIApplication, willContinueUserActivityWithType userActivityType: String) -> Bool
{
    // trueを返した場合、 application:continueUserActivity:restorationHandler: が呼ばれます
    // falseを返した場合、 Handoffの応答を無視します
    // userActivityTypeはinfo.plistのNSUserActivityTypesプロパティに指定した文字列です
    if userActivityType == "myType" {
        return true
    }
    return false
}

func application(application: UIApplication, continueUserActivity userActivity: NSUserActivity, restorationHandler: ([AnyObject]!) -> Void) -> Bool 
{
    // ここでuserActivityのURLを調べてHandoffで起動するかどうか判定します
    var urlString = userActivity.webpageURL?.absoluteString;
    if urlString == "xxxxx" {
        // block引数にmyViewcontrollerのrestoreActivityStateが呼ばれます
        restorationHandler([Viewcontroller])
        return true
    }    
    return false
}
// ViewController

func restoreUserActivityState(activity: NSUserActivity)
{
    // Handoffから起動後に行う処理を実装します
    // Safariから起動した場合、webpageURLプロパティからSafariで開いているURLを取得できます
    if let recipeID = activity.webpageURL?.lastPathComponent.toInt() {
        self.showRecipe(recipeID)
    }
}

おわりに

いかがでしょうか。以上の手順でWebサイトとアプリ間の連携を実現することができます。便利な機能ですので興味のある方はぜひ試してみてください。


参考URL

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/