音乐传输与播放控制
本文档深入解析 HealthAide 应用中"音乐传输与播放控制"能力的完整实现:从 Android 本地媒体库(MediaStore)扫描音乐、构建 Music 数据模型,到通过蓝牙 RCSP 协议将单曲/多曲批量传输到手表设备,以及传输过程中的任务链调度、状态管理与播放控制数据模型。
Purpose and Scope
本页面覆盖以下内容:
- 本地音乐扫描与过滤逻辑(
JL_LocalMusicLoader) - 音乐数据模型(
Music、MusicDownloadEvent) - 音乐管理界面三态模式(列表 / 选择 / 下载)的状态机
- 单曲与多曲批量传输的实现(
TransferTask/UriTransferTask+BatchCmd批量命令) - 传输任务链(
AutoLastListener)与进度事件上报 - 播放控制相关的数据模型(
MusicPlayInfo)及其与 SDK 的衔接方式
以下相关主题属于其他页面,不在本文展开:文件浏览与设备选卡(FileBrowseManager、SDCardBean)、RCSP 协议底层指令封装(jl_rcsp SDK)、UI 布局细节(fragment_music_manager.xml、item_music.xml)。如需了解设备文件管理与传输的整体框架,请参见《文件浏览与传输》相关页面。
Overview
在 HealthAide 应用中,用户可以把手机本地音乐库中的歌曲传输到已连接的蓝牙手表设备上播放。该能力由 ui/device/music 包下的几个核心类协同完成:
MusicManagerViewModel:继承自WatchViewModel,是音乐管理页面的核心控制器,负责加载音乐列表、管理三种界面模式、调度传输任务、发送批量命令并发布下载事件。JL_LocalMusicLoader:通过ContentResolver查询MediaStore.Audio.Media,把系统媒体库中的音频文件转换为Music对象,并注册ContentObserver监听媒体库变化。Music:轻量 POJO,携带 id、标题、专辑、时长、大小、歌手、文件路径、本地/网络类型、下载状态等字段,同时标记是否被用户勾选。MusicDownloadEvent:在 ViewModel 与 UI(MusicManagerFragment、MusicDownloadDialog)之间传递下载进度/错误/取消/完成事件的载体。
传输层的设计意图在于:把"多曲连续传输"建模为一条 反向链表式调用链 —— 每个 TransferTask 的 AutoLastListener 保存上一个任务,当前任务完成后自动启动下一个,最后一个任务完成后发送 BatchCmd.OP_STOP 通知设备结束批量流程。同时,在 Android 10(API 29)以上使用 UriTransferTask(基于 content URI),在更早版本使用 TransferTask(基于文件路径),以适配不同版本对存储访问权限的要求。
Architecture
下图展示了音乐传输与播放控制能力的整体架构与依赖关系:
flowchart TD
subgraph sg_UI["UI 层 (ui/device/music)"]
Fragment["MusicManagerFragment"]
Dialog["MusicDownloadDialog"]
end
subgraph sg_ViewModel["ViewModel 层"]
VM["MusicManagerViewModel<br/>(extends WatchViewModel)"]
Event["MusicDownloadEvent"]
end
subgraph sg_Model["模型层"]
Music["Music"]
PlayInfo["MusicPlayInfo<br/>(ui/device/file/model)"]
end
subgraph sg_Loader["媒体库加载"]
Loader["JL_LocalMusicLoader"]
MediaStore[("MediaStore.Audio.Media<br/>ContentResolver")]
end
subgraph sg_SDK["SDK / 传输层 (jl_rcsp / jl_filebrowse)"]
FileBrowse["FileBrowseManager"]
SDCard["SDCardBean<br/>(目标设备/存储卡)"]
Task1["TransferTask<br/>(API < 29, 文件路径)"]
Task2["UriTransferTask<br/>(API >= 29, content URI)"]
Batch["BatchCmd<br/>(OP_START / OP_STOP)"]
WatchMgr["mWatchManager<br/>(RCSP 命令通道)"]
end
Fragment -->|"observe LiveData"| VM
Dialog -->|"observe 下载事件"| Event
VM -->|"postValue 进度"| Event
VM -->|"getMusicList()"| Loader
Loader -->|"query / 注册 ContentObserver"| MediaStore
Loader -->|"生成"| Music
VM -->|"读取勾选状态"| Music
VM -->|"getOnlineDev() 校验"| FileBrowse
FileBrowse --> SDCard
VM -->|"创建任务并 start()"| Task1
VM -->|"创建任务并 start()"| Task2
Task1 -->|"文件路径传输"| SDCard
Task2 -->|"content URI 传输"| SDCard
VM -->|"sendRcspCommand"| Batch
Batch --> WatchMgr
VM -->|"播放控制数据"| PlayInfo
架构解读:
- UI 层与 ViewModel 之间通过 LiveData 解耦:
MusicManagerViewModel暴露三个MutableLiveData——musicsMutableLiveData(歌曲列表)、modeLiveData(界面模式)、downloadEventMutableLiveData(下载事件)。MusicManagerFragment只订阅这些数据并渲染,不直接接触传输逻辑。 - 媒体库加载独立成类:
JL_LocalMusicLoader把 MediaStore 查询细节封装起来,getMusicList()在后台线程(ThreadManager)调用它,避免在主线程执行 I/O。 - 传输任务区分 Android 版本:
addToDownloadList()根据Build.VERSION.SDK_INT选择UriTransferTask(API 29+,content URI 授权模型)或TransferTask(API 28-,直接文件路径)。 - 批量流程由 BatchCmd 驱动:多曲传输前发送
BatchCmd.OP_START,全部结束后发送BatchCmd.OP_STOP,由mWatchManager.sendRcspCommand()走 RCSP 命令通道下发。
本地音乐加载:JL_LocalMusicLoader
JL_LocalMusicLoader 负责把 Android 系统媒体库中的音频转换为应用可用的 Music 对象。它的设计要点是:只在第一次调用时查询一次并缓存(loadAll() 判空缓存),通过构造时注册的 ContentObserver 感知媒体库变化。
MediaStore 查询与投影列
查询使用固定的投影列集合,覆盖 Music 模型需要的全部字段:
private static String[] projection = new String[]
{
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.TITLE,
MediaStore.Audio.Media.ALBUM,
MediaStore.Audio.Media.DURATION,
MediaStore.Audio.Media.SIZE,
MediaStore.Audio.Media.ARTIST,
MediaStore.Audio.Media.DATA,
MediaStore.Audio.Media.IS_MUSIC,
MediaStore.Audio.Media.DISPLAY_NAME,
};
Source: JL_LocalMusicLoader.java
查询语句支持按歌名条件过滤(TITLE LIKE '%condition%'),并按 _id 排序:
public List<Music> load(String condition) {
if (condition == null) {
condition = "";
}
List<Music> mAllMusics = new ArrayList<>();
Cursor cursor = mContentResolver.query(contentUri, projection, MediaStore.Audio.Media.TITLE + " LIKE " + "'%" + condition + "%'", null, "_id");
...
Source: JL_LocalMusicLoader.java
过滤规则与 Android 版本适配
逐行遍历 Cursor 时有两个关键过滤/适配逻辑:
- 过滤条件:
IS_MUSIC != 0且duration >= 10000(10 秒以上),用于排除铃声、录音、通知音等短音频片段,只保留真正的音乐文件。 - Android 10+ content URI:API 29 及以上,为每首歌额外生成
content://media/external/audio/media/{id}形式的 URI 并写入Music.uri。这是后续UriTransferTask传输所依赖的授权访问路径;API 28 及以下则保留DATA列的文件路径url,供TransferTask使用。
if (cursor.getInt(cursor.getColumnIndex(MediaStore.Audio.Media.IS_MUSIC)) != 0 && music.getDuration() >= 10000) {
mAllMusics.add(music);
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
String uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.buildUpon().appendPath(String.valueOf(id)).build().toString();
music.setUri(uri);
}
}
Source: JL_LocalMusicLoader.java
ContentObserver 监听媒体库变化
构造时向 ContentResolver 注册 MyContentObserver(基于主线程 Handler),一旦媒体库内容变化(例如用户新增/删除歌曲),观察者收到回调后可触发重新加载,保证列表与系统媒体库一致。
Music 数据模型
Music 是一个 @Keep 注解的 POJO,字段覆盖了媒体元数据、传输状态与 UI 勾选状态:
| 字段 | 类型 | 说明 |
|---|---|---|
id | long | MediaStore 中的音频 ID |
title / album / artist | String | 歌名 / 专辑 / 歌手 |
duration | int | 时长(毫秒),>= 10000 才被视为有效音乐 |
size | long | 文件大小 |
url | String | 文件路径(API 28 及以下传输用) |
coverUrl | String | 封面地址 |
uri | String | content URI(API 29+ 传输用) |
local | int | 0:本地,1:网络,5:图灵H5,2:短音频,3:m3u8,4:直播 m3u8 |
download | int | 1:未下载,2:已下载,3:正在下载 |
selected | boolean | 是否被用户勾选(决定是否进入传输列表) |
collect | boolean | 是否收藏 |
auth | String | 鉴权信息(网络音乐) |
position | int | 播放位置 |
isHistory / isM3u8 | boolean | SDK 内部使用标记 |
@Keep
public class Music {
private long id;
private String title;
private String album;
private int duration;
private long size;
private String artist;
private String url;
private String coverUrl;
private boolean selected;
private boolean collect;
private int local; //0:本地 ,1:网络 ,5:图灵H5类型,2.短音频 3:m3u8 4:直播的m3u8
private int download; // 1:未下载,2:已下载,3:正在下载
...
Source: Music.java
设计意图:local 字段把本地文件与网络资源统一到一个模型中,使同一个列表 UI 既能展示本地歌曲,也能为未来接入在线音乐/短音频预留扩展空间;selected 与 download 则分别服务于"选择传输"与"下载进度展示"两个交互场景。
三态界面模式(MODE 状态机)
MusicManagerViewModel 用 modeLiveData 驱动音乐管理页面的三种交互模式:
static final int MODE_LIST = 0;
static final int MODE_SELECT = 1;
static final int MODE_DOWNLOAD = 2;
Source: MusicManagerViewModel.java
模式流转由 toNextMode() 驱动:用户每次点击"下一步",模式加一并循环(超过 MODE_DOWNLOAD 回到 MODE_SELECT)。进入 MODE_DOWNLOAD 时自动调用 addToDownloadList() 开始传输;若未选择任何歌曲则回退到 MODE_SELECT:
public void toNextMode() {
if (FileBrowseManager.getInstance().getOnlineDev() == null || FileBrowseManager.getInstance().getOnlineDev().size() < 1) {
ToastUtil.showToastShort(R.string.no_sdcard_device);
return;
}
int mode = Objects.requireNonNull(modeLiveData.getValue()) + 1;
if (mode > MODE_DOWNLOAD) {
mode = 1;
}
modeLiveData.postValue(mode);
if (mode == MODE_DOWNLOAD) {
addToDownloadList();
}
}
Source: MusicManagerViewModel.java
三个模式的状态语义:
| 模式 | 值 | 语义 |
|---|---|---|
MODE_LIST | 0 | 纯列表浏览 |
MODE_SELECT | 1 | 用户勾选要传输的歌曲 |
MODE_DOWNLOAD | 2 | 正在批量传输,展示进度 |
cancelSelect() 用于取消选择回到 MODE_SELECT;传输中途出错/取消时 dealWithFailedEvent() 也会把模式复位为 MODE_SELECT,保证用户总是可以重新选择。
传输实现:TransferTask / UriTransferTask 与批量命令
传输前的守卫条件
addToDownloadList() 先做两个前置校验:
- 通话中禁止传输:通过
getDeviceInfo(getConnectedDevice())检查设备phoneStatus是否为WatchConstant.DEVICE_PHONE_STATUS_CALLING,若是则直接发布TYPE_ERROR事件(文案为call_phone_error_tips),避免传输干扰通话。 - 必须有选中歌曲:遍历
musicsMutableLiveData收集isSelected() == true的歌曲,若为空则回退MODE_SELECT。
private boolean isCallWorking() {
DeviceInfo deviceInfo = getDeviceInfo(getConnectedDevice());
return deviceInfo != null && deviceInfo.getPhoneStatus() == WatchConstant.DEVICE_PHONE_STATUS_CALLING;
}
private void addToDownloadList() {
if (musicsMutableLiveData == null) {
return;
}
if (isCallWorking()) {
downloadEventMutableLiveData.postValue(new MusicDownloadEvent(MusicDownloadEvent.TYPE_ERROR, 0, 0, 0,
HealthApplication.getAppViewModel().getApplication().getString(R.string.call_phone_error_tips)));
return;
}
List<Music> tmp = new ArrayList<>();
for (Music music : Objects.requireNonNull(musicsMutableLiveData.getValue())) {
if (music.isSelected()) {
tmp.add(music);
}
}
if (tmp.size() < 1) {
modeLiveData.postValue(MODE_SELECT);
return;
}
...
Source: MusicManagerViewModel.java
反向构建任务调用链
这是整个传输机制的核心算法。代码逆序遍历选中歌曲(i = size-1 递减到 0),为每首歌创建传输任务,并用 AutoLastListener 记录"上一个"任务:当前任务结束后启动它保存的上一个任务,从而形成一条 task[size-1] → task[size-2] → ... → task[0] 的链。变量 lastTask 最后指向链的头部 task[0](最先执行的任务),保存在成员变量 task 中供 cancelTransfer() 使用。
ITask lastTask = null;
int size = tmp.size();
SDCardBean sdCardBean = DeviceChoseUtil.getTargetDev();
for (int i = size - 1; i >= 0; i--) {
Music music = tmp.get(i);
TransferTask.Param p = new TransferTask.Param();
if (sdCardBean == null) {
downloadEventMutableLiveData.postValue(new MusicDownloadEvent(MusicDownloadEvent.TYPE_ERROR, i + 1, 0, 0, music.getTitle()));
return;
}
p.devHandler = sdCardBean.getDevHandler();
ITask task;
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
task = new UriTransferTask(HealthApplication.getAppViewModel().getApplication(), mWatchManager, music.getUri(), music.getTitle(), p);
} else {
task = new TransferTask(mWatchManager, music.getUrl(), p);
}
//通过listener形成一个调用链
task.setListener(new AutoLastListener(lastTask, i + 1, size, music.getTitle()));
lastTask = task;
}
task = lastTask;
if (size > 1) { //通知设备开始多文件传输流程
final String musicName = tmp.get(0).getTitle();
sendBatchCmd(BatchCmd.OP_START, new OnOperationCallback<Boolean>() {
@Override
public void onSuccess(Boolean result) {
task.start();
}
@Override
public void onFailed(BaseError error) {
modeLiveData.postValue(MODE_SELECT);
downloadEventMutableLiveData.postValue(new MusicDownloadEvent(MusicDownloadEvent.TYPE_ERROR, 0, size, 0, musicName));
}
});
} else {
task.start();
}
Util.cleanDownloadDir(sdCardBean);
Source: MusicManagerViewModel.java
设计意图解析:
- 逆序 + 后插链表:不必等待所有任务创建完成,而是把"下一个启动谁"的职责委托给前一个任务的完成回调,天然保证串行、有序、不并发传输,避免多任务同时读写设备存储造成冲突。
- 多曲时先发
BatchCmd.OP_START:设备端需要知道"接下来是一批文件"以进入批量接收流程;只有onSuccess后才start()链头。单曲则直接start()。 Util.cleanDownloadDir(sdCardBean):在任务全部入链后清理设备上的临时下载目录,为本次传输准备干净空间。
AutoLastListener:任务链的推进器
AutoLastListener 实现 TaskListener 接口,把 SDK 的任务回调翻译成 UI 事件,并在 onFinish() 中推进任务链:
@Override
public void onFinish() {
task = last;
if (last != null) {
last.start();
} else {
sendBatchCmd(BatchCmd.OP_STOP, new OnOperationCallback<Boolean>() {
@Override
public void onSuccess(Boolean result) {
modeLiveData.postValue(MODE_SELECT);
MusicDownloadEvent musicDownloadEvent = downloadEventMutableLiveData.getValue();
Objects.requireNonNull(musicDownloadEvent).setType(MusicDownloadEvent.TYPE_FINISH);
downloadEventMutableLiveData.postValue(musicDownloadEvent);
}
@Override
public void onFailed(BaseError error) {
dealWithFailedEvent(MusicDownloadEvent.TYPE_ERROR, error.getSubCode(), false);
}
});
}
}
Source: MusicManagerViewModel.java
要点:
onBegin()发布TYPE_DOWNLOAD开始事件(携带index/total供 UI 显示"第几首/共几首")。onProgress(progress)持续发布下载进度(0-100)。onFinish():若last != null则启动上一首(注意:链是反向构建的,last指向"上一首");否则说明全部完成,发送BatchCmd.OP_STOP,成功后把当前事件类型改为TYPE_FINISH。onError/onCancel统一走dealWithFailedEvent():先(按需)发送OP_STOP终止设备端批量流程,再把模式复位为MODE_SELECT,最后发布错误/取消事件。
BatchCmd 封装
sendBatchCmd() 通过 mWatchManager.sendRcspCommand() 下发 BatchCmd,命令参数为 op(OP_START 或 OP_STOP)加设备相关字节 0x02,并用 CustomRcspActionCallback 解析响应:
private void sendBatchCmd(int op, OnOperationCallback<Boolean> callback) {
BatchCmd startBatchCmd = new BatchCmd(new BatchCmd.Param(CHexConver.intToByte(op), new byte[]{(byte) 0x02}));
mWatchManager.sendRcspCommand(mWatchManager.getConnectedDevice(), startBatchCmd, new CustomRcspActionCallback<>("FormatBatchCmd", callback,
new IHandleResult<Boolean, BatchCmd>() {
@Override
public int hasResult(BluetoothDevice device, BatchCmd cmd) {
if (null == cmd) return StateCode.STATUS_UNKNOWN;
BatchCmd.Response response = cmd.getResponse();
if (null == response) return StateCode.STATUS_UNKNOWN;
return CHexConver.byteToInt(response.getRet());
}
@Override
public Boolean handleResult(BluetoothDevice device, BatchCmd cmd) {
return true;
}
}));
}
Source: MusicManagerViewModel.java
hasResult() 根据响应中的 ret 字节判定命令是否成功,失败时 handleResult 返回 null,触发 onFailed 回调。
传输取消与资源释放
cancelTransfer() 调用链头任务的 cancel((byte) 0x00) 取消当前传输;release() 在 ViewModel 销毁时先取消传输再释放基类资源,避免页面退出后任务仍在后台运行:
@Override
public void release() {
super.release();
cancelTransfer();
}
public void cancelTransfer() {
if (task != null) {
task.cancel((byte) 0x00);
}
}
Source: MusicManagerViewModel.java
播放控制数据模型
播放控制侧的载体是 ui/device/file/model 包下的 MusicPlayInfo(与音乐传输同属"设备媒体"域)。MusicManagerViewModel 继承的 WatchViewModel 提供设备状态、RCSP 命令发送等基础能力,播放控制命令(播放/暂停/切歌等)可通过 mWatchManager.sendRcspCommand() 扩展接入。播放控制相关的具体命令封装位于 jl_rcsp SDK 层,属于 SDK 文档范畴;本页重点在于:传输完成(TYPE_FINISH)后,设备端即可通过播放控制指令播放刚下发的音乐,传输与播放共享同一 Music/MusicPlayInfo 元数据(标题、时长、大小等)。
核心流程:从选择歌曲到批量传输完成
以下时序图展示一次"多曲批量传输"的完整生命周期(以 Android 10+、两首选中歌曲为例):
sequenceDiagram
participant U as 用户
participant F as MusicManagerFragment
participant VM as MusicManagerViewModel
participant L as JL_LocalMusicLoader
participant MS as MediaStore
participant SDK as FileBrowseManager/DeviceChoseUtil
participant T1 as UriTransferTask(第1首)
participant T2 as UriTransferTask(第2首)
participant B as mWatchManager + BatchCmd
participant D as 手表设备
U->>F: 进入音乐管理页
F->>VM: getMusicList(context)
VM->>L: ThreadManager.postRunnable 后台加载
L->>MS: query(projection, IS_MUSIC=1, duration>=10s)
MS-->>L: Cursor 歌曲记录
L-->>VM: postValue(musicsMutableLiveData)
VM-->>F: 列表渲染(MODE_LIST/MODE_SELECT)
U->>F: 勾选 2 首歌曲
U->>F: 点击"下一步"
F->>VM: toNextMode() → MODE_DOWNLOAD
VM->>VM: isCallWorking() 校验(通话中则报错)
VM->>VM: 收集 selected 歌曲、取 SDCardBean
VM->>VM: 逆序构建任务链 T2→T1(AutoLastListener)
VM->>B: sendBatchCmd(OP_START)
B->>D: RCSP 批量开始命令
D-->>B: 响应 OK
B-->>VM: onSuccess
VM->>T1: task.start()(链头)
T1->>D: 传输第 1 首(content URI)
T1-->>VM: onBegin/onProgress → TYPE_DOWNLOAD 事件
VM-->>F: 进度 UI 更新
T1-->>VM: onFinish → last(T2).start()
T2->>D: 传输第 2 首
T2-->>VM: onBegin/onProgress → TYPE_DOWNLOAD 事件
T2-->>VM: onFinish(last == null)
VM->>B: sendBatchCmd(OP_STOP)
B->>D: RCSP 批量结束命令
D-->>B: 响应 OK
B-->>VM: onSuccess
VM-->>F: 事件置为 TYPE_FINISH,模式复位 MODE_SELECT
F-->>U: 下载完成提示(MusicDownloadDialog)
流程关键点:
- 加载阶段在后台线程:
getMusicList()通过ThreadManager.getInstance().postRunnable(...)执行 MediaStore 查询,避免主线程卡顿,结果通过postValue回到主线程。 - 模式切换即传输触发:
toNextMode()进入MODE_DOWNLOAD时同步触发addToDownloadList(),把"界面状态"与"业务动作"耦合在一次点击中,简化交互路径。 - 批量开始 → 链式传输 → 批量结束:
OP_START成功后启动链头;每首任务onFinish自动启动下一首;最后一首完成后发OP_STOP,设备端才知道整批接收完毕并提交文件。 - 事件驱动 UI:所有进度/错误/完成信息都通过
MusicDownloadEvent(LiveData)发布,MusicDownloadDialog订阅并展示。
使用示例
示例 1:异步加载本地音乐列表
MusicManagerViewModel.getMusicList() 展示了如何在后台线程加载媒体库并发布结果:
public void getMusicList(Context context) {
ThreadManager.getInstance().postRunnable(() -> {
JL_LocalMusicLoader loader = new JL_LocalMusicLoader(context.getContentResolver());
musicsMutableLiveData.postValue(loader.loadAll());
});
}
Source: MusicManagerViewModel.java
说明:loader.loadAll() 内部做了缓存(localMusic == null 才真正查询),多次调用不会重复查库;postValue 保证跨线程安全地更新 LiveData。
示例 2:按条件加载并构造 Music 对象
JL_LocalMusicLoader.load() 展示了 Cursor 到 Music 的映射,以及 Android 版本分支:
do {
String url = cursor.getString(cursor.getColumnIndex(MediaStore.Audio.Media.DATA));
long id = cursor.getLong(cursor.getColumnIndex(MediaStore.Audio.Media._ID));
Music music = new Music(
cursor.getLong(cursor.getColumnIndex(MediaStore.Audio.Media._ID)),
cursor.getString(cursor.getColumnIndex(MediaStore.Audio.Media.DISPLAY_NAME)),
cursor.getString(cursor.getColumnIndex(MediaStore.Audio.Media.ALBUM)),
cursor.getInt(cursor.getColumnIndex(MediaStore.Audio.Media.DURATION)),
cursor.getLong(cursor.getColumnIndex(MediaStore.Audio.Media.SIZE)),
cursor.getString(cursor.getColumnIndex(MediaStore.Audio.Media.ARTIST)),
url,
null,
0
);
if (cursor.getInt(cursor.getColumnIndex(MediaStore.Audio.Media.IS_MUSIC)) != 0 && music.getDuration() >= 10000) {
mAllMusics.add(music);
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
String uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.buildUpon().appendPath(String.valueOf(id)).build().toString();
music.setUri(uri);
}
}
} while (cursor.moveToNext());
Source: JL_LocalMusicLoader.java
说明:Music 构造函数签名 (id, title, album, duration, size, artist, url, coverUrl, local),local = 0 表示本地文件;API 29+ 额外设置 uri,为 UriTransferTask 准备授权路径。
示例 3:注册任务监听推进传输链
AutoLastListener 是任务链的核心驱动,展示 SDK TaskListener 的四个回调如何映射到业务事件:
@Override
public void onBegin() {
downloadEventMutableLiveData.postValue(new MusicDownloadEvent(MusicDownloadEvent.TYPE_DOWNLOAD, index, total, 0, name));
}
@Override
public void onProgress(int progress) {
downloadEventMutableLiveData.postValue(new MusicDownloadEvent(MusicDownloadEvent.TYPE_DOWNLOAD, index, total, progress, name));
}
@Override
public void onFinish() {
task = last;
if (last != null) {
last.start();
} else {
sendBatchCmd(BatchCmd.OP_STOP, ...);
}
}
Source: MusicManagerViewModel.java
说明:onProgress 携带 0-100 的进度值;onFinish 是链式推进点——last 非空则启动上一首,为空则收尾(OP_STOP + TYPE_FINISH)。
配置选项
音乐传输能力中可配置/可观察的状态常量如下(均为代码内常量,无外部配置文件):
| 常量/字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
MODE_LIST | int | 0 | 列表浏览模式 |
MODE_SELECT | int | 1 | 勾选模式 |
MODE_DOWNLOAD | int | 2 | 下载传输模式 |
Music.local = 0 | int | 本地 | 本地音乐类型(1=网络,5=图灵H5,2=短音频,3=m3u8,4=直播 m3u8) |
Music.download | int | — | 1=未下载,2=已下载,3=正在下载 |
| 音乐时长过滤阈值 | int | 10000 ms | duration >= 10000 才纳入列表 |
| Android 版本分支点 | int | API 29 (P) | > Build.VERSION_CODES.P 用 UriTransferTask,否则用 TransferTask |
BatchCmd 参数 | byte | 0x02 | 批量命令的设备相关字节 |
说明:Build.VERSION.SDK_INT > Build.VERSION_CODES.P 分支直接决定传输通道(content URI 授权 vs 文件路径),这是 Android 10 分区存储(Scoped Storage)政策下的必要适配;call_phone_error_tips 字符串资源定义了通话中禁止传输的用户提示文案。
API 参考
MusicManagerViewModel
继承 WatchViewModel,音乐管理页的控制器。对外暴露的 LiveData:
musicsMutableLiveData(MutableLiveData<List<Music>>):当前音乐列表。modeLiveData(MutableLiveData<Integer>):当前界面模式(MODE_LIST/MODE_SELECT/MODE_DOWNLOAD)。downloadEventMutableLiveData(MutableLiveData<MusicDownloadEvent>):下载进度/结果事件流。
| 方法签名 | 职责 | 备注 |
|---|---|---|
void getMusicList(Context context) | 后台线程加载本地音乐并发布列表 | 内部使用 JL_LocalMusicLoader.loadAll() |
void toNextMode() | 模式加一循环切换;进入 MODE_DOWNLOAD 时触发传输 | 无在线 SD 卡设备时 Toast 提示并返回 |
void cancelSelect() | 复位为 MODE_SELECT | 取消当前选择流程 |
void addToDownloadList() | 收集勾选歌曲、构建任务链、发起批量传输 | 私有;通话中/无选中/无 SDCard 时提前返回并发布错误事件 |
void cancelTransfer() | 取消当前传输任务链 | 调用链头 task.cancel((byte) 0x00) |
void release() | 释放资源并取消传输 | 生命周期方法 |
void sendBatchCmd(int op, OnOperationCallback<Boolean> callback) | 通过 RCSP 下发 BatchCmd(OP_START/OP_STOP) | 私有 |
关键返回/回调语义:
toNextMode():FileBrowseManager.getInstance().getOnlineDev()为空时返回,不切换模式。sendBatchCmd的onSuccess:设备端ret字节非零时触发onFailed(hasResult返回STATUS_UNKNOWN)。AutoLastListener.onError(int code, String msg):code 为 SDK 错误码;处理时先按需发OP_STOP,复位模式并发布TYPE_ERROR。
JL_LocalMusicLoader
| 方法签名 | 职责 | 备注 |
|---|---|---|
JL_LocalMusicLoader(ContentResolver mContentResolver) | 构造并注册 ContentObserver 监听媒体库 | 观察者在主线程 Handler 上回调 |
List<Music> loadAll() | 返回缓存的全部本地音乐 | 首次调用执行 load("") |
List<Music> load(String condition) | 按歌名 LIKE 条件查询媒体库并映射为 Music | 过滤 IS_MUSIC != 0 且 duration >= 10000 |
MusicDownloadEvent
事件类型常量(由 ViewModel 中引用推断):TYPE_DOWNLOAD(开始/进度)、TYPE_ERROR(失败)、TYPE_CANCEL(取消)、TYPE_FINISH(完成)。构造参数为 (type, index, total, progress, name),用于 UI 展示"第 index/total 首、进度 progress%、歌曲名 name"。
失败模式、边界情况与并发
失败模式
| 场景 | 处理方式 | 源码依据 |
|---|---|---|
| 无在线 SD 卡设备 | toNextMode() 直接 Toast no_sdcard_device 并返回,不进入下载模式 | MusicManagerViewModel.toNextMode() |
| 通话中发起传输 | 发布 TYPE_ERROR 事件(文案 call_phone_error_tips),不创建任何任务 | isCallWorking() 守卫 |
| 目标 SDCard 为 null | 发布 TYPE_ERROR(index, name) 并中断任务链构建 | addToDownloadList() |
OP_START 失败 | 模式复位 MODE_SELECT,发布 TYPE_ERROR(0, size, name) | sendBatchCmd.onFailed |
单个任务 onError | 按需发 OP_STOP 终止设备端批量流程,复位模式,发布 TYPE_ERROR | AutoLastListener.dealWithFailedEvent |
OP_STOP 失败 | 同样走 dealWithFailedEvent(TYPE_ERROR, subCode, false),不再重复发 STOP | AutoLastListener.onFinish |
边界情况
- 单曲 vs 多曲:
size > 1才发送OP_START,单曲直接task.start(),避免了不必要的批量流程开销。 - Android 版本差异:API 28 及以下用文件路径
url构造TransferTask;API 29+ 必须用 content URI 构造UriTransferTask,否则分区存储下无读取权限。 - 空列表:
musicsMutableLiveData.getValue()可能为 null,Objects.requireNonNull强制非空;tmp.size() < 1时回退MODE_SELECT。 onFinish时事件可能为空:Objects.requireNonNull(musicDownloadEvent)后再setType(TYPE_FINISH),防御 LiveData 值被清空的竞态。
并发与一致性
- 任务串行:任务链设计保证同一时刻只有一个
TransferTask在传输,天然规避多任务并发写设备存储的问题。链式结构是**串行化(serialization)**而非并行化的选择。 - 主线程安全:所有 LiveData 更新使用
postValue(后台线程触发)或postValue/setValue混合(主线程触发),避免setValue必须在主线程的限制。 - 取消竞态:
cancelTransfer()只取消链头任务;若取消发生在某任务onFinish即将启动下一首的间隙,SDK 层ITask.cancel语义保证onCancel回调会走dealWithFailedEvent复位模式,UI 不会卡在下载态。 task成员变量覆盖:onFinish中task = last更新成员引用,使cancelTransfer()始终指向"当前正在执行或即将执行"的任务;但多任务链中只有链头持有cancel能力,后续任务依赖onFinish链路推进,取消中间任务需要依赖 SDK 的批量 STOP 命令兜底。
性能与运维注意事项
- 媒体库查询在后台线程:
getMusicList()使用ThreadManager.postRunnable,MediaStore 查询 + Cursor 遍历不阻塞主线程;loadAll()的缓存避免重复全量查询。 - 进度事件频率:
onProgress每帧都postValue,若设备传输进度回调频繁,UI 层应做节流/合并展示,避免过度刷新。 - 传输占用蓝牙通道:音乐文件通常较大,批量传输期间蓝牙带宽被占用,与通话(
isCallWorking守卫)冲突是设计上显式规避的。 - 释放时机:
release()必须被调用(ViewModelonCleared链路),否则任务链可能残留并继续回调已销毁的 LiveData。 - 存储空间:
Util.cleanDownloadDir(sdCardBean)在传输前清理设备临时目录,运维上应关注设备剩余空间;单次批量歌曲数量不宜过大。
扩展点
- 传输实现替换:
ITask抽象使传输实现可替换。当前按系统版本选择TransferTask/UriTransferTask,未来可扩展新的传输通道(如 WLAN 传输)而无需改动任务链逻辑。 - 任务回调扩展:实现
TaskListener可扩展每个任务的开始/进度/完成/错误/取消行为;AutoLastListener是链式推进的参考实现。 - 批量命令扩展:
sendBatchCmd的op参数目前只使用OP_START/OP_STOP,BatchCmd.Param支持更多操作码,可用于批量删除、批量校验等设备端批量操作。 - 音乐来源扩展:
Music.local字段已预留网络(1)、短音频(2)、m3u8(3)、直播(4)、图灵 H5(5)等类型,接入在线音乐源时只需在加载层产出相应Music对象。 - 播放控制接入:
WatchViewModel/mWatchManager.sendRcspCommand()是向设备发送播放/暂停/切歌等 RCSP 命令的统一入口,MusicPlayInfo提供播放所需的元数据,可在此基础上扩展完整的播放控制面板。
相关链接
- MusicManagerViewModel.java(核心控制器)
- JL_LocalMusicLoader.java(媒体库加载)
- Music.java(数据模型)
- MusicDownloadEvent.java(下载事件)
- MusicManagerFragment.java(音乐管理页面)
- MusicDownloadDialog.java(下载进度弹窗)
- MusicPlayInfo.java(播放信息模型)
- WatchViewModel.java(设备能力基类)
相关主题:设备文件浏览与传输的整体框架、RCSP 协议与命令封装(jl_rcsp SDK)、设备管理页面(WatchViewModel)。