はじめに
みなさん、CarPlay をご存知でしょうか?
実はiOS7以降のiOSには CarPlay という機能が搭載されており、対応車種であればカーナビに iPhone を繋いで、カーナビ上で CarPlay 対応された iPhone アプリを操作できるようになっています。
対応車種はこちら
本稿ではその CarPlay 対応アプリのサンプルとしてナビゲーションアプリを取り上げ、地図を表示させるところまで説明します。
準備
実際にカーナビにつなげる CarPlay のアプリの開発を始めるに当たってXcodeを開く前に色々と準備をする必要があります。
- CarPlay の開発申請
- Provisioning Profile の設定
なお、とりあえず Simulator でいいから作ってみたい、という人はここの項目を飛ばしてもらってCarPlay アプリを実装するの項目に行ってもらって結構です。
CarPlay の開発申請
まず、iOS アプリ開発の基本として実機デバッグするには Apple Developer Program (以下ADP) に試験用端末の登録と Provisioning Profile の登録が必要なのですが CarPlay の場合はその Provisioning Profile に CarPlay 用の設定を行わないといけません。
ですが ADP の Provisioning Profile の設定画面みてもありません。
その設定を行うにはまず Apple に申請を ここ から行う必要があります(要 ADP アカウント)。
その際、開発する CarPlay 向けアプリがどのようなものか App Type のセレクトボックスから以下のカテゴリから選びます
- Audio - 音楽アプリ
- Automaker - 車両組み込み機能アプリ
- Communication - チャットアプリ
- EV Charging - 充電スポット検索アプリ
- Navigation - カーナビアプリ
- Parking - 駐車場検索アプリ
- Quick Food Ordering - レストラン検索アプリ
上記カテゴリ以外のアプリは開発は許されておらず、また、2つ以上を組み合わせるアプリを作ることも許されてはいません。
この中で特殊なのが 2 の Automaker です。エアコンの操作やラジオと言った車両に組み込まれてる機能を操作するためのアプリです。
もちろん車両側からそのAPIが提供されている必要があるため実際には開発する際は車両 or 周辺機器メーカーと協業する必要がでるでしょう。
なお、申請を行えるのは ADP アカウント保持者のみです。 InHouseバイナリを配布できる法人向けアカウント Apple Developer Enterprise Program(以下 ADEP )アカウントで申請しようとすると、この画面ではなく以下のような権限がないことを伝える画面が表示されます。
実はこれは結構厄介で、
現状では CarPlay 対応させると InHouse ビルドができなくなることを意味しています。
業務で iOS アプリを開発してる人のほとんどが社内テスト用に InHouse ビルドしてると思うのですが、
CarPlay 対応の開発をする際はビルドコンフィルグ等をいじって InHouse ビルド時は コンパイル範囲から CarPlay を外すと言った処理が必要になってくるでしょう。(もしくは TestFlight を使うか)
Provisioning Profile の設定
CarPlay の申請が通ると Provisioning Profile 作成画面に新たに Entitlement を設定する項目が追加されます
そこの項目に App Type で設定したカテゴリを選択します。
以上で CarPlay を実機デバッグするための事前準備は終了です。次に実際に CarPlay を実装していくために必要な設定及びコードを説明していきます。
CarPlay 向けアプリを実装する
では実際にiOSアプリを CarPlay 対応させていきましょう。
CarPlay 対応アプリと書いてしまうと tvOS や watchOS アプリのように専用の Bundle Identifier を用意して
ターゲットを新たに作るというイメージを持つ人がいるかもしれませんが実はそうではなく、すでにある iOS アプリのUIをカーナビ向けに最適化させる作業になります。
どちらかというと iPhone アプリの iPad 対応の方が感覚的に近いです。
では、はじめに、で述べたようにナビゲーションアプリを作っていきます
Simulator について
前段で実機デバッグするための準備について書きましたが、ずっと車両やカーナビに繋いで開発していくのも難しいため Simulator を使うことになると思います。
CarPlay の Simulator は実は iPhone Simulator に組み込まれており以下の図の様に I/O → External Display → CarPlay を選択して表示できます
Entitlements ファイルを作成
開発するアプリが CarPlay 対応アプリであるということを設定する必要があります。
Entitlements ファイルを作り Provisioning Profile に設定されたカテゴリIDを有効化させます。
今回作るナビゲーションアプリの場合、カテゴリIDは com.apple.developer.carplay-maps
になります
<dict>
<key>com.apple.developer.carplay-maps</key>
<true/>
</dict>
CarPlay 用 SceneDelegate を作る
実は Entitlements ファイルを作成した時点で CarPlay からアプリを呼ぶことが可能になります。
が、この時点で CarPlay 側から呼びだすと CarPlay 用の SceneDelegate が設定されていないためクラッシュします。
そのため、まず以下の様な CarPlay 用の SceneDelegate を用意します。
class CarPlaySceneDelegate: UIResponder {
var interfaceController: CPInterfaceController?
var window: CPWindow?
}
extension CarPlaySceneDelegate: CPTemplateApplicationSceneDelegate {
func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene,
didConnect interfaceController: CPInterfaceController, to window: CPWindow) {
self.interfaceController = interfaceController
}
}
上記が CarPlay 用の SceneDelegate の最小限実装です。
CarPlay から呼ばれるために CPTemplateApplicationSceneDelagate
を実装します。
CarPlay 上でアプリが呼ばれると上記の templateApplicationScene(_ templateApplicationScene:didConnect interfaceController:to window:)
メソッドが呼ばれるのでその中で CarPlay 用の画面を作っていくことになります。
CarPlay 用の SceneDelegate を呼び出せるようにする
CarPlay 用の SceneDelegate を作ったら今度は CarPlay から呼び出せるように設定をします。
方法としては、Info.plist でやる方法と AppDelegate でやる方法の2つがあります
呼び分け方法その1 - Info.plist でやる方法
Info.plist に Application Scene Manifest
という項目があります。
XML のキーとしては UIApplicationSceneManifest
になります。
その中に項目として UISceneConfigurations
を追加し、iPhone用の SceneDelegate
クラス、 CarPlay 用の SceneDelegate
クラスをそれぞれ以下のように追記します
<key>UIApplicationSceneManifest</key>
<dict>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<!-- "iPhone用のSceneDelegate設定"-->
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>iPhone</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).IPhoneAppSceneDelegate</string>
</dict>
</array>
<!-- "CarPlay用のSceneDelegate設定"-->
<key>CPTemplateApplicationSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>CarPlay</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).CarPlaySceneDelegate</string>
</dict>
</array>
</dict>
</dict>
これを記述することで自動的に呼び出し元が iPhone 画面なのか CarPlay 画面なのかが判定されそれに合わせた SceneDelegate
が呼ばれるようになります。
呼び分け方法その2 - AppDelegate でやる方法
Info.plist だけでなく AppDelegate 側にコードを記述して出しわけの制御を書くこともできます。
iOS13から AppDelegate に画面情報( UIScene
)を受け取るデリゲートメソッドを定義することができるようになりました。
@available(iOS 13.0, *)
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
switch connectingSceneSession.role {
case .carTemplateApplication: // CarPlay 用のアプリとして起動された
let config = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
config.delegateClass = CarPlaySceneDelegate.self
return config
default: // それ以外
let config = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
config.delegateClass = IPhoneAppSceneDelegate.self
return config
}
}
渡ってくる UISceneSession
の role
プロパティをみるとアプリがどのシーンから起動されたのかがわかるので、
そこで出しわけをすることになります。
Info.plist
で出しわけをする方がいいのか AppDelegate
で出しわけをする方がいいのか正直悩ましいです。
アプリのデプロイ戦略に応じて変わると考えています。
ちなみに、両方書いた場合はどちらかが無視される、というわけではなくどうやら結果が合成されるようです。
これ以上は UIScene
の概念の説明となり本稿の主題からずれるので説明しませんが、omochimetaru さんの記事が詳しいので
以下を読んでいただけるといいかもしれません。
https://qiita.com/omochimetaru/items/31df103ef98a9d84ae6b
CarPlay 向けの画面を用意する
では準備が全て終わったので CarPlay アプリを作っていきましょう。
以下のコードが CarPlay から呼び出された際に画面を表示する為の最小限のコードです。
ナビゲーションアプリを作るための UI テンプレートである CPMapTemplate
をセットし、起動時にそのテンプレートが表示されるようにしています。
class CarPlaySceneDelegate: UIResponder {
var interfaceController: CPInterfaceController?
var window: CPWindow?
}
extension CarPlaySceneDelegate: CPTemplateApplicationSceneDelegate {
func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene,
didConnect interfaceController: CPInterfaceController,
to window: CPWindow) {
self.interfaceController = interfaceController
self.window = window
let mapTemplate = CPMapTemplate()
interfaceController.setRootTemplate(mapTemplate, animated: true)
}
}
CPTemplate とは
ViewController
を起点として画面を構築していく iOS や Mac アプリと違い、CarPlay 対応には基本的に ViewController
は出てきません。
代わりに CPTemplateApplicationSceneDelegate
のデリゲートメソッドで渡ってくる CPInterfaceController
に CPTemplate
を継承したクラスをセットして画面表示を行います。
CPTemplate
系クラスそのものには画面遷移を行うメソッドはないです。
したがって、画面遷移を行う場合は templateApplicationScene
で渡ってくる CPInterfaceController
を持ち回して実装することになります。
地図を表示する
さて、起動時にナビゲーション用のテンプレートが表示できるようになった、と言ってもテンプレートに何もセットしていないため
起動しても以下の様なただ黒い画面が表示されるだけです。
なので簡単に地図を表示してみます。
前項で CarPlay 対応には基本的に ViewController
は出てきません、と書いたのですが例外があります。
ナビーゲーションアプリの開発に使われる CPMapTemplate
は地図表示部分に関してのみ ViewController
を使うことになります。
具体的には CPWindow
の rootViewController
プロパティに地図表示用の ViewController
をセットすると
地図が表示されるようになります。
(MapViewController
自体のコードは非常に単純なものなので割愛)
func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene,
didConnect interfaceController: CPInterfaceController,
to window: CPWindow) {
self.interfaceController = interfaceController
self.window = window
let mapTemplate = CPMapTemplate()
interfaceController.setRootTemplate(mapTemplate, animated: true) { _,_ in
// MapViewController は内部的に MKMapView を保持した ViewController
window.rootViewController = MapViewController()
}
}
すると以下のように Apple の地図が表示されるようになります。
よくみると上に黒いバーがあるのがわかると思うのですが、ここにボタンを置いて行って様々なインタラクションを行えるようにしていくことになります。
地図のパンニングやルート表示についてをここで説明すると長くなりすぎるため、興味があるようでしたら
参考文献のGuideとサンプルコードを参照することをお勧めします
実装する上での留意点
以上が CarPlay のナビゲーションアプリで地図を表示するまでの実装になります。
様々なテンプレートが用意されているため実装量がすくなく結構簡単に作れることがわかったと思います。
(それまでの Apple とのやりとりの方がコストが高い)
ですが、実装していく上で何点か留意すべき点があったため記述します。
タップ範囲が決められてる
CPMapTemplate
はほかのテンプレートと違い ViewController
を表示できるのでデザインの自由度は高いように思えます。
(実際、地図以外も表示させることは可能です)
ですが、実は ViewController
にタップイベントを伝搬させることはできません。
CPMapTemplate
におかれたボタン経由でしかイベントを伝搬させることしかできず、地図を直接スワイプさせたりすることはできません。
そして CPMapTemplate
のみならず他のテンプレートもボタンの表示数、領域等に制限があります。
結果的にそれらの条件を考慮する必要があり、自由度の高いデザインができず、どのアプリも UI は似たものになります。
カスタムテンプレートを作れない
CPMapTemplate
の基底クラスである CPTemplate
は他のテンプレートクラスでも使われており画面遷移機能を持たないので
UIView
に近い印象を受けるのですが開発者側のほうで継承させてカスタムテンプレートを作る、ということはできません。
Swift の言語仕様上、継承自体はできるのですが CPInterfaceController
を使って画面に表示させようとすると
以下のエラーを吐いてクラッシュします
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'Unsupported object <View.Test: 0x60000047e920> <identifier: F6B164D2-E640-4644-8C06-AAFB57AD7192, userInfo: (null), tabTitle: (null), tabImage: (null), showsTabBadge: 0> passed to setRootTemplate:animated:completion:. Allowed classes: {(
CPContactTemplate,
CPPointOfInterestTemplate,
CPGridTemplate,
CPMapTemplate,
CPTabBarTemplate,
CPInformationTemplate,
CPListTemplate,
CPNowPlayingTemplate,
CPSearchTemplate
)}'
標準の組み込まれたテンプレートでなければいけない、ということらしいです。
カテゴリによっては使えないテンプレートがある
Apple に申請を出した際、CarPlay 向けにどのような機能を提供するかカテゴリを決める必要があることは申請の項目で説明しましたが、
その選んだカテゴリによって表示できる UI 、つまり CPTemplate
系のクラスが決まります。
対応表は以下のようになっています
この表をみるとナビゲーションアプリは表示できる UI が多いのですが Now Playing 用と Point of Interest 用のテンプレートである
CPNowPlayingTemplate
と CPPointOfInterestTemplate
が表示できないことになっています。
実際、表示しようとすると
Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'Unsupported object
というエラーを吐いて落ちます。
テンプレートによっては画面遷移の仕方に制限がある(し、公式ドキュメントにずれがある)
基本的に CPInterfaceController
の presentTemplate
及び pushTemplate
というメソッドを使って画面遷移をさせていくのですが
presentTemplate
に関しては
CarPlay can only present one modal template at a time. templateToPresent must be one of CPActionSheetTemplate, CPAlertTemplate, or CPVoiceControlTemplate.
と制限があり
pushTemplate
に関しては
use pushTemplate with a supported CPTemplate class such as CPGridTemplate, CPListTemplate, CPSearchTemplate, or
CPVoiceControlTemplate.
とあり指定されたクラスしか使えないような記述がされています。
が、実は CPMapTemplate
を pushTemplate
を使って表示させることができたりします。
また、
All apps are limited to pushing up to 5 templates in depth, including the root template. Quick food ordering
apps are limited to 2 templates in depth.
とあり、pushTemplate
を使って画面遷移したとしても5階層以上いけないようになっている様です。(未検証)
CarPlay 対応アプリを呼び出すには
カーナビ上で操作を完結させるために CarPlay 対応アプリから URL スキーム等つかって他の CarPlay アプリを呼び出したい時があると思います(ex. Calendar, music, etc...)
通常の iOS アプリでは UIApplication.shared.open(url:options:complicationHandler)
を使ってたと思うのですが、
CarPlay 上でそれを使うと CarPlay 側ではなにも起きずカーナビに繋いでる iPhone 側の方で別アプリが呼び出されます。
CarPlay 側でアプリ呼び出しをしたい場合は CPTemplateApplicationSceneDelegate
のデリゲートメソッドで渡される templateApplicationScene
の open(url:options:complicationHandler)
の方をつかう必要があります。
func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene,
didConnect interfaceController: CPInterfaceController, to window: CPWindow) {
// こちらを使うと iPhone 側でアプリが起動する
UIApplication.shared.open(URL(string: "なんらかの URL スキーマ")!, options: nil, complicationHandler: nil)]
// こちらを使うと CarPlay 側のアプリが起動する
templateApplicationScene.open(URL(string: "なんらかの URL スキーマ")!, options: nil, complicationHandler: nil)
}
個人的には iPhone 側の方でも UIApplication.shared.open
を使うのは基本的にやめて UIScene.open
を使うべきな気はしています。
まとめ
以上、CarPlay 対応のナビゲーションアプリの作り方と注意点を紹介してみました。
読んでいただいてわかると思うのですがカーナビという安全に直結するアプリケーションの UI であるため
非常に縛りが多く、自由なレイアウトというのはできない設計になります。
とはいえ、主観ではありますが従来の広く販売されているカーナビと比べるとやはりだいぶ使いやすいです。
また、ナビゲーションアプリに関しては ViewController を使って地図を表示してるので
その気になれば動画や音楽を流せる気がしています(未検証)。
Apple の審査に落とされそうな気がしてますが審査に出さず個人でやる分には問題ないはずなので
どこかのタイミングで実験して記事にしたいなと考えています。
参考文献
-
CarPlay App Programing Guide
- 2020年7月更新。CarPlay の概念については一番わかりやすい
-
CarPlay Navigation App Programing Guid
- 2018年更新、説明のベースが iOS12 になっておりちょっと古い
-
Integrating CarPlay with Your Navigation App
- ナビゲーションアプリの作り方のサンプルコード
-
https://qiita.com/koogawa/items/71710027a4ee7af267bc
- koogawa さんの CarPlay の記事
-
https://koogawa.hateblo.jp/entry/2019/01/15/003501
- koogawa さんの CarPlay でAudioアプリを作ってみた際の記事