最初に
去年の年末から今年の6月をかけて僕が担当している案件でCarPlayとAndroidAutoに対応する事になりました。
この記事ではCarPlayの内容に絞って得たことをアウトプットできればと思います。
CarPlayとは
CarPlayに対応してるカーナビにBluetoothまたは有線でiPhoneを接続することで、カーナビに最適化されたUIでサービスを提供できるというもの。
あくまで最適化されたUIのサービス提供するというものなので、カーナビ自体に何かがインストールされることはなく、通信などはiPhone側に依存する形になります。
やったこと
- AppDelegateからSceneDelegateへの移行、CarPlay対応に必要なプロジェクト設定
- UI設計
- CPTemplate、CPInterfaceControllerの理解
- 全体の構成設計
- Flutterとの連携
UI設計のポイント
CarPlayはAppleが提供しているTemplateに沿って実装をすることになります。
そして使用できるUIはエンタイトルメントで設定した物によって制限されます。
今回はメディアアプリなので、com.apple.developer.carplay-audioを指定しています。
すでにメディアアプリで使えるUIを先達の方がまとめてくれているページがあるのでそれを確認してください。
iOS13以前でもCarPlay対応はされていますが、今回は対象外として起動時にエラー画面が出るようにしています。
実装に際して理解しておいたほうがいいものを用語集としてまとめてみました。
用語集
- テンプレート
- CarPlayの画面そのもの
- 提供されているテンプレートを使うことしか出来ないので、実現できるUIは限られる
-
CPTabBarTemplate,CPListTemplate,CPAlertTemplate,CPNowPlayingTemplateなどがある
- セクション
-
CPListTemplateに渡すことでリストの表示ができる - セクションの配列を渡すことで複数のセクション表示に対応ができる
- 端末によっては1セクションに最大12件までしか表示できないことがあり、13件以降は非表示になる
- 13件以上表示したい場合はセクションを12件ごとに分割することで実現可能
-
CPListSectionなどがある
-
- アイテム
- リスト内の1セル毎のコンテンツに当たるもの
-
CPListItem,CPListImageRowItemなどがある
- インターフェースコントローラー(
CPInterfaceController)- 画面表示に関するインターフェース
-
pushTemplate(),presentTemplate()をすることで画面遷移ができる -
topTemplateで画面上に表示されているテンプレートを取得できる - アプリ内のCarPlayのUI全体で一つのインスタンスを使いまわす必要がある
- コントローラー
- テンプレートを管理するためのCarPlayの仕様にはない概念
- テンプレート関連のクラスは継承して独自クラスを作ることができないので、テンプレートを管理するクラスが必要になる
- 具体的にはデータの整形、アイテム、セクションの作成などを行う
- 1テンプレート1コントローラーで実装している
- テンプレートは
userinfoを持つことが出来て、その中に自分を管理するコントローラーのインスタンスを格納している- 循環参照の様になってしまうが、画面更新を考慮するとこの形にするしかなかった
- 画面上に表示されているテンプレートはインターフェースコントローラーから取り出すことができるので、そのテンプレートが持つコントローラーを扱うことで、データ更新など外からのアクションを処理できるようにしている
- テンプレートを管理するためのCarPlayの仕様にはない概念
CarPlayでアプリを起動するとCPTemplateApplicationSceneDelagateのtemplateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, didConnect interfaceController: CPInterfaceController)が呼ばれ画面を作っていくことになります。
class CarPlaySceneDelegate: UIResponder {
func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
}
}
extension CarPlaySceneDelegate: CPTemplateApplicationSceneDelegate {
func templateApplicationScene(
_ templateApplicationScene: CPTemplateApplicationScene,
didConnect interfaceController: CPInterfaceController
) {
CarPlayController.shared.templateApplicationScene(
templateApplicationScene, didConnect: interfaceController)
}
func templateApplicationScene(
_ templateApplicationScene: CPTemplateApplicationScene,
didDisconnectInterfaceController interfaceController: CPInterfaceController
) {
CarPlayController.shared.templateApplicationScene(
templateApplicationScene, didDisconnect: interfaceController)
}
}
enum UpdateType {
case willAppear
case dataChanged
}
class CarPlayBaseController: NSObject {
var interfaceController: CPInterfaceController?
private var checkPlayabilityCompletionHandler: ((_ errorText: String) -> Void)?
override init() {}
internal func invoke(
method: CarPlayMethodChannel, arguments: [String: Any] = [:],
completion: ((Any?) -> Void)? = nil
) {
let channel = CarPlayMethodChannel.name
DispatchQueue.main.async {
guard let rootViewController = UIApplication.shared.connectedScenes
.compactMap({ ($0 as? UIWindowScene)?.windows.first?.rootViewController })
.first(where: { $0 is FlutterViewController }) as? FlutterViewController else {
return
}
let methodChannel = FlutterMethodChannel(
name: channel, binaryMessenger: rootViewController.binaryMessenger)
methodChannel.invokeMethod(method.rawValue, arguments: arguments) { [weak self] result in
guard self != nil else { return }
if let error = result as? FlutterError {
print("\(error.message ?? "不明なエラー")")
completion?(error)
} else {
print("method: \(method) result: \(String(describing: result))")
completion?(result)
}
}
}
}
@available(iOS 14.0, *)
internal func updateTemplate(_ type: UpdateType = .dataChanged) {}
}
class CarPlayController: CarPlayBaseController {
static let shared = CarPlayController()
private override init() {}
func templateApplicationScene(
_ templateApplicationScene: CPTemplateApplicationScene,
didConnect interfaceController: CPInterfaceController
) {
sendCarNavigationConnected(isConnected: true)
self.interfaceController = interfaceController
interfaceController.delegate = self
if #available(iOS 14.0, *) {
self.interfaceController?.setRootTemplate(setupTabBarTemplate(), animated: true)
} else {
// iOS13向けにはOSのアップデートを促すダイアログを表示するだけ
let cancelAction = CPAlertAction(
title: "戻る",
style: .cancel
) { _ in
self.interfaceController?.dismissTemplate(animated: true)
}
let alertTemplate = CPAlertTemplate(
titleVariants: ["iOSのバージョンを14以上にアップデートしてご利用ください。"],
actions: [cancelAction]
)
self.interfaceController?.presentTemplate(alertTemplate, animated: true)
}
}
func templateApplicationScene(
_ templateApplicationScene: CPTemplateApplicationScene,
didDisconnect interfaceController: CPInterfaceController
) {
sendCarNavigationConnected(isConnected: false)
}
}
extension CarPlayController {
@available(iOS 14.0, *)
private func setupTabBarTemplate() -> CPTabBarTemplate {
var templates: [CPTemplate] = []
if let interfaceController = self.interfaceController {
let carPlayHogeHomeController = CarPlayHogeHomeController(interfaceController)
let hogeHomeTemplate = carPlayHogeHomeController.createHogeHomeTemplate()
let carPlayFugaHomeController = CarPlayFugaHomeController(interfaceController)
let fugaHomeTemplate = carPlayFugaHomeController.createFugaHomeTemplate()
templates = [hogeHomeTemplate, fugaHomeTemplate]
}
return CPTabBarTemplate(templates: templates)
}
// 1Templateに1Controllerの設計なので、表示されているTemplateからControllerを取り出して画面の更新などに使用する
@available(iOS 14.0, *)
internal func currentTemplateController() -> CarPlayBaseController? {
var currentTemplate: CPTemplate? = nil
if let tabBarTemplate = interfaceController?.topTemplate as? CPTabBarTemplate,
let selectedTemplate = tabBarTemplate.selectedTemplate
{
currentTemplate = selectedTemplate
} else if let topTemplate = interfaceController?.topTemplate as? CPTemplate {
currentTemplate = topTemplate
}
if let userinfo = currentTemplate?.userInfo as? [String: Any],
let controller = userinfo["controller"] as? CarPlayBaseController
{
return controller
}
return nil
}
}
CarPlayControllerがシングルトンパターンで実装されている理由はFlutter側からデータが渡ってきた際に、InterFaceControllerを扱って表示画面を簡単に取得できるようにするためです。
そのたエラーダイアログ表示なども実装しておくと便利に扱うことができます。
Flutterとの連携
AppdeleagteでFlutterEngineを生成し、CPTemplateApplicationSceneDelagateでFlutter関連の処理を扱おうとしたのですが、アプリ側のUIWindowSceneDelegateがないためかCarPlay側のアプリ単体起動だとFlutterの処理が正しく動かない状態でした。
なので、iPhone側でアプリが起動している状態でしか動かせないという制約が生まれてしまっています。
現状ではCarPlay側アプリ単体での起動方法は見つかったのですが、まだ複数の課題が残っており修正ができていない状態です。
FlutterとCarPlayはSceneDelegateでCarPlay用のPluginを登録して、シングルトンのクラスを介して画面に表示します。
例えばCarPlayPluginというクラスでFlutterからのイベントを受信してAPIの値を取得、CarPlayDataManagerというシングルトンのクラスへ値を渡し保持を行い、各コントローラーで値を扱い画面に表示するという形です。
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
static let flutterEngine = FlutterEngine(name: "my_flutter")
internal var window: UIWindow?
func scene(
_ scene: UIScene, willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = (scene as? UIWindowScene) else { return }
let flutterViewController = FlutterViewController(
engine: flutterEngine, nibName: nil, bundle: nil)
let window = UIWindow(windowScene: windowScene)
window.rootViewController = flutterViewController
self.window = window
window.makeKeyAndVisible()
if let registar = flutterEngine.registrar(forPlugin: CarPlayMethodChannel.name) {
CarPlayPlugin.register(with: registar)
}
}
}
fileprivate var methodChannel: FlutterMethodChannel
static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: CarPlayMethodChannel.name, binaryMessenger: registrar.messenger())
registrar.addMethodCallDelegate(CarPlayPlugin(methodChannel: channel), channel: channel)
}
init(methodChannel: FlutterMethodChannel) {
self.methodChannel = methodChannel
}
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let code = CarPlayMethodChannel(rawValue: call.method) else {
assertionFailure()
return
}
switch code {
case "sendData":
if let args = call.arguments as? [String: Any],
let contents = args["data"] as? [[String: String]] {
let data = contents.compactMap { Data(dictionary: $0) }
CarPlayDataManager.shared.data = data
}
}
}
class CarPlayDataManager {
static let shared = CarPlayDataManager()
var data:[Data] = []
}
class CarPlayHogeHomeController {
init(_ interfaceController: CPInterfaceController) {
super.init()
self.interfaceController = interfaceController
}
@available(iOS 14.0, *)
func createHogeHomeTemplate() -> CPListTemplate {
let hogeListTemplate = CPListTemplate(
title: CarPlayText.hoge, sections: createHogeHomeSections())
hogeListTemplate.tabImage = UIImage(named: "CarPlayTabHoge")
// TemplateにControllerをもたせる
hogeListTemplate.userInfo = [
"isHogeHome": true,
"controller": self,
]
return hogeListTemplate
}
@available(iOS 14.0, *)
private func createHogeHomeSections() -> [CPListSection] {
var sections: [CPListSection] = []
let hogeSection = createHogeData { [weak self] id in
guard let weakSelf = self else { return }
// セルのタップアクション
}
sections.append(contentsOf: hogeSection)
return sections
}
@available(iOS 14.0, *)
func createHogeData(
itemSelectHandler: @escaping (_ id: String) -> Void
) -> [CPListSection] {
var items: [CPListItem] = []
var groupTitle: String?
for dataItem in CarPlayDataManager.shared.data {
groupTitle = groupTitle ?? dataItem.groupTitle
let listItem = CPListItem(text: dataItem.title, detailText: dataItem.detail)
listItem.accessoryType = .disclosureIndicator
listItem.handler = { [weak self] _, completion in
guard self != nil else {
completion()
return
}
itemSelectHandler(data.id)
completion()
}
// サムネイル表示
if let imageURL = exchangeSquareImageUrl(data.imageUrl) {
ImageFetcher.shared.fetchImage(from: imageURL) { image in
guard let image = image else { return }
DispatchQueue.main.async {
listItem.setImage(image)
}
}
}
items.append(listItem)
}
let sections = CPListSection(items: items, header: groupTitle, sectionIndexTitle: "")
.divideSectionPer12()
return sections
}
@available(iOS 14.0, *)
override func updateTemplate(_ type: UpdateType = .dataChanged) {
super.updateTemplate()
// ここで表示されている画面が自分自身であるか判定を行う
if let tabBarTemplate = interfaceController?.topTemplate as? CPTabBarTemplate,
let hogeHomeTemplate = tabBarTemplate.selectedTemplate as? CPListTemplate,
let userinfo = hogeHomeTemplate.userInfo as? [String: Any],
userinfo["isHogeHome"] as? Bool ?? false
{
hogeHomeTemplate.updateSections(createHogeHomeSections())
}
}
}
実機での動作確認について
iPhoneにさえアプリがインストールできていれば、実際にカーナビに接続することで動作の確認ができます。
ただ手元にカーナビがあることは少ないと思います。
その場合はAppleが提供しているAdditional Toolsを使うことでMacと接続したiPhoneにて確認ができます。
リンク先から使っているXcodeにあったバージョンをダウンロードし/Volumes/Additional Tools/Hardware/CarPlay Simulator.appを起動しますとCarPlayのシミュレーターを起動することができます。
iPhoneそのものでシミュレーターを使っている場合はシミュレーターアプリのステータスバーメニューのI/OにExternal Displaysがありその中のCarPlayを選択するとCarPlayのシミュレーターが起動します。
最後に
CarPlayの性質として問題になりやすいのがUIの制限。
思った以上に制限が強く、ユーザービリティの高いUIを作るのに苦労をしました。
ただ今回紹介できなかったAndroidAutoの方が、使用しているPluginとの兼ね合いで実装が大変なことになり。。。
まだ単独起動の修正も残っていますので、今後またシェアできればと思います。