0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FlutterのメディアアプリでCarPlayに対応してみた

Posted at

最初に

去年の年末から今年の6月をかけて僕が担当している案件でCarPlayとAndroidAutoに対応する事になりました。
この記事ではCarPlayの内容に絞って得たことをアウトプットできればと思います。

CarPlayとは

CarPlayに対応してるカーナビにBluetoothまたは有線でiPhoneを接続することで、カーナビに最適化されたUIでサービスを提供できるというもの。
あくまで最適化されたUIのサービス提供するというものなので、カーナビ自体に何かがインストールされることはなく、通信などはiPhone側に依存する形になります。

やったこと

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でアプリを起動するとCPTemplateApplicationSceneDelagatetemplateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, didConnect interfaceController: CPInterfaceController)が呼ばれ画面を作っていくことになります。

CarPlaySceneDelegate.swift
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)
    }
}
CarPlayBaseController.swift
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) {}
}
CarPlayController.swift
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との連携

AppdeleagteFlutterEngineを生成し、CPTemplateApplicationSceneDelagateでFlutter関連の処理を扱おうとしたのですが、アプリ側のUIWindowSceneDelegateがないためかCarPlay側のアプリ単体起動だとFlutterの処理が正しく動かない状態でした。
なので、iPhone側でアプリが起動している状態でしか動かせないという制約が生まれてしまっています。
現状ではCarPlay側アプリ単体での起動方法は見つかったのですが、まだ複数の課題が残っており修正ができていない状態です。
FlutterとCarPlayはSceneDelegateでCarPlay用のPluginを登録して、シングルトンのクラスを介して画面に表示します。
例えばCarPlayPluginというクラスでFlutterからのイベントを受信してAPIの値を取得、CarPlayDataManagerというシングルトンのクラスへ値を渡し保持を行い、各コントローラーで値を扱い画面に表示するという形です。

SceneDelegate.swift
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)
        }
    }
}

CarPlayPlugin.swift
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
                }
        }
    }
CarPlayDataManager.swift
class CarPlayDataManager {
    static let shared = CarPlayDataManager()
    var data:[Data] = []
}
CarPlayHogeHomeController.swift
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との兼ね合いで実装が大変なことになり。。。
まだ単独起動の修正も残っていますので、今後またシェアできればと思います。

0
0
0

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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?