基础入门 React Native 鸿蒙跨平台开发:Video 视频列表与轮播播放

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

一、核心知识点

视频列表播放是视频应用的核心功能之一,支持用户浏览多个视频并快速切换。在鸿蒙端,通过 react-native-video 配合 FlatListScrollView 可以实现流畅的视频列表播放体验。

视频列表核心概念

typescript 复制代码
import Video, { VideoRef } from 'react-native-video';
import { FlatList, ScrollView } from 'react-native';

// 视频列表数据结构
interface VideoItem {
  id: string;
  uri: string;
  title: string;
  thumbnail: string;
  duration: number;
}

// 基础视频列表
const videoList: VideoItem[] = [
  { id: '1', uri: 'https://media.w3.org/2010/05/sintel/trailer.mp4', title: '视频1', thumbnail: '', duration: 120 },
  { id: '2', uri: 'https://media.w3.org/2010/05/bunny/trailer.mp4', title: '视频2', thumbnail: '', duration: 180 },
];

视频列表播放模式

视频列表播放支持以下两种展示方式:

  • 垂直列表播放 - 使用 FlatList 垂直滚动,当前视频播放时其他暂停
  • 网格展示播放 - 多列网格布局,点击播放全屏视频

二、实战核心代码解析

1. 垂直视频列表播放

typescript 复制代码
// 垂直视频列表
const VerticalVideoList = () => {
  const [videoList] = useState<VideoItem[]>([
    { id: '1', uri: 'https://media.w3.org/2010/05/sintel/trailer.mp4', title: 'Sintel 预告片', thumbnail: '', duration: 60 },
    { id: '2', uri: 'https://media.w3.org/2010/05/bunny/trailer.mp4', title: 'Big Buck Bunny', thumbnail: '', duration: 120 },
    { id: '3', uri: 'https://media.w3.org/2010/05/video/movie_300.mp4', title: '电影片段', thumbnail: '', duration: 300 },
  ]);

  const [currentPlayingId, setCurrentPlayingId] = useState<string>('1');

  const renderVideoItem = ({ item }: { item: VideoItem }) => {
    const isPlaying = currentPlayingId === item.id;

    return (
      <TouchableOpacity onPress={() => setCurrentPlayingId(item.id)}>
        <Video
          source={{ uri: item.uri }}
          style={styles.videoItem}
          paused={!isPlaying}
          resizeMode="cover"
          repeat={true}
          muted={!isPlaying}
        />
        <Text style={styles.videoTitle}>{item.title}</Text>
      </TouchableOpacity>
    );
  };

  return (
    <FlatList
      data={videoList}
      renderItem={renderVideoItem}
      keyExtractor={(item) => item.id}
      pagingEnabled={true}
      showsVerticalScrollIndicator={false}
    />
  );
};

2. 视频网格展示

typescript 复制代码
// 视频网格展示
const VideoGrid = () => {
  const [selectedVideo, setSelectedVideo] = useState<VideoItem | null>(null);

  const videoList: VideoItem[] = [
    { id: '1', uri: 'https://media.w3.org/2010/05/sintel/trailer.mp4', title: '网格视频 1', thumbnail: '', duration: 60 },
    { id: '2', uri: 'https://media.w3.org/2010/05/bunny/trailer.mp4', title: '网格视频 2', thumbnail: '', duration: 120 },
    { id: '3', uri: 'https://media.w3.org/2010/05/video/movie_300.mp4', title: '网格视频 3', thumbnail: '', duration: 300 },
    { id: '4', uri: 'https://media.w3.org/2010/05/sintel/trailer.mp4', title: '网格视频 4', thumbnail: '', duration: 60 },
  ];

  if (selectedVideo) {
    return (
      <View style={styles.fullscreenContainer}>
        <Video
          source={{ uri: selectedVideo.uri }}
          style={styles.fullscreenVideo}
          resizeMode="contain"
          repeat={true}
        />
        <TouchableOpacity
          style={styles.closeButton}
          onPress={() => setSelectedVideo(null)}
        >
          <Text style={styles.closeButtonText}>✕</Text>
        </TouchableOpacity>
      </View>
    );
  }

  return (
    <View style={styles.gridContainer}>
      {videoList.map((item) => (
        <TouchableOpacity
          key={item.id}
          style={styles.gridItem}
          onPress={() => setSelectedVideo(item)}
        >
          <Video
            source={{ uri: item.uri }}
            style={styles.gridVideo}
            paused={true}
            resizeMode="cover"
          />
          <View style={styles.gridOverlay}>
            <Text style={styles.playIcon}>▶</Text>
          </View>
          <Text style={styles.gridTitle}>{item.title}</Text>
        </TouchableOpacity>
      ))}
    </View>
  );
};

三、实战完整版:视频列表播放器

typescript 复制代码
import React, { useState, useRef, useEffect } from 'react';
import {
  View,
  Text,
  TouchableOpacity,
  StyleSheet,
  FlatList,
  ScrollView,
  Dimensions,
  StatusBar,
  SafeAreaView,
  ActivityIndicator,
  Image,
} from 'react-native';
import Video, { VideoRef, ResizeMode } from 'react-native-video';

interface VideoItem {
  id: string;
  uri: string;
  title: string;
  description: string;
  thumbnail: string;
  duration: number;
  views: number;
}

const VideoListCarouselScreen = () => {
  const [activeTab, setActiveTab] = useState<'list' | 'grid'>('list');
  const [currentPlayingId, setCurrentPlayingId] = useState<string>('1');
  const [selectedVideo, setSelectedVideo] = useState<VideoItem | null>(null);
  const [isLoading, setIsLoading] = useState<boolean>(true);

  const videoList: VideoItem[] = [
    {
      id: '1',
      uri: 'https://media.w3.org/2010/05/sintel/trailer.mp4',
      title: 'Sintel 动画预告片',
      description: '一部精彩的3D动画短片',
      thumbnail: '',
      duration: 60,
      views: 1000000,
    },
    {
      id: '2',
      uri: 'https://media.w3.org/2010/05/bunny/trailer.mp4',
      title: 'Big Buck Bunny',
      description: '可爱的大雄兔动画故事',
      thumbnail: '',
      duration: 120,
      views: 800000,
    },
    {
      id: '3',
      uri: 'https://media.w3.org/2010/05/video/movie_300.mp4',
      title: '经典电影片段',
      description: '精选电影精彩片段',
      thumbnail: '',
      duration: 300,
      views: 1200000,
    },
    {
      id: '4',
      uri: 'https://media.w3.org/2010/05/sintel/trailer.mp4',
      title: '动画世界探索',
      description: '探索奇妙的动画世界',
      thumbnail: '',
      duration: 90,
      views: 600000,
    },
    {
      id: '5',
      uri: 'https://media.w3.org/2010/05/bunny/trailer.mp4',
      title: '自然风光',
      description: '美丽的自然风光展示',
      thumbnail: '',
      duration: 150,
      views: 900000,
    },
    {
      id: '6',
      uri: 'https://media.w3.org/2010/05/video/movie_300.mp4',
      title: '电影精彩剪辑',
      description: '经典电影精彩瞬间',
      thumbnail: '',
      duration: 200,
      views: 750000,
    },
  ];

  useEffect(() => {
    setTimeout(() => setIsLoading(false), 1000);
  }, []);

  const formatDuration = (seconds: number): string => {
    const mins = Math.floor(seconds / 60);
    const secs = Math.floor(seconds % 60);
    return `${mins}:${secs.toString().padStart(2, '0')}`;
  };

  const formatViews = (views: number): string => {
    if (views >= 1000000) {
      return `${(views / 1000000).toFixed(1)}M`;
    } else if (views >= 1000) {
      return `${(views / 1000).toFixed(1)}K`;
    }
    return views.toString();
  };

  const renderTabButton = (tab: 'list' | 'grid', title: string) => (
    <TouchableOpacity
      style={[styles.tabButton, activeTab === tab && styles.tabButtonActive]}
      onPress={() => setActiveTab(tab)}
    >
      <Text style={[styles.tabButtonText, activeTab === tab && styles.tabButtonTextActive]}>
        {title}
      </Text>
    </TouchableOpacity>
  );

  const renderVerticalList = () => (
    <FlatList
      data={videoList}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => {
        const isPlaying = currentPlayingId === item.id;

        return (
          <TouchableOpacity
            style={styles.listItem}
            onPress={() => setCurrentPlayingId(item.id)}
          >
            <View style={styles.listVideoContainer}>
              <Video
                source={{ uri: item.uri }}
                style={styles.listVideo}
                paused={!isPlaying}
                resizeMode="cover"
                repeat={true}
                muted={!isPlaying}
                volume={isPlaying ? 1.0 : 0}
              />
              {!isPlaying && (
                <View style={styles.playOverlay}>
                  <Text style={styles.playIcon}>▶</Text>
                </View>
              )}
              <View style={styles.durationBadge}>
                <Text style={styles.durationText}>{formatDuration(item.duration)}</Text>
              </View>
            </View>
            <View style={styles.listInfo}>
              <Text style={styles.listTitle} numberOfLines={2}>
                {item.title}
              </Text>
              <Text style={styles.listDescription} numberOfLines={2}>
                {item.description}
              </Text>
              <Text style={styles.listStats}>
                {formatViews(item.views)} 次观看
              </Text>
            </View>
            {isPlaying && (
              <View style={styles.playingIndicator}>
                <Text style={styles.playingText}>播放中</Text>
              </View>
            )}
          </TouchableOpacity>
        );
      }}
      showsVerticalScrollIndicator={false}
    />
  );

  const renderGrid = () => (
    <View style={styles.gridContainer}>
      {videoList.map((item) => (
        <TouchableOpacity
          key={item.id}
          style={styles.gridItem}
          onPress={() => setSelectedVideo(item)}
        >
          <View style={styles.gridVideoContainer}>
            <Video
              source={{ uri: item.uri }}
              style={styles.gridVideo}
              paused={true}
              resizeMode="cover"
            />
            <View style={styles.gridOverlay}>
              <Text style={styles.gridPlayIcon}>▶</Text>
            </View>
            <View style={styles.gridDurationBadge}>
              <Text style={styles.gridDurationText}>{formatDuration(item.duration)}</Text>
            </View>
          </View>
          <Text style={styles.gridTitle} numberOfLines={2}>
            {item.title}
          </Text>
          <Text style={styles.gridViews}>{formatViews(item.views)} 次观看</Text>
        </TouchableOpacity>
      ))}
    </View>
  );

  if (isLoading) {
    return (
      <SafeAreaView style={styles.loadingContainer}>
        <ActivityIndicator size="large" color="#007DFF" />
        <Text style={styles.loadingText}>加载中...</Text>
      </SafeAreaView>
    );
  }

  if (selectedVideo) {
    return (
      <SafeAreaView style={styles.fullscreenContainer}>
        <StatusBar hidden={true} />
        <Video
          source={{ uri: selectedVideo.uri }}
          style={styles.fullscreenVideo}
          resizeMode={ResizeMode.CONTAIN}
          repeat={true}
          volume={1.0}
        />
        <View style={styles.fullscreenOverlay}>
          <Text style={styles.fullscreenTitle}>{selectedVideo.title}</Text>
          <Text style={styles.fullscreenDescription}>{selectedVideo.description}</Text>
        </View>
        <TouchableOpacity
          style={styles.closeButton}
          onPress={() => setSelectedVideo(null)}
        >
          <Text style={styles.closeButtonText}>✕</Text>
        </TouchableOpacity>
      </SafeAreaView>
    );
  }

  return (
    <SafeAreaView style={styles.container}>
      <StatusBar barStyle="dark-content" />

      <View style={styles.header}>
        <Text style={styles.headerTitle}>视频播放器</Text>
        <Text style={styles.headerSubtitle}>列表 / 网格</Text>
      </View>

      <View style={styles.tabContainer}>
        {renderTabButton('list', '列表')}
        {renderTabButton('grid', '网格')}
      </View>

      <View style={styles.contentContainer}>
        {activeTab === 'list' && renderVerticalList()}
        {activeTab === 'grid' && renderGrid()}
      </View>

      <View style={styles.footer}>
        <Text style={styles.footerText}>共 {videoList.length} 个视频</Text>
      </View>
    </SafeAreaView>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#F2F3F5',
  },
  loadingContainer: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: '#F2F3F5',
  },
  loadingText: {
    marginTop: 12,
    fontSize: 14,
    color: '#666',
  },
  header: {
    padding: 20,
    backgroundColor: '#fff',
    borderBottomWidth: 1,
    borderBottomColor: '#E5E6EB',
  },
  headerTitle: {
    fontSize: 20,
    fontWeight: '600',
    color: '#333',
  },
  headerSubtitle: {
    fontSize: 14,
    color: '#666',
    marginTop: 4,
  },
  tabContainer: {
    flexDirection: 'row',
    backgroundColor: '#fff',
    paddingHorizontal: 20,
    paddingVertical: 12,
    borderBottomWidth: 1,
    borderBottomColor: '#E5E6EB',
  },
  tabButton: {
    flex: 1,
    paddingVertical: 8,
    marginRight: 12,
    borderRadius: 8,
    backgroundColor: '#F2F3F5',
    alignItems: 'center',
  },
  tabButtonActive: {
    backgroundColor: '#007DFF',
  },
  tabButtonText: {
    fontSize: 14,
    color: '#666',
    fontWeight: '500',
  },
  tabButtonTextActive: {
    color: '#fff',
  },
  contentContainer: {
    flex: 1,
  },
  listItem: {
    flexDirection: 'row',
    backgroundColor: '#fff',
    marginHorizontal: 16,
    marginVertical: 8,
    borderRadius: 12,
    overflow: 'hidden',
    elevation: 2,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 1 },
    shadowOpacity: 0.1,
    shadowRadius: 2,
  },
  listVideoContainer: {
    width: 160,
    height: 120,
    position: 'relative',
  },
  listVideo: {
    width: '100%',
    height: '100%',
  },
  playOverlay: {
    position: 'absolute',
    top: 0,
    left: 0,
    right: 0,
    bottom: 0,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: 'rgba(0, 0, 0, 0.3)',
  },
  playIcon: {
    fontSize: 32,
    color: '#fff',
  },
  durationBadge: {
    position: 'absolute',
    bottom: 8,
    right: 8,
    backgroundColor: 'rgba(0, 0, 0, 0.7)',
    paddingHorizontal: 6,
    paddingVertical: 3,
    borderRadius: 4,
  },
  durationText: {
    fontSize: 12,
    color: '#fff',
    fontWeight: '500',
  },
  listInfo: {
    flex: 1,
    padding: 12,
    justifyContent: 'center',
  },
  listTitle: {
    fontSize: 15,
    fontWeight: '600',
    color: '#333',
    marginBottom: 6,
  },
  listDescription: {
    fontSize: 13,
    color: '#666',
    marginBottom: 8,
  },
  listStats: {
    fontSize: 12,
    color: '#999',
  },
  playingIndicator: {
    position: 'absolute',
    top: 8,
    right: 8,
    backgroundColor: '#007DFF',
    paddingHorizontal: 8,
    paddingVertical: 4,
    borderRadius: 12,
  },
  playingText: {
    fontSize: 12,
    color: '#fff',
    fontWeight: '500',
  },
  gridContainer: {
    flexDirection: 'row',
    flexWrap: 'wrap',
    padding: 8,
  },
  gridItem: {
    width: '48%',
    margin: '1%',
    backgroundColor: '#fff',
    borderRadius: 12,
    overflow: 'hidden',
    elevation: 2,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 1 },
    shadowOpacity: 0.1,
    shadowRadius: 2,
  },
  gridVideoContainer: {
    aspectRatio: 16 / 9,
    position: 'relative',
  },
  gridVideo: {
    width: '100%',
    height: '100%',
  },
  gridOverlay: {
    position: 'absolute',
    top: 0,
    left: 0,
    right: 0,
    bottom: 0,
    justifyContent: 'center',
    alignItems: 'center',
    backgroundColor: 'rgba(0, 0, 0, 0.3)',
  },
  gridPlayIcon: {
    fontSize: 40,
    color: '#fff',
  },
  gridDurationBadge: {
    position: 'absolute',
    bottom: 8,
    right: 8,
    backgroundColor: 'rgba(0, 0, 0, 0.7)',
    paddingHorizontal: 6,
    paddingVertical: 3,
    borderRadius: 4,
  },
  gridDurationText: {
    fontSize: 12,
    color: '#fff',
    fontWeight: '500',
  },
  gridTitle: {
    fontSize: 14,
    fontWeight: '600',
    color: '#333',
    padding: 12,
    paddingTop: 8,
  },
  gridViews: {
    fontSize: 12,
    color: '#999',
    paddingHorizontal: 12,
    paddingBottom: 12,
  },
  fullscreenContainer: {
    flex: 1,
    backgroundColor: '#000',
  },
  fullscreenVideo: {
    width: '100%',
    height: '100%',
  },
  fullscreenOverlay: {
    position: 'absolute',
    bottom: 0,
    left: 0,
    right: 0,
    padding: 24,
    backgroundColor: 'linear-gradient(to top, rgba(0,0,0,0.8), transparent)',
  },
  fullscreenTitle: {
    fontSize: 22,
    fontWeight: '600',
    color: '#fff',
    marginBottom: 8,
  },
  fullscreenDescription: {
    fontSize: 15,
    color: 'rgba(255, 255, 255, 0.8)',
  },
  closeButton: {
    position: 'absolute',
    top: 50,
    right: 20,
    width: 40,
    height: 40,
    borderRadius: 20,
    backgroundColor: 'rgba(0, 0, 0, 0.5)',
    justifyContent: 'center',
    alignItems: 'center',
  },
  closeButtonText: {
    fontSize: 24,
    color: '#fff',
    fontWeight: '600',
  },
  footer: {
    padding: 16,
    backgroundColor: '#fff',
    borderTopWidth: 1,
    borderTopColor: '#E5E6EB',
    alignItems: 'center',
  },
  footerText: {
    fontSize: 14,
    color: '#666',
  },
});

export default VideoListCarouselScreen;

四、TypeScript 类型错误修复说明

在使用视频列表功能时,建议正确定义类型以避免类型错误:

typescript 复制代码
import { NativeSyntheticEvent, NativeScrollEvent } from 'react-native';

// 正确定义滚动事件处理函数类型
const handleScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
  console.log('滚动事件:', event.nativeEvent.contentOffset);
};

五、OpenHarmony6.0 专属避坑指南

以下是鸿蒙 RN 开发中实现「视频列表播放」的所有真实高频踩坑点,按出现频率排序,问题现象贴合开发实际,解决方案均为「一行代码/简单配置」,所有方案均为鸿蒙端专属最优解:

问题现象 问题原因 鸿蒙端最优解决方案
列表滚动时视频卡顿 多个视频同时加载导致内存占用过高 ✅ 只播放当前可见区域的视频,使用 paused 控制其他视频暂停
网格布局视频尺寸不一致 未正确设置视频容器的宽高比 ✅ 使用 aspectRatio: 16/9 保持统一的视频比例
视频列表内存占用过高 所有视频组件同时渲染 ✅ 使用 FlatListremoveClippedSubviews 属性优化渲染
全屏播放返回后列表错位 全屏状态未正确处理 ✅ 使用 SafeAreaView 确保布局在状态栏下方
视频切换时音频重叠 旧视频未正确暂停 ✅ 切换前先暂停当前播放的视频
视频列表滚动性能差 未启用视图回收机制 ✅ 确保 FlatList 使用 getItemLayout 优化性能
网格点击视频无反应 视频容器遮挡了点击事件 ✅ 确保点击事件绑定在最外层容器上

⚠️ 特别注意:鸿蒙端使用视频列表的要求:

  • 内存优化 - ✅ 同时播放的视频不超过 2 个
  • 性能优化 - ✅ 使用 removeClippedSubviews 减少渲染负担
  • 用户体验 - ✅ 提供清晰的视频切换指示和加载状态
  • 网络优化 - ✅ 使用低码率的缩略图,高清视频按需加载

六、扩展用法:视频列表高频进阶优化(纯原生 无依赖 鸿蒙适配)

基于本次的核心视频列表代码,结合 RN 的内置能力,可轻松实现鸿蒙端开发中所有高频的视频列表进阶需求

✔️ 扩展1:视频预加载

提前加载下一个视频,提升切换流畅度:

typescript 复制代码
const [preloadIndex, setPreloadIndex] = useState<number>(1);

useEffect(() => {
  setPreloadIndex((currentIndex + 1) % videoList.length);
}, [currentIndex]);

<Video
  source={{ uri: videoList[currentIndex].uri }}
  preload={videoList[preloadIndex].uri}
/>

✔️ 扩展2:视频缓存管理

实现视频缓存清理和管理:

typescript 复制代码
const clearVideoCache = () => {
  if (videoRef.current) {
    videoRef.current.seek(0);
  }
};

✔️ 扩展3:视频播放进度记忆

记住每个视频的播放进度:

typescript 复制代码
const [videoProgress, setVideoProgress] = useState<Record<string, number>>({});

const handleProgress = (item: VideoItem, currentTime: number) => {
  setVideoProgress(prev => ({
    ...prev,
    [item.id]: currentTime,
  }));
};

<Video
  source={{ uri: item.uri }}
  onProgress={(data) => handleProgress(item, data.currentTime)}
  initialPosition={videoProgress[item.id]}
/>

✔️ 扩展4:视频分享功能

支持分享当前播放的视频:

typescript 复制代码
import { Share } from 'react-native';

const shareVideo = (item: VideoItem) => {
  Share.share({
    message: `观看精彩视频:${item.title}\n${item.uri}`,
    url: item.uri,
  });
};
相关推荐
方安乐2 小时前
react笔记之tanstack
前端·笔记·react.js
2501_921930834 小时前
基础入门 React Native 鸿蒙跨平台开发:react-native-button三方库适配
react native·react.js·harmonyos
CappuccinoRose18 小时前
React框架学习文档(七)
开发语言·前端·javascript·react.js·前端框架·reactjs·react router
●VON20 小时前
React Native for OpenHarmony:贪吃蛇游戏的开发与跨平台适配实践
学习·react native·react.js·游戏·openharmony
摘星编程1 天前
在OpenHarmony上用React Native:Switch禁用状态
javascript·react native·react.js
徐同保1 天前
react-markdown使用
前端·react.js·前端框架
●VON1 天前
React Native for OpenHarmony:猜数字游戏完整技术实现文档
学习·react native·react.js·游戏·开源鸿蒙·von
jin4213521 天前
基于React Native鸿蒙跨平台一款阅读追踪应用完成进度条的增加与减少,可以实现任务的进度计算逻辑
javascript·react native·react.js·ecmascript·harmonyos