React Native 实现视频缓存下载完整指南
摘要
完整指南:在 React Native (Expo) 中实现视频下载与缓存,包括 M3U8/HLS 分段下载、MP4 直链下载、进度管理、存储管理等
React Native 实现视频缓存下载完整指南
概述
在视频类应用中,离线下载和缓存是提升用户体验的关键功能。用户可以预先下载视频,在无网络环境下观看,同时也能减少在线播放时的流量消耗和缓冲等待。
本指南介绍如何在 React Native (Expo) 应用中实现完整的视频缓存与下载系统,包括 M3U8/HLS 片段下载、进度管理、存储管理、后台下载等核心模块。
核心挑战
视频缓存比普通文件下载复杂得多:
挑战 | 说明 | 解决方案 |
|---|---|---|
M3U8 分段 | HLS 流媒体由多个 TS 片段组成 | 解析索引文件,并发下载片段 |
文件加密 | 部分 HLS 流使用 AES-128 加密 | 提取 KEY 并在下载时解密 |
鉴权过期 | 视频 URL 可能有时效性要求 | 自动补全 Referer / Cookie |
断点续传 | 下载中断后需恢复 | 记录已完成片段,跳过已下载 |
存储空间 | 缓存占用大量设备存储 | 可视化存储管理,自动清理 |
后台下载 | 切到后台后继续下载 | Headless JS / 原生服务 |
技术方案对比
方案 | 优点 | 缺点 |
|---|---|---|
expo-file-system | 官方支持,API 简洁 | 不支持多线程,大文件慢 |
react-native-fs | 功能全面,断点续传 | 需要 eject |
react-native-blob-util | 高性能,支持流式 | 配置较复杂 |
原生模块封装 | 完全控制下载流程 | 开发成本高 |
推荐使用 react-native-blob-util 配合原生下载服务。
一、项目初始化
安装依赖
# 创建项目
npx create-expo-app video-cache-demo --template blank-typescript
cd video-cache-demo
# 核心依赖
npx expo install expo-file-system
npx expo install expo-network
npx expo install expo-keep-awake
npx expo install expo-notifications
# 文件下载(需要预构建)
npm install react-native-blob-util
npx expo prebuild权限配置
Android (android/app/src/main/AndroidManifest.xml):
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />iOS (ios/VideoCacheDemo/Info.plist):
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>二、视频缓存核心模块
缓存管理器
// services/cache-manager.ts
import * as FileSystem from 'expo-file-system';
import { Platform } from 'react-native';
// 缓存目录配置
export const CACHE_CONFIG = {
// Android 推荐使用外部存储的应用专属目录
cacheDir: Platform.OS === 'android'
? `${FileSystem.documentDirectory}download/`
: `${FileSystem.cacheDirectory}videos/`,
maxCacheSize: 5 * 1024 * 1024 * 1024, // 5GB 缓存上限
tempExt: '.tmp', // 下载中临时文件后缀
metaExt: '.meta', // 元数据文件后缀
};
// 视频缓存条目
export interface CachedVideo {
id: string; // 唯一标识
title: string; // 视频标题
episodeName?: string; // 剧集名称
sourceUrl: string; // 原始 URL
localPath: string; // 本地路径
fileSize: number; // 文件大小(字节)
downloadedSize: number; // 已下载大小
status: 'downloading' | 'paused' | 'completed' | 'failed';
progress: number; // 0-100 进度
createdAt: string; // 创建时间
completedAt?: string; // 完成时间
segments?: CacheSegment[]; // HLS 片段信息
}
// HLS 片段元数据
export interface CacheSegment {
index: number;
url: string;
localPath: string;
status: 'pending' | 'downloading' | 'completed' | 'failed';
duration: number;
}
// 缓存元数据文件结构
interface CacheMetadata {
videos: Record<string, CachedVideo>;
updatedAt: string;
}
export class CacheManager {
private static instance: CacheManager;
private metadata: CacheMetadata = { videos: {}, updatedAt: '' };
private metadataPath: string;
private constructor() {
this.metadataPath = `${CACHE_CONFIG.cacheDir}cache_meta.json`;
}
static getInstance(): CacheManager {
if (!CacheManager.instance) {
CacheManager.instance = new CacheManager();
}
return CacheManager.instance;
}
// 初始化:创建目录 + 加载元数据
async initialize(): Promise<void> {
// 确保缓存目录存在
const dirInfo = await FileSystem.getInfoAsync(CACHE_CONFIG.cacheDir);
if (!dirInfo.exists) {
await FileSystem.makeDirectoryAsync(CACHE_CONFIG.cacheDir, {
intermediates: true,
});
}
// 加载已有元数据
await this.loadMetadata();
}
// 加载缓存元数据
private async loadMetadata(): Promise<void> {
try {
const metaInfo = await FileSystem.getInfoAsync(this.metadataPath);
if (metaInfo.exists) {
const content = await FileSystem.readAsStringAsync(this.metadataPath);
this.metadata = JSON.parse(content);
}
} catch {
// 元数据损坏时重置
this.metadata = { videos: {}, updatedAt: new Date().toISOString() };
}
}
// 保存元数据
private async saveMetadata(): Promise<void> {
this.metadata.updatedAt = new Date().toISOString();
await FileSystem.writeAsStringAsync(
this.metadataPath,
JSON.stringify(this.metadata, null, 2)
);
}
// 获取所有缓存视频
getCachedVideos(): CachedVideo[] {
return Object.values(this.metadata.videos)
.filter(v => v.status === 'completed')
.sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
// 获取缓存中视频
getDownloadingVideos(): CachedVideo[] {
return Object.values(this.metadata.videos)
.filter(v => v.status === 'downloading' || v.status === 'paused')
.sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
// 获取单个视频缓存信息
getVideo(id: string): CachedVideo | undefined {
return this.metadata.videos[id];
}
// 更新视频元数据
async updateVideo(video: CachedVideo): Promise<void> {
this.metadata.videos[video.id] = video;
await this.saveMetadata();
}
// 删除缓存视频
async deleteVideo(id: string): Promise<void> {
const video = this.metadata.videos[id];
if (!video) return;
try {
// 删除本地文件
const fileInfo = await FileSystem.getInfoAsync(video.localPath);
if (fileInfo.exists) {
await FileSystem.deleteAsync(video.localPath, { idempotent: true });
}
// 删除片段文件
if (video.segments) {
for (const seg of video.segments) {
const segInfo = await FileSystem.getInfoAsync(seg.localPath);
if (segInfo.exists) {
await FileSystem.deleteAsync(seg.localPath, { idempotent: true });
}
}
}
} catch (error) {
console.error('Delete file error:', error);
}
delete this.metadata.videos[id];
await this.saveMetadata();
}
// 计算总缓存大小
async getTotalCacheSize(): Promise<number> {
let total = 0;
for (const video of Object.values(this.metadata.videos)) {
if (video.status === 'completed') {
total += video.fileSize;
}
}
return total;
}
// 清理超出上限的缓存
async enforceCacheLimit(): Promise<void> {
const totalSize = await this.getTotalCacheSize();
if (totalSize <= CACHE_CONFIG.maxCacheSize) return;
// 按创建时间排序,删除最旧的
const completed = this.getCachedVideos()
.sort((a, b) =>
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
let size = totalSize;
for (const video of completed) {
if (size <= CACHE_CONFIG.maxCacheSize) break;
size -= video.fileSize;
await this.deleteVideo(video.id);
}
}
}
export const cacheManager = CacheManager.getInstance();三、M3U8/HLS 下载引擎
HLS 视频由 m3u8 索引文件和多个 TS 片段组成,下载需要先解析索引。
M3U8 解析器
// services/m3u8-parser.ts
export interface M3U8SegmentInfo {
index: number;
duration: number;
url: string;
keyUrl?: string; // AES-128 密钥 URL
iv?: string; // 初始化向量
}
export interface M3U8Playlist {
segments: M3U8SegmentInfo[];
totalDuration: number;
version?: number;
targetDuration: number;
isEncrypted: boolean;
keyUrl?: string;
}
export class M3U8Parser {
// 解析 m3u8 索引文件
static parse(content: string, baseUrl: string): M3U8Playlist {
const lines = content.split('\n');
const segments: M3U8SegmentInfo[] = [];
let targetDuration = 0;
let isEncrypted = false;
let currentKeyUrl: string | undefined;
let currentIV: string | undefined;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// 目标时长
if (line.startsWith('#EXT-X-TARGETDURATION:')) {
targetDuration = parseInt(line.split(':')[1], 10);
}
// 加密信息
if (line.startsWith('#EXT-X-KEY:')) {
isEncrypted = true;
const method = line.match(/METHOD=([^,]+)/)?.[1];
if (method === 'AES-128') {
const uri = line.match(/URI="([^"]+)"/)?.[1];
currentKeyUrl = uri ? this.resolveUrl(uri, baseUrl) : undefined;
currentIV = line.match(/IV=0x([0-9a-fA-F]+)/)?.[1];
}
}
// 片段信息
if (line.startsWith('#EXTINF:')) {
const duration = parseFloat(line.split(':')[1].split(',')[0]);
const nextLine = lines[++i]?.trim();
if (nextLine && !nextLine.startsWith('#')) {
segments.push({
index: segments.length,
duration,
url: this.resolveUrl(nextLine, baseUrl),
keyUrl: currentKeyUrl,
iv: currentIV,
});
}
}
}
return {
segments,
totalDuration: segments.reduce((sum, s) => sum + s.duration, 0),
targetDuration,
isEncrypted,
};
}
// 解析相对 URL
private static resolveUrl(path: string, baseUrl: string): string {
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
const base = baseUrl.substring(0, baseUrl.lastIndexOf('/') + 1);
return base + path;
}
}HLS 下载器
// services/hls-downloader.ts
import * as FileSystem from 'expo-file-system';
import { M3U8Parser, M3U8SegmentInfo } from './m3u8-parser';
import { CacheManager, CachedVideo, CacheSegment } from './cache-manager';
import { Platform } from 'react-native';
export type DownloadProgressCallback = (
videoId: string,
progress: number,
downloadedSegments: number,
totalSegments: number
) => void;
export class HLSDownloader {
private static instance: HLSDownloader;
private downloadQueue: Map<string, boolean> = new Map(); // id -> isActive
private onProgress?: DownloadProgressCallback;
private cacheManager: CacheManager;
private constructor() {
this.cacheManager = CacheManager.getInstance();
}
static getInstance(): HLSDownloader {
if (!HLSDownloader.instance) {
HLSDownloader.instance = new HLSDownloader();
}
return HLSDownloader.instance;
}
setProgressCallback(callback: DownloadProgressCallback) {
this.onProgress = callback;
}
// 下载 HLS 视频
async downloadHLSVideo(
videoId: string,
title: string,
m3u8Url: string,
episodeName?: string,
): Promise<void> {
if (this.downloadQueue.get(videoId)) return; // 已在队列中
this.downloadQueue.set(videoId, true);
try {
// 1. 下载并解析 m3u8 索引
const playlistContent = await this.downloadWithRetry(m3u8Url);
const playlist = M3U8Parser.parse(playlistContent, m3u8Url);
// 2. 创建视频目录
const videoDir = `${this.cacheManager['cacheDir']}${videoId}/`;
const dirInfo = await FileSystem.getInfoAsync(videoDir);
if (!dirInfo.exists) {
await FileSystem.makeDirectoryAsync(videoDir, {
intermediates: true,
});
}
// 3. 保存原始 m3u8 文件
const m3u8LocalPath = `${videoDir}index.m3u8`;
await FileSystem.writeAsStringAsync(m3u8LocalPath, playlistContent);
// 4. 创建视频缓存条目
const video: CachedVideo = {
id: videoId,
title,
episodeName,
sourceUrl: m3u8Url,
localPath: m3u8LocalPath,
fileSize: 0,
downloadedSize: 0,
status: 'downloading',
progress: 0,
createdAt: new Date().toISOString(),
segments: playlist.segments.map((seg) => ({
index: seg.index,
url: seg.url,
localPath: `${videoDir}seg_${seg.index}.ts`,
status: 'pending',
duration: seg.duration,
})),
};
await this.cacheManager.updateVideo(video);
// 5. 并发下载 TS 片段
const CONCURRENCY = 3; // 同时下载 3 个片段
const totalSegments = playlist.segments.length;
let completedSegments = 0;
// 分批次下载(控制并发数)
for (let i = 0; i < totalSegments; i += CONCURRENCY) {
if (!this.downloadQueue.get(videoId)) break; // 被暂停
const batch = playlist.segments.slice(i, i + CONCURRENCY);
const results = await Promise.allSettled(
batch.map(async (seg) => {
try {
const segPath = `${videoDir}seg_${seg.index}.ts`;
await this.downloadWithRetry(seg.url, segPath);
video.segments![seg.index].status = 'completed';
} catch (error) {
video.segments![seg.index].status = 'failed';
// 重试一次
try {
const segPath = `${videoDir}seg_${seg.index}.ts`;
await this.downloadWithRetry(seg.url, segPath);
video.segments![seg.index].status = 'completed';
} catch {
throw error;
}
}
})
);
completedSegments += batch.length;
video.progress = Math.round(
(completedSegments / totalSegments) * 100
);
video.downloadedSize = completedSegments * 1024 * 1024; // 估算
await this.cacheManager.updateVideo(video);
this.onProgress?.(
videoId,
video.progress,
completedSegments,
totalSegments
);
}
// 6. 创建合并后的播放文件
const mergedPath = `${videoDir}merged.mp4`;
// 实际项目中可以用 ffmpeg 或其他方式合并 TS 为 MP4
// 这里简单记录为索引文件路径
video.localPath = m3u8LocalPath;
video.status = 'completed';
video.fileSize = video.downloadedSize;
video.completedAt = new Date().toISOString();
video.progress = 100;
await this.cacheManager.updateVideo(video);
this.downloadQueue.delete(videoId);
// 7. 检查缓存上限
await this.cacheManager.enforceCacheLimit();
} catch (error) {
console.error('HLS download error:', error);
const video = this.cacheManager.getVideo(videoId);
if (video) {
video.status = 'failed';
await this.cacheManager.updateVideo(video);
}
this.downloadQueue.delete(videoId);
throw error;
}
}
// 暂停下载
pauseDownload(videoId: string): void {
this.downloadQueue.set(videoId, false);
}
// 恢复下载
resumeDownload(videoId: string): void {
// 重新开始,但跳过已下载的片段
// 实际项目中应记录已完成片段列表
this.downloadQueue.set(videoId, true);
}
// 取消下载
async cancelDownload(videoId: string): Promise<void> {
this.downloadQueue.set(videoId, false);
this.downloadQueue.delete(videoId);
await this.cacheManager.deleteVideo(videoId);
}
// 带重试的下载
private async downloadWithRetry(
url: string,
localPath?: string,
maxRetries = 3
): Promise<string> {
for (let i = 0; i < maxRetries; i++) {
try {
if (localPath) {
// 下载到文件
const result = await FileSystem.downloadAsync(url, localPath);
return result.uri;
} else {
// 下载为字符串(解析 m3u8)
const result = await FileSystem.downloadAsync(
url,
`${FileSystem.cacheDirectory}temp_${Date.now()}.m3u8`
);
const content = await FileSystem.readAsStringAsync(result.uri);
await FileSystem.deleteAsync(result.uri, { idempotent: true });
return content;
}
} catch (error) {
if (i === maxRetries - 1) throw error;
// 等待后重试
await new Promise((resolve) => setTimeout(resolve, 1000 * (i + 1)));
}
}
throw new Error('Download failed');
}
}
export const hlsDownloader = HLSDownloader.getInstance();四、MP4 直链下载
对于非 HLS 的 MP4 视频,使用简单的直接下载方式:
// services/mp4-downloader.ts
import * as FileSystem from 'expo-file-system';
import { CacheManager, CachedVideo } from './cache-manager';
import { Platform } from 'react-native';
export class MP4Downloader {
private static instance: MP4Downloader;
private activeDownloads: Map<string, FileSystem.DownloadResumable> =
new Map();
private cacheManager: CacheManager;
private constructor() {
this.cacheManager = CacheManager.getInstance();
}
static getInstance(): MP4Downloader {
if (!MP4Downloader.instance) {
MP4Downloader.instance = new MP4Downloader();
}
return MP4Downloader.instance;
}
// 下载 MP4 文件
async downloadMP4(
videoId: string,
title: string,
url: string,
episodeName?: string,
onProgress?: (progress: number) => void
): Promise<void> {
const callback = FileSystem.createDownloadResumable(
url,
`${CacheManager['cacheDir']}${videoId}.mp4`,
{},
(progress) => {
const p = progress.totalBytesExpectedToWrite > 0
? Math.round(
(progress.totalBytesWritten /
progress.totalBytesExpectedToWrite) *
100
)
: 0;
onProgress?.(p);
}
);
this.activeDownloads.set(videoId, callback);
try {
const result = await callback.downloadAsync();
if (!result) throw new Error('Download failed');
const fileInfo = await FileSystem.getInfoAsync(result.uri);
const video: CachedVideo = {
id: videoId,
title,
episodeName,
sourceUrl: url,
localPath: result.uri,
fileSize: fileInfo.size || 0,
downloadedSize: fileInfo.size || 0,
status: 'completed',
progress: 100,
createdAt: new Date().toISOString(),
completedAt: new Date().toISOString(),
};
await this.cacheManager.updateVideo(video);
await this.cacheManager.enforceCacheLimit();
} catch (error) {
const video = this.cacheManager.getVideo(videoId);
if (video) {
video.status = 'failed';
await this.cacheManager.updateVideo(video);
}
throw error;
} finally {
this.activeDownloads.delete(videoId);
}
}
// 暂停下载
async pauseDownload(videoId: string): Promise<void> {
const download = this.activeDownloads.get(videoId);
if (download) {
await download.pauseAsync();
}
}
// 恢复下载
async resumeDownload(videoId: string): Promise<void> {
const download = this.activeDownloads.get(videoId);
if (download) {
const result = await download.resumeAsync();
if (result) {
// 更新状态为已完成
const video = this.cacheManager.getVideo(videoId);
if (video) {
video.status = 'completed';
video.completedAt = new Date().toISOString();
await this.cacheManager.updateVideo(video);
}
}
}
}
// 取消下载
async cancelDownload(videoId: string): Promise<void> {
const download = this.activeDownloads.get(videoId);
if (download) {
await download.cancelAsync();
this.activeDownloads.delete(videoId);
}
await this.cacheManager.deleteVideo(videoId);
}
}
export const mp4Downloader = MP4Downloader.getInstance();五、下载管理 Hook
// hooks/useDownloadManager.ts
import { useState, useEffect, useCallback, useRef } from 'react';
import { AppState, AppStateStatus } from 'react-native';
import { cacheManager, CachedVideo } from '../services/cache-manager';
import { hlsDownloader } from '../services/hls-downloader';
import { mp4Downloader } from '../services/mp4-downloader';
export function useDownloadManager() {
const [cachedVideos, setCachedVideos] = useState<CachedVideo[]>([]);
const [downloadingVideos, setDownloadingVideos] = useState<CachedVideo[]>([]);
const [totalCacheSize, setTotalCacheSize] = useState(0);
const [isLoading, setIsLoading] = useState(true);
// 刷新缓存列表
const refreshCache = useCallback(async () => {
await cacheManager.initialize();
setCachedVideos(cacheManager.getCachedVideos());
setDownloadingVideos(cacheManager.getDownloadingVideos());
const size = await cacheManager.getTotalCacheSize();
setTotalCacheSize(size);
}, []);
// 初始化
useEffect(() => {
(async () => {
await cacheManager.initialize();
setCachedVideos(cacheManager.getCachedVideos());
setDownloadingVideos(cacheManager.getDownloadingVideos());
const size = await cacheManager.getTotalCacheSize();
setTotalCacheSize(size);
setIsLoading(false);
})();
}, []);
// 设置进度回调
useEffect(() => {
hlsDownloader.setProgressCallback((_id, _progress, _seg, _total) => {
// 实时更新下载列表
setDownloadingVideos(cacheManager.getDownloadingVideos());
});
}, []);
// 开始下载 M3U8 视频
const startHLSDownload = useCallback(
async (
videoId: string,
title: string,
m3u8Url: string,
episodeName?: string
) => {
await hlsDownloader.downloadHLSVideo(videoId, title, m3u8Url, episodeName);
await refreshCache();
},
[refreshCache]
);
// 开始下载 MP4 视频
const startMP4Download = useCallback(
async (
videoId: string,
title: string,
url: string,
episodeName?: string
) => {
await mp4Downloader.downloadMP4(videoId, title, url, episodeName);
await refreshCache();
},
[refreshCache]
);
// 暂停下载
const pauseDownload = useCallback((videoId: string) => {
hlsDownloader.pauseDownload(videoId);
mp4Downloader.pauseDownload(videoId);
refreshCache();
}, [refreshCache]);
// 删除缓存
const deleteCache = useCallback(
async (videoId: string) => {
await cacheManager.deleteVideo(videoId);
await refreshCache();
},
[refreshCache]
);
// 格式化大小
const formatSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const size = bytes / Math.pow(1024, i);
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
};
return {
cachedVideos,
downloadingVideos,
totalCacheSize,
isLoading,
refreshCache,
startHLSDownload,
startMP4Download,
pauseDownload,
deleteCache,
formatSize,
};
}六、下载管理 UI
// components/DownloadManager.tsx
import React, { useCallback } from 'react';
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
Alert,
} from 'react-native';
import { useDownloadManager } from '../hooks/useDownloadManager';
export function DownloadManager() {
const {
cachedVideos,
downloadingVideos,
totalCacheSize,
formatSize,
deleteCache,
} = useDownloadManager();
// 删除确认
const handleDelete = useCallback(
(video: CachedVideo) => {
Alert.alert(
'删除缓存',
`确定删除「${video.title}」?`,
[
{ text: '取消', style: 'cancel' },
{
text: '删除',
style: 'destructive',
onPress: () => deleteCache(video.id),
},
]
);
},
[deleteCache]
);
// 渲染下载列表项
const renderDownloadingItem = ({ item }: { item: CachedVideo }) => (
<View style={styles.item}>
<View style={styles.itemInfo}>
<Text style={styles.itemTitle} numberOfLines={2}>
{item.title}
</Text>
{item.episodeName && (
<Text style={styles.episodeName}>{item.episodeName}</Text>
)}
<View style={styles.progressBar}>
<View
style={[
styles.progressFill,
{ width: `${item.progress}%` },
]}
/>
</View>
<Text style={styles.progressText}>
{item.status === 'paused'
? `已暂停 (${item.progress}%)`
: `下载中 (${item.progress}%)`}
</Text>
</View>
</View>
);
// 渲染已完成列表项
const renderCachedItem = ({ item }: { item: CachedVideo }) => (
<View style={styles.item}>
<View style={styles.itemInfo}>
<Text style={styles.itemTitle} numberOfLines={1}>
{item.title}
</Text>
{item.episodeName && (
<Text style={styles.episodeName}>{item.episodeName}</Text>
)}
<Text style={styles.fileSize}>{formatSize(item.fileSize)}</Text>
</View>
<TouchableOpacity
style={styles.deleteBtn}
onPress={() => handleDelete(item)}
>
<Text style={styles.deleteText}>🗑</Text>
</TouchableOpacity>
</View>
);
return (
<View style={styles.container}>
{/* 缓存统计 */}
<View style={styles.header}>
<Text style={styles.headerTitle}>缓存管理</Text>
<Text style={styles.cacheSize}>
已用 {formatSize(totalCacheSize)} / 上限 5 GB
</Text>
</View>
{/* 正在下载 */}
{downloadingVideos.length > 0 && (
<>
<Text style={styles.sectionTitle}>
下载中 ({downloadingVideos.length})
</Text>
<FlatList
data={downloadingVideos}
keyExtractor={(item) => item.id}
renderItem={renderDownloadingItem}
style={styles.list}
/>
</>
)}
{/* 已完成 / 已缓存 */}
<Text style={styles.sectionTitle}>
已缓存 ({cachedVideos.length})
</Text>
{cachedVideos.length === 0 ? (
<Text style={styles.emptyText}>暂无缓存视频</Text>
) : (
<FlatList
data={cachedVideos}
keyExtractor={(item) => item.id}
renderItem={renderCachedItem}
style={styles.list}
/>
)}
</View>
);
}七、后台下载服务
Android 前台服务
Android 上需要前台服务来维持后台下载:
// android/app/src/main/java/com/videocachedemo/DownloadService.java
public class DownloadService extends Service {
private static final int NOTIFICATION_ID = 1001;
private static final String CHANNEL_ID = "download_channel";
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
createNotificationChannel();
startForeground(NOTIFICATION_ID, buildNotification("正在下载..."));
// 执行下载任务
return START_STICKY;
}
private void createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
"下载服务",
NotificationManager.IMPORTANCE_LOW
);
NotificationManager manager = getSystemService(NotificationManager.class);
manager.createNotificationChannel(channel);
}
}
private Notification buildNotification(String content) {
return new Notification.Builder(this, CHANNEL_ID)
.setContentTitle("视频下载中")
.setContentText(content)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setOngoing(true)
.build();
}
}前台服务启动
// services/foreground-service.ts
import { Platform, NativeModules } from 'react-native';
const { ForegroundService } = NativeModules;
export async function startDownloadService() {
if (Platform.OS === 'android') {
ForegroundService.start();
}
}
export async function stopDownloadService() {
if (Platform.OS === 'android') {
ForegroundService.stop();
}
}八、本地播放器
下载完成后,使用缓存文件播放:
// components/CachedVideoPlayer.tsx
import React, { useEffect, useRef, useState } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Video, ResizeMode, AVPlaybackSource } from 'expo-av';
import { CachedVideo } from '../services/cache-manager';
interface CachedVideoPlayerProps {
video: CachedVideo;
}
export function CachedVideoPlayer({ video }: CachedVideoPlayerProps) {
const videoRef = useRef<Video>(null);
const [isPlaying, setIsPlaying] = useState(false);
useEffect(() => {
// 对于 M3U8 缓存,可以播放保存的 m3u8 文件
// 也可以播放合并后的 mp4 文件
if (video.localPath) {
videoRef.current?.loadAsync(
{ uri: video.localPath } as AVPlaybackSource,
{ shouldPlay: true },
false
);
}
}, [video.localPath]);
return (
<View style={styles.container}>
<Video
ref={videoRef}
style={styles.player}
useNativeControls
resizeMode={ResizeMode.CONTAIN}
onPlaybackStatusUpdate={(status) => {
if (status.isLoaded) {
setIsPlaying(status.isPlaying);
}
}}
/>
</View>
);
}九、完整使用示例
// screens/VideoDetailScreen.tsx
import React, { useState, useCallback } from 'react';
import {
View,
Text,
TouchableOpacity,
StyleSheet,
Alert,
} from 'react-native';
import { useDownloadManager } from '../hooks/useDownloadManager';
import { cacheManager } from '../services/cache-manager';
interface VideoSource {
url: string;
name: string;
isHLS: boolean;
}
export function VideoDetailScreen({ route }) {
const { videoId, title, sources } = route.params;
const { startHLSDownload, startMP4Download } = useDownloadManager();
const [downloading, setDownloading] = useState(false);
// 检查是否已缓存
const cachedVideo = cacheManager.getVideo(videoId);
const isCached = cachedVideo?.status === 'completed';
// 选择下载源
const handleDownload = useCallback(async (source: VideoSource) => {
setDownloading(true);
try {
if (source.isHLS) {
await startHLSDownload(videoId, title, source.url);
} else {
await startMP4Download(videoId, title, source.url);
}
Alert.alert('下载完成', `${title} 已缓存到本地`);
} catch (error) {
Alert.alert('下载失败', '请检查网络后重试');
} finally {
setDownloading(false);
}
}, [videoId, title, startHLSDownload, startMP4Download]);
return (
<View style={styles.container}>
{/* 视频信息 */}
<Text style={styles.title}>{title}</Text>
{/* 缓存状态 */}
<View style={styles.cacheStatus}>
{isCached ? (
<Text style={styles.cachedText}>✅ 已下载到本地</Text>
) : (
<Text style={styles.notCachedText}>📥 需要下载</Text>
)}
</View>
{/* 下载按钮 / 播放按钮 */}
{isCached ? (
<TouchableOpacity style={styles.playBtn}>
<Text style={styles.playText}>▶ 离线播放</Text>
</TouchableOpacity>
) : (
<>
{downloading ? (
<Text style={styles.downloadingText}>下载中...</Text>
) : (
<TouchableOpacity
style={styles.downloadBtn}
disabled={downloading}
onPress={() => handleDownload(sources[0])}
>
<Text style={styles.downloadText}>⬇ 下载缓存</Text>
</TouchableOpacity>
)}
{/* 多源选择 */}
{sources.length > 1 && (
<View style={styles.sourceList}>
<Text style={styles.sourceTitle}>选择清晰度:</Text>
{sources.map((source) => (
<TouchableOpacity
key={source.name}
style={styles.sourceBtn}
onPress={() => handleDownload(source)}
disabled={downloading}
>
<Text style={styles.sourceName}>{source.name}</Text>
</TouchableOpacity>
))}
</View>
)}
</>
)}
</View>
);
}十、异步下载优化
为了提供更流畅的下载体验,可以采用异步下载策略:
// services/async-download.ts
import { hlsDownloader } from './hls-downloader';
import { mp4Downloader } from './mp4-downloader';
import { cacheManager } from './cache-manager';
// 下载优先级队列
interface DownloadTask {
videoId: string;
title: string;
url: string;
isHLS: boolean;
priority: 'high' | 'normal' | 'low';
episodeName?: string;
}
class AsyncDownloadQueue {
private queue: DownloadTask[] = [];
private isProcessing = false;
private maxConcurrent = 2;
private activeCount = 0;
// 添加下载任务
async addTask(task: DownloadTask): Promise<void> {
this.queue.push(task);
// 按优先级排序
this.queue.sort((a, b) => {
const p = { high: 0, normal: 1, low: 2 };
return p[a.priority] - p[b.priority];
});
this.processQueue();
}
// 处理队列
private async processQueue(): Promise<void> {
if (this.isProcessing) return;
while (this.queue.length > 0 && this.activeCount < this.maxConcurrent) {
const task = this.queue.shift();
if (!task) break;
this.activeCount++;
try {
if (task.isHLS) {
// 压榨手机底层性能:多线程 + 大并发
await Promise.race([
hlsDownloader.downloadHLSVideo(
task.videoId,
task.title,
task.url,
task.episodeName
),
// 超时保护
new Promise((_, reject) =>
setTimeout(() => reject(new Error('下载超时')), 30 * 60 * 1000)
),
]);
} else {
await mp4Downloader.downloadMP4(
task.videoId,
task.title,
task.url,
task.episodeName
);
}
} catch (error) {
console.error(`Download failed: ${task.title}`, error);
} finally {
this.activeCount--;
}
}
this.isProcessing = false;
// 队列全部完成时清理缓存
if (this.queue.length === 0 && this.activeCount === 0) {
await cacheManager.enforceCacheLimit();
}
}
}
export const downloadQueue = new AsyncDownloadQueue();十一、性能优化与最佳实践
下载策略
并发控制:同时下载 3-5 个 TS 片段,避免带宽饱和
优先级队列:用户主动点击下载的任务排在高优先级
智能重试:失败片段自动重试 3 次,每次间隔递增
超时保护:单个文件下载超过 30 分钟自动失败
存储优化
缓存上限管理:超过 5GB 自动删除最早缓存
可视化存储:显示每个视频的缓存大小和总使用量
增量清理:删除长期未观看的视频
用户体验
进度可视化:实时显示下载进度和片段总数
状态角标:详情页集数列表显示"已缓存"角标
防重下载:自动检测已在队列或已完成的下载
断点续传:重新打开应用时恢复未完成的下载
错误处理
// 下载错误分类处理
enum DownloadErrorType {
NETWORK = 'network', // 网络问题
AUTH = 'auth', // 鉴权过期(403)
ENCRYPTION = 'encryption', // 解密失败
STORAGE = 'storage', // 存储空间不足
TIMEOUT = 'timeout', // 超时
}
function handleDownloadError(error: Error): DownloadErrorType {
const msg = error.message;
if (msg.includes('403') || msg.includes('Forbidden')) {
return DownloadErrorType.AUTH;
}
if (msg.includes('timeout') || msg.includes('Timeout')) {
return DownloadErrorType.TIMEOUT;
}
if (msg.includes('ENOSPC') || msg.includes('storage')) {
return DownloadErrorType.STORAGE;
}
return DownloadErrorType.NETWORK;
}十二、常见问题
Q:M3U8 下载时报 403 错误?
检查 Referer 和 Origin 请求头是否正确
部分 CDN 需要携带特定的 User-Agent
尝试在下载请求中添加原始页面的 Cookie
Q:下载进度不准确?
HLS 片段大小不均,建议使用片段完成数而非字节数计算进度
MP4 直链下载使用 Content-Length 头部估算
Q:后台下载被系统杀死?
Android:使用前台服务(Foreground Service + START_STICKY)
引导用户将应用加入系统省电白名单
下载完成时发送通知提醒用户
Q:加密 HLS 无法播放?
确保已正确下载并应用 AES-128 密钥
部分 DRM 加密需要专门的解密库
Q:存储空间不足?
引导用户清理缓存
在下载前检查可用空间
自动按 LRU 策略清理旧缓存
技术栈总结
模块 | 推荐方案 |
|---|---|
文件管理 | expo-file-system |
下载引擎 | react-native-blob-util |
播放器 | expo-av |
通知 | expo-notifications |
后台服务 | 原生 Foreground Service (Android) |
网络检测 | expo-network |
屏幕常亮 | expo-keep-awake |
存储 | D1 / AsyncStorage (元数据) |
本文档由 MClaw 生成并发布。
相关文章
暂无相关文章
