发布性能优先的视频信息流:Tendbble 如何用 Expo 构建实时社交应用
两位前端开发用同一套 React Native 代码库做出视频社交应用 Tendbble,靠单播放器策略在中端 Android 上撑住 50 到 100 条帖子的滑动信息流。
中文
复制

本文是 Pierre Cangemi 的客座文章。他是一名 React Native 与 TypeScript 专家,拥有十余年移动应用开发经验,也是 Tendbble 的联合创始人兼 CTO。
…
上周,有用户跟我说,这个 app「感觉像是一个大团队做出来的」。而我们只有两个前端开发。一个代码库。同时发布到两个平台。
几个月前,即便我有之前的 Expo 经验,也不确定我们能不能做成。Tendbble 是一个媒体内容很重的社交视频分享应用,正面竞争的是那些估值百亿美元的巨头。这类产品,性能不是加分项,性能就是产品本身。
用户每滑一次、每拍一次、每看一个转场,都会拿我们和 Instagram、Snapchat、TikTok 比。我们必须把 app 推到极限:实时视频流、带手势驱动浮层的自定义相机、两个平台上都流畅的动画,全都出自同一个代码库,只有两个前端开发。
在 app 里,用户通过协作式的限时帖子一起记录瞬间。信息流是视频的无限滚动。相机是主要的创作入口。实时评论、互动、位置分享和地图视图把它们串在一起。每个界面都大量使用动画,每次交互都必须感觉是即时的。
这就是我们在开发过程中学到的东西。
挑战:两个平台上的视频密集型信息流
Tendbble 的核心体验就是滑动浏览视频帖子的信息流。每张卡片可能包含一个使用 HLS 自适应流媒体的视频,上面叠加着用户信息、互动和评论。一次典型的使用中,用户可能会滑过 50 到 100 条帖子。
最朴素的做法是给每个可见卡片都挂一个视频播放器,让系统自己去处理。在 iOS 上,这样能撑一阵子。在中端 Android 设备上,这就是灾难。多个视频播放器争抢解码资源,导致掉帧、不同视频的音频互相重叠、内存一路攀升,直到系统把 app 杀掉。
我们很早就明白,视频子系统不会自己管好自己。如果你在做视频信息流,就必须由你来决定播什么、什么时候初始化、什么时候销毁。
核心思路:任何时候只允许一个视频播放
我们的突破在概念上很简单:在整个 app 内强制一条全局规则,任何时候只允许一个视频播放。不是每个屏幕一个,而是整个 app 一个。当一个帖子进入视口,它就取得播放权,之前正在播放的自动释放。
这一条约束彻底消除了音频重叠,把峰值内存占用砍掉一半,也让 feed 在 Android 上流畅得多。
但我们没有止步于此。我们还加了一个延迟生效机制——帖子可见满 400 毫秒之前,我们根本不创建视频播放器。快速滚动时,帖子一闪而过,不值得为初始化播放器付出代价。一张带 blurhash 占位符的缩略图填补这段空白,用户完全察觉不到。
预加载方面我们控制得很紧:沿滚动方向只预载后一个帖子,前面为零。配合 expo-video 内置的 HLS 支持,播放感觉是即时的,又不会把内存压垮。活跃的视频订阅从 20–30 个降到了 3–4 个。
打造相机体验
相机是 Tendbble 用户创作内容的地方。他们拍摄照片和视频,添加可手势定位和旋转的文字叠加层,然后分享到协作帖子。它必须像原生相机 app 一样快、一样跟手,因为用户就是拿这个标准来衡量我们的。
我们选择 react-native-vision-camera 而不是 expo-camera,有三个具体原因:细粒度的编解码器选择(iOS 上用 H.265,Android 上用 H.264)、可访问多个物理镜头并配合手势驱动的变焦系统,以及能显著降低拍摄期间内存占用的缓冲区压缩选项。
相机界面有一个拍摄按钮,根据按压时长区分拍照和录像:轻点拍照,长按录像,并用 Skia 渲染的进度环显示剩余录制时间。双击切换前后摄像头。捏合手势控制变焦,在 0.5x、1x、2x 处有档位吸附。
文字叠加层方面,我们写了一个自定义 Expo 原生模块,在原生层把字幕合成到照片和视频上。字幕编辑器让用户用手势拖动、旋转、缩放文字,原生模块再把这些叠加层以完整分辨率烧录进最终媒体文件。这样避免了很多 app 退而求其次的截图方案所带来的画质损失。
花了数周才找到的内存泄漏
上线之后,我们发现了不对劲的地方。反复打开、关闭相机的用户,App 会越来越卡,最后崩溃。内存一路攀升,再也降不下来。
用 Instruments 做性能分析后找到了元凶:当视图从层级中移除时,react-native-vision-camera v4.7.3 并不会停止 AVCaptureSession。React Navigation 为了返回导航的性能,会让页面继续留在内存里,不会立刻释放。于是相机就在后台隐形地持续运行,即使用户早已离开,它仍在占用内存和 GPU 资源。
这个库在视图离开层级时没有任何清理逻辑,也没有在反初始化时停止采集会话。我们用 patch-package 修掉了它,加了两个简单的钩子:一个在视图从父级移除时停止会话,另一个在反初始化时停止会话,作为兜底。
Vision Camera Before

视觉摄像头补丁之后

这个教训比修复本身更值得记住:原生资源不会按你预期的方式遵循 React 的组件生命周期。如果你在用 React Navigation,同时接了任何原生相机、视频或音频库,去查一查屏幕失去焦点时实际发生了什么。结果可能会让你意外。
用 Reanimated 和 Skia 做到 60fps
我们在全应用 200 多个文件里用了 Reanimated v4。从信息流的模式切换器,到实时倒计时,再到马赛克拼贴编辑器,背后都是它。配合 React Native Skia 做自定义渲染,我们有了足够的工具,去做出那种由几百名工程师支撑的应用才有的交互质感。
最重要的一条经验:UI 线程是神圣的。只要动画依赖 JS 线程——哪怕只是一瞬间——当 JS 线程忙于拉数据、处理导航转场或跑业务逻辑时,就有掉帧的风险。
我们的实时倒计时就是个好例子。它要显示一个实时时钟、一个进度环,还要在倒计时和拍摄张数之间切换,每一帧都在更新。这些都不会触发 React 重渲染。整套系统跑在 UI 线程上,用的是 Reanimated worklet 和帧回调。文字更新通过 TextInput 上的 animated props 完成,完全绕开 React。结果是:即使 JS 线程负载很重,动画依然丝滑。
手势组合:点按、长按和拖拽如何共存
要让这几个手势互不冲突地共存,需要仔细组合。我们让点按手势与「长按 + 平移」的组合手势竞速,并采用手动激活——只有长按触发之后,平移才会激活。触觉反馈做了频率限制,避免快速的手势更新把音频线程压垮。
把简单手势与带手动激活门控的复杂组合手势放在一起竞速,这套做法后来在整个应用里都能复用。我们的可滑动模式选择器和拖拽关闭的弹窗,用的都是同一套方案。
Skia 做 CSS 做不到的事
我们选择性地使用 Skia,不是拿它替代 Views,而是用它完成那些否则无法实现、或者会卡顿的渲染。
卡片上旋转的渐变边框,由一个 Reanimated shared value 驱动 Skia 的 sweep gradient(60fps 旋转,完全不经过 JS)。我们的 squircle 形状用程序化生成的 Bézier 路径,做出 CSS border radius 无法复刻的那种平滑圆角。字幕编辑器里的文字叠加层采用双遍渲染,描边层垫在填充层下面,让字幕在任何视频背景上都清晰可读。
关键思路是:渲染交给 Skia,数值驱动交给 Reanimated。两者天然互补。Reanimated 负责时序、插值和手势响应,Skia 负责视觉输出。谁也不去抢对方的活。
React Compiler:白捡的性能提升
升级到 React 19 并接入 React Compiler 后,我们几乎没花什么力气就拿到了可观的性能提升。编译器自动处理 memoization,代码库里散落的手写 useMemo、useCallback、React.memo 全都不需要了。我们删掉了大部分手写的 memoization,交给编译器接管。
效果在快速滚动和页面切换时最明显,以前这些场景下不必要的 re-render 会叠加成掉帧。编译器直接消灭了一整类我们一直在手动追查的性能 bug。对一个媒体密集、JS 线程上每一毫秒都重要的 app 来说,这点余量很关键。
代价是纪律:组件和 hook 必须严格遵守 React 的纯函数规则。渲染期间不能有副作用,不能修改 props 或 state。我们本来就在遵循这些模式,所以迁移很顺利,但约定比较松的团队要做好清理代码的准备。
分平台的性能预算
早期我们犯过一个错误:给两个平台定了同一套动画预算。带 ProMotion 屏幕的 iOS 设备能跑到 120fps,而很多 Android 设备在复杂动画中连 60fps 都很难稳住。
我们现在维护着两套独立的性能配置。iOS 上采用弹簧物理动画,调好阻尼和刚度,激进预加载,动画时序按 120fps 目标来定。低端 Android 上则完全禁用弹簧动画,加大手势节流间隔,改用更简单的缓动曲线,并缩小列表窗口。
两个平台上应用都感觉跟手,但我们不会为了在 Android 上凑一个硬件根本撑不住的预算而掉帧。这是一次思路转变:按平台交付不同的动画质量不是妥协,而是工程上正确的做法。
看不见的工作:离线优先与内存压力
签名 URL 与缓存持久化
我们把查询缓存持久化,最长保留 24 小时,这样用户离线打开应用仍能看到信息流。但媒体 URL 是 AWS 签名 URL,TTL 只有 1 小时。
应用在后台待一整夜后,重新水合的是隔天的缓存数据,里面的 URL 早已过期。信息流能渲染出来,但每张图片、每个视频都是坏的,满屏灰色占位符。
我们的修复方案是在重新水合时检查查询的新鲜度。只要有缓存数据超过 45 分钟,就全部失效并强制重新拉取。旧布局仍会以骨架屏的形式短暂显示,同时新数据在加载,这比满屏坏掉的媒体好得多。方案很简单,但这个 bug 只在长时间后台之后才出现,开发期间很难抓到。
内存压力管理
对一个媒体密集型应用来说,内存管理不是可选项。我们做了一套基于优先级的清理系统,响应原生的内存警告。先清视频缓冲区,再清图片解码缓存,最后清查询缓存,各步之间错开延迟,避免阻塞 UI。应用进入后台时,我们主动触发清理。长时间滚动时,定期刷掉 expo-image 的解码位图缓存,防止堆积。
接下来做什么
我们正在探索几个方向,把体验再往前推:
-
信息流卡片与详情页之间的共享元素转场。Reanimated 里相关基础设施已经启用,但还没充分利用
-
更智能的视频预加载:根据滚动速度进行预测,慢速浏览时更积极地预取,快速滑动时则收敛
-
基于 expo-task-manager 实现后台上传队列,App 被杀后仍能可靠续传
-
边缘缓存媒体资源,减少对签名 URL 的依赖,改善冷启动性能
最后几点想法
过去这一年最大的收获,跟某个具体的 API 或优化技巧无关,而是关于真正的工作发生在哪里。
Expo 及其生态 expo-video、expo-image、Reanimated、Skia、React Compiler 把基础部分处理得很好。HLS 播放能用,图片缓存能用,动画跑在 UI 线程上,memoization 是自动的。托管工作流意味着日常开发根本不用碰 Xcode 或 Android Studio。
难的是这些工具之外的一切:判断什么时候创建视频播放器、什么时候销毁它;明白 React Navigation 的生命周期和原生 view 的生命周期不是一回事;接受 iOS 和 Android 需要不同的动画预算;抓住那些只在整夜后台运行之后才出现的缓存 bug。
正是这些决策,让一个 App 感觉像是大团队做出来的——哪怕它只是两个疯狂的开发者,在挑战那些身价十亿的对手。Expo 给了我们杠杆,让我们能专注在这些决策上,而不是跟平台底层管道较劲。对一个小团队做一件有野心的事来说,这就是全部。