iOS CarPlay|与你分享 CarPlay 音频 App 的开发过程与细节

·  阅读 4081
iOS CarPlay|与你分享 CarPlay 音频 App 的开发过程与细节

关键词:CarPlay framework; 字数:12k

前言

笔者负责了宝宝巴士 CarPlay App 的研发,想与你分享下开发经验。通过本篇文章,你可以学习到:

  • CarPlay 是什么,适用于哪些类型的 App
  • 让你的工程兼容 UIScene,也就是从传统的 UIWindow 和 AppDelegate 向 SceneDelegate 过渡
  • 开发一个音频类 CarPlay App 的详细过程

GitHub:iOS-CarPlay

CarPlay 是什么

CarPlay 是 Apple 发布的一个车载系统,可以配合 iPhone 使用(iPad 不支持)。其前身是叫 iOS in the Car,2014 年更名为 CarPlay。

简单地说,如果你的汽车支持 CarPlay,那么当你的汽车(通过数据线、蓝牙、Wi-Fi)连接 iPhone 时,汽车显示屏会自动切换到 CarPlay,iPhone 上所有支持 CarPlay 的 App 会自动显示在 CarPlay 中,你可以在 iPhone 设置(通用 > CarPlay 车载)中屏蔽指定 App 或调整顺序。Apple 对 CarPlay App 用户界面采用一致的设计,内容由 App 自己提供。

控制 CarPlay 主要有 3 种方式:Siri、触屏显示屏、物理按键。

CarPlay 支持哪些 App 及功能

  • 音频 App 可以提供音乐、新闻、播客等。
  • 通信 App(Messaging、VoIP calling)可以发送和接收消息,同时可以配合 Siri 使用。
  • 导航 App 可以提供详细的地图、目的地搜索、路线指导和用户通知。
  • 汽车制造商的 App 可以提供特定于车辆的控制和显示,让司机在不离开 CarPlay 的情况下保持联系。
  • iOS 14 新功能,支持 EV 充电、停车和快餐订购 App。此外,所有 CarPlay App 都可以利用 CarPlay framework 提供一致的设计,并针对在汽车中的使用进行了优化。

CarPlay App 开发流程概览

  1. 首先需要确定你的 App 是否适用于 CarPlay,然后去开发者网站申请对应 App 类型的 CarPlay 权限,并对工程进行配置。只有这样你的工程才能使用 CarPlay Simulator。因此如果你计划要开发 CarPlay App 的话,最好提前去申请权限,因为 Apple 审核还要时间。在此期间可以看看相关开发文档,等权限申请下来就可以使用 CarPlay Simulator 调试开发啦。参考文档:Apple|申请 CarPlay 权限
  2. 从 iOS 14 开始,你可以使用 CarPlay framework 来开发音频 CarPlay App(如果是导航类 App,在 iOS 12 就可以使用),它提供了一些 UI 模版来支持开发者自定义界面;如果你要兼容 iOS 13 及更早版本的话需要使用 MediaPlayer framework 开发,它向前兼容。因此,如果你的 App 需要在 iOS 14 及更高版本上使用 CarPlay framework,并且兼容 iOS 13 及更早版本的话,就要维护两套代码,开发工作量可能接近 double。笔者仅支持了 iOS 14 及更高版本,在本篇文章中会详细讲解使用 CarPlay framework 的开发细节。如果你想支持低版本的话也可以看看笔者对「WWDC17 - 让您的 App 支持 CarPlay 车载」和「WWDC18 - CarPlay 车载音频和导航 App」做的笔记。
  3. 在 iOS 14 及更高版本中使用 CarPlay framework 来开发 CarPlay App 必须使用 UIScene(UIScene 是 Apple 于 iOS 13 引入的,用于构建多窗口应用),因此你的工程必须从传统的 UIWindow 和 AppDelegate 向 SceneDelegate 过渡。如果你的工程已经兼容了 UIScene,那么就可以省去这步骤的工作;如果还未兼容的话,也可以看看我在本篇文章中提到的一些注意点。
  4. CarPlay App 是附属于 iPhone App 的,它们是同一进程。
    • 如果是先启动了 CarPlay App,那么系统会在后台启动你的 iPhone App;
    • 如果杀死了 iPhone App 进程,那么 CarPlay App 就会关闭(在 UIScene 中是断开场景的连接);
    • 如果关闭 CarPlay App(在 UIScene 中是断开场景的连接),iPhone App 进程不会被杀死。(好像只有退出整个 CarPlay,才会关闭所有 CarPlay App,没办法关闭指定 CarPlay App);
    在 iOS 13 中,Apple 对 CarPlay 做了改进,CarPlay App 和 iPhone App 可以一个处于后台一个处于前台。而 iOS 13 之前 CarPlay App 和 iPhone App 是高度绑定的,只能共处前台或后台,用户体验不好。例如你在使用 CarPlay 导航时,手机将无法进行别的操作,否则会打断导航进程。
  5. 笔者开发的是音频类 CarPlay App,对其它类型的 App 没做了解。开发一个音频类 CarPlay App 就是从 CPTemplateApplicationSceneDelegate 入口开始来构建 UI,填充数据。CarPlay App 的用户界面相对来说比较固定,但使用 CarPlay framework,Apple 支持更多可定制化的 UI 了。当车机连接后,音频将通过汽车扬声器播放。无论你是使用 CarPlay framework 还是 MediaPlayer framework 来构建的 CarPlay App,都是通过 MPNowPlayingInfoCenter 和 MPRemoteCommandCenter 来提供播放界面的音频信息以及响应远程播放控制事件。只不过在 CarPlay framework 中,一些远程控制事件通过 CPNowPlayingButton 的 handler 来处理了,比如播放模式、播放速度等等。当然如果你的 App 是音频类的话,应该已经支持了这些功能,因为 iPhone 锁屏界面以及控制中心的音频播放信息和播放控制也是通过它们提供。因此,我们只需要针对 CarPlay 做下优化或者功能增强就行。
    • 设置和更新 MPNowPlayingInfoCenter 的 nowPlayingInfo,它包含当前播放音频的元数据,如标题、作者、时长等等;
    • 响应 MPRemoteCommandCenter 事件,对远程播放控制事件做出响应,如播放、暂停、切换歌曲等等;
    除了上面两点,你可能还需要:
    • 设置和更新 MPNowPlayingInfoCenter 的 playbackState,以更新 CarPlay App 上显示的音频播放状态:播放/暂停;
    • 设置和更新 MPRemoteCommandCenter 的 changeRepeatModeCommand.currentRepeatType,以更新 CarPlay App 上显示的播放模式状态:顺序循环/单曲循环
    即使你的 App 暂时还不打算支持 CarPlay,你也可以通过适配好 MPNowPlayingInfoCenter 和 MPRemoteCommandCenter 来使你的 App 支持 CarPlay ”播放中“ App。
  6. 了解 CarPlay framework 都支持哪些功能、 UI,然后帮助你的 PM 和 UI 完成需求、原型和设计。学习 UI 的使用,界面基本就是由 Template 和 Item 组成,常用的有 CPTabBarTemplate、CPListTemplate、CPListItem、CPListImageRowItem 等等。
  7. 然后,就是开发啦!此处省略 ... 字。
  8. 最后,在真实环境(汽车中)测试。Apple|使用 CarPlay Simulator 运行和调试 CarPlay App 中列举了一些在 CarPlay Simulator 上无法测试的功能。另外,Apple 建议我们多测试弱网以及无网环境下的用户体验,因为驾驶过程中可能会经过网络不好的路段或区域。
  9. 你可以玩玩 Apple 提供的音频类 CarPlay App 示例:Apple|CarPlay Music App

兼容 UIScene

在 iOS 14 及更高版本中使用 CarPlay framework 来开发 CarPlay app 必须使用 UIScene(UIScene 是 Apple 于 iOS 13 引入的,用于构建多窗口应用),因此你的工程必须从传统的 UIWindow 和 AppDelegate 向 SceneDelegate 过渡。如果你的工程已经兼容了 UIScene,那么就可以省去这步骤的工作;如果还未兼容的话,可以参考本章节中的步骤。

UIScene 是什么

在 iOS 13 之前,在功能职责上,UIApplication 负责 App 状态,UIApplicationDelegate(AppDelegate)负责 App 事件和生命周期,包括进程和 UI 的。对于单窗口的 App 来说这没有问题,但是要想开发多窗口的 iPad App 或者 Mac Catalyst App 的话,这种功能职责的划分已经不支持了。

因此, Apple 于 iOS 13 引入用于构建多窗口应用的 UIScene,并对功能职责进行了拆分,将 UI 相关的状态、事件和生命周期交与 UIWindowSceneUIWindowSceneDelegate(SceneDelegate)负责,UISceneSession 负责持久化的 UI 状态。

兼容 UIScene

因为 UIScene 只能在 iOS 13 及更高版本中使用,因此如果你的 App 最低版本支持小于 iOS 13 的话,你就不能完全使用 SceneDelegate,在 iOS 13 及更高版本中使用 AppDelegate + SceneDelegate,而在低于 iOS 13 的版本中继续只使用 AppDelegate。

在 Info.plist 声明一个 UIWindowScene

Info.plist 中添加以下 key-value。一些参数说明:

  • Enable Multiple Windows,需要设置为 NO,否则你的 iPad App 将支持多窗口(如果你的 iPhone 和 iPad 工程放在同一工程下的话)。
  • Application Session Role,一个数组,配置你的 App 场景,每一项有 4 个参数:
    • Class Name:Scene 类型
    • Configuration Name:当前配置的名字
    • Delegate Class Name:与哪个 Scene 代理类关联
    • StoryBoard name:这个 Scene 使用的哪个 storyBoard 如果有的话

<key>UIApplicationSceneManifest</key>
<dict>
    <key>UIApplicationSupportsMultipleScenes</key>
        <false/>
    <key>UISceneConfigurations</key>
    <dict>
        <key>UIWindowSceneSessionRoleApplication</key>
        <array>
            <dict>
                <key>UISceneClassName</key>
                <string>UIWindowScene</string>
                <key>UISceneConfigurationName</key>
                <string>DefaultSceneConfiguration</string>
                <key>UISceneDelegateClassName</key>
                <string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
            </dict>
        </array>
        <key>CPTemplateApplicationSceneSessionRoleApplication</key>
        // ...
        // CPTemplateApplicationScene
        // ...
    </dict>
</dict>
复制代码

Project

Targets > General > Deployment Info > Supports multiple windows 取消勾选。该选项选中状态会影响 Info.plist 中 Enable Multiple Windows 的值。

AppDelegate 改动

由于类功能职责的变化,一些原本在 AppDelegate API 中的实现需要迁移到 SceneDelegate API 中。

@implementation AppDelegate

- (UIWindow *)window {
    if (@available(iOS 13, *)) {
        return [(SceneDelegate *)TTScene.main.delegate window];
    } else {
        return _window;
    }
}

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    ...
    if (@available(iOS 13.0, *)) {} else {
        // 1. create window
        // 2. do something after window created。需要注意原先在 window created 之后才执行的代码,也要兼容 iOS 13
    }
    return YES;
}

@end
复制代码

以下方法选择性实现。

@available(iOS 13, *)
extension AppDelegate {

    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        // Called when a new scene session is being created.
        // Use this method to select a configuration to create the new scene with.
        return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
    }

    func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
        // Called when the user discards a scene session.
        // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
        // Use this method to release any resources that were specific to the discarded scenes, as they will not return.
    }
}
复制代码

SceneDelegate

@available(iOS 13, *)
class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    let configurationName = "DefaultSceneConfiguration"
    @objc var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = (scene as? UIWindowScene), session.configuration.name == configurationName else { return }
        // 1. create window
        let window = UIWindow(windowScene: windowScene)
        // ...
        self.window = window
        window.makeKeyAndVisible()
        // 2. do something after window created
    }
  
    func sceneDidDisconnect(_ scene: UIScene) {
        guard scene.session.configuration.name == configurationName else { return }
    }

    func sceneDidBecomeActive(_ scene: UIScene) {
        guard scene.session.configuration.name == configurationName else { return }
        UIApplication.shared.delegate?.applicationDidBecomeActive?(UIApplication.shared)
    }

    func sceneWillResignActive(_ scene: UIScene) {
        guard scene.session.configuration.name == configurationName else { return }
        UIApplication.shared.delegate?.applicationWillResignActive?(UIApplication.shared)
    }

    func sceneWillEnterForeground(_ scene: UIScene) {
        guard scene.session.configuration.name == configurationName else { return }
        UIApplication.shared.delegate?.applicationWillEnterForeground?(UIApplication.shared)
    }

    func sceneDidEnterBackground(_ scene: UIScene) {
        guard scene.session.configuration.name == configurationName else { return }
        UIApplication.shared.delegate?.applicationDidEnterBackground?(UIApplication.shared)
    }

    func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
        guard scene.session.configuration.name == configurationName else { return }
        guard let url = URLContexts.first?.url else { return }
        _ = UIApplication.shared.delegate?.application?(UIApplication.shared, open: url, options: [:])
    }
    
    func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
        guard scene.session.configuration.name == configurationName else { return }
        _ = UIApplication.shared.delegate?.application?(UIApplication.shared, continue: userActivity, restorationHandler: { _ in })
    }
}
复制代码

TTScene: 用于获取场景

import UIKit
import CarPlay

@available(iOS 13.0, *)
class TTScene: NSObject {

    static var connectedScenes: Set<UIScene> {
        UIApplication.shared.connectedScenes
    }
    
    @objc static var main: UIWindowScene? {
        connectedScenes.first(where: { $0 is UIWindowScene }) as! UIWindowScene?
    }
    
    static var carPlay: CPTemplateApplicationScene? {
        connectedScenes.first(where: { $0 is CPTemplateApplicationScene }) as! CPTemplateApplicationScene?
    }
}
复制代码

UIWindow+SceneHook

使用 UIScene 后,UI 层级结构发生了一些变化,原本的 UIScreen 和 UIWindow 层中加入了一层 UIWindowScene。

而 UIWindow 也新增了一个 windowScene 属性,以及 windowScene 构造器。一个 UIWindow 必须使用 windowScene 进行初始化或者设置 windowScene 属性才能显示在屏幕上。

// instantiate a UIWindow already associated with a given UIWindowScene instance, with matching frame & interface orientations.
- (instancetype)initWithWindowScene:(UIWindowScene *)windowScene API_AVAILABLE(ios(13.0));

// If nil, window will not appear on any screen.
// changing the UIWindowScene may be an expensive operation and should not be done in performance-sensitive code
@property (nullable, nonatomic, weak) UIWindowScene *windowScene API_AVAILABLE(ios(13.0));
复制代码

为了兼容旧代码,我们可以 hook UIView 的 initWithFrame: 方法,为 UIWindow 设置 windowScene。

@implementation UIView (SceneHook)

+ (void)load {

    if (@available(iOS 13.0, *)) {
      
        static dispatch_once_t onceToken;
        dispatch_once(&onceToken, ^{
            SEL selector = @selector(initWithFrame:);
            Method method = class_getInstanceMethod(self, selector);
            if (!method) {
                NSAssert(NO, @"Method not found for [UIView initWithFrame:]");
            }
            IMP imp = method_getImplementation(method);
            class_replaceMethod(self, selector, imp_implementationWithBlock(^(UIView *self, CGRect frame) {
                ((UIView * (*)(UIView *, SEL, CGRect))imp)(self, selector, frame);
                if ([self isKindOfClass:UIWindow.class]) {
                    [(UIWindow *)self setWindowScene:TTScene.main];
                }
                return self;
            }), method_getTypeEncoding(method));
        });
    }
}

@end
复制代码

首次启动隐私弹窗适配

如果你的首次启动隐私弹窗是通过在 AppDelegate 的 init 方法中 hook - application:didFinishLaunchingWithOptions: 方法进行拦截的话,也需要 hook - scene:willConnectToSession:options:,然后可以将隐私弹窗弹出的时机放在这里。- scene:willConnectToSession:options: 调用时机将在 - application:didFinishLaunchingWithOptions: return 之后。

相关资料

让你的音频 App 支持 CarPlay

从 iOS 14 开始,你可以使用 CarPlay framework 来开发音频 CarPlay App(如果是导航类 App,在 iOS 12 就可以使用),它提供了一些 UI 模版来支持开发者自定义界面;如果你要兼容 iOS 13 及更早版本的话需要使用 MediaPlayer framework 开发,它向前兼容。因此,如果你的 App 需要在 iOS 14 及更高版本上使用 CarPlay framework,并且兼容 iOS 13 及更早版本的话,就要维护两套代码,开发工作量可能接近 double。笔者仅支持了 iOS 14 及更高版本,在本章节中会详细讲解使用 CarPlay framework 的开发细节。如果你想支持低版本的话也可以看看笔者对「WWDC17 - 让您的 App 支持 CarPlay 车载」和「WWDC18 - CarPlay 车载音频和导航 App」做的笔记。

申请权限并配置工程

首先,需要确定你的 App 是否适用于 CarPlay,然后去开发者网站申请对应 App 类型的 CarPlay 权限,并对工程进行配置。只有这样你的工程才能使用 CarPlay Simulator,否则你的 CarPlay Simulator 无法打开(灰显禁用)。不过笔者注意到,只要你的 CarPlay Simulator 启用过,即便是不支持 CarPlay 的 App 也是可以打开 CarPlay Simulator 的。因此你可以跑一遍 Apple 的 CarPlay 示例 App CarPlay Music App 来启用 CarPlay Simulator。

不过,要使用 CarPlay Simulator 运行和调试你的 CarPlay App,还是得你自己的工程支持才行。

因此,如果你计划要开发 CarPlay App 的话,最好提前去申请权限,因为 Apple 审核还要时间。在此期间可以看看相关开发文档,等权限申请下来并配置好工程就可以使用 CarPlay Simulator 调试开发啦。

参考文档:申请 CarPlay 权限

使用 CarPlay Simulator 运行和调试 CarPlay App

每个 iPhone Simulator 都附带一个 CarPlay Simulator,在 I/O > External Displays > CarPlay 打开。默认的标准的 CarPlay Simulator 窗口大小和比例为 800 x 480, @2x

如果是导航类 App,Apple 建议开启 CarPlay Simulator 的附加选项,在终端输入以下命令。这允许你每次启动 CarPlay Simulator 前都可以设置窗口大小和比例,用来测试确保你的地图内容适配了所有推荐的配置。也许只支持导航类 App 吧,音频类 App 改变窗口大小后显示效果不尽如人意,建议关闭。

defaults write com.apple.iphonesimulator CarPlayExtraOptions -bool YES
复制代码

如果你打开了 CarPlay Simulator 却没有在主屏幕上看到你的 App,那么你可能是忘了添加对应的权利。你需要将 Key com.apple.developer.carplay-audio 添加到 Entitlements.plist 中并设置 Value 为 1。

<key>com.apple.developer.carplay-audio</key>
<true/>
复制代码

参考文档:使用 CarPlay Simulator 运行和调试 CarPlay App

如果你是 M1 Mac,那可能无法使用 CarPlay Simulator。如果你的 Xcode 以 Rosetta 模式运行,那么启动 CarPlay App 会直接 crash。将 Simulator 也以 Rosetta 运行并不能解决问题。这个问题暂时没有解决方案。issueexplorer.com/issue/mapbo…

声明一个 CarPlay scene

在 Info.plist 中声明一个 scene。

<key>UIApplicationSceneManifest</key>
<dict>
    <key>UIApplicationSupportsMultipleScenes</key>
    <false/>
    <key>UISceneConfigurations</key>
    <dict>
        <key>CPTemplateApplicationSceneSessionRoleApplication</key>
        <array>
            <dict>
                <key>UISceneClassName</key>
                <string>CPTemplateApplicationScene</string>
                <key>UISceneConfigurationName</key>
                <string>CarPlaySceneConfiguration</string>
                <key>UISceneDelegateClassName</key>
                <string>$(PRODUCT_MODULE_NAME).CarPlaySceneDelegate</string>
            </dict>
        </array>
    </dict>
</dict>
复制代码

实现 CarPlaySceneDelegate

import CarPlay

class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDelegate {
    var interfaceController: CPInterfaceController?

    func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene,
            didConnect interfaceController: CPInterfaceController) {

        self.interfaceController = interfaceController
        let item = CPListItem(text: "Rubber Soul", detailText: "The Beatles")
        let section = CPListSection(items: [item])
        let listTemplate = CPListTemplate(title: "Albums", sections: [section])
        interfaceController.setRootTemplate(listTemplate, animated: true)
    }

    func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene,
            didDisconnect interfaceController: CPInterfaceController) {
        self.interfaceController = nil
    }
}
复制代码

CPTemplateApplicationSceneDelegate 协议定义了 CarPlay 在场景连接、断开连接、以及响应某些用户操作的方法。你需要在 CarPlay 启动你的 App 并连接其场景时创建和设置根模板。一般我们实现以下两个方法:

  • - templateApplicationScene:didConnectInterfaceController:
    通知代理 CarPlay Scene 已连接。当 App 在车机屏幕上启动时,CarPlay framework 将调用此方法,在该方法中对模板进行初始化。可以看到,系统自动创建了 CPInterfaceController 实例(类似 UINavigationController),作为我们 CarPlay App 的入口 controller,我们只需在回调中持有这个实例即可。在以上示例代码中,我们创建了一个列表模板 CPListTemplate(类似 UITableView),其接收多组 CPListSection,section 中包含多个 CPListItem(类似 UITableViewCell。最后,我们将 CPListTemplate 设置为 App 的根模板(类似 rootViewController)。
  • - templateApplicationScene:didDisconnectInterfaceController:
    通知代理 CarPlay Scene 已断开连接。当车机断开连接时,该方法将被调用,可以做一些清理工作。

参考文档:在你的 CarPlay App 中显示内容

CarPlay 界面搭建

CarPlay App 界面基本就是由 Template 和 Item 组成,而音频类的 CarPlay App 基本上使用 CPTabBarTemplate、CPListTemplate、CPListImageRowItem、CPListItem 等等即可完成界面的搭建。

TemplatesDescription
CPListTemplate
- CPListItem
- CPListImageRowItem
- CPMessageListItem
列表模版(类似 UITableView)
- 一个通用的、可选择的列表项(对应下图第 2 个)
- 显示一系列图像的列表项(对应下图第 3 个)
- 表示对话或联系人的列表项(用于通信类 App)(对应下图第 1 个)
CPGridTemplate显示和管理 items 网格的模版
CPTabBarTemplateTabBar 模版(类似 UITabBarController)

CPTabBarTemplate

TabBar 模版,类似 UIKit 中的 UITabBarController。使用一组 CPTemplate 进行初始化,可以将其作为 interfaceController 的 rootTemplate。

let tabBarTemplate = CPTabBarTemplate(templates: templates)
interfaceController.setRootTemplate(tabBarTemplate, animated: true)
复制代码

需要注意一下,CPTabBarTemplate 的 templates 是有个数限制的,最大数量通过 maximumTabCount 类属性获取,它的值取决于在 Entitlements.plist 中添加的权利,音频 App 最多添加 4 个,超过数量会 crash。

WWDC17 - 让你的 App 支持 CarPlay 车载 中 Apple 提到过使用 MediaPlayer framework 来构建的 CarPlay App 时,推荐使用最多 4 个 tabs 并且使用较短的标题,因为空间有限并且有些汽车的屏幕比较窄,而且有音频正在播放的时候还需在 rootTemplate 右上角显示 “正在播放” 按钮。

/**
 The maximum number of tabs that your app may display in a @c CPTabBarTemplate,
 depending on the entitlements that your app declares.

 @warning The system will throw an exception if your app attempts to display more
 than this number of tabs in your tab bar template.
 */
open class var maximumTabCount: Int { get }
复制代码

可以设置每个 template 的 tab 的 tabTitle 和 tabImage,也可以设置 tabSystemItem 用系统样式(可用样式较少,且没办法自定义文案)。如果你没有设置 tabSystemItem 并且 tabImage 为 nil 的话,那么该 tabBarItem 将会使用 UITabBarItem.SystemItem.more。

// 自定义 tab 样式
listTemplate.tabTitle = "推荐"
listTemplate.tabImage = UIImage(named: "tabbar_recommend")
// 使用系统 tab 样式,如果同时设置了 tabTitle 和 tabImage,那么 tabSystemItem 将不生效
listTemplate.tabSystemItem = .favorites
// 显示红点
listTemplate.showsTabBadge = true
复制代码

这时候就要找 UI 出图了,参考文档:CarPlay UI 设计指南

CPTabBarTemplate 有个遵循 CPTabBarTemplateDelegate 协议的 delegate 属性,CPTabBarTemplateDelegate 就一个方法:

public protocol CPTabBarTemplateDelegate : NSObjectProtocol {
    func tabBarTemplate(_ tabBarTemplate: CPTabBarTemplate, didSelect selectedTemplate: CPTemplate)
}
复制代码

你可以在该方法中刷新 selectedTemplate 数据,如果需要的话。

需要注意一点,与 UITabBarController 的 - tabBarController:didSelectViewController: 不同的是:

  • 在 CPTabBarTemplate 呈现并默认选中第一个 tab 时,就会调用该代理方法一次,因此你需要注意在该代理方法实现中是否需要过滤掉第一次调用
  • 点击当前选中的 tab 也会调用代理方法,因此你也需要注意下是否过滤这一情况

CPListTemplate

列表模版,类似 UITableview。可以使用一组 CPListTemplate 来初始化 CPTabBarTemplate。CPListTemplate 由遵循 CPListTemplateItem 协议的 item 组成,item 类似 UITableviewCell。一般音频 App 就使用 CPListItem 和 CPListImageRowItem。

CPListTemplate 有个遵循 CPListTemplateDelegate 协议的 delegate 属性,CPListTemplateDelegate 就一个方法,在用户点击 item 时触发,我们可以在该方法实现中 push 其它 Template。

@available(iOS, introduced: 12.0, deprecated: 14.0)
protocol CPListTemplateDelegate : NSObjectProtocol {
    func listTemplate(_ listTemplate: CPListTemplate, didSelect item: CPListItem, completionHandler: @escaping () -> Void)
}
复制代码

注意这里有个 completionHandler 参数。当 - listTemplate:didSelectListItem:completionHandler: 方法被调用,在 completionHandler 调用之前,didSelectListItem 上会在右边显示一个 loading 活动指示器。最佳实践是,在要播放的内容已经准备好,或者页面跳转完成时(- pushTemplate:animated:completion: 的 completion 中)调用。当然,你也要保证 completionHandler 被调用,比如提前退出时,否则活动指示器会一直存在。

CPListTemplateDelegate 在 iOS 14 中已经被标记为弃用,建议使用 CPSelectableListItem 协议的 handler 属性来处理 action,它是一个可选的 action block。CPListItem、CPListImageRowItem 等都遵循 CPSelectableListItem 协议。

/**
 @c CPListSelectable describes list items that accept a list item handler, called when
 the user selects this list item.
 */
@available(iOS 14.0, *)
public protocol CPSelectableListItem : CPListTemplateItem {
    /**
     An optional action block, fired when the user selects this item in a list template.
     You must call the completion block after processing the user's selection.
     */
    var handler: ((CPSelectableListItem, @escaping () -> Void) -> Void)? { get set }
}
复制代码

CPListImageRowItem

可以用来展示由一组专辑组成的模块。使用文本和一组图片初始化,文本可以展示模块名称,图片可以展示模块里前 n 个专辑的封面。

let imagesCount = CPMaximumNumberOfGridImages // 取决于汽车显示屏的可用宽度
let images = Array(repeating: image, count: imagesCount)
let listImageRowItem = CPListImageRowItem(text: text, images: images)
复制代码

CPListImageRowItem 的点击区域可以分为每张图片区域、图片以外的所有区域。

每张图片的 action 通过设置 CPListImageRowItem 实例的 listImageRowHandler 属性来处理。点击可以 push 到该专辑的音频列表页面。

var listImageRowHandler: ((CPListImageRowItem, Int, @escaping () -> Void) -> Void)? // The image row item that the user selected.
复制代码

图片以外的区域的 action 通过设置 CPListImageRowItem 实例的 handler 属性来处理。点击可以 push 到该模块的专辑列表页面。

var handler: ((CPSelectableListItem, @escaping () -> Void) -> Void)?
复制代码

CPListItem

可以用来展示专辑或音频,或者其它的 item 如 “正在加载中”、“还没有播放记录”、”播放全部“ 等等。

对于音频 item,一般由音频封面、音频名称、音频描述组成,可以使用以下构造器初始化 CPListItem。

let listItem = CPListItem(text: audioName, 
                          detailText: audioDesc, 
                          image: audioCover)
复制代码

对于专辑 item,还需要右边的导航箭头来和音频 item 做区分,指示用户点击可以打开音频列表页面,可以使用以下构造器初始化 CPListItem。

let listItem = CPListItem(text: albumName, 
                          detailText: albumDesc, 
                          image: albumCover, 
                          accessoryImage: nil, 
                          accessoryType: .disclosureIndicator)
复制代码

右边的图标有两个系统样式(箭头、云朵),同时也支持自定义。

enum CPListItemAccessoryType : Int {
    case none = 0
    case disclosureIndicator = 1
    case cloud = 2
}
复制代码

需要注意一点,如果 CPListItem 的 detailText 为 nil 的话,view 高度会减小,并将 text 居中显示,如下图所示,这会影响美观以及整体统一性。因此,你可以考虑当音频没有副标题时,使用音频时长或其它数据填充 detailText。

CPNowPlayingTemplate

音频播放页是音频类 CarPlay App 最重要的页面了,使用 CPNowPlayingTemplate,它是一个单例。

你可以根据自己的需求配置 CPNowPlayingTemplate,比如添加控制按钮。你可以使用 CarPlay framework 提供的一些系统按钮,也可以自定义按钮。需要注意的是,你要在 - templateApplicationScene:didConnectInterfaceController: 的时机就配置好 CPNowPlayingTemplate,而不应该在 push 到 CPNowPlayingTemplate 的时候才去配置,因为 CPNowPlayingTemplate 并不一定是通过主动 push 时触发,还可能是通过 “播放中” App 或者 rootTemplate 右上角的 “正在播放按钮”。

let nowPlayingTemplate = CPNowPlayingTemplate.shared
let repeatButton = CPNowPlayingRepeatButton() { ... }
let playbackRateButton = CPNowPlayingPlaybackRateButton() { ... }
nowPlayingTemplate.updateNowPlayingButtons([repeatButton, playbackRateButton])
复制代码

无论你是使用 CarPlay framework 还是 MediaPlayer framework 来构建的 CarPlay App,都是通过 MPNowPlayingInfoCenterMPRemoteCommandCenter 来提供播放界面的音频信息以及响应远程播放控制事件。只不过在 CarPlay framework 中,一些远程控制事件通过 CPNowPlayingButton 的 handler 来处理了,比如播放重复模式、播放速率等等。当然如果你的 App 是音频类的话,应该已经支持了这些功能,因为 iPhone 锁屏界面以及控制中心的音频播放信息和播放控制也是通过它们提供。因此,我们只需要针对 CarPlay 做下优化或者功能增强就行。我们具体要做的是:

  • 设置和更新 MPNowPlayingInfoCenter 的 nowPlayingInfo,它包含当前播放音频的元数据,如标题、作者、时长等等。时机:
    • 切换音频(上一首、下一首等等)
    • 暂停、恢复、停止播放
    • seek(跳过片头、拖动进度等等)
    • 更新播放速度(CPNowPlayingTemplate 中播放速度按钮的显示状态)。如果当前音频不在播放状态,需将播放速度设置为 0
    • ...
import MediaPlayer
// Set Metadata to be Displayed in Now Playing Info Center
let infoCenter = MPNowPlayingInfoCenter.default()
infoCenter.nowPlayingInfo = [MPMediaItemPropertyTitle: "Style",
                             MPMediaItemPropertyArtist: "Taylor Swift",
                             MPMediaItemPropertyAlbumTitle: "1989",
                             MPMediaItemPropertyGenre: "Pop",
                             MPMediaItemPropertyReleaseDate: "2014",
                             MPMediaItemPropertyPlaybackDuration: 231,
                             MPMediaItemPropertyArtwork: mediaItemArtwork,
                             MPNowPlayingInfoPropertyElapsedPlayback: 53,
                             MPNowPlayingInfoPropertyDefaultPlaybackRate: 1,
                             MPNowPlayingInfoPropertyPlaybackRate: 1,
                             MPNowPlayingInfoPropertyPlaybackQueueCount: 13,
                             MPNowPlayingInfoPropertyPlaybackQueueIndex: 3,
                             ]
复制代码

需要注意一点,对于播放进度也就是当前音频已播放时长的更新,系统会根据先前提供的已播放时长播放速度自动推断出来。因此不需要也不推荐频繁更新 nowPlayingInfo,这代价很大。

  • 除了 nowPlayingInfo,还有一些状态需要通过其它方式同步到 CarPlay App,比如:

    • 音频播放状态:暂停/播放(CPNowPlayingTemplate 中播放按钮的显示状态)
    typedef NS_ENUM(NSUInteger, MPNowPlayingPlaybackState) {
        MPNowPlayingPlaybackStateUnknown = 0,
        MPNowPlayingPlaybackStatePlaying,
        MPNowPlayingPlaybackStatePaused,
        MPNowPlayingPlaybackStateStopped,
        MPNowPlayingPlaybackStateInterrupted
    };
    
    if (@available(iOS 13.0, *)) {
        MPNowPlayingInfoCenter.defaultCenter.playbackState = MPNowPlayingPlaybackStatePlaying; 
    }
    复制代码
    • 播放重复模式状态:顺序循环/单曲循环(CPNowPlayingTemplate 中播放重复模式按钮的显示状态)
    typedef NS_ENUM(NSInteger, MPRepeatType) {
        MPRepeatTypeOff,    /// Nothing is repeated during playback.
        MPRepeatTypeOne,    /// Repeat a single item indefinitely.
        MPRepeatTypeAll,    /// Repeat the current container or playlist indefinitely.
    };
    
    MPRemoteCommandCenter.sharedCommandCenter.changeRepeatModeCommand.currentRepeatType = MPRepeatTypeOne;
    复制代码

下图中的音频封面、名称、描述、时长、当前播放进度、播放重复模式、播放速度等音频播放信息,都是通过以上方式同步显示到 CPNowPlayingTemplate 上的。你的音频 App 之前应该已经实现了该功能以将音频播放信息同步到 iPhone 锁屏界面以及控制中心,现在只需要检查下 CarPlay App 的 CPNowPlayingTemplate 中显示的音频播放信息是否准确即可。

  • 响应 MPRemoteCommandCenter 事件,对远程播放控制事件做出响应,如播放、暂停、切换歌曲等等。
    • playCommand
    • pauseCommand
    • previousTrackCommand
    • nextTrackCommand
    • togglePlayPauseCommand
    • changeRepeatModeCommand
    • changePlaybackRateCommand
    • ...
  • 使用 CarPlay framework 时,changeRepeatModeCommand、changePlaybackRateCommand 远程控制命令不再通过 target-action 处理,而是通过 CPNowPlayingRepeatButtonCPNowPlayingPlaybackRateButton 的 handler 处理,但 command.enabled 还是要开启。例如:
    • 当 command.enabled 为 true 时,用户点击了播放重复模式按钮,CPNowPlayingRepeatButton 的 handler 就会被触发,然后你可以更新 App 播放模式,并将播放重复模式状态通过上述方式同步到 CarPlay。如果你的 App 还支持随机播放模式,可以添加 CPNowPlayingShuffleButton 并启用 changeShuffleModeCommand,配合 CPNowPlayingRepeatButton 完成 3 种模式的切换。
    • 由于只能得知用户点击了按钮,而不知道用户点击按钮的具体意图,因此 CPNowPlayingPlaybackRateButton handler 的最佳实践是,设定一个播放速度范围,当用户点击时,增加 App 播放速度,并通过更新 nowPlayingInfo 同步到 CarPlay。如果当前音频正在以最快的支持速度播放,那么继续增加播放速度就将其调到最小速度,以此循环。

最佳实践

使用 userInfo 存储数据

CPListItem、CPListImageRowItem 都有个 userInfo 属性,它用来存储数据。CPListItem -> userInfo 关系就类似于 UITableViewCell -> model

// Use this property to attach a value that provides additional context to the list item. For example, you can attach a model object and reference it in the list item’s handler when processing the selection.
var userInfo: Any?
复制代码

通过 isEnabled 设置 item 的可交互性(iOS 15)

CPListItem、CPListImageRowItem 都有个 isEnabled 属性,它用来设置 item 的可交互性(默认值为 true)。isEnabled 设置为 false 的 item 将灰显且不可点击,也就是不会触发 item 的 handler 或者 CPListTemplateDelegate 的 - listTemplate:didSelectListItem:completionHandler: 方法。最佳实践是,将 “还没有播放记录”、“正在加载中” 这些本身就没有交互的 item 的 isEnabled 设置为 false,这样呈现的 UI 效果更好,不过该 API 在 iOS 15 开始才支持。

// A Boolean value that indicates if the item is enabled.
@available(iOS 15.0, *)
var isEnabled: Bool
复制代码

使用 handle 响应 item 的点击事件

CPListItem、CPListImageRowItem 都遵循 CPSelectableListItem 协议,有个 handler 属性,用来响应 item 的点击事件。如果你给 item 设置了 handler,那么点击 item 将触发 handler 而不触发 CPListTemplateDelegate 的方法。CPListTemplateDelegate 在 iOS 14 中已经被标记为弃用,建议使用 handler 来处理 action。

/**
 @c CPListSelectable describes list items that accept a list item handler, called when
 the user selects this list item.
 */
@available(iOS 14.0, *)
public protocol CPSelectableListItem : CPListTemplateItem {
    /**
     An optional action block, fired when the user selects this item in a list template.
     You must call the completion block after processing the user's selection.
     */
    var handler: ((CPSelectableListItem, @escaping () -> Void) -> Void)? { get set }
}
复制代码

handler 对比 CPListTemplateDelegate 处理 action 有个优点,handler 是针对 item 的,而 CPListTemplateDelegate 针对 CPListTemplate 里的所有 item。如果使用 CPListTemplateDelegate 的话我们就需要针对 “还没有播放记录”、“正在加载中” 等 item 做 guard 处理,而使用 handler 就可以单独处理或者不处理这些 item 的 action 了。

let item = CPListItem(text: "正在加载中", detailText: nil)
if #available(iOS 15.0, *) {
    item.isEnabled = false
} else {
    item.handler = { (item, completionHandler) in
        completionHandler()
    }
}
复制代码

通过 isPlaying 属性来显示正在播放的指示器

使用 CPListItem 来展示音频,你还可以通过 isPlaying 属性来显示正在播放的指示器。

var isPlaying: Bool
复制代码

指示器的位置默认在左边,隐藏了 image。你还可以通过 playingIndicatorLocation 属性设置指示器位置为右边。

enum CPListItemPlayingIndicatorLocation : Int {
    case leading = 0
    case trailing = 1
}

var playingIndicatorLocation: CPListItemPlayingIndicatorLocation
复制代码

支持打开当前播放列表

一个好用的音频 App 的播放页应该支持打开当前播放列表,方便用户切歌,CarPlay App 也应支持。特别是,如果一个专辑是通过 iPhone App 进行播放的,而 CarPlay App 没有该专辑数据(CarPlay 和 iPhone App 的数据可能不同),那么用户想听该专辑的其它歌曲的话,就只能通过“上一首/下一首”或者“iPhone App 的播放列表”进行切歌。因此,在 CarPlay App 的播放页中支持打开当前播放列表是很棒的。

CPNowPlayingTemplate 支持在右上角显示一个打开当前播放列表的按钮,点击后 push 一个 CPListTemplate,来展示当前的播放列表。

let nowPlayingTemplate = CPNowPlayingTemplate.shared
nowPlayingTemplate.isUpNextButtonEnabled = true
nowPlayingTemplate.upNextTitle = "播放列表"
nowPlayingTemplate.add(observer)

ObserverClass: CPNowPlayingTemplateObserver {
    func nowPlayingTemplateUpNextButtonTapped(_ nowPlayingTemplate: CPNowPlayingTemplate) {
        interfaceController.pushTemplate(aListTemplate, animated: true)
    }
}
复制代码

页面跳转

还记得在 CarPlay App 入口 - templateApplicationScene:didConnectInterfaceController: 中的 CPInterfaceController 吗,它作为我们 CarPlay App 的入口 controller,我们将一个 template 作为 rootTemplate 赋值给它。当我们要进行页面跳转时也是靠它,有点类似于 UINavigationController,它支持 push、pop、present、dismiss 等等操作(present、dismiss 操作仅 CPActionSheetTemplate、CPVoiceControlTemplate、CPAlertTemplate)。对于音频 App,一般 push 操作就够用,子页面的左上角都自带返回按钮的。

图片

图标和图片

可以看看 CarPlay - 设计指南,并将它发给你的 PM 和 UI。

深色/浅色模式

适配方案和 iPhone App 一样,如果你的 App 需要两种风格的话就支持一下。

异步图片

  • CarPlay 不支持 GIF 图片,配置会导致 crash。笔者尝试取出 GIF 图的第一帧,发现还是不支持,或许是我使用方式不对。
  • asyncImage 也需要适配下 scale,否则会模糊。
  • 可以对 CPListItem 和 CPListImageRowItem 扩展下 asyncImage 的方法,方便使用。
@available(iOS 14, *)
protocol CPAsyncImage {
    func loadImage(with url: URL?, complete: @escaping (UIImage?) -> (Void))
    static var placeholderImage: UIImage { get }
}

@available(iOS 14, *)
extension CPAsyncImage {
    
    func loadImage(with url: URL?, complete: @escaping (UIImage?) -> (Void)) {
        
        guard let url = url else {
            complete(nil)
            return
        }
        
        // 这儿也可以根据 urlType 做下过滤
        
        SDWebImageManager.shared.loadImage(with: url, options: .retryFailed, progress: nil) { image, data, error, type, finished, imageURL in
            
            guard var image = image, image.images == nil else {
                complete(nil)
                return
            }
                   
            if let cgImage = image.cgImage {
                let screen = TTScene.carPlay?.value(forKey: "screen") as? UIScreen
                let scale = screen?.scale ?? 1
                image = UIImage(cgImage: cgImage, scale: scale, orientation: .up)
            }
            complete(image)
        }
    }
    
    static var placeholderImage: UIImage {
        UIImage(named: "CP_default") ?? UIImage()
    }
}

@available(iOS 14, *)
extension CPListItem: CPAsyncImage {
    
    func asyncImage(with url: URL?, placeholderImage: UIImage? = CPListItem.placeholderImage, complete: ((UIImage?) -> (Void))? = nil) {
        
        self.setImage(placeholderImage)
        loadImage(with: url) { [weak self] image in
            guard let image = image else { return }
            self?.setImage(image)
            complete?(image)
        }
    }
}

@available(iOS 14, *)
extension CPListImageRowItem: CPAsyncImage {
    
    func asyncImage(with urls: [URL?], placeholderImage: UIImage = CPListImageRowItem.placeholderImage, complete: (((index: Int, image: UIImage?)) -> (Void))? = nil) {
        
        var images = Array(repeating: placeholderImage, count: urls.count)
        self.update(images)
        
        for (index, url) in urls.enumerated() {
            loadImage(with: url) { [weak self] image in
                guard let image = image else { return }
                images[index] = image
                self?.update(images)
                complete?((index, image))
            }
        }
    }
}
复制代码

重新加载数据

网络不好的情况下启动 CarPlay App,可能就会存在请求超时,CarPlay App 中无数据。处理方式有以下几种:

  1. 不重新加载。如果是首页,则用户需要重新启动 CarPlay App 才能重新加载;如果是子页面,则用户需要退出并重新进入子页面才能重新加载。看了网易云 CarPlay App 就是这样处理的,不过它的首页有固定的几个 item,倒是不影响体验。如果你的首页内容没有本地 item,那不建议以这种方式处理;
  2. 如果 rootTemplate 是 CPTabBarTemplate,那么你可以在 - tabBarTemplate:didSelectTemplate: 时机对 selectedTemplate 进行重新加载操作;
  3. 对于第二种方式,重新加载对用户来说是无感知的,因为你没办法或者不方便自己添加个活动指示器。我的方案是,在请求数据的过程中,添加个 loading item(比如使用 CPListItem 并显示 “正在加载中”)。如果请求失败,则更新 item 为 failure item(比如使用 CPListItem 并显示 “加载失败,点击重试”)。当用户点击 failure item 时,更新 item 为 loading item,并重新请求数据。对于每个需要从服务端拉取数据的页面都可以这样处理。

是否需要刷新?如果想要允许使用 CarPlay App 的过程中去刷新数据,那么可以通过第二种方式处理。不过,用户单次使用 CarPlay 的时间不会很长,没有必要做刷新,下次启动的时间拉取新数据就好。因此,我只针对首次数据加载失败的情况做了重新加载处理。

关注弱网以及无网环境下的用户体验

在 WWDC 或相关文档中,Apple 多次提到要关注弱网以及无网环境下的用户体验,因为驾驶过程中可能会经过网络不好的路段或区域。例如上面提到的 ”请求超时,重新加载数据“ 问题、CPNowPlayingTemplate 中数据同步以及播放控制事件是否出现异常等等。

Siri

即使你的 App 不支持 SiriKit,也还是可以支持通过 Siri 来切歌、暂停或恢复播放的,因为这些远程控制事件天然就支持 Siri。

测试

在真实环境(汽车中)测试。Apple|使用 CarPlay Simulator 运行和调试 CarPlay App 中列举了一些在 CarPlay Simulator 上无法测试的功能。

对于音频 App,Simulator 在播放状态下有一些局限,不能反映真实的用户体验。

为了用 LLDB 全面调试你的 App,Xcode 9 开始支持无线调试,这样就可以在 iPhone 连接汽车的同时调试你的 App。可以观看 WWDC19 - 使用 Xcode 9 进行调试 了解更多。

注意点 & 最佳实践

  • 单例问题。CarPlay 用到了单例类,CarPlay App 关闭,但 iPhone App 没关闭,进程是还在的,单例还未释放,可能会造成一些问题。可以在 - templateApplicationScene:didConnectInterfaceController: 中初始化单例,在 - templateApplicationScene:didDisconnectInterfaceController: 中释放单例。
  • CarPlay 的语言是跟随 iPhone 的,Simulator 也是如此。
  • CarPlay framework 需要设置为弱引用 optional(Target > Build phases > Link Binary With Libraries),否则在 iOS 12 以下启动 App 会 crash。
  • CarPlay 断开连接时,建议暂停音乐。
  • CarPlay 断开连接时,可以通过 Memory Graph 检查下有无内存泄漏。
  • Template 页面最好至少显示一项内容,特别是你没有使用 CPTabBarTemplate 作为 rootTemplate 的情况,否则页面将一片空白,影响用户体验。例如,在最近播放页面,当没有播放记录时,填充一个 CPListItem 并显示 “当前没有播放记录”。
  • 你需要注意一些情况,比如 iPhone 锁屏、用户未登录时,你的 App 仍然需要在 CarPlay 中展示完美的功能性。

常见问题解答

CarPlay 连接

1. 为什么车机连接了,CarPlay 车载却没有出来?

  • 汽车不支持 CarPlay;
  • iPhone 没有开启 Siri。在 设置 > Siri 与搜索 中开启 用“嘿 Siri”唤醒。如果没有开启 Siri 的话,iPhone 的 设置 > 通用 中也不会显示 CarPlay 车载 这一项。

Simulator

1. 为什么 Simulator 菜单栏 I/O > External Displays 中 CarPlay 选项是禁用状态?

你可能还未在开发者网站申请 CarPlay 权限并将配置文件导入到工程中。

可以参考:申请 CarPlay 权限

2. 为什么打开 CarPlay Simulator 没有显示我的 App?

你可能是忘了添加权利文件。你需要将 Key com.apple.developer.carplay-audio 添加到 Entitlements.plist 中并设置 Value 为 1。

<key>com.apple.developer.carplay-audio</key>
<true/>
复制代码

可以参考:申请 CarPlay 权限

3. 为什么 M1 Mac 打开 CarPlay App 直接崩溃?

如果你是 M1 Mac,那可能无法使用 CarPlay Simulator。如果你的 Xcode 以 Rosetta 模式运行,那么启动 CarPlay App 会直接 crash。将 Simulator 也以 Rosetta 运行并不能解决问题。这个问题暂时没有解决方案。issueexplorer.com/issue/mapbo…

4. 从 “播放中” App 返回到音频源 App,页面显示异常

感觉是 Apple 的 bug,模拟器和真机都可能出现。bug 出现的步骤是:

  1. 先不要启动你的 CarPlay App
  2. 在你的 iPhone App 中播放音频
  3. 打开 CarPlay 的 “播放中” App
  4. 通过 “播放中” App 返回到你的 CarPlay App

可能会出现的问题:

  • tabBarItem 重叠
  • CPListImageRowItem 本应该只显示 4 张图片,却显示了 5 张

切换 template、退后台再进入,页面恢复正常。

图片

1. 为什么 CarPlay 上的图片模糊?

无论是本地图片还是异步图片都需要适配下 scale。

正在播放

1. rootTemplate 右上角的 “正在播放按钮” 什么时候出现?

当前 App 正在播放音频时出现,点击它将 push 到 CPNowPlayingTemplate。

2. CarPlay 主界面的 “播放中(Now Playing)” App 是什么?

  • CPNowPlayingTemplate 是个单例类,所有 CarPlay App 的 “正在播放” 界面都是使用这个单例。
  • “播放中” App 将从 nowPlayingCenter 中取数据,也就是说该 App 将显示 iPhone 上正在播放的音频信息,并将音频源 App 的 appName 显示在右上角,即使该音频源 App 不支持 CarPlay。因此,即使你的 App 暂时不支持 CarPlay,你也可以通过适配好 MPNowPlayingInfoCenter 和 MPRemoteCommandCenter 来使你的 App 支持 CarPlay ”播放中“ App。

  • 如果 “播放中” App 的音频源 App 支持 CarPlay,那么启动 “播放中” App 就会触发音频源 App 的 CarPlay 场景连接,也就是启动音频源 CarPlay App。这就是为什么 Apple 让我们在 - templateApplicationScene:didConnectInterfaceController: 的时机就配置好 CPNowPlayingTemplate 的原因,而不应该在 push 到 CPNowPlayingTemplate 的时候去配置,因为 CPNowPlayingTemplate 并不一定通过主动 push 时触发,可能是通过 “播放中” App 或者 rootTemplate 右上角的 “正在播放按钮”。点击 “播放中” App 左上角的返回按钮,将返回到该音频源的 CarPlay App 的 rootTemplate。

3. 正在播放页面中音频插图(封面)不显示

  • 如果是显示了占位图,那可能是因为你没将有效图片设置到 nowPlayingInfo 的 MPMediaItemPropertyArtwork key 中。
  • 如果是连占位图都没有:
    • 如果是真实环境汽车中不显示,需要在 CarPlay 设置中将 显示专辑插图 打开。如果设置中没有显示该选项,那可能是当前 iPhone 设置的语言和地区不支持,在 iPhone 的 设置 > 语言和地区 中设置。参考帖子 tieba.baidu.com/p/627697684…
    • 如果是 CarPlay Simulator,那应该没办法显示,即使按照上面的步骤操作了,这点我暂时没有依据。

WWDC 概览

WWDC16 - 开发 CarPlay 车载系统 - 第 1 部分

  • 地址developer.apple.com/wwdc16/722
  • 时长:30 分钟
  • 概览:CarPlay 车载让你能够更智能、更安全地在车内使用 iPhone。了解 CarPlay 车载的工作方式,以及如何设计你的车载信息娱乐系统来与 iPhone 密切协作。了解通过将 CarPlay 车载与车辆原生系统整合来打造出色用户体验的最佳做法。

WWDC16 - 开发 CarPlay 车载系统 - 第 2 部分

  • 地址developer.apple.com/wwdc16/723
  • 时长:26 分钟
  • 概览:了解 CarPlay 车载如何与你的车辆信息娱乐系统整合。了解 CarPlay 车载如何与你的车载资源协作,如显示屏、扬声器、麦克风、用户输入、方向盘控制键、仪表盘和传感器等。

WWDC17 - 开发无线 CarPlay 车载系统

  • 地址developer.apple.com/wwdc17/717
  • 时长:35 分钟
  • 概览:无论去向哪里,无线 CarPlay 车载都是旅程的绝佳搭档。无需将 iPhone 从包里或口袋中取出,直接开门上车,轻松开始享受 CarPlay 车载体验。学习如何设计你的 CarPlay 车载系统来以无线方式连接至 iPhone。了解相关的硬件要求、提供出色用户体验的最佳做法,以及如何优化配对和重新连接过程。

WWDC17 - 让您的 App 支持 CarPlay 车载

  • 地址developer.apple.com/wwdc17/719
  • 时长:28 分钟
  • 概览:了解如何让你的音频、信息、VolP 通话或汽车制造商 App 支持 CarPlay 车载。音频、信息、VolP 通话 App 采用一致的设计,并且为在车内使用进行过优化。汽车制造商 App 提供车辆相关的控制和显示功能,让驾驶员无需离开 CarPlay 车载就能保持互联。探索最佳做法,并了解适用于 CarPlay 车载 App 的工具和框架。

WWDC18 - CarPlay 车载音频和导航 App

  • 地址developer.apple.com/wwdc18/213
  • 时长:38 分钟
  • 概览:了解如何更新音频或导航 App 来支持 CarPlay 车载。CarPlay 车载中的 App 针对车用进行了优化,能够自动适应可用的汽车屏幕和输入控制。音频 App 能够输出音乐、新闻、播客等。通过新的 CarPlay 车载框架,导航 App 可以提供详细地图、目的地搜索、逐向导航和用户通知。

WWDC19 - CarPlay 车载系统改进

  • 地址developer.apple.com/wwdc19/252
  • 时长:16 分钟
  • 概览:CarPlay 车载让你能够在车内更加智能、安全地使用 iPhone。了解如何更新你的车载系统,以利用 iOS 13 中的新功能。添加对动态变换屏幕尺寸、仪表盘等辅助屏幕,甚至是不规则显示屏的支持。了解如何支持 “嘿 Siri” 来进行免提语音激活。

WWDC20 - 使用 CarPlay 车载系统为你的 App 提速

  • 地址developer.apple.com/wwdc20/1063…
  • 时长:26 分钟
  • 概览:CarPlay 车载可为用户提供更智能、更安全的 iPhone 车内使用方式。我们将向你展示如何为车辆屏幕打造优质 App,并教你开发电动机车充电、泊车、快速订购食物外卖等类型的 CarPlay 车载 App。此外,我们还会使用现存的音频与通讯 App 作为范例,详细解释如何利用 CarPlay 车载框架的种种改进,制作出更加灵活多变的用户界面。
  • 内参xiaozhuanlan.com/topic/76208…

相关资料

WWDC

文档

后续

笔者会继续关注 CarPlay 的,该文章会一直维护更新下去。你的点赞和收藏将给予我更大的动力。

分类:
iOS
标签:
分类:
iOS
标签: