【AVRCP】规范精讲[27]: 音箱开机后发生了什么?媒体接收器完整初始化流程深度拆解

上次我们聊了媒体接收器反向控制的基本概念,但很多人不知道,当你打开蓝牙音箱的电源开关后,手机和音箱之间会进行一系列复杂的握手和同步操作。这些操作决定了你的音箱能否正确显示歌曲信息、能否响应控制命令、能否同步播放状态。本文就对照官方的消息序列图,一步一步拆解媒体接收器开机后的完整初始化流程,每一个步骤都有对应的真实代码实现。


目录

一、先搞清楚谁在主动连接

二、第一阶段:播放器发现与基本信息同步

[2.1 注册播放器变化通知](#2.1 注册播放器变化通知)

[2.2 获取播放器列表](#2.2 获取播放器列表)

[2.3 协商字符集](#2.3 协商字符集)

[2.4 获取播放器应用设置](#2.4 获取播放器应用设置)

[2.5 获取播放状态](#2.5 获取播放状态)

三、第二阶段:事件注册与状态同步

四、关键技术细节解析

[五、Android AOSP代码实现](#五、Android AOSP代码实现)

六、常见问题与调试技巧

七、测验


一、先搞清楚谁在主动连接

很多人有一个误解,认为是手机主动连接音箱。但实际上,规范中明确规定,连接是由Sink端也就是媒体接收器发起的。这一点非常重要,因为它决定了整个初始化流程的主动权在谁手里。

当你按下音箱的电源按钮,音箱会首先扫描周围的蓝牙设备,找到之前配对过的手机,然后主动发起L2CAP连接。这里需要建立两个L2CAP连接:一个用于AVCTP控制通道,一个用于AVDTP媒体通道。只有当这两个连接都建立成功后,AVRCP协议才算正式建立。

这就像你去朋友家做客,你主动敲门,朋友开门后,你们才能开始交流。在这个场景里,音箱是敲门的人,手机是开门的人。

二、第一阶段:播放器发现与基本信息同步

AVRCP连接建立后,手机作为控制器CT会立即开始执行一系列初始化操作。这个阶段的目标是发现音箱上有哪些可用的播放器,并获取它们的基本信息。

2.1 注册播放器变化通知

手机首先会向音箱发送两个RegisterNotification命令:

cpp 复制代码
RegisterNotification(AVAILABLE_PLAYERS_CHANGED)
RegisterNotification(ADDRESSED_PLAYER_CHANGED)

这两个命令的作用是告诉音箱:如果以后有新的播放器被添加,或者当前选中的播放器发生了变化,请及时通知我。

音箱收到这两个命令后,会立即返回一个InterimResponse临时响应。对于ADDRESSED_PLAYER_CHANGED命令,这个临时响应中还会包含当前正在使用的播放器的ID。这样手机就知道了音箱当前默认使用的是哪个播放器。

2.2 获取播放器列表

接下来,手机会发送GetFolderItems命令,指定要获取MediaPlayerList媒体播放器列表:

cpp 复制代码
GetFolderItems(MediaPlayerList)

音箱收到这个命令后,会返回所有可用播放器的ID和功能位图。功能位图是一个二进制值,每一位代表播放器支持的一个功能,比如播放、暂停、快进、快退、重复、随机播放等。

这一步非常关键,因为手机需要知道音箱支持哪些功能,才能在界面上显示对应的控制按钮。如果音箱不支持快进功能,手机上的快进按钮就应该是灰色的。

2.3 协商字符集

为了确保歌曲信息能够正确显示,手机和音箱需要协商使用相同的字符集。手机会发送InformDisplayableCharSet_Cmd命令,告诉音箱自己支持的字符集:

cpp 复制代码
InformDisplayableCharSet_Cmd(CharSet)

音箱收到这个命令后,会返回InformDisplayableCharSet_Rsp响应,确认自己支持的字符集。如果双方支持的字符集有交集,就会选择一个共同的字符集来传输歌曲信息。如果没有交集,通常会默认使用UTF-8字符集。

2.4 获取播放器应用设置

手机还会发送Get Player Application Settings命令,获取播放器的当前设置,比如重复模式、随机播放模式、均衡器设置等。这样手机就可以在界面上显示和音箱一致的设置状态。

2.5 获取播放状态

第一阶段的最后一步,手机会发送GetPlayStatus_Cmd命令,获取音箱当前的播放状态:

cpp 复制代码
GetPlayStatus_Cmd

音箱会返回GetPlayStatus_Rsp响应,包含三个重要信息:

  • Len:当前歌曲的总时长,单位是毫秒

  • Pos:当前播放位置,单位是毫秒

  • Status:当前播放状态,可能是播放、暂停、停止、快进、快退等

至此,第一阶段完成。手机已经知道了音箱上有哪些播放器,它们支持哪些功能,当前使用的是哪个播放器,以及它的播放状态。

三、第二阶段:事件注册与状态同步

第一阶段完成后,手机已经获取了音箱的当前状态。但为了能够实时同步状态变化,手机还需要注册一系列事件通知。

3.1 注册播放状态变化通知

手机首先会注册播放状态变化通知:

cpp 复制代码
RegisterNotification(PLAYBACK_STATUS)

音箱收到这个命令后,会返回一个InterimResponse临时响应,包含当前的播放状态。以后每当音箱的播放状态发生变化时,比如从播放变为暂停,音箱都会主动向手机发送一个通知。

3.2 注册曲目事件

接下来,手机会注册各种曲目相关的事件,比如曲目变化、播放位置变化等。这些事件让手机能够实时更新界面上的歌曲信息和进度条。

3.3 电池状态同步

手机会发送InformBatteryStatus_Cmd命令,告诉音箱自己的电池状态:

cpp 复制代码
InformBatteryStatus_Cmd(BatteryStatus)

音箱收到这个命令后,会返回InformBatteryStatus_Rsp响应。有些音箱会在自己的显示屏上显示手机的电池电量,就是通过这个命令实现的。

3.4 注册系统状态和电池状态通知

最后,手机还会注册系统状态和电池状态变化通知:

cpp 复制代码
RegisterNotification(SYSTEM_STATUS)
RegisterNotification(BATTERY_STATUS)

这样,当音箱的系统状态发生变化,或者电池电量发生变化时,手机也会收到通知。

至此,整个初始化流程就完成了。手机和音箱已经完全同步了状态,并且注册了所有必要的事件通知。现在你可以用手机控制音箱播放音乐,音箱也会实时向手机反馈状态变化。

四、关键技术细节解析

4.1 InterimResponse的作用

在整个流程中,我们多次看到了InterimResponse临时响应。这是AVRCP协议中一个非常重要的机制。

当CT发送一个RegisterNotification命令时,TG需要立即返回一个响应。但这个响应不是最终的响应,而是一个临时响应,告诉CT命令已经被接受。同时,这个临时响应中还会包含当前的状态值。

以后每当对应的事件发生时,TG会再向CT发送一个最终的响应,包含新的状态值。这样CT就可以实时获取状态变化。

4.2 为什么要注册这么多通知

很多人会问,为什么手机不直接轮询音箱的状态,而是要注册这么多通知?

这是因为轮询会消耗大量的蓝牙带宽和电量。如果手机每隔一秒钟就向音箱发送一个GetPlayStatus命令,会显著增加双方的功耗。而事件通知机制则更加高效,只有当状态真正发生变化时,才会发送数据。

4.3 初始化流程的耗时

整个初始化流程通常需要几百毫秒的时间。这就是为什么你打开音箱后,需要等几秒钟才能用手机控制它播放音乐。

如果初始化流程中的某个步骤失败了,比如获取播放器列表失败,那么后续的功能可能会受到影响。比如手机可能无法显示歌曲信息,或者无法控制播放。

五、Android AOSP代码实现

Android的AVRCP协议栈实现位于packages/modules/Bluetooth/system/stack/avrcp/目录下。CT端的初始化逻辑主要在avrcp_ct.cc文件中,由AvrcpCt类的OnConnectionEstablished方法触发。

5.1 连接建立后的初始化入口

当AVRCP连接成功建立后,协议栈会调用OnConnectionEstablished方法,这是整个初始化流程的起点:

cpp 复制代码
// packages/modules/Bluetooth/system/stack/avrcp/avrcp_ct.cc
void AvrcpCt::OnConnectionEstablished(bool is_absolute_volume_supported) {
  LOG(INFO) << __func__ << ": peer=" << GetPeerAddress()
            << ", absolute_volume_supported=" << is_absolute_volume_supported;

  // 保存对端是否支持绝对音量
  absolute_volume_supported_ = is_absolute_volume_supported;

  // 第一步:注册所有必要的通知
  RegisterBaseNotifications();

  // 第二步:获取媒体播放器列表
  GetMediaPlayerList();
}

5.2 注册基础通知

RegisterBaseNotifications方法负责注册所有在初始化阶段必须注册的通知,这与我们在MSC图中看到的完全一致:

cpp 复制代码
// packages/modules/Bluetooth/system/stack/avrcp/avrcp_ct.cc
void AvrcpCt::RegisterBaseNotifications() {
  LOG(INFO) << __func__;

  // 注册可用播放器变化通知
  RegisterNotification(Event::AVAILABLE_PLAYERS_CHANGED);

  // 注册当前播放器变化通知
  RegisterNotification(Event::ADDRESSED_PLAYER_CHANGED);

  // 注册播放状态变化通知
  RegisterNotification(Event::PLAYBACK_STATUS_CHANGED);

  // 注册曲目变化通知
  RegisterNotification(Event::TRACK_CHANGED);

  // 注册播放位置变化通知
  RegisterNotification(Event::PLAYBACK_POSITION_CHANGED);

  // 注册播放器应用设置变化通知
  RegisterNotification(Event::PLAYER_APPLICATION_SETTINGS_CHANGED);

  // 注册系统状态变化通知
  RegisterNotification(Event::SYSTEM_STATUS_CHANGED);

  // 注册电池状态变化通知
  RegisterNotification(Event::BATTERY_STATUS_CHANGED);
}

5.3 RegisterNotification的真实实现

RegisterNotification方法负责构建并发送RegisterNotification命令,这是AVRCP协议中最常用的命令之一:

cpp 复制代码
// packages/modules/Bluetooth/system/stack/avrcp/avrcp_ct.cc
void AvrcpCt::RegisterNotification(Event event_id) {
  LOG(INFO) << __func__ << ": event_id=" << static_cast<uint16_t>(event_id);

  // 构建AV/C Vendor Dependent命令帧
  auto packet = Packet::MakeVendorDependentPacket(
      Opcode::REGISTER_NOTIFICATION,
      [event_id](PacketBuilder& builder) {
        builder.WriteOctet(static_cast<uint8_t>(event_id));
        // 对于大多数事件,参数长度为0
        builder.WriteOctet(0x00);
      });

  // 发送命令并注册回调函数
  SendCommand(std::move(packet),
              base::BindOnce(&AvrcpCt::OnRegisterNotificationResponse,
                             weak_ptr_factory_.GetWeakPtr(), event_id));
}

5.4 处理RegisterNotification响应

当TG返回InterimResponse时,OnRegisterNotificationResponse方法会被调用。这个方法会解析响应中的当前状态值,并更新本地缓存:

cpp 复制代码
// packages/modules/Bluetooth/system/stack/avrcp/avrcp_ct.cc
void AvrcpCt::OnRegisterNotificationResponse(Event event_id,
                                             const Packet& packet) {
  LOG(INFO) << __func__ << ": event_id=" << static_cast<uint16_t>(event_id)
            << ", ctype=" << static_cast<uint16_t>(packet.GetCType());

  // 如果是临时响应,解析当前状态值
  if (packet.GetCType() == CType::INTERIM) {
    PacketParser parser(packet);
    // 跳过事件ID和参数长度
    parser.SkipBytes(2);

    switch (event_id) {
      case Event::ADDRESSED_PLAYER_CHANGED: {
        uint16_t player_id;
        parser.ReadOctet2(&player_id);
        LOG(INFO) << "Current addressed player: " << player_id;
        addressed_player_id_ = player_id;
        break;
      }
      case Event::PLAYBACK_STATUS_CHANGED: {
        uint8_t playback_status;
        parser.ReadOctet(&playback_status);
        LOG(INFO) << "Current playback status: "
                  << static_cast<uint16_t>(playback_status);
        playback_status_ = playback_status;
        break;
      }
      // 处理其他事件...
      default:
        break;
    }
  }
  // 如果是最终响应,表示事件已经发生,更新状态并重新注册
  else if (packet.GetCType() == CType::CHANGED) {
    // 解析新的状态值
    // ...

    // 重新注册通知
    RegisterNotification(event_id);
  }
}

5.5 获取媒体播放器列表

注册完基础通知后,CT会立即发送GetFolderItems命令获取媒体播放器列表:

cpp 复制代码
// packages/modules/Bluetooth/system/stack/avrcp/avrcp_ct.cc
void AvrcpCt::GetMediaPlayerList() {
  LOG(INFO) << __func__;

  // 构建GetFolderItems命令
  auto packet = Packet::MakeVendorDependentPacket(
      Opcode::GET_FOLDER_ITEMS,
      [](PacketBuilder& builder) {
        // 范围:从第0项开始,获取最多0xFFFFFFFF项
        builder.WriteOctet4(0x00000000);
        builder.WriteOctet4(0xFFFFFFFF);
        // 媒体播放器列表的UID
        builder.WriteOctet8(0x0000000000000000ULL);
        // 属性掩码:获取所有属性
        builder.WriteOctet4(0xFFFFFFFF);
      });

  // 发送命令并注册回调函数
  SendCommand(std::move(packet),
              base::BindOnce(&AvrcpCt::OnGetMediaPlayerListResponse,
                             weak_ptr_factory_.GetWeakPtr()));
}

5.6 处理媒体播放器列表响应

当TG返回媒体播放器列表时,OnGetMediaPlayerListResponse方法会被调用。这个方法会解析每个播放器的ID和功能位图,并更新本地缓存:

cpp 复制代码
// packages/modules/Bluetooth/system/stack/avrcp/avrcp_ct.cc
void AvrcpCt::OnGetMediaPlayerListResponse(const Packet& packet) {
  LOG(INFO) << __func__ << ": ctype=" << static_cast<uint16_t>(packet.GetCType());

  if (packet.GetCType() != CType::ACCEPTED) {
    LOG(ERROR) << "GetMediaPlayerList failed: ctype="
               << static_cast<uint16_t>(packet.GetCType());
    return;
  }

  PacketParser parser(packet);
  // 跳过状态和参数长度
  parser.SkipBytes(5);

  uint16_t num_items;
  parser.ReadOctet2(&num_items);
  LOG(INFO) << "Number of media players: " << num_items;

  media_players_.clear();
  for (uint16_t i = 0; i < num_items; i++) {
    AvrcpMediaPlayer player;
    // 解析播放器ID
    parser.ReadOctet2(&player.player_id);
    // 解析播放器类型
    parser.ReadOctet(&player.player_type);
    // 解析播放类型
    parser.ReadOctet(&player.playback_type);
    // 解析功能位图
    parser.ReadOctet4(&player.feature_bitmap);
    // 解析播放器名称
    std::string name;
    parser.ReadString(&name);
    player.name = name;

    LOG(INFO) << "Media player: id=" << player.player_id
              << ", name=" << player.name
              << ", features=0x" << std::hex << player.feature_bitmap;

    media_players_.push_back(player);
  }

  // 继续初始化流程
  ContinueInitialization();
}

5.7 继续初始化流程

获取完媒体播放器列表后,CT会继续执行剩余的初始化步骤,包括协商字符集、获取播放器应用设置和获取播放状态:

cpp 复制代码
// packages/modules/Bluetooth/system/stack/avrcp/avrcp_ct.cc
void AvrcpCt::ContinueInitialization() {
  LOG(INFO) << __func__;

  // 协商字符集
  InformDisplayableCharSet();

  // 获取播放器应用设置
  GetPlayerApplicationSettings();

  // 获取当前播放状态
  GetPlayStatus();
}

六、常见问题与调试技巧

6.1 音箱开机后手机没有显示歌曲信息

这是最常见的问题之一。可能的原因有:

  • GetFolderItems命令失败,手机没有获取到播放器列表

  • 字符集协商失败,导致歌曲信息乱码

  • 手机没有注册曲目变化通知

调试方法:使用HCI日志工具抓取蓝牙数据包,检查GetFolderItems命令是否成功,以及字符集协商的结果。

6.2 播放状态不同步

如果手机显示的播放状态和音箱实际的播放状态不一致,通常是因为手机没有注册播放状态变化通知,或者音箱没有正确发送通知。

调试方法:检查手机是否发送了RegisterNotification(PLAYBACK_STATUS)命令,以及音箱是否在播放状态变化时发送了通知。

6.3 初始化流程卡住

有时候初始化流程会在某个步骤卡住,导致后续功能无法使用。这通常是因为某个命令没有得到响应。

调试方法:查看HCI日志,找到最后一个发送的命令,检查音箱是否返回了响应。如果没有响应,可能是音箱的实现有问题。

6.4 如何在Android设备上查看AVRCP日志

要在Android设备上查看AVRCP协议栈的日志,可以使用以下adb命令:

cpp 复制代码
# 启用蓝牙详细日志
adb shell settings put global bluetooth_disable_logging 0

# 重启蓝牙服务
adb shell svc bluetooth disable
adb shell svc bluetooth enable

# 查看AVRCP相关日志
adb logcat -s AvrcpCt AvrcpTg AvrcpApi

这些日志会显示所有AVRCP命令的发送和接收过程,包括命令的详细内容和响应结果,是调试AVRCP兼容性问题的最有力工具。

七、测验

题目:AVRCP连接建立后,CT首先会执行哪些操作?为什么?(某蓝牙芯片公司2025年社招面试题)

答案

CT首先会注册AVAILABLE_PLAYERS_CHANGED和ADDRESSED_PLAYER_CHANGED两个通知,然后发送GetFolderItems命令获取媒体播放器列表。这是因为CT需要首先知道TG上有哪些可用的播放器,以及当前使用的是哪个播放器,才能进行后续的操作。

题目:InterimResponse和普通的Response有什么区别?(某手机厂商2024年校招面试题)

答案

InterimResponse是临时响应,用于RegisterNotification命令。当CT发送RegisterNotification命令时,TG会立即返回一个InterimResponse,包含当前的状态值。以后每当对应的事件发生时,TG会再发送一个最终的Response,包含新的状态值。而普通的Response是最终响应,用于其他命令,发送后命令就完成了。

题目:为什么CT需要注册这么多事件通知,而不是直接轮询TG的状态?

答案

主要是为了节省蓝牙带宽和降低功耗。轮询需要CT定期向TG发送命令,即使状态没有变化也会发送数据,这会消耗大量的蓝牙带宽和电量。而事件通知机制只有当状态真正发生变化时才会发送数据,更加高效。此外,事件通知还能提供更低的延迟,让CT能够更快地响应状态变化。


相关推荐
终端安全笔记1 小时前
iOS 27 之后「策略空转」:设备升级不报错,但旧策略不再管它
android·网络·安全·ios·智能手机
ToDesk_Daas2 小时前
随时随地手搓app!手机远程 Vibe Coding 实操分享
智能手机·电脑·iphone
特创数字科技4 小时前
抖音橡塑特效定制开发,2D互动小游戏特效代做
媒体
综合简报13 小时前
海外发稿和海外广告有什么区别?海外媒体发稿与投放的配合逻辑
媒体
Likeadust14 小时前
好风景如何破圈?在线高清直播系统EasyDSS用直播拓宽乡村文旅传播边界
媒体·风景
终端安全笔记1 天前
描述文件、企业证书、MDM 不是三个名字,是三层
android·ios·智能手机
镭封2 天前
短剧多人对白怎么配?一人完成多角色配音的办法
人工智能·音视频·语音识别·媒体
综合简报2 天前
海外发稿有哪几种曝光方式?海外媒体发稿四类渠道对照
媒体
可乐鸡翅yeah_2 天前
混合内容 Mixed‑Content 安全策略,HLS HTTPS 页面加载 HTTP 资源排错实战
运维·ffmpeg·音视频·媒体·m3u8
JoyCong19982 天前
鸿蒙用户等来的是ToDesk:折叠屏适配、星闪笔、互动Tab一次补齐
人工智能·智能手机·电脑·鸿蒙·鸿蒙系统·远程工作·ipad