基于RN的免费视频APP

24 阅读5分钟

React Native 视频应用的插件化数据源架构设计与实现

didi-video:一个基于插件化数据源的 React Native 视频应用,通过将数据源逻辑与 App 壳分离,实现一套壳适配无限数据源的目标。
开源地址:gitee.com/konodayio/d… (MIT 协议)

1. 背景与问题

本人二次元喜欢看番剧,但三流app当中广告太多,于是乎根据RN写了一个视频播放器,通过网上获取第三方资源在APP内进行播放,但又遇到如果该ip服务停止使用了,又要从新编写新的逻辑与编译安卓APK

针对上述问题,本项目提出一种插件化数据源的架构:将数据获取与解析逻辑从 App 主包中剥离,由独立的插件文件提供。App 本体仅负责播放器、搜索、收藏、下载等核心功能,并通过统一的数据接口与插件交互。当数据源失效时,只需替换或更新插件文件,即可恢复服务,无需重新发布 App。

2. 核心架构

整体架构分为三层:App 壳、插件沙箱运行时和插件实例。

text

┌─────────────────────────────────┐
│           App 壳(本仓库)        │
│  播放器 / 搜索 / 收藏 / 下载 / UI  │
│         ↑ 统一数据接口            │
├─────────────────────────────────┤
│         插件沙箱运行时            │
│  new Function + 受限 require     │
├─────────────────────────────────┤
│   插件 A     插件 B     插件 C    │
│  (站点X适配) (站点Y适配) (自建源)  │
└─────────────────────────────────┘

每个插件是一个独立的 JavaScript 文件,实现约定好的接口。App 不关心插件内部是解析 HTML、请求 JSON 接口还是读取本地文件,只要返回符合约定的数据结构即可。

3. 插件接口定义

插件模块需导出以下接口:

typescript

interface IPluginModule {
  name: string;
  version: string;
  baseURL: string;
  getHomeList: (page: number) => Promise<HomeType[]>;      // 首页列表
  getDetailData: (url: string) => Promise<contentType>;    // 详情
  getPlayUrlData: (url: string) => Promise<playUrlType>;   // 播放地址
  getSearchData: (keyWord: string) => Promise<HomeType[]>; // 搜索
}

用户在 App 设置中导入插件文件(本地选择或输入 URL 下载),启用后即刻生效。更换数据源等同于更换插件文件,无需重新安装或更新 App。

4. 插件沙箱实现

插件本质上是外部提供的 JavaScript 代码,运行时加载必须考虑安全性。本项目的沙箱方案是在隔离作用域中执行插件代码。

function loadPluginFromCode(code: string): IPluginModule {
const context = Object.freeze({
    axios,
    htmlparser2,
    // global: null,
  });
  const useStrict = `"use strict"\n` + code;

  const moduleFn = new Function("context", useStrict);
  const pluginModule = moduleFn(context);
  if (!pluginModule || !pluginModule.name || !pluginModule.version) {
    throw new Error("插件文件格式错误:缺少 name 或 version 字段");
  }
  return pluginModule;
}

设计要点如下:

  • 隔离执行:通过严格模式不让动态代码使用require获取RNFS等可读写的依赖包保证安全
  • 模块缓存:插件加载后缓存在内存中,重复激活无额外开销。
  • 持久化存储:插件元数据(名称、版本、路径)使用 MMKV 持久化,App 重启后自动恢复。

5. 播放器手势与 UI 线程优化

播放器是视频应用的核心模块,本项目重点关注手势操作的流畅性。以左右滑动快进快退、左侧调节亮度、右侧调节音量为例,若在手势回调中直接调用 setState,由于 React 状态更新发生在 JS 线程,容易导致指示器动画与列表滚动同时卡顿。

解决方案是将所有实时反馈逻辑保留在 UI 线程,通过 Reanimated 的 SharedValue 驱动动画,并采用节流方式将必要数据同步回 JS 线程。

// 音量指示器动画:完全在 UI 线程运行
const volumeIndicatorOpacity = useSharedValue(0);

const indicatorStyle = useAnimatedStyle(() => ({
  opacity: volumeIndicatorOpacity.value,
}));

const volumeGesture = Gesture.Pan()
  .onBegin((e) => {
    // 判断落在左半屏还是右半屏(亮度 / 音量)
    isRightSide.value = e.x > wrapperWidth.value / 2;
    runOnJS(recordStartValue)();
  })
  .onUpdate((e) => {
    // UI 线程实时更新指示器,不经 JS 线程
    volumeShared.value = clamp(startVolume.value - e.translationY / 250);
    volumeIndicatorOpacity.value = 1;
    // 节流提交:每 100ms 才桥接一次到 JS 侧
    if (now() - lastVolumeCommit.value > 100) {
      lastVolumeCommit.value = now();
      runOnJS(commitVolume)(volumeShared.value);
    }
  });

该模式将高频手势的 JS 开销降低至每秒约 10 次,而用户看到的是 60fps 的实时反馈。同时,通过 Ref 镜像解决了手势回调中无法获取最新 React state 的闭包问题。

6. 其他技术细节

技术方案作用
FlashList 回收式列表长列表滚动性能稳定
MMKV 替代 AsyncStorage收藏、配置等数据毫秒级读写
Node 脚本自动生成 Icon 组件与组件导出20 个组件零手工维护
自研 rpx 响应式方案一套样式适配不同屏幕宽度
明/暗/自定义背景主题,20+ 语义化颜色变量主题切换全局即时生效

技术栈:Expo 53 + React Native 0.79(新架构)+ React 19 + TypeScript + Jotai + Reanimated 3。

7. 使用场景

本项目适用于以下场景:

  1. 直接使用:克隆仓库,执行 npm install && npm start,即可在 Android / iOS 上运行。
  2. 插件开发:为特定站点编写适配插件,仓库提供完整示例(plugins/example-omofun.js),仅需一个 module.exports
  3. 架构借鉴:插件沙箱与 UI 线程手势优化两部分代码均可独立复用于阅读器、漫画、资讯类 App。

8. 结语

本项目是一次“将 App 核心做小、扩展性做大”的实践:核心代码聚焦于播放体验与插件运行时,数据源等可变部分全部插件化。若您对插件化架构或 React Native 性能优化感兴趣,欢迎访问仓库获取源码或参与讨论。

APK下载地址: gitee.com/konodayio/d…
Gitee:gitee.com/konodayio/d…
GitHub:github.com/MasayumeKTY…
协议:MIT,可自由使用与修改。