FM 收音机控制
本文档介绍 Flutter-JL_Home 蓝牙 SDK 中 FM 收音机控制能力的完整实现:从 Flutter 侧发送指令(BleFmManager)、接收设备状态事件(fmInfoStream)到示例页面(FmPage)的完整端到端机制。
Purpose and Scope
本页面覆盖 FM 收音机控制这一完整能力链路:
- 发送指令层:
BleFmManager提供的全部 FM 控制方法(模式切换、搜索、选台、播放/暂停、频率选择); - 事件接收层:
BleEventStream.fmInfoStream→BleAudioProcessor.fmInfoStream的事件过滤与数据映射; - 事件常量与数据模型:
typeFmInfo、keyFmStatus、keyFmFrequency等键定义及默认值; - UI 示例层:
FmPage如何初始化 FM 模式、订阅事件、处理断连与状态刷新。
以下主题属于其他页面,不在本文范围:蓝牙基础连接与设备发现、音频播放(ID3 音乐状态/进度)、LineIn 模式(lineInStatusStream)、Android 原生端 FMManager/FMProcessor 的具体实现细节(仅作为跨端桥接的引用提及)。
Overview
FM 收音机控制是 JieLi 蓝牙音箱/耳机设备的一项核心媒体功能。设备端具备完整的 FM 调谐器硬件,SDK 通过 BLE 通道将控制指令下发给设备,设备随后上报搜索进度、当前频率与播放状态。
整个功能采用指令(Command)与事件(Event)分离的架构模式:
- 指令方向(App → 设备):通过
BleBaseManager.invokeMethod以 MethodChannel 方式调用原生层,再由原生层经 BLE 协议下发到设备。指令均为"发后即忘"(fire-and-forget),调用方不等待设备确认; - 事件方向(设备 → App):设备状态变化通过 BLE 通知上报,经原生层转换为 Flutter 事件,最终由
BleAudioProcessor按事件类型过滤,形成类型化的 Dart Stream 供 UI 订阅。
该设计的好处是:指令与状态解耦,UI 无需在每次操作后轮询设备,而是被动响应状态流;多个页面/模块可以同时订阅同一个 Stream 而互不干扰。
Architecture
flowchart TD
subgraph sg_UI["UI 层 (example)"]
FmPage["FmPage (StatefulWidget)"]
JLFMStatus["JLFMStatus 枚举"]
FMConstants["FMConstants"]
end
subgraph sg_Send["发送接口层 (libs/Send Interface)"]
BleFmManager["BleFmManager"]
BleBaseManager["BleBaseManager.invokeMethod"]
end
subgraph sg_Receive["接收接口层 (libs/Receive Interface)"]
BleEventStream["BleEventStream.fmInfoStream"]
BleAudioProcessor["BleAudioProcessor.fmInfoStream"]
BleBaseEventProcessor["BleBaseEventProcessor.filterByType"]
end
subgraph sg_Const["常量与模型"]
BleMethodConstants["BleMethodConstants"]
BleEventConstants["BleEventConstants (typeFmInfo='fm_info')"]
end
subgraph sg_Native["原生桥接层 (Android)"]
FMManager["FMManager.kt"]
FMProcessor["FMProcessor.kt"]
FmStatusInfo["FmStatusInfo.java"]
end
FmPage -->|"调用"| BleFmManager
FmPage -->|"订阅"| BleEventStream
BleFmManager --> BleBaseManager
BleFmManager --> BleMethodConstants
BleBaseManager -->|"MethodChannel"| FMManager
FMManager -->|"BLE 协议"| FMProcessor
FMProcessor --> FmStatusInfo
FMManager -->|"事件回调"| BleBaseEventProcessor
BleAudioProcessor --> BleBaseEventProcessor
BleAudioProcessor --> BleEventConstants
BleEventStream --> BleAudioProcessor
架构分层说明:
- UI 层:
FmPage是示例工程中的 FM 收音机页面(fm_page.dart),负责展示频率、播放状态,并通过滑块与按钮触发指令; - 发送接口层:
BleFmManager是 FM 指令的唯一入口(ble_fm_manager.dart),所有方法都是静态方法,内部统一委托给BleBaseManager.invokeMethod; - 接收接口层:
BleEventStream.fmInfoStream是公开的事件流入口(ble_event_stream.dart),它转发BleAudioProcessor.fmInfoStream;后者负责按typeFmInfo过滤并映射出Map<String, dynamic>; - 常量层:方法名集中在
BleMethodConstants,事件类型与数据键集中在BleEventConstants,避免魔法字符串散落在业务代码中; - 原生桥接层:Android 端的
FMManager.kt、FMProcessor.kt、FmStatusInfo.java负责 MethodChannel 到 BLE 协议之间的转换,属于本页的跨端边界,其内部细节请查阅 Android 原生文档。
发送接口层:BleFmManager
BleFmManager 是 FM 控制的命令门面(Facade),位于 libs/Send Interface/ble_fm_manager.dart。全部 9 个方法均为 static,无需实例化即可调用。每个方法只做一件事:把语义化方法名翻译成 BleMethodConstants 中的平台方法名,并转发给 BleBaseManager.invokeMethod。
/// FM Manager
class BleFmManager {
/// Switch to FM mode
static Future<void> changeFMMode() async {
await BleBaseManager.invokeMethod(
BleMethodConstants.methodChangeFmMode,
);
}
/// FM global search
static Future<void> fmSearchAll() async {
await BleBaseManager.invokeMethod(
BleMethodConstants.methodFmSearchAll,
);
}
/// FM select frequency
static Future<void> fmSelectFrequency(double frequency) async {
await BleBaseManager.invokeMethod(
BleMethodConstants.methodFmSelectFrequency,
arguments: {BleMethodConstants.argFrequency: frequency},
);
}
}
Source: ble_fm_manager.dart
设计意图分析:
- 静态门面:FM 指令无状态(设备端持有调谐器状态),因此使用静态方法而非实例方法,避免调用方管理对象生命周期,也方便在任何上下文(页面、服务、回调)中直接调用;
- 参数传递:仅
fmSelectFrequency(double frequency)携带参数,通过arguments字典以BleMethodConstants.argFrequency为键传递,其余指令均为无参调用——这反映了设备端 FM 调谐器的命令设计:频率选择需要显式传值,而搜索/切台/播放等动作由设备端自己维护内部状态; - "发后即忘"语义:方法返回
Future<void>且不解析设备响应,意味着 UI 层必须依赖事件流(而非指令返回值)来感知操作结果。
完整指令清单
| 语义方法 | 平台方法常量 | 参数 | 说明 |
|---|---|---|---|
changeFMMode() | methodChangeFmMode | 无 | 切换设备到 FM 模式(从音乐等模式进入) |
fmSearchAll() | methodFmSearchAll | 无 | 全局搜台(全频段扫描) |
fmStopSearch() | methodStopSearch | 无 | 停止搜索(注意常量名为通用 methodStopSearch,非 FM 专属) |
fmChannelBefore() | methodFmChannelBefore | 无 | 上一个已存台 |
fmChannelNext() | methodFmChannelNext | 无 | 下一个已存台 |
fmSearchForward() | methodFmSearchForward | 无 | 向高频方向搜索 |
fmSearchNext() | methodFmSearchNext | 无 | 向低频方向搜索(命名与"下一台"语义相反,需注意) |
fmPlayPause() | methodFmPlayPause | 无 | 播放/暂停切换 |
fmSelectFrequency(double) | methodFmSelectFrequency | argFrequency | 直接指定频率(MHz) |
注意 fmSearchForward 与 fmSearchNext 的命名易混淆:Forward 指向前(高频)搜索,Next 在代码注释中标注为 "FM backward search"(向后/低频搜索),实现时应以实际语义为准。
事件接收层:fmInfoStream
设备状态通过事件流回传。公开入口是 BleEventStream.fmInfoStream:
static Stream<Map<String, dynamic>> get fmInfoStream =>
BleAudioProcessor.fmInfoStream;
Source: ble_event_stream.dart
真正完成过滤与映射的是 BleAudioProcessor.fmInfoStream:
/// FM info stream
static Stream<Map<String, dynamic>> get fmInfoStream {
return BleBaseEventProcessor.filterByType(BleEventConstants.typeFmInfo)
.map((event) {
final data = BleBaseEventProcessor.getValueFromEvent(event);
return {
BleEventConstants.keyFmStatus: data[BleEventConstants.keyFmStatus] as int? ?? 0,
BleEventConstants.keyFmFrequency: data[BleEventConstants.keyFmFrequency] as double? ?? 0.0,
};
});
}
Source: ble_audio_processor.dart
处理管线分两步:
- 过滤:
filterByType(BleEventConstants.typeFmInfo)从统一的底层事件总线中只挑出type = 'fm_info'的事件(该类型常量定义见 ble_event_constants.dart)。这种"单一总线 + 类型分发"的模式与音频(typeId3MusicStatus)、设备状态(typeDeviceStatus)等事件共用,避免为每种事件创建独立通道; - 映射:把原始事件解包(
getValueFromEvent)后提取两个字段,并为缺失字段提供安全默认值——keyFmStatus缺省为0(暂停),keyFmFrequency缺省为0.0。默认值策略保证了 UI 端即使收到不完整数据也不会崩溃。
事件流对外暴露为 Map<String, dynamic>,键为 BleEventConstants.keyFmStatus(int,0=暂停/1=播放)与 BleEventConstants.keyFmFrequency(double,单位 MHz)。
Core Flow:端到端控制流程
sequenceDiagram
participant UI as FmPage
participant FM as BleFmManager
participant CH as BleBaseManager (MethodChannel)
participant NAT as 原生层 FMManager
participant DEV as 设备 FM 调谐器
participant BP as BleBaseEventProcessor
participant AP as BleAudioProcessor
participant UI2 as FmPage 订阅者
UI->>FM: changeFMMode()
FM->>CH: invokeMethod(methodChangeFmMode)
CH->>NAT: MethodChannel 调用
NAT->>DEV: BLE 协议下发 FM 模式切换
DEV-->>NAT: FM 状态通知 (fm_info)
NAT-->>BP: 事件回调入总线
BP-->>AP: filterByType(typeFmInfo) 过滤
AP-->>UI2: fmInfoStream 映射为 {keyFmStatus, keyFmFrequency}
UI2->>UI2: setState 刷新频率与播放状态
UI->>FM: fmSelectFrequency(97.5)
FM->>CH: invokeMethod(methodFmSelectFrequency, {argFrequency: 97.5})
CH->>NAT: MethodChannel 调用
NAT->>DEV: BLE 协议设置频率
DEV-->>NAT: 新频率状态通知
NAT-->>BP: 事件回调
BP-->>AP: 过滤 fm_info
AP-->>UI2: 新频率事件
UI2->>UI2: 更新滑块与显示
流程要点:
- 进入 FM 模式:UI 初始化时先调用
changeFMMode(),确保设备处于 FM 模式(设备若在音乐/LineIn 模式,FM 指令可能无效); - 订阅事件:
changeFMMode()之后立即订阅fmInfoStream,避免模式切换瞬间的状态丢失——这是示例页_initializeFm()中两个操作的固定顺序(先命令后订阅,见下文代码); - 指令下发:所有指令经
BleBaseManager.invokeMethod走 MethodChannel 到 Android 原生层,原生FMManager转 BLE 协议发送; - 状态回传:设备主动上报
fm_info事件(非指令应答),经事件总线 → 类型过滤 → 字段映射后推送给所有订阅者; - UI 刷新:订阅回调中通过
setState更新_currentFrequency与_currentPlayStatus。
示例页面实现:FmPage
FmPage 展示了完整的 FM 控制最佳实践(初始化、订阅、断连处理、释放):
/// Initializes FM mode and starts listening to events
void _initializeFm() async {
await BleFmManager.changeFMMode();
_fmSubscription = BleEventStream.fmInfoStream.listen(_handleFmInfoUpdate);
}
/// Handles FM information updates from the stream
void _handleFmInfoUpdate(Map<String, dynamic> fmInfo) {
if (!mounted) return;
setState(() {
_currentPlayStatus = _convertStatusToFmStatus(fmInfo[BleEventConstants.keyFmStatus]);
_currentFrequency = fmInfo[BleEventConstants.keyFmFrequency];
_displayFrequency = _currentFrequency;
});
}
Source: fm_page.dart
页面同时定义了频率边界与状态码常量,这些数值构成了 FM 功能的隐式契约:
class FMConstants {
// FM frequency constants
static const double fmMinFrequency = 87.5;
static const double fmMaxFrequency = 108.0;
static const int sliderDivisions = 205;
// FM status codes
static const int fmStatusPause = 0;
static const int fmStatusPlay = 1;
...
}
/// FM player status enumeration
enum JLFMStatus {
pause, // Paused state
play, // Playing state
unknown, // Unknown status
}
Source: fm_page.dart
关键设计点:
- 频率边界:87.5 ~ 108.0 MHz 是 FM 广播标准频段(中国/欧洲制式),滑块划分数 205 对应 87.5 到 108.0 之间每 0.1 MHz 一步((108.0−87.5)×10 = 205),确保滑块只能选择合法频率;
- 状态映射:
keyFmStatus的 int 值(0/1)被映射为JLFMStatus枚举(pause/play),unknown用于设备未就绪或字段缺失的场景,体现了"显式表达未知状态"而不是用魔法数字; - 生命周期管理:
_fmSubscription在dispose()中调用cancel(),防止页面销毁后 Stream 泄漏导致的内存问题与回调触发; - 断连处理:页面通过
context.watch<ConnectionStateManager>()监听连接状态(fm_page.dart),当connectState == connectionDisconnected(0)时自动Navigator.maybePop返回上一页——这是设备功能页的通用行为模式:蓝牙断开时设备功能全部失效,留在页面只会让用户面对一个无响应的 UI; - 搜索动画:搜索状态(
_isFmSearching)配合searchFmGif资源展示动态搜索效果,按钮圆角在搜索中/正常状态间切换(buttonBorderRadiusSearching/buttonBorderRadiusNormal),给用户即时的操作反馈。
API Reference
BleFmManager(发送指令)
所有方法均为 static Future<void>,通过 BleBaseManager.invokeMethod 走 MethodChannel。
changeFMMode()
- 作用:将设备切换到 FM 模式。进入 FM 页面/功能前的必要前置步骤。
- 参数:无。返回:
Future<void>。抛出:MethodChannel 调用失败时由invokeMethod传播异常。
fmSearchAll()
- 作用:触发设备全频段全局搜台(自动扫描并保存电台)。
- 参数:无。返回:
Future<void>。
fmStopSearch()
- 作用:停止当前搜索。注意底层方法常量为通用的
methodStopSearch。 - 参数:无。返回:
Future<void>。
fmChannelBefore() / fmChannelNext()
- 作用:切换到上一个/下一个已保存的电台(频道步进,非频率扫描)。
- 参数:无。返回:
Future<void>。
fmSearchForward() / fmSearchNext()
- 作用:向高频方向(Forward)/低频方向(Next,注释为 "FM backward search")手动搜索下一个信号。
- 参数:无。返回:
Future<void>。
fmPlayPause()
- 作用:切换 FM 播放/暂停状态。
- 参数:无。返回:
Future<void>。
fmSelectFrequency(double frequency)
- 作用:直接指定频率调台。
- 参数:
frequency(double)— 目标频率,单位 MHz;应位于 87.5 ~ 108.0 范围内(与FMConstants.fmMinFrequency/fmMaxFrequency对应),通过BleMethodConstants.argFrequency键传递。 - 返回:
Future<void>。
BleEventStream / BleAudioProcessor(接收事件)
BleEventStream.fmInfoStream → Stream<Map<String, dynamic>>
- 公开的 FM 状态事件流,转发自
BleAudioProcessor.fmInfoStream。 - 事件载荷键:
BleEventConstants.keyFmStatus(int,0=暂停,1=播放)、BleEventConstants.keyFmFrequency(double,MHz)。 - 缺省值:status 缺省
0,frequency 缺省0.0(见 ble_audio_processor.dart)。
事件常量(BleEventConstants)
| 常量 | 值 | 用途 |
|---|---|---|
typeFmInfo | 'fm_info' | FM 状态事件类型标识(ble_event_constants.dart) |
keyFmStatus | — | 事件载荷中的播放状态键 |
keyFmFrequency | — | 事件载荷中的频率键(double,MHz) |
失败模式、边界情况与并发
设备未处于 FM 模式
在音乐/LineIn 模式下直接调用 FM 搜索或频率选择指令可能被设备忽略。正确做法是先 changeFMMode() 并等待 fm_info 事件确认模式生效,再执行后续操作(示例页 _initializeFm() 即采用此顺序)。
状态字段缺失或类型不符
事件映射层为 keyFmStatus/keyFmFrequency 提供了 ?? 0 / ?? 0.0 兜底,且 as int? / as double? 可空强转——但若设备上报的字段类型与预期不符(例如频率以字符串返回),强转会抛 TypeError。当前实现假定原生端始终按约定类型上报。
蓝牙断连
设备断开后事件流不再产生数据,但 UI 不会自动感知。示例页通过 ConnectionStateManager 监听连接状态并在断开时自动退出页面(fm_page.dart)。自定义实现应同样监听连接事件并清理 FM 状态。
指令与事件之间的竞态
fmSelectFrequency 是异步下发的,事件回传需要若干 BLE 周期。若用户在滑块上连续快速拖动,会累积多条未确认指令,设备端按到达顺序执行,最终事件频率可能与最后一次滑块位置一致,也可能存在中间帧。UI 应以最终事件为准,而不是以本地期望值直接覆盖显示。
搜索期间的状态
搜索过程中设备可能不响应频道切换指令,或上报中间频率。页面用 _isFmSearching 标志切换按钮样式(圆角 14.0 vs 9.0),提示用户当前处于搜索态。业务实现应在搜索期间禁用或降级相关操作,等待 fmStopSearch() 或搜索完成事件。
性能与运维注意点
- Stream 订阅必须取消:
FmPage.dispose()中调用_fmSubscription?.cancel(),否则页面退出后回调仍会触发setState(尽管有mounted保护)并造成订阅泄漏; - 指令频率控制:BLE 通道带宽有限,应避免高频连续调用
fmSelectFrequency;UI 滑块场景建议在onChangeEnd时才真正下发指令(或做节流),而非每次onChanged都发送; - 事件流是广播式的:
filterByType从统一事件总线分发,多个页面同时订阅会各自收到完整事件;每个订阅者各自维护状态,互不影响,但应避免重复监听造成多余开销。
扩展点
- 新增 FM 指令:在
BleFmManager增加静态方法并对应扩展BleMethodConstants的平台方法名;无需改动事件层。原生端需同步实现新方法(FMManager.kt); - 扩展状态字段:如需上报信号强度、电台名称等更多信息,需同时修改原生端
FmStatusInfo模型、BleEventConstants新键、BleAudioProcessor.fmInfoStream的映射逻辑三处,并保持缺省值策略一致; - 自定义 UI:
fmInfoStream是公开且无 UI 绑定的纯数据流,任何页面/组件都可以独立订阅实现自定义收音机界面,无需复用FmPage。
Related Links
- 蓝牙事件流 BleEventStream — FM 事件流入口
- BleFmManager 源码 — FM 指令门面
- BleAudioProcessor 源码 — FM 事件过滤与映射
- FmPage 示例页面 — FM 收音机 UI 完整实现
- Android 原生 FMManager — 原生桥接层(跨端边界)
- 音频播放与音乐状态(ID3)属于设备媒体能力的另一部分,见对应页面