杰理 SDK 文档中心
首页
首页
  • 项目概述

    • 项目简介与核心能力
    • 运行环境与SDK版本
  • 快速开始

    • 工程导入与依赖配置
    • 权限配置与示例运行
  • 平台架构

    • SDK分层架构与RCSP协议
    • 蓝牙连接库
    • 健康SDK核心库 JL_Watch
    • 健康服务器与云端服务
  • 健康与运动数据

    • 健康数据同步
    • 运动数据同步
    • 本地数据持久化
  • 设备管理功能

    • 表盘管理
    • 闹钟与健康提醒
    • 消息与联系人同步
    • 天气同步
    • 设备查找
    • 支付宝集成
  • 传输与媒体处理

    • 文件传输与文件管理
    • 音乐传输与播放控制
    • 图像转换库
    • 音频编解码与解密
  • OTA 升级

    • 固件空中升级流程
    • 4G模块与差分升级
  • AI 能力

    • AI表盘与云服务
    • AI语音助手
  • 示例应用

    • HealthAide 健康助手应用
    • WatchTestTool 测试工具
  • 开发者指南

    • 自定义命令扩展
    • 调试技巧与问题排查
    • 版本历史与兼容性

音乐传输与播放控制

本文档深入解析 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 时有两个关键过滤/适配逻辑:

  1. 过滤条件:IS_MUSIC != 0 且 duration >= 10000(10 秒以上),用于排除铃声、录音、通知音等短音频片段,只保留真正的音乐文件。
  2. 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 勾选状态:

字段类型说明
idlongMediaStore 中的音频 ID
title / album / artistString歌名 / 专辑 / 歌手
durationint时长(毫秒),>= 10000 才被视为有效音乐
sizelong文件大小
urlString文件路径(API 28 及以下传输用)
coverUrlString封面地址
uriStringcontent URI(API 29+ 传输用)
localint0:本地,1:网络,5:图灵H5,2:短音频,3:m3u8,4:直播 m3u8
downloadint1:未下载,2:已下载,3:正在下载
selectedboolean是否被用户勾选(决定是否进入传输列表)
collectboolean是否收藏
authString鉴权信息(网络音乐)
positionint播放位置
isHistory / isM3u8booleanSDK 内部使用标记
@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_LIST0纯列表浏览
MODE_SELECT1用户勾选要传输的歌曲
MODE_DOWNLOAD2正在批量传输,展示进度

cancelSelect() 用于取消选择回到 MODE_SELECT;传输中途出错/取消时 dealWithFailedEvent() 也会把模式复位为 MODE_SELECT,保证用户总是可以重新选择。

传输实现:TransferTask / UriTransferTask 与批量命令

传输前的守卫条件

addToDownloadList() 先做两个前置校验:

  1. 通话中禁止传输:通过 getDeviceInfo(getConnectedDevice()) 检查设备 phoneStatus 是否为 WatchConstant.DEVICE_PHONE_STATUS_CALLING,若是则直接发布 TYPE_ERROR 事件(文案为 call_phone_error_tips),避免传输干扰通话。
  2. 必须有选中歌曲:遍历 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)

流程关键点:

  1. 加载阶段在后台线程:getMusicList() 通过 ThreadManager.getInstance().postRunnable(...) 执行 MediaStore 查询,避免主线程卡顿,结果通过 postValue 回到主线程。
  2. 模式切换即传输触发:toNextMode() 进入 MODE_DOWNLOAD 时同步触发 addToDownloadList(),把"界面状态"与"业务动作"耦合在一次点击中,简化交互路径。
  3. 批量开始 → 链式传输 → 批量结束:OP_START 成功后启动链头;每首任务 onFinish 自动启动下一首;最后一首完成后发 OP_STOP,设备端才知道整批接收完毕并提交文件。
  4. 事件驱动 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_LISTint0列表浏览模式
MODE_SELECTint1勾选模式
MODE_DOWNLOADint2下载传输模式
Music.local = 0int本地本地音乐类型(1=网络,5=图灵H5,2=短音频,3=m3u8,4=直播 m3u8)
Music.downloadint—1=未下载,2=已下载,3=正在下载
音乐时长过滤阈值int10000 msduration >= 10000 才纳入列表
Android 版本分支点intAPI 29 (P)> Build.VERSION_CODES.P 用 UriTransferTask,否则用 TransferTask
BatchCmd 参数byte0x02批量命令的设备相关字节

说明: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_ERRORAutoLastListener.dealWithFailedEvent
OP_STOP 失败同样走 dealWithFailedEvent(TYPE_ERROR, subCode, false),不再重复发 STOPAutoLastListener.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() 必须被调用(ViewModel onCleared 链路),否则任务链可能残留并继续回调已销毁的 LiveData。
  • 存储空间:Util.cleanDownloadDir(sdCardBean) 在传输前清理设备临时目录,运维上应关注设备剩余空间;单次批量歌曲数量不宜过大。

扩展点

  1. 传输实现替换:ITask 抽象使传输实现可替换。当前按系统版本选择 TransferTask / UriTransferTask,未来可扩展新的传输通道(如 WLAN 传输)而无需改动任务链逻辑。
  2. 任务回调扩展:实现 TaskListener 可扩展每个任务的开始/进度/完成/错误/取消行为;AutoLastListener 是链式推进的参考实现。
  3. 批量命令扩展:sendBatchCmd 的 op 参数目前只使用 OP_START/OP_STOP,BatchCmd.Param 支持更多操作码,可用于批量删除、批量校验等设备端批量操作。
  4. 音乐来源扩展:Music.local 字段已预留网络(1)、短音频(2)、m3u8(3)、直播(4)、图灵 H5(5)等类型,接入在线音乐源时只需在加载层产出相应 Music 对象。
  5. 播放控制接入: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)。

Prev
文件传输与文件管理
Next
图像转换库