FM收音与发射
FM收音与发射(FM RX/TX)是 Jieli 蓝牙 RCSP 协议中多媒体控制的重要功能模块:App 通过 RCSPController 与设备(小机)通信,实现 FM 收音(搜索频道、选台、播放/暂停、收藏频点)以及 FM 发射(TX)的完整控制链路,并配合刻度尺(RulerView)、搜索弹窗等自定义控件完成交互。
Purpose and Scope
本页面向 device-functions.fm-control 目录主题,完整介绍 FM 控制子系统在 App 侧的实现:
- FM 接收(收音)控制链路:
FMControlFragment→FMControlViewModel→RCSPController→ 设备,以及状态/频道回调如何驱动 UI 刷新; - FM 状态数据模型:
FmStatusInfo(播放状态、频点、频率、模式); - 收藏频点持久化:基于 Room 的
FMCollectInfoEntity与DataRepository读写; - FM 发射(TX)入口:
FMTXControlFragment/FMTXControlViewModel的职责边界; - SDK 侧 API 用法:以
FmDemo.java为示例说明RCSPController的 FM 命令与回调。
以下内容属于其他目录页、此处不做展开:摇一摇切歌(JLShakeItManager)整体机制、多媒体主控页布局、RCSP 协议底层报文封装。相关说明见文末「Related Links」。
Overview
FM 控制是典型的一问一答式 RCSP 功能:App 端发起命令(如获取 FM 状态、向前搜索频道、播放指定频点),设备端处理后通过事件回调把结果推回 App,由 ViewModel 转为 LiveData 驱动 UI。
整个子系统分为三层:
- UI 层:
FMControlFragment(收音页)与FMTXControlFragment(发射页),通过 DataBinding 绑定 ViewModel,配合FMRulerView刻度尺、FMSearchDialog搜索弹窗、FMFreqCollectAdapter收藏列表等控件; - 逻辑层:
FMControlViewModel/FMTXControlViewModel,继承BtBasicVM,持有RCSPController单例,负责命令下发、回调分发、命令节流与收藏读写; - 数据层:
DataRepository+ Room(FMCollectInfoEntity/FMCollectInfoDao),以及 SDK 侧的FmStatusInfo、ChannelInfo等设备数据结构。
设计意图:把"设备命令"与"UI 状态"解耦——ViewModel 只暴露语义化动作(onFMPlayNextChannel() 等)和 LiveData 状态,UI 层不直接触碰 RCSP 协议细节;同时通过 isCanSendFMCmdToDevice()、checkIsQuickContinuousSend() 在命令入口做统一拦截,避免在断连或用户快速连续操作时向设备发送无效命令。
Architecture
flowchart TD
subgraph sg_UI["UI 层 (btsmart)"]
RXFragment["FMControlFragment"]
TXFragment["FMTXControlFragment"]
RulerView["FMRulerView"]
SearchDialog["FMSearchDialog"]
CollectAdapter["FMFreqCollectAdapter"]
end
subgraph sg_VM["逻辑层 (ViewModel)"]
RXVM["FMControlViewModel"]
TXVM["FMTXControlViewModel"]
BtBasicVM["BtBasicVM (基类)"]
end
subgraph sg_SDK["RCSP SDK 层"]
Controller["RCSPController (单例)"]
EventCB["BTRcspEventCallback"]
ActionCB["OnRcspActionCallback"]
end
subgraph sg_Data["数据层"]
DataRepo["DataRepository"]
RoomDB[(Room / FMCollectInfoDao)]
Entity["FMCollectInfoEntity"]
end
subgraph sg_Device["设备端 (小机)"]
Device["蓝牙设备 (FM 收音/发射)"]
end
RXFragment -->|"DataBinding / LiveData"| RXVM
TXFragment --> TXVM
RXFragment --> RulerView
RXFragment --> SearchDialog
RXFragment --> CollectAdapter
RXVM --> BtBasicVM
TXVM --> BtBasicVM
RXVM -->|"fmPlaySelectedFrequency / fmSearchAllChannels 等"| Controller
TXVM --> Controller
Controller -->|"getFmInfo / fmForwardSearchChannels"| Device
Device -->|"onFmStatusChange / onFmChannelsChange"| EventCB
EventCB --> RXVM
ActionCB --> RXVM
RXVM -->|"insert / delete FMCollectInfo"| DataRepo
DataRepo --> RoomDB
RoomDB --> Entity
架构说明:
FMControlFragment通过ViewModelProvider获取FMControlViewModel(见 FMControlFragment.java),DataBinding 将 ViewModel 直接暴露给布局(mBinding.setFmControlViewModel(...))。FMControlViewModel构造函数即注册BTRcspEventCallback,release()时注销,保证与页面生命周期绑定、不泄漏监听器(见 FMControlViewModel.java)。- 命令层统一走
RCSPController单例,回调分两类:动作结果回调OnRcspActionCallback<Boolean>(命令是否送达/执行)与事件回调BTRcspEventCallback(设备主动上报的状态/频道变化)。 - 收藏数据与设备无关地持久化到 Room,按设备 MAC(
btDeviceSSid)隔离。
核心流程
获取 FM 状态
App 打开收音页时调用 getFMInfo(),命令下发后设备上报 FmStatusInfo,经 onFmStatusChange 回调写入 fmStatusInfoLiveData,UI 随之刷新播放/暂停图标、当前频率与频道号。
sequenceDiagram
participant F as FMControlFragment
participant VM as FMControlViewModel
participant RC as RCSPController
participant D as 蓝牙设备
participant CB as BTRcspEventCallback
F->>VM: getFMInfo()
VM->>RC: getFmInfo(device, null)
RC->>D: 下发 RCSP FM 状态查询命令
D-->>RC: 状态数据上报
RC-->>CB: onFmStatusChange(device, FmStatusInfo)
CB-->>VM: 更新 fmStatusInfoLiveData
VM-->>F: LiveData 观察者收到新状态
F->>F: 刷新播放状态 / 频率 / 频道 UI
搜索频道流程
向前/向后/全部搜索都会先 setPlayStateToPause() 暂停播放、弹出扫描对话框并播放搜索动画,然后下发对应命令;设备端把搜索到的频道列表通过 onFmChannelsChange(device, List<ChannelInfo>) 推回,写入 channelListLiveData。
flowchart TD
Start([用户点击搜索]) --> Gate{"isCanSendFMCmdToDevice?"}
Gate -->|"No"| Ignore["忽略操作"]
Gate -->|"Yes"| Quick{"checkIsQuickContinuousSend?"}
Quick -->|"True"| Ignore
Quick -->|"False"| Pause["setPlayStateToPause"]
Pause --> Dialog["scanDialogShowStateLiveData = true"]
Dialog --> Gif["fmSearchIngGifLiveData = ic_fm_searching"]
Gif --> Cmd["fmForwardSearchChannels / fmBackwardSearchChannels / fmSearchAllChannels"]
Cmd --> Scan["设备搜索中"]
Scan --> Found["onFmChannelsChange 回调"]
Found --> Update["channelListLiveData 更新收藏/列表 UI"]
Update --> Stop["onFMSearchStop 停止搜索"]
Stop --> End([结束])
选台与播放控制
用户滑动 FMRulerView 选中频率后,onFMPlaySelectFreq(float freq) 将频率放大 10 倍(875 表示 87.5MHz)存入 fmSelectedFreqLiveData,并调用 fmPlaySelectedFrequency 下发选台命令;上一台/下一台、上一频点/下一频点、播放暂停均有对应的语义化方法。
状态模型:FmStatusInfo
App 侧 FmStatusInfo(FmStatusInfo.java)是设备状态在 App 内的投影,包含四个字段:
| 字段 | 类型 | 含义 |
|---|---|---|
isPlay | boolean | 是否正在播放(收音中) |
channel | int | 当前频道号 |
freq | float | 当前频率(MHz) |
mode | int | 工作模式 |
提供全参构造函数、getter/setter 与 toString()。SDK 侧 com.jieli.bluetooth.bean.device.fm.FmStatusInfo 与 ChannelInfo 用于事件回调,App 侧模型与之映射。
收藏频点与本地持久化
收藏是 FM 收音页的重要交互:用户把当前频点加入收藏,之后可通过收藏列表快速选台。收藏数据按设备 MAC 隔离存储,切换设备后互不干扰。
public void onFMFreqAddCollect() {
String address = getConnectedDevice() == null ? "11:22:33:44:55:66" : getConnectedDevice().getAddress();
FMCollectInfoEntity entity = new FMCollectInfoEntity();
entity.setBtDeviceSSid(address);
entity.setPlay(fmStatusInfoLiveData.getValue() != null && fmStatusInfoLiveData.getValue().isPlay());
entity.setFreq(fmCurrentFreqLiveData.getValue());
entity.setMode(fmStatusInfoLiveData.getValue() == null ? 0 : fmStatusInfoLiveData.getValue().getMode());
DataRepository.getInstance().insertFMCollectInfo(entity, new DataRepository.DataRepositoryCallback() {
@Override
public void success() {//添加成功
fmCollectStateLiveData.postValue(true);
}
@Override
public void failed() {//代表已存在
fmCollectStateLiveData.postValue(false);
}
});
}
Source: FMControlViewModel.java
要点:
- 收藏实体
FMCollectInfoEntity记录设备地址(btDeviceSSid)、播放状态、频率与模式;无设备时使用占位地址"11:22:33:44:55:66",避免空指针; DataRepository回调区分两种结果:success()表示新增成功,failed()表示该频点已存在(收藏去重逻辑在数据层完成);- 删除收藏时若列表只剩一条,会自动退出收藏管理态(
onFMFreqDeleteCollect中size() == 1时置fmCollectManageStateLiveData = false),避免用户删除最后一项后仍停留在管理态; - 收藏列表通过
fmCollectFreqLiveData暴露给 UI,由FMFreqCollectAdapter渲染,DAO 为FMCollectInfoDao。
命令入口统一拦截与防抖
ViewModel 中几乎所有动作方法都以 isCanSendFMCmdToDevice() 开头,搜索类动作还额外叠加 checkIsQuickContinuousSend() 快速连点保护:
public void onFMPlaySelectFreq(float freq) {
if (!isCanSendFMCmdToDevice()) return;
fmSelectedFreqLiveData.setValue((int) (freq * 10));
JL_Log.d("zhm_fm", "onFMPlaySelectFreq: " + freq);
mRCSPController.fmPlaySelectedFrequency(getConnectedDevice(), freq, null);
}
Source: FMControlViewModel.java
设计意图:
- 断连保护:设备未连接/未初始化时直接丢弃命令,避免 BLE 写入失败引发异常或体验问题;
- 快速连点保护:
mLastSendTime记录上次发命令时间,checkIsQuickContinuousSend()在用户极短时间内重复触发(如狂点搜索)时拦截,防止命令风暴导致设备端处理错乱;fmCurrentFreqLiveData的默认值875与mIsViewFlingStop配合FMRulerView的 fling 惯性滑动,确保刻度尺滑动过程中不频繁下发选台命令。
FM 发射(TX)侧
发射侧由 FMTXControlFragment(UI)与 FMTXControlViewModel(逻辑)组成,位于同一 ui/multimedia/control/fm 与 viewmodel 目录,与收音侧结构对称:同样继承 BtBasicVM、复用 RCSPController 下发发射频率等命令。发射与接收共用 FmStatusInfo/ChannelInfo 设备数据模型与收藏持久化体系,二者的差异集中在命令语义与 UI 展示(发射页关注发射频率设置与状态展示,收音页关注频道搜索与选台)。发射侧具体命令细节未在本页展开,请以 SDK 侧 RCSPController 实现为准。
Usage Examples
1. 判断设备是否支持 FM 及当前是否处于 FM 模式
SDK 的 DeviceInfo 提供能力位与当前功能标志,是进入 FM 页面前的前置校验:
public boolean isSupportFMMode() {
//获取RCSPController对象
RCSPController controller = RCSPController.getInstance();
//获取当前操作设备
BluetoothDevice usingDevice = controller.getUsingDevice();
if (null == usingDevice) return false;
DeviceInfo deviceInfo = controller.getDeviceInfo(usingDevice);
if (null == deviceInfo) return false; //设备未初始化
return deviceInfo.isFmEnable();
}
public boolean isDeviceInFMMode() {
RCSPController controller = RCSPController.getInstance();
BluetoothDevice usingDevice = controller.getUsingDevice();
if (null == usingDevice) return false;
DeviceInfo deviceInfo = controller.getDeviceInfo(usingDevice);
if (null == deviceInfo) return false; //设备未初始化
return deviceInfo.getCurFunction() == AttrAndFunCode.SYS_INFO_FUNCTION_FM;
}
Source: FmDemo.java
2. 获取 FM 状态信息(命令 + 事件回调配合)
getFmInfo 只是发起查询,真正的状态数据在 BTRcspEventCallback#onFmStatusChange 中异步送达:
void getFMStatus() {
RCSPController controller = RCSPController.getInstance();
controller.addBTRcspEventCallback(new BTRcspEventCallback() {
@Override
public void onFmStatusChange(BluetoothDevice device, FmStatusInfo fmStatusInfo) {
//此处回调FM状态信息
}
});
controller.getFmInfo(controller.getUsingDevice(), new OnRcspActionCallback<Boolean>() {
@Override
public void onSuccess(BluetoothDevice device, Boolean message) {
//成功回调
//结果将会在BTRcspEventCallback#onFmStatusChange回调
}
@Override
public void onError(BluetoothDevice device, BaseError error) {
//失败回调
//error - 错误信息
}
});
}
Source: FmDemo.java
3. 频道搜索(前向搜索示例)
搜索类命令同样采用"动作回调确认 + 事件回调推送频道列表"的双回调模式:
void fmScanForward() {
RCSPController controller = RCSPController.getInstance();
controller.addBTRcspEventCallback(new BTRcspEventCallback() {
@Override
public void onFmChannelsChange(BluetoothDevice device, List<ChannelInfo> channels) {
//此处回调已发现的FM频道列表
}
});
controller.fmForwardSearchChannels(controller.getUsingDevice(), new OnRcspActionCallback<Boolean>() {
@Override
public void onSuccess(BluetoothDevice device, Boolean message) {
//成功回调
}
@Override
public void onError(BluetoothDevice device, BaseError error) {
//失败回调
}
});
}
Source: FmDemo.java
4. 收音页 ViewModel 接线(Fragment 侧)
FMControlFragment 通过 ViewModelProvider 创建 ViewModel,并用 DataBinding 绑定,页面销毁后由 BtBasicVM.release() 自动注销回调:
@Override
public void actionsOnViewInflate() {
super.actionsOnViewInflate();
ViewModelProvider provider = new ViewModelProvider(this);
mFMControlViewModel = provider.get(FMControlViewModel.class);
mBinding.setFmControlViewModel(mFMControlViewModel);
mBinding.setFmReceiveSuspension(suspensionLiveData);
mBinding.setLifecycleOwner(this);
mBinding.getRoot().setOnClickListener(v -> {
});//拦截点击事件而已
}
Source: FMControlFragment.java
API Reference
RCSPController FM 命令(App → 设备)
以下方法均来自 com.jieli.bluetooth.impl.rcsp.RCSPController 单例,调用形态统一为 方法(BluetoothDevice device, OnRcspActionCallback<Boolean> callback);callback 可传 null(如 ViewModel 中多数调用)。
| 方法 | 说明 |
|---|---|
getFmInfo(device, callback) | 查询 FM 状态;结果经 BTRcspEventCallback#onFmStatusChange 回调 |
fmPlaySelectedFrequency(device, float freq, callback) | 播放指定频点(freq 单位 MHz,如 87.5) |
fmPlayPrevChannel(device, callback) | 播放上一频道 |
fmPlayNextChannel(device, callback) | 播放下一频道 |
fmPlayPrevFrequency(device, callback) | 播放上一频点 |
fmPlayNextFrequency(device, callback) | 播放下一频点 |
fmPlayOrPause(device, callback) | 播放/暂停切换 |
fmForwardSearchChannels(device, callback) | 向前搜索频道;结果经 onFmChannelsChange 回调 |
fmBackwardSearchChannels(device, callback) | 向后搜索频道 |
fmSearchAllChannels(device, callback) | 全部频道搜索 |
fmStopSearch(device, callback) | 停止搜索 |
BTRcspEventCallback 事件回调(设备 → App)
| 回调方法 | 触发时机 | 参数说明 |
|---|---|---|
onFmStatusChange(BluetoothDevice device, FmStatusInfo fmStatusInfo) | 设备上报 FM 状态变化 | FmStatusInfo 含播放状态、频道、频率、模式 |
onFmChannelsChange(BluetoothDevice device, List<ChannelInfo> channels) | 设备上报搜索到的频道列表 | ChannelInfo 为 SDK 侧频道模型 |
OnRcspActionCallback<Boolean> 动作回调
onSuccess(BluetoothDevice device, Boolean message):命令已送达/执行成功;onError(BluetoothDevice device, BaseError error):失败,error携带错误信息。
FMControlViewModel 对外 LiveData 状态
| LiveData | 类型 | 初始值 | 含义 |
|---|---|---|---|
scanDialogShowStateLiveData | Boolean | false | 扫描弹窗是否显示 |
channelListLiveData | ArrayList<Integer> | — | 设备扫描到的 FM 频道 |
fmStatusInfoLiveData | FmStatusInfo | — | 设备回复的 FM 状态 |
fmCollectFreqLiveData | List | — | 收藏频点列表 |
fmCurrentFreqLiveData | Integer | 875 | 刻度尺当前频点(×10,87.5MHz) |
fmSelectedFreqLiveData | Integer | — | 刻度尺选中频点 |
fmShowSearchLiveData | Boolean | — | 搜索弹窗显示状态 |
fmCollectManageStateLiveData | Boolean | false | 收藏管理态开关 |
fmCollectStateLiveData | Boolean | — | 收藏结果(true 成功 / false 已存在) |
fmSearchIngGifLiveData | Integer | R.drawable.ic_fm_searching | 搜索动画 Gif 资源 |
Configuration Options
FM 控制没有独立的配置文件,运行时状态/行为由以下默认值与常量决定:
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
fmCurrentFreqLiveData 初始值 | int | 875 | 刻度尺起始频点,表示 87.5MHz |
scanDialogShowStateLiveData 初始值 | boolean | false | 进入页面时默认不显示扫描弹窗 |
fmCollectManageStateLiveData 初始值 | boolean | false | 默认非收藏管理态 |
fmSearchIngGifLiveData 初始值 | int | R.drawable.ic_fm_searching | 搜索动画起始帧 |
| 无设备时收藏地址占位 | String | "11:22:33:44:55:66" | onFMFreqAddCollect 中防空处理 |
AttrAndFunCode.SYS_INFO_FUNCTION_FM | int | SDK 定义 | 设备当前功能为 FM 的模式标志 |
JLShakeItManager.MODE_CUT_SONG_TYPE_FM | int | 常量 | 摇一摇切歌的 FM 切歌类型 |
SConstant.ALLOW_SWITCH_FUN_DISCONNECT | boolean | 常量 | 允许切换功能时断开连接的开关(Fragment 静态引用) |
Failure Modes, Edge Cases & Concurrency
- 设备未连接 / 未初始化:所有动作命令前先执行
isCanSendFMCmdToDevice(),不满足直接 return,UI 无响应但不产生 BLE 写入异常;FmDemo中通过getUsingDevice()与getDeviceInfo()双重判空并检查isFmEnable()。 - 非 FM 模式:
getCurFunction() != SYS_INFO_FUNCTION_FM时,App 不应进入 FM 操作(Demo 提供isDeviceInFMMode()校验)。 - 快速连续操作:搜索类命令叠加
checkIsQuickContinuousSend()(基于mLastSendTime)防抖,防止命令风暴;选台依赖mIsViewFlingStop标记,刻度尺 fling 惯性滑动未停止时不下发选台命令,避免设备端频率抖动。 - 收藏重复:
DataRepository.insertFMCollectInfo对重复频点返回failed(),UI 通过fmCollectStateLiveData=false提示"已存在";删除最后一条收藏时自动退出管理态。 - 并发/线程模型:设备事件回调可能来自 BLE 工作线程,ViewModel 使用
LiveData.postValue()(异步线程安全)与setValue()(主线程)区分场景;MutableLiveData保证 UI 只观察主线程更新。 - 页面生命周期:
BTRcspEventCallback在构造时注册、release()时注销,避免页面销毁后仍接收设备事件导致内存泄漏或空引用。
Performance & Operational Notes
- FM 命令为低频交互(选台/搜索/播放控制),单条命令体积小,BLE 传输开销可忽略;主要性能关注点是避免高频命令,即上文
mLastSendTime与 fling 状态节流。 - 搜索动画通过
fmSearchIngGifLiveData切换资源帧,UI 侧只做状态监听,Gif 播放开销集中在 Fragment 的 Dialog 控件内。 - 收藏读写走 Room(
DataRepository单例),插入为异步回调模式,不阻塞主线程;删除/查询同样由 DAO 承担。
Extension Points
- 新 FM 动作:在
FMControlViewModel增加语义化方法,内部调用RCSPController对应命令即可;UI 通过 DataBinding 绑定新方法,无需改动 SDK。 - 自定义搜索交互:
FMSearchDialog是独立 Widget(ui/widget/FMSearchDialog.java),可替换为自定义弹窗;onShowFMSearchView已预留dismissCallback联动fmShowSearchLiveData。 - 刻度尺交互:
FMRulerView(ui/widget/rulerview/FMRulerView.java)负责滑动手势与频率映射,配合setIsViewFlingStop()控制命令下发时机。 - 收藏扩展:
FMCollectInfoEntity可增字段(如备注、标签),配合 DAO 迁移即可扩展收藏维度。
Related Links
- FMControlViewModel.java — 收音逻辑核心
- FMTXControlViewModel.java — 发射逻辑
- FMControlFragment.java — 收音页 UI
- FMTXControlFragment.java — 发射页 UI
- FmStatusInfo.java — 状态模型
- FMCollectInfoEntity.java — 收藏实体
- FmDemo.java — SDK FM 命令/回调示例
- 相关页面:多媒体控制主流程、摇一摇切歌(
JLShakeItManager)、RCSP 连接管理(RCSPController)。