62 · 字段逐个精讲 — CachedBluetoothDevice 的 41 个实例字段

源码基线:base/settingsLibAndroid/src/f3dif/java/com/android/settingslib/bluetooth/CachedBluetoothDevice.java(2719 行,f3dif = A17 主线) 本文回答初学者的第一个问题:“开头那一大片字段声明都是干什么的?” 读法:每组先看”是什么”(新手视角),再看”谁写谁读”(工程师视角),最后看”坑在哪”(排障视角)。 口径:本文件实例字段共 41 个(:112-:228 实测),本篇按 9 组逐讲 38 个;mHandler/mBatteryMetadataListener/mBluetoothFailureTimerScheduler 在 §8/§9 已顺带覆盖,唯 mBluetoothFailureFuture(失败延迟刷新任务的 Future 句柄,与 §8 看门狗机制配套)从略。

0. 全景图:先背下来这 9 组

flowchart TD
    CBD[CachedBluetoothDevice 一台设备的档案] --> G1[§1 常量<br/>耐心值/阈值]
    CBD --> G2[§2 身份与依赖<br/>mDevice 是灵魂]
    CBD --> G3[§3 profile 集合<br/>这台设备能干什么]
    CBD --> G4[§4 可见性状态<br/>mJustDiscovered 等]
    CBD --> G5[§5 回调注册表<br/>UI 怎么知道变了]
    CBD --> G6[§6 时间戳与 ACL<br/>回连窗口的账本]
    CBD --> G7[§7 active 设备<br/>当前输出目标]
    CBD --> G8[§8 失败标志+看门狗<br/>60s 超时机制]
    CBD --> G9[§9 组与缓存<br/>sub/member/LruCache]

一个总纲:这个类的大部分字段不是”数据仓库”,而是缓存的事件痕迹——每个布尔/时间戳对应一类广播事件到达后留下的印记(onXxxChanged 系列方法的产物)。读字段先想”哪个事件写它”,字段就活起来了。


§1 常量(:94-110)

private static final String TAG = "CachedBluetoothDevice";          // :94
private static final ParcelUuid ANDROID_AUTO_UUID = ...;            // :95-96
private static final long MAX_UUID_DELAY_FOR_AUTO_CONNECT = 35000;  // :99
private static final long MAX_HEARING_AIDS_DELAY_FOR_AUTO_CONNECT = 45000;  // :101
private static final long MAX_HOGP_DELAY_FOR_AUTO_CONNECT = 60000;  // :102
private static final long MAX_LEAUDIO_DELAY_FOR_AUTO_CONNECT = 60000;  // :103
private static final long MAX_MEDIA_PROFILE_CONNECT_DELAY = 60000;  // :104
private static final int DEFAULT_LOW_BATTERY_THRESHOLD = 20;        // :106
private static final int SUMMARY_NO_COLOR_FOR_LOW_BATTERY = 0;      // :110
常量一句话深入
TAG日志标签bugreport 取证入口:grep CachedBluetoothDevice
ANDROID_AUTO_UUID识别 Android Auto 车机的特征 UUID消费者 isAndroidAuto()(:2651)——UUID 命中或 UiModeManager 投影类型
MAX_*_DELAY_FOR_AUTO_CONNECT自动回连的耐心窗口(ms)用法见 66 章注意选择顺序是 if-else 链(:1113-1120):HOGP(60s) > HEARING_AID(45s) > LE_AUDIO(60s) > 默认(35s)——同时含助听器与 LE Audio UUID 的设备拿 45s 而非 60s,顺序敏感,重排即改行为
MAX_MEDIA_PROFILE_CONNECT_DELAY单 profile 连接看门狗时长§8:Handler 60s 超时判失败
DEFAULT_LOW_BATTERY_THRESHOLD低电量阈值兜底 20%isBatteryLow(:2155)按 metadata key 取阈值,<=0 才回落此常量;只在 TV 摘要着色链路使用
SUMMARY_NO_COLOR_FOR_LOW_BATTERY”不着色”哨兵值 0魔数 0 表示低电量文字不变色(resource id 语义)

新手常见疑问:为什么耐心值要分设备类型? 蓝牙连上后要做 SDP 服务发现(问对方”你支持哪些服务”),助听器(尤其第二只耳)做服务发现特别慢(:100 注释原话),HOGP/LE Audio 也慢。窗口太短→该连的没连上;太长→失败后傻等。


§2 身份与依赖(:112-123)

private final Context mContext;                                  // :112
private final LocalBluetoothProfileManager mProfileManager;      // :113
private final Object mProfileLock = new Object();                // :114
BluetoothDevice mDevice;                                         // :115 ← 注意非 private!
private HearingAidInfo mHearingAidInfo;                          // :116
private int mGroupId;                                            // :117
private Timestamp mBondTimestamp;                                // :118
private LocalBluetoothManager mBluetoothManager;                 // :119
private BluetoothAdapter mLocalAdapter;                          // :120
short mRssi;                                                     // :123 ← 包级可见

逐个讲

  • mDevice(:115)— 灵魂字段。真正的 framework BluetoothDevice(本质是 MAC 地址封装)。三个关键事实:
    1. 整个类的身份就是它equals(:1371-1376)只比 mDevice.equals(...)hashCode(:1379-1381)= mDevice.getAddress().hashCode()CBD = 被包装对象的身份证
    2. 非 private(包级可见)——组管理器(CsipDeviceManager 等)直接改它,这是”换壳”模式(§9)能实现的前提。
    3. 它是同步 binder 调用的源头getBondState()(:908-910)直通 mDevice.getBondState(),每次跨进程问 BluetoothManagerService——68 §5 N801PROB-134132 锁内秒级阻塞的代价来源。
  • mProfileManager(:113) — 管各 profile 连接器的总管,connect/updateProfiles 都靠它。构造函数三参之一。
  • mProfileLock(:114) — 一把普通对象锁,保护 mProfiles/mRemovedProfiles 的并发访问。加锁点:connectDevice(:596)、disconnect(:456)、onProfileStateChanged(:277 起)、onBondingStateChanged 的 clear(:1149)、updateProfiles(:1069)。设计铁律:临界区内不做 binder 调用(134132 的教训就是把这条踩碎了)。
  • mHearingAidInfo(:116)/mGroupId(:117) — 助听器信息与 CSIP 组号。mGroupId 构造时置 GROUP_ID_INVALID(:237),后续由 CsipDeviceManager 写(:587 setGroupId)。组的完整机制见 67 章
  • mBondTimestamp(:118) — 配对成功时刻。写点:BONDED 时 new Timestamp(...)(:1190);BOND_NONE 后台线程清 null(:1159)。诊断用途(getBondTimestamp :1264)。
  • mBluetoothManager(:119)/mLocalAdapter(:120) — 总管引用与本机适配器。注意构造函数并不初始化它们(构造只有 ctx/profileManager/device 三参,:230-242),mLocalAdapter 在构造里 BluetoothAdapter.getDefaultAdapter()(:232-235 一带);mBluetoothManager@VisibleForTesting 注入钩子(:2631)。
  • mRssi(:123) — 信号强度。没有主动查询接口(:122 注释),只能扫描回调写入:setRssi(:991-996,值变才写+dispatch)。所以 RSSI 是”最近一次广播里看到的强度”,会过期。

§3 profile 集合(:125-130)

// :125-126 注释:mProfiles 和 mRemovedProfiles 不在 main/sub 设备间做 swap(),
// 因为当前副设备只用于助听器,其 profile 与主设备相同。
private final Collection<LocalBluetoothProfile> mProfiles = new CopyOnWriteArrayList<>();       // :127
private final Collection<LocalBluetoothProfile> mRemovedProfiles = new CopyOnWriteArrayList<>(); // :130

这两个字段回答”这台设备能干什么”mProfiles = 当前支持的 profile(A2DP 音乐、HFP 通话、PBAP 通讯录…);mRemovedProfiles = 曾在 mProfiles 后来被移除的(diff 用)。

三个必须知道的工程事实:

  1. 主写入者是外部类!CBD 自己只在 onProfileStateChanged 里 add/remove(:345-364),整体重建在 LocalBluetoothProfileManager.updateProfiles(LBPM:634,synchronized)——updateProfiles()(:1056-1085)把 mProfiles 引用交出去,那边先 clear() 再按 UUID 重填。也就是说:你看到的 mProfiles 内容,是远端 UUID × 本地 UUID 的交集,由别人算好塞回来。
  2. CopyOnWriteArrayList 的选型逻辑:读极多(每次 UI 刷新、isConnected 判断都遍历)写极少(状态变化时),且需要在遍历中安全修改。代价是每次写复制整个数组——写频繁就是性能问题。
  3. BOND_NONE 时 mProfiles.clear()mRemovedProfiles 不清(:1149-1151)——残留的 removed 列表是排查时的噪音源。

什么时候重建?onUuidChanged(:1109,UUID 广播到达/SDP 完成)→ updateProfiles()。这也解释了为什么配对刚完成时 mProfiles 可能是空的——SDP 还没跑完,空列表时 connectDevice() 直接放弃(:599-609,注释:防与 carkit 配对期 UUID 竞态,等 ACTION_UUID 再连)。


§4 可见性与杂项状态(:132-137)

private boolean mLocalNapRoleConnected;        // :133
boolean mJustDiscovered;                       // :135
boolean mIsCoordinatedSetMember = false;       // :137
  • mLocalNapRoleConnected(:133) — PAN(蓝牙网络共享)角色标记:设备支持 PANU 但不支持 NAP 时,从 NAP 断开后移除 PanProfile(注释原话)。写点:onProfileStateChanged :350(连)/ :365(断);只读传参 updateProfiles :1071。
  • mJustDiscovered(:135) — “本轮扫描刚见过”。唯一写 true 入口:BEM DeviceFoundHandler:385 → setJustDiscovered(:901-903,值变才 dispatch);清 false:新扫描开始时 CBDM.onScanningStateChanged(:315-334;注意与 clearNonBondedSubDevices:303 同款早退——:327 遇首个带 member 的组即 return,其后条目本轮不清)。用途有二compareTo 排序权重(:1386-1405,已连接 > 已配对 > justDiscovered > RSSI > 名字);UI 层”附近设备”过滤。
  • mIsCoordinatedSetMember(:137) — 是否 CSIP 协调组成员(TWS 耳机副耳)。唯一写点 setIsCoordinatedSetMember(:559,外部入口 EventManager:386)。

§5 回调注册表(:139-141)

private final Collection<Callback> mCallbacks = new CopyOnWriteArrayList<>();          // :139
private final Map<Callback, Executor> mCallbackExecutorMap = new ConcurrentHashMap<>(); // :141

观察者模式的两个通道,一个 deprecated 一个新

旧 API registerCallback(Callback)(:1313)新 API registerCallback(Executor, Callback)(:1327)
存储mCallbacksmCallbackExecutorMap
派发线程调用者线程直调(:1348-1350)——通常是广播线程注册时指定的 executor(:1351-1352)
状态@Deprecated推荐

dispatchAttributesChanged()(:1347-1353)先遍历旧通道直调,再遍历新通道丢给 executor。Callback 接口只有一个方法 onDeviceAttributesChanged()(:1408-1410)——属性变了这一种事件,粒度很粗(不告诉你哪个属性变了)。

两个坑

  1. 双注册双发:同一 callback 若两个 API 都注册,一次属性变化收两次通知(§惊喜 7)。
  2. 旧通道的接收方必须自己保证线程安全——回调可能落在任意广播线程。车机 UI 层(NewBluetoothDevicePreference:116)用的是 registerCallback(UI 层自己 post 回主线程)。

unregisterCallback(:1337-1345)要两个集合都空才注销电量 metadata listener——防止多注册方互相踩。


§6 时间戳与 ACL 链路(:143-154)

private long mConnectAttempted = -1;             // :150
private long mBondFailureTimeMillis = -1;        // :151
private long mConnectionFailureTimeMillis = -1;  // :152
private boolean mIsAclConnectedBrEdr = false;    // :153
private boolean mIsAclConnectedLe = false;       // :154

⚠️ mConnectAttempted:注释会骗人(本组最重要的一课)

字段注释(:143-150)说它是 “Last time a bt profile auto-connect was attempted”(上次自动连接尝试时间)。实际实现(:1226-1227):

// onAclStateChanged() 内
if (!mIsAclConnectedBrEdr && !mIsAclConnectedLe) {
    mConnectAttempted = SystemClock.elapsedRealtime();   // ← 写点:首个 ACL 链路建立时刻
  • 它记的是 ACL 物理链路建立时刻,不是”连接尝试”时刻;
  • 复位 -1 的条件是双传输都断开(:1251-1252),不是注释说的”断开即复位”(半断不复位);
  • 换壳时还会被搬运到新 main(switchMemberDeviceContent :2580/:2591);
  • dcddif 版的 onUuidChanged 判定行(DC:811)因此漏了 -1 守卫——开机早期 mConnectAttempted=-1-1+timeout > now 可能为真,误触发连接。f3dif 补了 mConnectAttempted != -1(:1138)。

教学点:字段名、注释、实现三者不一致时,以写入点代码为准。读开源代码要养成”顺着赋值语句找语义”的习惯。完整回连窗口机制见 66 章

ACL 双标志(:153-154)

onAclStateChanged(state, transport)(:1212-1262)按 transport 分流维护:经典蓝牙(BR/EDR)和低功耗(LE)各一条 ACL 物理链路,比 profile 更底层。“ACL 通了但没有任何 profile 连上”是合法中间态(配对后 SDP/连接进行中)。DCD/XCD 老版本没有这两个标志——是 A17 基线新加的。

两个失败时间戳(:151-152)

字段写点语义
mBondFailureTimeMillisBONDING→NONE 且诊断 flag 开(:1163,后台线程);BONDED 复位 -1(:1201)配对失败时刻(CAN_NOT_PAIR 延迟刷新配套)
mConnectionFailureTimeMillisonProfileStateChanged :378(isProfileConnectedFail 且 !isBusy)/ :394(清 -1)连接失败时刻(重试节流用)

§7 active 设备标志(:156-160)

private boolean mIsActiveDeviceA2dp = false;        // :157
private boolean mIsActiveDeviceHeadset = false;     // :158
private boolean mIsActiveDeviceHearingAid = false;  // :159
private boolean mIsActiveDeviceLeAudio = false;     // :160

“这台设备是不是当前的输出目标”。车机连两台耳机,音乐只从一台出——那台在 A2DP 维度是 active。每个 profile 维度各一个 active 位。

  • 被动写onActiveDeviceChanged(isActive, profile)(:918-951,入口 EventManager:296-304 的四个 ACTIVE_DEVICE_CHANGED 广播,值变才 dispatch)。
  • 主动对账fetchActiveDevices()(:1087-1104)——A2DP/HFP 用 equals(getActiveDevice())(单 active 语义),HA/LeAudio 用 getActiveDevices().contains(mDevice)双 active 语义——两台助听器可以同时 active)。fillData()onProfileStateChanged 尾部(:400)都会调。
  • 主动设置setActive()(:782-813)对四个 profile 依次 setActiveDevice已连接才设

详见 67 章


§8 profile 连接失败标志 + 60 秒看门狗(:161-166 + :179-202)

private boolean mIsA2dpProfileConnectedFail = false;       // :162
private boolean mIsHeadsetProfileConnectedFail = false;    // :163
private boolean mIsHearingAidProfileConnectedFail = false; // :164
private boolean mIsLeAudioProfileConnectedFail = false;    // :165
private boolean mIsListeningBatteryChange = false;         // :166

这四个失败标志与一个很巧的看门狗机制配套——mHandler(:179-202)直接把 profileId 当 message.what 用:

STATE_CONNECTING   → sendEmptyMessageDelayed(profileId, 60_000)   // :298-301 上弦
STATE_CONNECTED    → removeMessages(profileId)                    // :295-297   解除
STATE_DISCONNECTING→ 有则 remove                                  // :302-306
STATE_DISCONNECTED → 有则 remove;
  且 getConnectionPolicy > FORBIDDEN 时判"真失败"                   // :307-331
  → setProfileConnectedStatus(id, true)                           // :436-449 写失败标志

60 秒后消息还活着 → handleMessage 命中 :184/:187/:190/:193
  → "Connect to profile : X timeout, show error message !"        // :199
  → 置失败标志 + refresh()                                        // :200

零额外状态字的超时器设计:不用单独的 Timer/状态机,Handler 消息本身就是”看门狗已上弦”的证据。消费者 isProfileConnectedFail()(:2210-2242)综合 4 标志 + profile isEnabled 判定,UI 据此显示”连接失败”。

排障提示:isProfileConnectedFail 里有一条跨 6 行的 Log.d 常驻(:2211-2216,单条语句打印 4 个失败标志 + SAP 状态),正常路径每次调用都打——bugreport 里大量出现不是异常,别误判。

另外 mIsListeningBatteryChange(:166)是电量 metadata listener 的注册守卫(防重复注册,:2672/:2683/:2698/:2706),详见 67 章 §电量。


§9 组管理与资源缓存(:167-177)

private boolean mUnpairing;                              // :167
@Nullable private InputDevice mInputDevice;              // :168-169
private boolean mIsDeviceStylus;                         // :170
private CachedBluetoothDevice mSubDevice;                // :173
private Set<CachedBluetoothDevice> mMemberDevices = new HashSet<>();  // :175
@VisibleForTesting LruCache<String, BitmapDrawable> mDrawableCache;   // :176-177
  • mUnpairing(:167)— 单向旗。只有两个写点:构造置 false(:239)、unpair() 置 true(:666)——永不复位。能这么写是因为 unpair 后对象即被丢弃;消费者 HearingAidDeviceManager:439 靠它在删键期间吞掉副设备的 STATE_DISCONNECTED 事件。若对象被复用就是 bug(教学点:生命周期假设要写进注释——这里恰好没写)。
  • mInputDevice/mIsDeviceStylus(:168-170) — 手写笔场景(构造 :240-241 BluetoothUtils.getInputDevice 判定)。
  • mSubDevice(:173) — 助听器”另一只耳”。唯一挂 sub 赋值入口 setSubDevice(:2490,调用方 HearingAidDeviceManager:316/:407;另有 CBDM 4 处 setSubDevice(null) 清除调用 :310/:350/:433/:437)。换壳 switchSubDeviceContent(:2494-2520):备份 → 双方 release()互换 mDevice/mRssi/mJustDiscovered/mHearingAidInfo 四字段(不换 mProfiles,原因见 :125-126 注释:副设备只有助听器场景、profile 必同)。
  • mMemberDevices(:175)— 裸 HashSet,唯一没有并发保护的集合mProfiles/mCallbacks 都是 CopyOnWriteArrayList、mCallbackExecutorMap 是 ConcurrentHashMap,唯独它是普通 HashSet——组操作全靠调用方 CBDM 的 synchronized 方法(addDevice 等)持锁串行化(组管理器自身方法并无锁保护:CsipDeviceManager 全类 0 个 synchronized,HearingAidDeviceManager 仅 1 处且与组挂靠无关)。在 CBDM 锁外碰它就是数据竞争
  • mDrawableCache(:176-177) — 设备图标 LruCache。initDrawableCache(:249-258)容量 = 进程最大内存的 1/8(KB)refresh()(:877-899)后台线程预取 METADATA_MAIN_ICON 塞缓存;releaseLruCache(:2622,unpair :679 调用)evictAll。每个 CBD 对象一份 → 设备多时隐性内存占用可观。68 §1 MANNPROB-2403 的图标问题就发生在这条链上。
  • 同族资源还有 mBluetoothFailureTimerScheduler = Executors.newSingleThreadScheduledExecutor()(:226-227)——无 shutdown 路径release()(:245-247)只清 Handler 消息。泄漏面:每设备一个永不关的单线程调度器。

换壳的身份陷阱(高级话题)

switchMemberDeviceContent(:2553-2598)互换 7 个字段(比 sub 版多 mConnectAttempted/mIsAclConnectedBrEdr/mIsAclConnectedLe,:2563-2582)。因为 hashCode 以地址计算(:1380),换壳会改变对象在 HashSet 里的 hash——所以 :2554-2556 必须先 removeMemberDevice 再事后 addMemberDevice,注释原话 “prevent hash mismatch problem due to mDevice switch”。这是”包装器身份 = 被包装对象身份”设计的反面教材。


§10 惊喜点集锦(读懂这个类的暗面)

  1. 死代码对connectInt(:621-632)与 onBondingDockConnect(:590-594)全仓库无调用者(grep 实证)——旧 API 迁移化石。
  2. TURNING_OFF 吞事件onProfileStateChanged 开头 STATE_TURNING_OFF 直接 return(:282-288),日志原文 “BT Turninig Off…”(Turninig 拼错)——关蓝牙期间的 profile 断开不进失败统计,排”关蓝牙后状态残留”必看。
  3. compareTo 自认不稳定:头注释(:1383-1385)明说排序键非 final、属性变化会变序,依赖 Settings 整表刷新兜底——排序实现与 UI 刷新协议是隐式契约。
  4. toString(:1356-1368)是免费 debug 工具:输出匿名化地址/名字/groupId/member(+助听器信息)。日志里看到它就能对上设备。
  5. f3dif 无 MICAR-PORTING 标记 ≠ 无小米改动::1152-1182 有 MIUI MOD: BT_MIUIBluetoothFrame 块(BOND_NONE 后台重置三权限)——小米改动换了标记体系。
  6. 名字是”读时取”不是缓存getName()(:753)每次实时 mDevice.getAlias()refreshName()(:815)只 dispatch 不存值。(老版本 AOSP 的 fetchNameAndImages 在 f3dif 已不存在——搜不到这个方法不是你眼花。)

自测清单

  • mProfiles 的整体重建者是谁?(不是 CBD 自己——是 LocalBluetoothProfileManager.updateProfiles LBPM:634)
  • mConnectAttempted 注释说记什么、实际记什么?(“连接尝试时间” vs ACL 链路建立时刻)
  • 60 秒看门狗用什么当 message.what?(profileId 本身)
  • 哪个集合没有并发保护?(mMemberDevices 裸 HashSet)
  • mUnpairing 会被写回 false 吗?(不会,单向旗)
  • 换壳时为什么必须先 remove 再 add member?(hashCode 随 mDevice 变化,防 hash 错配)
  • equals 比什么?(只比 mDevice——蓝牙地址)

下一章 63-对象的一生:这些字段所在的对象,从哪里来、到哪里去。