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. 使用场景
本项目适用于以下场景:
- 直接使用:克隆仓库,执行
npm install && npm start,即可在 Android / iOS 上运行。 - 插件开发:为特定站点编写适配插件,仓库提供完整示例(
plugins/example-omofun.js),仅需一个module.exports。 - 架构借鉴:插件沙箱与 UI 线程手势优化两部分代码均可独立复用于阅读器、漫画、资讯类 App。
8. 结语
本项目是一次“将 App 核心做小、扩展性做大”的实践:核心代码聚焦于播放体验与插件运行时,数据源等可变部分全部插件化。若您对插件化架构或 React Native 性能优化感兴趣,欢迎访问仓库获取源码或参与讨论。
APK下载地址: gitee.com/konodayio/d…
Gitee:gitee.com/konodayio/d…
GitHub:github.com/MasayumeKTY…
协议:MIT,可自由使用与修改。