从 flv.js 到声网

内容分享2天前发布
0 0 0


一、传统方案:flv.js 的优势与痛点

✅ 优势回顾

兼容性强:利用 MSE(Media Source Extensions),在现代浏览器中无需插件即可播放 FLV 流。
部署简单:只需一个支持分块传输(chunked transfer)的 HTTP 服务器(如 Nginx + RTMP 模块)。
低门槛接入:前端仅需引入 
flv.js
,几行代码即可播放直播流。



const player = flvjs.createPlayer({ type: 'flv', url: 'http://example.com/live.flv' });
player.attachMediaElement(video);
player.load();

❌ 难以回避的痛点

尽管 flv.js 在“能播”上表现不错,但在高可用、低卡顿、强交互场景下,问题逐渐暴露:

卡顿频繁
网络抖动时,MSE 缓冲机制容易导致
waiting
/
stalled
事件频发,即使增加缓冲区也难以根治。

延迟不可控
虽然号称“低延迟”,但实际端到端延迟常在 2~5 秒,且随网络波动剧烈变化。

缺乏 QoS 保障
flv.js 本身不提供丢包重传、带宽自适应、FEC 等能力,完全依赖底层 TCP,而 TCP 在弱网下会加剧卡顿。

维护成本高
为应对上述问题,我们不得不在业务层实现:

自动追帧(seek to live edge)
卡死检测与重连
控制台日志抑制
页面切回恢复逻辑
多重定时器兜底
最终代码膨胀至 800+ 行,且仍无法彻底解决黑屏、转圈、无声等问题。

📌 结论:flv.js 适合“单向观看、容忍秒级延迟”的轻量直播;但对于实时互动、高稳定性要求的场景,它已力不从心。


二、新方案:为什么选择声网(Agora)?

声网作为全球领先的实时音视频云服务商,其 Web SDK(Agora RTC SDK for Web) 基于 WebRTC,具备以下核心优势:

特性 声网 (WebRTC) flv.js (HTTP-FLV)
端到端延迟 200ms ~ 800ms 1s ~ 5s+
弱网抗性 ✅ FEC + ARQ + 带宽自适应 ❌ 仅依赖 TCP
自动重连 内置,毫秒级恢复 需手动实现
音画同步 精确到毫秒级 依赖编码器,易失步
开发效率 高(封装完善) 低(需大量兜底逻辑)
跨平台一致性 强(iOS/Android/Web 统一体验) 弱(移动端需另做适配)

更重要的是,声网提供了完整的信令、媒体、录制、转码一体化服务,大幅降低后端运维复杂度。


三、迁移实践:从 flv.js 组件到 Agora RemotePlayer

1. 组件结构对比

旧:
VideoPlayer.vue
(flv.js)

手动管理 
flvPlayer
 生命周期
绑定 
waiting
/
stalled
/
playing
 等原生事件
实现复杂的重连、追帧、卡死检测逻辑
样式隐藏 video.js 默认控件

新:
RemotePlayer.vue
(Agora)


<template>
  <div class="remote-player">
    <div ref="container"></div>
    <div v-if="isLoading">加载中...</div>
    <div v-if="errorText">{
  
  { errorText }}</div>
  </div>
</template>
 
<script>
import AgoraRTC from 'agora-rtc-sdk-ng';
 
export default {
  async join(option) {
    const client = AgoraRTC.createClient({ mode: 'live', codec: 'vp8' });
    await client.join(option.appId, option.channelName, option.token);
    client.on('user-published', async (user, mediaType) => {
      await client.subscribe(user, mediaType);
      if (mediaType === 'video') user.videoTrack.play(this.$refs.container);
    });
  }
}
</script>

💡 关键变化从“拉流”变为“订阅”。Agora 自动处理连接、解码、渲染,开发者只需关注业务逻辑。

2. 性能与体验提升

首帧时间缩短 60%:WebRTC 直接建立 P2P 或 SFU 连接,无需等待 HLS 分片或 FLV 初始化。
卡顿率下降 90%+:声网智能网络调度 + 动态码率调整,弱网下仍可流畅播放。
零黑屏:SDK 内置重连机制,断网恢复后自动续播,无需手动 reload。
全链路监控:通过 Agora Analytics 可查看丢包率、码率、延迟等指标,便于运维。

3. 开发者体验飞跃

代码量减少 70%:无需处理 MSE、buffered、currentTime 等底层细节。
API 更语义化
join
 / 
leave
 / 
subscribe
 清晰表达意图。
错误边界明确:所有异常通过 Promise reject 或事件回调暴露,易于统一处理。


四、迁移建议与注意事项

信令服务对接
Agora 不负责用户鉴权和频道管理,需自行实现信令服务(可用 WebSocket + 自定义协议)。

Token 安全
生产环境务必启用 Token 鉴权,避免 AppID 被盗用。

角色区分
直播场景通常设观众为
audience
,主播为
broadcaster
,可通过
client.setClientRole()
切换。

资源释放
组件销毁时务必调用
client.leave()

track.stop()
,防止内存泄漏。

兼容性
Agora Web SDK 支持 Chrome/Firefox/Safari/Edge 等主流浏览器,但 iOS Safari 需注意 autoplay 策略。



<template>
  <div class="player">
    <video class="fullscreen video-js vjs-default-skin vjs-big-play-centered" ref="fullscreen"
      @dblclick.stop="fullScreen()" autoplay muted preload="auto"></video>
  </div>
</template>
 
<script>
import flvjs from "flv.js";
 
export default {
  name: "videoPlayer",
  data() {
    return {
      player: null,
      // HTMLVideoElement 引用(追帧/追直播边缘必须用它的 buffered/currentTime)
      _videoEl: null,
      lastDecodedFrame: 0,
      _readyTimer: null,
      _retryCount: 0,
      _maxRetries: 3,
      _retryDelayMs: 1200,
      _onUnhandledRejection: null,
      _onWindowError: null,
      // 追直播边缘定时器:用于把延迟持续拉回到 live edge 附近
      _liveEdgeTimer: null,
      // 码流“停帧”计数:用于判定是否需要温和重连(减少一直转圈)
      _stalledCount: 0,
      _stallLastCheckAt: 0,
      _stallLastDecoded: null,
      _stallSecondsStuck: 0,
      // 限制 reload 频率:避免网络抖动时频繁重连导致“动不动就转圈”
      _lastReloadAt: 0,
      _minReloadIntervalMs: 30 * 1000,
      // 切屏恢复看门狗:用于处理“切回页面黑屏+转圈不恢复”
      _resumeCheckTimer: null,
      // init-stall(初始化卡死)检测:readyState 长时间为 0 时按指数退避重建,避免无限转圈
      _initStallTimer: null,
      _initStallSince: 0,
      _initStallReloadTimer: null,
      _initStallBackoffMs: 3000,
      _inInitStall: false,
      // waiting/stalled 处理定时器
      _waitingKickTimer: null,
      _waitingHardTimer: null,
      _waitingStartedAt: 0,
      _lastForceReloadAt: 0,
      _minForceReloadIntervalMs: 8000,
      _onVideoWaiting: null,
      _onVideoStalled: null,
      _onVideoPlaying: null,
      _onVideoCanplay: null,
      // 连续 waiting 计数:只有长时间 waiting 才重连(优先 seek)
      _waitingCount: 0,
 
      // flv.js 控制台刷屏抑制(仅在本组件生命周期内生效)
      _originalConsoleWarn: null,
 
      // ===== 诊断开关(默认关闭) =====
      // 开启方式:在浏览器控制台执行:window.__vpDebug = true
      // 关闭:window.__vpDebug = false
      _debugLogEveryMs: 2000,
      _lastDebugLogAt: 0,
      _waitingTotal: 0,
      _stalledTotal: 0,
      // 诊断心跳:即使没有触发 waiting / STATISTICS_INFO,也能看到日志
      _debugHeartbeatTimer: null,
      // 诊断开关监听:支持在控制台后置开启 window.__vpDebug=true
      _debugFlagWatcherTimer: null,
 
      _debugEnabledLast: false,
    };
  },
  props: ["url", "elId"],
  emits: ["ready", "error"],
  mounted() {
    if (this.url) this.reloadVideo();
    document.addEventListener('visibilitychange', this.handlePageVisibility);
    // Windows 下 Alt+Tab/切换到其他应用,有时不会触发 visibilitychange;用 focus/pageshow 兜底恢复。
    try {
      window.addEventListener('pageshow', this.handlePageShow);
    } catch (_) { }
 
    // ===== 诊断开关监听(默认关闭) =====
    // 说明:你可能在播放器创建后才在控制台输入 window.__vpDebug=true。
    // 这里用一个轻量轮询来保证一旦开启就能看到日志。
    try {
      if (this._debugFlagWatcherTimer) clearInterval(this._debugFlagWatcherTimer);
      this._debugFlagWatcherTimer = setInterval(() => {
        try {
          const enabled = this._isDebugEnabled();
          if (enabled && !this._debugEnabledLast) {
            this._maybeDebugLog('debug_enabled');
          }
          this._debugEnabledLast = enabled;
          this._ensureDebugHeartbeat();
        } catch (_) { }
      }, 500);
    } catch (_) { }
 
    // 说明:不要全局 patch HTMLMediaElement.prototype.play(副作用过大)。
 
    // 过滤浏览器省电策略导致的 play() 中断,避免在开发环境被 error overlay 显示到界面
    this._onUnhandledRejection = (event) => {
      try {
        const reason = event && event.reason;
        if (this._isBackgroundPlayInterruptedError(reason)) {
          console.warn('[videoPlayer] suppressed unhandledrejection (power-saving policy):', reason);
          if (event && typeof event.preventDefault === 'function') event.preventDefault();
          return;
        }
      } catch (_) { }
    };
    this._onWindowError = (event) => {
      try {
        const msg = (event && (event.message || (event.error && event.error.message))) || '';
        if (this._isBackgroundPlayInterruptedError(msg)) {
          console.warn('[videoPlayer] suppressed window error (power-saving policy):', msg);
          if (event && typeof event.preventDefault === 'function') event.preventDefault();
          return;
        }
      } catch (_) { }
    };
    try {
      window.addEventListener('unhandledrejection', this._onUnhandledRejection);
      window.addEventListener('error', this._onWindowError);
    } catch (_) { }
  },
  beforeDestroy() {
    document.removeEventListener('visibilitychange', this.handlePageVisibility);
    try {
      window.removeEventListener('pageshow', this.handlePageShow);
    } catch (_) { }
 
    try {
      if (this._onUnhandledRejection) window.removeEventListener('unhandledrejection', this._onUnhandledRejection);
      if (this._onWindowError) window.removeEventListener('error', this._onWindowError);
    } catch (_) { }
    this._onUnhandledRejection = null;
    this._onWindowError = null;
 
    // 说明:移除全局 play patch 后无需恢复。
  },
  watch: {
    url(newVal) {
      try {
        if (!newVal) {
          this.destroyed();
          return;
        }
        this.reloadVideo();
      } catch (e) { console.error(e); }
    }
  },
  methods: {
    _isDebugEnabled() {
      try {
        // 诊断开关:推荐 window.__vpDebug=true;兼容 window._vpDebug=true
        return !!(
          (window && window.__vpDebug) ||
          (window && window._vpDebug)
        );
      } catch (_) {
        return false;
      }
    },
    _getNumber(v, fallback) {
      const fb = (typeof fallback === 'number') ? fallback : 0;
      return Number.isFinite(v) ? v : fb;
    },
    _incCounter(key, delta) {
      // 统一安全自增:避免热更新/字段未初始化导致 NaN/undefined
      const d = (typeof delta === 'number') ? delta : 1;
      try {
        const cur = this._getNumber(this[key], 0);
        this[key] = cur + d;
        return this[key];
      } catch (_) {
        return 0;
      }
    },
 
    _maybeDebugLog(tag, extra) {
      try {
        if (!this._isDebugEnabled()) return;
        const now = this._now();
        if (now - this._lastDebugLogAt < this._debugLogEveryMs && tag !== 'waiting_hard_timeout') return;
        this._lastDebugLogAt = now;
 
        const ve = this._videoEl || this.$refs.fullscreen;
        let lag = null;
        try {
          if (ve && ve.buffered && ve.buffered.length) {
            const end = ve.buffered.end(ve.buffered.length - 1);
            const cur = ve.currentTime;
            const diff = end - cur;
            if (Number.isFinite(diff) && diff >= 0) lag = +diff.toFixed(3);
          }
        } catch (_) { }
        const payload = Object.assign({
          tag,
          lag,
          waitingTotal: this._getNumber(this._waitingTotal, 0),
          stalledTotal: this._getNumber(this._stalledTotal, 0),
          waitingCount: this._getNumber(this._waitingCount, 0),
          retryCount: this._getNumber(this._retryCount, 0),
        }, extra || {});
        console.log('[videoPlayer][diag]', payload);
      } catch (_) { }
    },
    _now() {
© 版权声明

相关文章

暂无评论

none
暂无评论...