查询、播放与保存录像
先查询目标设备的录像,再播放、下载或截取需要的内容。下载可以直接处理指定时间段,也可以保存正在播放的片段。
确认录像格式可以播放
录像的音视频格式由设备上传的编码帧决定。客户端 SDK 只播放这些已经上传的格式。
| 类型 | 客户端 SDK 支持的格式 |
|---|---|
| 视频 | H.264、H.265、JPEG(MJPEG) |
| 音频 | G.711 A-law、AAC、16 位 PCM、Opus |
G.711 μ-law 和 G.726 音频无法播放。H.264、H.265、JPEG 和音频帧还必须符合上传录像中的帧要求。格式不受支持或帧数据不完整时,录像时间段仍可能被查询到,但播放或下载会返回格式不支持或录像不可读。此时应修正设备的编码和送帧方式,再上传新的录像;重复请求同一段录像不会改变结果。
准备 AppId 和客户端 Token
AppId来自申请开通;app_access_token由业务服务端签发,见云端签发 Token。
初始化 SDK
Android 示例通过页面的 lifecycleScope 调用挂起 API,因此 Kotlin 调用处不写 await。
import 'package:tirtc_flutter/tirtc_flutter.dart';
final initCode = await TiStore.init(appId: appId);
if (initCode != kTiStoreErrorOk) return;import androidx.lifecycle.lifecycleScope
import com.tange.ai.tirtc.*
import kotlinx.coroutines.launch
val initCode = TiStore.init(applicationContext, appId)
if (initCode != TiStoreErrorCode.OK) returnimport Foundation
import TiRTC
guard TiStore.initialize(appId: appId) == TiStoreErrorCode.ok else { return }import {
TiStore,
TiStoreAudioOutput,
TiStoreReplaySpeed,
TiStoreVideoFit,
TiStoreVideoOutput,
TiStoreVideoOutputView,
TISTORE_ERROR_OK,
} from 'tirtc';
const initCode = await TiStore.init({context: uiAbilityContext, appId});
if (initCode !== TISTORE_ERROR_OK) return;import type {ReactElement} from 'react';
import {
TiStore,
TiStoreAudioOutput,
TiStoreReplaySpeed,
TiStoreVideoOutput,
TISTORE_ERROR_OK,
} from 'tirtc-react-native';
const initCode = await TiStore.init({appId});
if (initCode !== TISTORE_ERROR_OK) return;import {
TiStore,
TiStoreAudioOutput,
TiStoreReplaySpeed,
TiStoreVideoOutput,
TiVideoView,
TISTORE_ERROR_OK,
} from '@tange-ai/tirtc-electron';
// Electron Runtime 对象只在主进程创建。
const initCode = TiStore.init({appId});
if (initCode !== TISTORE_ERROR_OK) return;初始化失败时不要继续创建 store。记录当前平台、SDK 版本和初始化错误码。根据对应平台的 API 说明修正 AppId、运行环境或初始化顺序。
创建用于访问目标设备的 Store 实例
使用目标设备对应的 app_access_token 创建 TiStore 实例。这个实例固定访问 Token 所绑定的设备,后续查询、播放和导出都通过它完成。同时访问多台设备时,为每台设备分别创建实例。
final store = TiStore(token: token);val store = TiStore(token)let store = TiStore(token: token)const store = new TiStore(token);const store = new TiStore(token);const store = new TiStore(token);同一设备的 Token 续签后调用 updateToken。已经开始的查询、播放或导出仍使用旧 Token;失败的操作需要重新发起。
查询哪些日期有录像
月历或日期列表不需要逐日查询录像时间段。使用 listRecordingDays 一次取得包含式日期范围内的完整逐日结果,再保留 hasRecording 为 true 的日期。
final resp = await store.listRecordingDays(
startDate: '2026-08-01',
endDate: '2026-08-31',
timeZoneId: 'Asia/Shanghai',
);
if (!resp.success || resp.data == null) return;
final availableDays = resp.data!
.where((day) => day.hasRecording)
.map((day) => day.date)
.toList();lifecycleScope.launch {
val result = store.listRecordingDays(
startDate = "2026-08-01",
endDate = "2026-08-31",
timeZoneId = "Asia/Shanghai",
)
if (result.code != TiStoreErrorCode.OK) return@launch
val availableDays = result.days
.filter { it.hasRecording }
.map { it.date }
}let result = await store.listRecordingDays(
startDate: "2026-08-01",
endDate: "2026-08-31",
timeZoneId: "Asia/Shanghai"
)
guard result.code == TiStoreErrorCode.ok else { return }
let availableDays = result.days
.filter(\.hasRecording)
.map(\.date)const result = await store.listRecordingDays(
'2026-08-01',
'2026-08-31',
'Asia/Shanghai',
);
if (result.code !== TISTORE_ERROR_OK) return;
const availableDays = result.days
.filter((day) => day.hasRecording)
.map((day) => day.date);const result = await store.listRecordingDays(
'2026-08-01',
'2026-08-31',
'Asia/Shanghai',
);
if (result.code !== TISTORE_ERROR_OK) return;
const availableDays = result.days
.filter((day) => day.hasRecording)
.map((day) => day.date);const result = await store.listRecordingDays(
'2026-08-01',
'2026-08-31',
'Asia/Shanghai',
);
if (result.code !== TISTORE_ERROR_OK) return;
const availableDays = result.days
.filter((day) => day.hasRecording)
.map((day) => day.date);startDate 和 endDate 都使用严格的 YYYY-MM-DD 格式,并且均包含在结果中。timeZoneId 使用 IANA 时区 ID;省略时默认为 Asia/Shanghai。单次最多查询 31 天,成功结果按日期升序返回范围内每一天,包括 hasRecording == false 的日期。
选择某一天后,按下面的边界计算 listRecordings 参数:
- 在查询使用的 IANA 时区中,把所选日期当天
00:00作为开始边界。 - 在同一时区中,把下一个自然日的
00:00作为结束边界。 - 分别把两个边界转换为 UTC Unix 毫秒,得到左闭右开区间
[startTimeMs, endTimeMs)。
不要用 startTimeMs + 86400000 推导结束边界。采用夏令时的地区可能存在 23 或 25 小时的自然日。
查询可回放的时间段
listRecordings 返回设备在查询范围内有录像的时间段。没有录像时,结果为空列表。
final resp = await store.listRecordings(
startTimeMs: startTimeMs,
endTimeMs: endTimeMs,
);
if (!resp.success || resp.data == null) return;
final recordings = resp.data!;
if (recordings.isEmpty) return;
final selectedRecording = recordings.first;
// 使用 selectedRecording 打开播放页。lifecycleScope.launch {
val result = store.listRecordings(
startTimeMs,
endTimeMs,
)
if (result.code != TiStoreErrorCode.OK) return@launch
val selectedRecording = result.recordings.firstOrNull() ?: return@launch
// 使用 selectedRecording 打开播放页。
}let result = await store.listRecordings(
startTimeMs: startTimeMs,
endTimeMs: endTimeMs
)
guard result.code == TiStoreErrorCode.ok,
let selectedRecording = result.recordings.first else { return }
// 使用 selectedRecording 打开播放页。const result = await store.listRecordings(
startTimeMs,
endTimeMs,
);
if (result.code !== TISTORE_ERROR_OK || result.recordings.length === 0) return;
const selectedRecording = result.recordings[0];const result = await store.listRecordings(
startTimeMs,
endTimeMs,
);
if (result.code !== TISTORE_ERROR_OK || result.recordings.length === 0) return;
const selectedRecording = result.recordings[0];const result = await store.listRecordings(
startTimeMs,
endTimeMs,
);
if (result.code !== TISTORE_ERROR_OK || result.recordings.length === 0) return;
const selectedRecording = result.recordings[0];查询结果按开始时间排序,并裁剪到请求范围。单次查询最长 10 天。返回的是设备整体有录像的时间段,不保证每个 Channel 都有媒体。播放使用的 Channel ID 由设备配置或业务服务提供。
Token 过期时,从业务服务端取得同一设备的新 app_access_token。调用 updateToken 后重新查询。权限不足时停止重试,确认当前用户、device_id 和 Token 的授权关系。查询跨度超过 10 天时,缩短时间范围后再查。
开始播放
创建录像播放对象(Replay)和需要的音视频输出对象(Output),绑定 Channel 后调用 play。initialTimeMs 是播放页选定的起播时间;没有指定时省略它,Replay 会从录像范围开头播放。
final replay = store.createReplay();
final videoOutput = TiStoreVideoOutput();
final audioOutput = TiStoreAudioOutput();
replay.onError = (code) { /* 处理来源错误 */ };
replay.onCompleted = () { /* 播放范围自然结束 */ };
videoOutput.attach(replay: replay, channelId: videoChannelId);
audioOutput.attach(replay: replay, channelId: audioChannelId);
replay.play(
startTimeMs: selectedRecording.startTimeMs,
endTimeMs: selectedRecording.endTimeMs,
initialTimeMs: initialTimeMs,
);
final video = AspectRatio(child: videoOutput.view());val replay = store.createReplay()
val videoOutput = TiStoreVideoOutput()
val audioOutput = TiStoreAudioOutput()
replay.onError = TiStoreReplayErrorListener { /* 处理来源错误 */ }
replay.onCompleted = TiStoreReplayCompletedListener { /* 自然结束 */ }
videoOutput.attachView(videoContainer)
videoOutput.attach(replay, videoChannelId)
audioOutput.attach(replay, audioChannelId)
replay.play(
startTimeMs = selectedRecording.startTimeMs,
endTimeMs = selectedRecording.endTimeMs,
initialTimeMs = initialTimeMs,
)let replay = store.createReplay()
let videoOutput = TiStoreVideoOutput()
let audioOutput = TiStoreAudioOutput()
replay.onError = { _ in /* 处理来源错误 */ }
replay.onCompleted = { /* 播放范围自然结束 */ }
videoOutput.attachView(videoView)
videoOutput.attach(replay: replay, channelId: videoChannelId)
audioOutput.attach(replay: replay, channelId: audioChannelId)
replay.play(
startTimeMs: selectedRecording.startTimeMs,
endTimeMs: selectedRecording.endTimeMs,
initialTimeMs: NSNumber(value: initialTimeMs)
)const replay = store.createReplay();
const videoOutput = new TiStoreVideoOutput();
const audioOutput = new TiStoreAudioOutput();
replay.onError = (code) => { /* 处理来源错误 */ };
replay.onCompleted = () => { /* 播放范围自然结束 */ };
videoOutput.attach(replay, videoChannelId);
audioOutput.attach(replay, audioChannelId);
replay.play(
selectedRecording.startTimeMs,
selectedRecording.endTimeMs,
initialTimeMs,
);
@Builder
function StorePlaybackVideo(): void {
TiStoreVideoOutputView({output: videoOutput, fit: TiStoreVideoFit.contain});
}const replay = store.createReplay();
const videoOutput = new TiStoreVideoOutput();
const audioOutput = new TiStoreAudioOutput();
replay.onError = (code) => { /* 处理来源错误 */ };
replay.onCompleted = () => { /* 播放范围自然结束 */ };
videoOutput.attach(replay, videoChannelId);
audioOutput.attach(replay, audioChannelId);
replay.play(
selectedRecording.startTimeMs,
selectedRecording.endTimeMs,
initialTimeMs,
);
function StorePlaybackVideo(): ReactElement {
return videoOutput.view({style: styles.video});
}// 主进程;browserWindow 和 bounds 由应用提供。
const replay = store.createReplay();
const videoOutput = new TiStoreVideoOutput();
const audioOutput = new TiStoreAudioOutput();
const videoView = new TiVideoView(browserWindow, bounds);
replay.onError = (code) => { /* 处理来源错误 */ };
replay.onCompleted = () => { /* 播放范围自然结束 */ };
videoOutput.mount(videoView);
videoOutput.attach(replay, videoChannelId);
audioOutput.attach(replay, audioChannelId);
replay.play(
selectedRecording.startTimeMs,
selectedRecording.endTimeMs,
initialTimeMs,
);播放范围自然结束时触发 Replay 的完成回调。Token、权限、网络或录像读取错误由 Replay 返回。解码、播放或渲染错误由对应 Output 返回。
attach 或 play 同步返回失败时,本次操作没有开始。先按错误码修正 Channel、时间范围或对象状态。已经开始播放后,如果 Replay 报告 Token 过期,更新 Token 并重新调用 play。录像不可读时不要持续重试同一段录像。网络或服务暂时不可用时,可以采用有上限的退避重试。
控制播放
Seek 使用当前播放范围内的绝对 UTC 时间。它是同步控制命令,不返回独立完成回调。定位生效后,Replay 的当前位置和时间变化通知会更新。
replay.pause();
replay.resume();
replay.seek(targetTimeMs);
replay.setSpeed(TiStoreReplaySpeed.x2);
audioOutput.setVolume(0); // 静音
replay.stop();replay.pause()
replay.resume()
replay.seek(targetTimeMs)
replay.setSpeed(TiStoreReplaySpeed.X2)
audioOutput.setVolume(0) // 静音
replay.stop()replay.pause()
replay.resume()
replay.seek(toTimeMs: targetTimeMs)
replay.setSpeed(.x2)
audioOutput.setVolume(0) // 静音
replay.stop()replay.pause();
replay.resume();
replay.seek(targetTimeMs);
replay.setSpeed(TiStoreReplaySpeed.x2);
audioOutput.setVolume(0); // 静音
replay.stop();replay.pause();
replay.resume();
replay.seek(targetTimeMs);
replay.setSpeed(TiStoreReplaySpeed.x2);
audioOutput.setVolume(0); // 静音
replay.stop();replay.pause();
replay.resume();
replay.seek(targetTimeMs);
replay.setSpeed(TiStoreReplaySpeed.x2);
audioOutput.setVolume(0); // 静音
replay.stop();Seek 只能在当前播放仍在运行或暂停时调用,目标必须位于当前播放范围。越界时同步返回错误,当前播放不受影响。
目标落在录像空洞中时,Replay 会定位到其后的第一个可播放位置。直到范围末尾都没有录像时按自然结束处理。播放已经结束时,从指定位置重新调用 play。
setVolume(0) 表示静音,恢复到 100 即可取消静音。支持 1x、2x、4x 和 8x;非 1x 倍速下,Audio Output 自动静音。应用切换到另一段录像时,直接在同一个 Replay 上再次调用 play;已有 Output 绑定、音量和倍速会保留。
截取当前画面
Video Output 出画后,可以把当前画面保存为 JPEG。截图操作(Snapshot)不包含字幕、按钮等页面 UI。
final result = await videoOutput.takeSnapshot();
if (!result.success || result.data == null) return;
final snapshot = result.data!;lifecycleScope.launch {
val result = videoOutput.takeSnapshot()
if (result.code != TiStoreErrorCode.OK || result.file == null) return@launch
val snapshot = result.file
}let result = await videoOutput.takeSnapshot()
guard result.code == TiStoreErrorCode.ok,
let snapshot = result.file else { return }const result = await videoOutput.takeSnapshot();
if (!result.success || result.data === null) return;
const snapshot = result.data;const result = await videoOutput.takeSnapshot();
if (!result.success || result.data === null) return;
const snapshot = result.data;const result = await videoOutput.takeSnapshot();
if (!result.success || result.data === null) return;
const snapshot = result.data;Video Output 尚未收到画面时,截图返回 no-frame;等待首帧后再试。截图文件写入失败时,先检查缓存目录和磁盘空间。
MP4 和 JPEG 都先生成在 SDK 私有 cache。Flutter 与 React Native 可以对返回的文件对象调用 moveToGallery(),并为保存的 MP4 或 JPEG 指定文件名。Android、iOS / macOS、HarmonyOS 与 Electron 由应用复制或移动普通文件,并在目标文件操作中设置名称。
直接读取、上传或分享完成后调用 delete()。需要长期保存时及时移动,不能把 cache 路径当作持久地址。
Future<void> saveFiles(
TiStoreRecordingFile recordingFile,
TiStoreSnapshotFile snapshotFile,
) async {
final recordingSave = await recordingFile.moveToGallery(
fileName: '客厅摄像头-2026-08-20-14-30-05.mp4',
);
if (!recordingSave.success || recordingSave.data == null) return;
final recordingAsset = recordingSave.data!;
final snapshotSave = await snapshotFile.moveToGallery(
fileName: '客厅摄像头-2026-08-20-14-30-05.jpg',
);
if (!snapshotSave.success || snapshotSave.data == null) return;
final snapshotAsset = snapshotSave.data!;
// 把 recordingAsset.uri 和 snapshotAsset.uri 交给应用展示或持久化。
}import type {
TiStoreRecordingFile,
TiStoreSnapshotFile,
} from 'tirtc-react-native';
async function saveFiles(
recordingFile: TiStoreRecordingFile,
snapshotFile: TiStoreSnapshotFile,
): Promise<void> {
const recordingSave = await recordingFile.moveToGallery(
'客厅摄像头-2026-08-20-14-30-05.mp4',
);
if (!recordingSave.success || recordingSave.data === null) return;
const snapshotSave = await snapshotFile.moveToGallery(
'客厅摄像头-2026-08-20-14-30-05.jpg',
);
if (!snapshotSave.success || snapshotSave.data === null) return;
// 把两个 data.uri 交给应用展示或持久化。
}上例的两个参数分别来自录像任务和截图接口的成功结果。文件名可省略扩展名,SDK 会按文件类型补上 .mp4 或 .jpg。如果传入扩展名,只接受对应类型;非法名称返回 6000,并保留 cache 源文件。省略文件名时,SDK 使用 Unix 毫秒时间戳生成名称。
下载录像
云存储支持直接下载指定时间段,也可以把正在播放的片段保存为 MP4。两种方式都会在 SDK 私有 cache 中生成本地文件。
下载指定时间段
不需要先播放时,通过同一个设备实例创建 ExportTask。exportStartTimeMs 和 exportEndTimeMs 来自选中的录像时间段,也可以是其中一个更小的范围。调用时再传入需要保存的 Channel。
final start = store.exportRecording(
startTimeMs: exportStartTimeMs,
endTimeMs: exportEndTimeMs,
videoChannelId: videoChannelId,
audioChannelId: audioChannelId,
onProgress: (progress) => exportProgress = progress,
);
if (!start.success || start.data == null) return;
final task = start.data!;
final completed = await task.result;
if (!completed.success || completed.data == null) return;
final file = completed.data!;
showSavedFile(file.path);val start = store.exportRecording(
TiStoreExportRequest(
startTimeMs = exportStartTimeMs,
endTimeMs = exportEndTimeMs,
videoChannelId = videoChannelId,
audioChannelId = audioChannelId,
),
TiStoreExportProgressListener { exportProgress = it },
) { result ->
if (result.code == TiStoreErrorCode.OK && result.file != null) {
showSavedFile(result.file.path)
}
}
if (start.code != TiStoreErrorCode.OK || start.task == null) return
val task = start.tasklet start = store.exportRecording(
TiStoreExportRequest(
startTimeMs: exportStartTimeMs,
endTimeMs: exportEndTimeMs,
videoChannelId: videoChannelId,
audioChannelId: NSNumber(value: audioChannelId)
),
progress: { exportProgress = $0 },
completion: { result in
guard result.code == TiStoreErrorCode.ok,
let file = result.file else { return }
showSavedFile(file.path)
}
)
guard start.code == TiStoreErrorCode.ok, let task = start.task else { return }const start = store.exportRecording({
startTimeMs: exportStartTimeMs,
endTimeMs: exportEndTimeMs,
videoChannelId,
audioChannelId,
}, (progress) => {
exportProgress = progress;
});
if (!start.success || start.data === null) return;
const task = start.data;
const completed = await task.result;
if (!completed.success || completed.data === null) return;
const file = completed.data;
showSavedFile(file.path);const start = store.exportRecording({
startTimeMs: exportStartTimeMs,
endTimeMs: exportEndTimeMs,
videoChannelId,
audioChannelId,
}, (progress) => {
exportProgress = progress;
});
if (!start.success || start.data === null) return;
const task = start.data;
const completed = await task.result;
if (!completed.success || completed.data === null) return;
const file = completed.data;
showSavedFile(file.path);const start = store.exportRecording({
startTimeMs: exportStartTimeMs,
endTimeMs: exportEndTimeMs,
videoChannelId,
audioChannelId,
}, (progress) => {
exportProgress = progress;
});
if (!start.success || start.data === null) return;
const task = start.data;
const completed = await task.result;
if (!completed.success || completed.data === null) return;
const file = completed.data;
showSavedFile(file.path);ExportTask 会自然处理完整个范围。需要提前结束下载时,可以调用 Task 的 stop。这会清理临时文件,不会把尚未完成的范围保存成部分 MP4。
任务创建成功只表示导出已经开始。必须等待 result、completion 或 Wait 返回成功结果后再使用文件。进度达到 100% 也不能替代最终结果。Token 过期时,更新同一设备的 Token 并重新创建任务。权限不足或录像不可读时不要持续重试原请求。
保存正在播放的片段
开始保存时,从当前 Replay 创建 RecordingTask。结束保存时调用 Task 的 stop。成功结果包含 MP4 路径和实际媒体时长。
final start = replay.startRecording(
videoChannelId: videoChannelId,
audioChannelId: audioChannelId,
);
if (!start.success || start.data == null) return;
final task = start.data!;
final completed = await task.stop();
if (!completed.success || completed.data == null) return;
final file = completed.data!;
showSavedFile(file.path);val start = replay.startRecording(
videoChannelId = videoChannelId,
audioChannelId = audioChannelId,
)
if (start.code != TiStoreErrorCode.OK || start.task == null) return
val task = start.task
lifecycleScope.launch {
val completed = task.stop()
if (completed.code != TiStoreErrorCode.OK || completed.file == null) return@launch
val file = completed.file
showSavedFile(file.path)
}let start = replay.startRecording(
videoChannelId: videoChannelId,
audioChannelId: NSNumber(value: audioChannelId)
)
guard start.code == TiStoreErrorCode.ok, let task = start.task else { return }
let completed = await task.stop()
guard completed.code == TiStoreErrorCode.ok,
let file = completed.file else { return }
showSavedFile(file.path)const start = replay.startRecording({
videoChannelId,
audioChannelId,
});
if (!start.success || start.data === null) return;
const task = start.data;
const completed = await task.stop();
if (!completed.success || completed.data === null) return;
const file = completed.data;
showSavedFile(file.path);const start = replay.startRecording({
videoChannelId,
audioChannelId,
});
if (!start.success || start.data === null) return;
const task = start.data;
const completed = await task.stop();
if (!completed.success || completed.data === null) return;
const file = completed.data;
showSavedFile(file.path);const start = replay.startRecording({
videoChannelId,
audioChannelId,
});
if (!start.success || start.data === null) return;
const task = start.data;
const completed = await task.stop();
if (!completed.success || completed.data === null) return;
const file = completed.data;
showSavedFile(file.path);RecordingTask 保存 Replay 交付的媒体,不包含页面 UI。Replay 自然结束时 Task 会自动完成;之后调用 stop 仍会返回同一文件结果。
startRecording 失败时不会创建 Task。stop 返回成功并交付文件后,MP4 才可以读取或展示。文件写入失败时,检查缓存目录权限、磁盘剩余空间和本地写入性能,再重新保存。
一个 Task 只保存一个视频 Channel。两个摄像头分别启动两个 Task,SDK 会生成两个独立文件;需要时可以给两个 Task 传入同一个音频 Channel。
确认播放和下载结果
释放页面资源前,确认本次操作已经得到预期结果:
- 查询返回至少一个录像时间段;
- 开始播放后收到音频帧或看到视频首帧;
- 暂停、继续和 Seek 后,状态与画面符合预期;
- 下载任务返回可读取的 MP4,截图返回可读取的 JPEG。
若任一步失败,记录平台、SDK 版本、目标 device_id、时间范围和公开错误码,不要记录 Token。
释放资源
离开播放页面并准备释放页面资源时,先结束页面持有且不再继续的 RecordingTask 或 ExportTask。等待已经接受的 Snapshot 后停止播放。使用 Video Output View 的平台先移除 View,然后解除并释放 Output。
播放页持有 store 且离开后不再访问这台设备时,一并释放它。如果应用在更高层复用同一个 store,等不再访问该设备时再释放。
replay.stop();
videoOutput.detach();
audioOutput.detach();
videoOutput.dispose();
audioOutput.dispose();
replay.dispose();
store.dispose();replay.stop()
videoOutput.detachView()
videoOutput.detach()
audioOutput.detach()
videoOutput.dispose()
audioOutput.dispose()
replay.dispose()
store.dispose()replay.stop()
videoOutput.detachView()
videoOutput.detach()
audioOutput.detach()
videoOutput.dispose()
audioOutput.dispose()
replay.dispose()
store.dispose()replay.stop();
videoOutput.detach();
audioOutput.detach();
videoOutput.dispose();
audioOutput.dispose();
replay.dispose();
store.dispose();replay.stop();
videoOutput.detach();
audioOutput.detach();
videoOutput.dispose();
audioOutput.dispose();
replay.dispose();
store.dispose();replay.stop();
videoOutput.unmount();
videoOutput.detach();
audioOutput.detach();
videoOutput.dispose();
audioOutput.dispose();
replay.dispose();
videoView.dispose();
store.dispose();释放失败时,对象仍然有效。等待任务和回调结束、解除绑定或移除 View 后,再次释放即可。离开页面后,确认没有仍被占用的 Replay、Output、Task 或 Store。