banner
约 300 字
1 分钟

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 配合原生下载服务。


一、项目初始化

安装依赖

bash
# 创建项目
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):

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):

XML
<key>UIBackgroundModes</key>
<array>
  <string>fetch</string>
  <string>processing</string>
</array>

二、视频缓存核心模块

缓存管理器

TypeScript
// 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 解析器

TypeScript
// 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 下载器

TypeScript
// 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 视频,使用简单的直接下载方式:

TypeScript
// 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

TypeScript
// 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

TSX
// 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 上需要前台服务来维持后台下载:

Java
// 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();
    }
}

前台服务启动

TypeScript
// 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();
  }
}

八、本地播放器

下载完成后,使用缓存文件播放:

TSX
// 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>
  );
}

九、完整使用示例

TSX
// 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>
  );
}

十、异步下载优化

为了提供更流畅的下载体验,可以采用异步下载策略:

TypeScript
// 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();

十一、性能优化与最佳实践

下载策略

  1. 并发控制:同时下载 3-5 个 TS 片段,避免带宽饱和

  2. 优先级队列:用户主动点击下载的任务排在高优先级

  3. 智能重试:失败片段自动重试 3 次,每次间隔递增

  4. 超时保护:单个文件下载超过 30 分钟自动失败

存储优化

  1. 缓存上限管理:超过 5GB 自动删除最早缓存

  2. 可视化存储:显示每个视频的缓存大小和总使用量

  3. 增量清理:删除长期未观看的视频

用户体验

  1. 进度可视化:实时显示下载进度和片段总数

  2. 状态角标:详情页集数列表显示"已缓存"角标

  3. 防重下载:自动检测已在队列或已完成的下载

  4. 断点续传:重新打开应用时恢复未完成的下载

错误处理

TypeScript
// 下载错误分类处理
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 生成并发布。

END

相关文章

暂无相关文章