问题描述

某项目的视频说明书APP开发过程中遇到一个问题,网络带宽远低于视频码率时使用IjkPlayer播放会频繁缓冲再播放,影响使用体验。

问题复现

先是想办法复现问题,为了可以控制网络速度,使用了Throttly - Network Tools这个软件来通过本地VPN限速的方式来模拟低带宽环境。视频源可以直接使用ijkPlayer自带demo里的sample。

实际试下来会发生缓冲-播放频繁切换的现象,较为影响使用体验。

问题解决

视频码率和网络环境没有改善可能的情况下,只能从缓冲策略下手,来改善使用体验。 网上搜索了一下,其他的很多都是加快起播速度(所谓秒开)的设置方案,无帮助,最终在ijkPlayer的GitHub里找到了有人提出的类似问题,但最终没有答案。

但题主提到的几个option还是提供了很重要的线索,顺藤摸瓜找到了这篇日志,日志详细解释了ijkPlayer的缓冲策略和可以调整的几个option。大致摘抄如下:

buffering机制描述 在read_thread的for(;;)循环中首帧未播放时每50ms/首帧播放后每500ms进行检查是否可以恢复播放; 当检查到能满足播放时,就升级时间梯度,进行更严格的检查,让队列中缓存尽可能多的数据,以避免卡顿;同样,当触发卡顿时,也必须得等到满足时间梯度才能进入播放。 在解码前取包时,若发现取不到包了,则暂停播放,触发缓冲,并置is->paused为1;此时依然要等循环调用ffp_check_buffering_l,填满队列,满足播放条件后才能恢复播放状态;是以牺牲延迟来保障流畅。 满足播放条件:能播放时长已经足够播放hwm_in_ms了 或者 能播放的数据量已经大于256K了 (这里我看实际代码是不准确的,对应看下面代码块第60行,我在ijkPlayer的源码里没找到,估计后来改了?) 时间梯度默认初始值

high_water_mark_in_bytes = DEFAULT_HIGH_WATER_MARK_IN_BYTES; 256K,始终不变
first_high_water_mark_in_ms = DEFAULT_FIRST_HIGH_WATER_MARK_IN_MS; 100
next_high_water_mark_in_ms = DEFAULT_NEXT_HIGH_WATER_MARK_IN_MS; 1000
last_high_water_mark_in_ms = DEFAULT_LAST_HIGH_WATER_MARK_IN_MS; 5000
current_high_water_mark_in_ms = DEFAULT_FIRST_HIGH_WATER_MARK_IN_MS; 100

buffer缓冲时长 100ms -> 1s -> 2s -> 4s -> 5s,最大递增到5s;但满足256K即可放行; 代码如下:

static int read_thread(void *arg) {
    for  (;;) {
        /*
         * 开启缓冲机制
         * 在for循环中,每读到一个包,都会检查是否进行缓冲
         */
	    if (ffp->packet_buffering) {
            io_tick_counter = SDL_GetTickHR();
 
            if ((!ffp->first_video_frame_rendered && is->video_st) ||
                (!ffp->first_audio_frame_rendered && is->audio_st)) {
                // 首帧未显示前,50ms检测一次
                if (abs((int) (io_tick_counter - prev_io_tick_counter)) > FAST_BUFFERING_CHECK_PER_MILLISECONDS) {
                    prev_io_tick_counter = io_tick_counter;
                    ffp->dcc.current_high_water_mark_in_ms = ffp->dcc.first_high_water_mark_in_ms;
                    ffp_check_buffering_l(ffp);
                }
            } else {
                if (abs((int) (io_tick_counter - prev_io_tick_counter)) > BUFFERING_CHECK_PER_MILLISECONDS) {
                	// 首帧显示后,500ms检测一次
                    prev_io_tick_counter = io_tick_counter;
                    ffp_check_buffering_l(ffp);
                }
            }
        }      
    }
}
 
/*
  * 循环检查是否缓冲够了,够了就去播放吧,取消缓冲状态,取消暂停,恢复播放
  */
void ffp_check_buffering_l(FFPlayer *ffp) {
    // 阶梯递增,最大DEFAULT_LAST_HIGH_WATER_MARK_IN_MS,5s
    int hwm_in_ms = ffp->dcc.current_high_water_mark_in_ms;
    int hwm_in_bytes = ffp->dcc.high_water_mark_in_bytes;
 
    // 队列里缓存的能播放的音视频播放时长
    int64_t audio_cached_duration = ffp->stat.audio_cache.duration;
    int64_t video_cached_duration = ffp->stat.video_cache.duration;
    int cached_duration_in_ms = min((video_cached_duration, audio_cached_duration);
    
    /*
      *  计算当前能播放的时长超过了多少hwm_in_ms
      *  我理解这块是个四舍五入,然后放大一百倍,cached_duration_in_ms * 100.5 / hwm_in_ms
      *  后面的算法,实际上表示cached_duration_in_ms>hwm_in_ms即可进行播放了
      */
    int  buf_time_percent = (int) av_rescale(cached_duration_in_ms, 1005, hwm_in_ms * 10);
 
    // 队列里缓存的音视频总大小
    int cached_size = is->audioq.size + is->videoq.size;
    // 计算缓存数据大小超过了多少hwm_in_bytes
    int buf_size_percent = (int) av_rescale(cached_size, 1005, hwm_in_bytes * 10);
 
    /*
      *  能播放时长已经足够播放hwm_in_ms了  ||
      *  能播放的数据量已经足够播放hwm_in_bytes了,
      *  就解除缓冲状态/暂停状态,设置为播放状态
      */ 
    int need_start_buffering = 0;
    if  (buf_time_percent >= 100 || buf_size_percent >= 100) {
        need_start_buffering = 1;
    }
 
    if (need_start_buffering) {
        if (hwm_in_ms < ffp->dcc.next_high_water_mark_in_ms) {
            hwm_in_ms = ffp->dcc.next_high_water_mark_in_ms;
        } else {
            hwm_in_ms *= 2;
        }
 
        if (hwm_in_ms > ffp->dcc.last_high_water_mark_in_ms)
            hwm_in_ms = ffp->dcc.last_high_water_mark_in_ms;
 
        ffp->dcc.current_high_water_mark_in_ms = hwm_in_ms;
 
        if (is->buffer_indicator_queue && is->buffer_indicator_queue->nb_packets > 0) {
            if ((is->audioq.nb_packets >= MIN_MIN_FRAMES || is->audio_stream < 0 || is->audioq.abort_request)
                && (is->videoq.nb_packets >= MIN_MIN_FRAMES || is->video_stream < 0 || is->videoq.abort_request)) {
                // 音视频队列的缓冲区差不多了,> MIN_MIN,则去除暂停状态,
                ffp_toggle_buffering(ffp, 0);
            }
        }
    }
}
 
void ffp_toggle_buffering(FFPlayer *ffp, int start_buffering) {
    SDL_LockMutex(ffp->is->play_mutex);
    ffp_toggle_buffering_l(ffp, start_buffering);
    SDL_UnlockMutex(ffp->is->play_mutex);
}
 
void ffp_toggle_buffering_l(FFPlayer *ffp, int buffering_on) {
    if (!ffp->packet_buffering) {
        // 缓存机制是否开启
        return;
    }
 
    VideoState *is = ffp->is;
    if (buffering_on && !is->buffering_on) {
        // 当前没buffering, 要去buffering,FFP_MSG_BUFFERING_START
        is->buffering_on = 1;
        stream_update_pause_l(ffp); // 暂停
    } else if (!buffering_on && is->buffering_on) {
        // 当前buffering,取消buffering,FFP_MSG_BUFFERING_END
        is->buffering_on = 0;
        stream_update_pause_l(ffp); // 取消暂停
    }
}
 
 
/*
  * 软件音频、软硬解视频时,阻塞等待直到退出或者有AVPacket数据
  * 在开启缓冲机制的情况下,会暂停进行缓冲,等待check buffering修复到播放状态
  */
static int packet_queue_get_or_buffering(FFPlayer *ffp, PacketQueue *q, AVPacket *pkt, int *serial,
                                         int *finished) {
    assert(finished);
    if (!ffp->packet_buffering){
        // 未开启缓冲机制
        return packet_queue_get(q, pkt, 1, serial); // queue为空时会阻塞等待
    }
 
    while (1) {
        int new_packet = packet_queue_get(q, pkt, 0, serial);
        if (new_packet < 0) {
            return -1;
        } else if (new_packet == 0) {
            if (q->is_buffer_indicator && !*finished) {
                ffp_toggle_buffering(ffp, 1);   // 暂停当前,去缓冲,设置为1
            }
            new_packet = packet_queue_get(q, pkt, 1, serial); // 缓冲了,再拿一次
            if (new_packet < 0) {
                return -1;
            }
        }
 
        if (*finished == *serial) {
            av_packet_unref(pkt);
            continue;
        } else {
            break;
        }
    }
 
    return 1;
}

实际我在ijkPlayer的代码里看到对于缓冲时长和缓冲数据量的处理是这样的,如果缓冲时长设置有效,缓冲时长的percent优先,否则以数据量缓冲进度为准: 于是我在demo里添加了如下的修改

ijkMediaPlayer.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "next-high-water-mark-ms", 10 * 1000L);

但无效,并且log显示设置option失败

Error setting option next-high-water-mark-ms to value 10000.

又进行了一番搜索之后,看到这么一篇博客,解释了ijkPlayer设置option时一些限制:

{ "max-buffer-size",                    "max buffer size should be pre-read",
 OPTION_OFFSET(dcc.max_buffer_size), OPTION_INT(MAX_QUEUE_SIZE, 0, MAX_QUEUE_SIZE) }

配置一个选项需要填写4个信息: max-buffer-size:选项的名称; max buffer size should be pre-read:选项的描述; OPTION_OFFSET(dcc.max_buffer_size):选项对应的系统变量; OPTION_INT(MAX_QUEUE_SIZE, 0, MAX_QUEUE_SIZE):选项的取值范围; 下面解释OPTION_OFFSET、OPTION_INT函数: OPTION_OFFSET:

#define OPTION_OFFSET(x) offsetof(FFPlayer, x)

系统最终回调用offsetof函数,这个是gcc中的函数,网上搜了下,其作用为:访问返回结构体FFPlayer中成员变量x相对于结构体首地址的偏移量,转换为Java的思维就是返回FFPlayer类中成员变量x. OPTION_INT:

#define OPTION_INT(default__, min__, max__) \
    .type = AV_OPT_TYPE_INT, \
    { .i64 = default__ }, \
    .min = min__, \
    .max = max__, \
    .flags = AV_OPT_FLAG_DECODING_PARAM

其第一个参数为选项的默认值,第二个参数为选项的最小值,第三个参数为选项的最大值

再看我们设置的option相关代码,发现它的最大最小值是写死的 DEFAULT_FIRST_HIGH_WATER_MARK_IN_MS 到 DEFAULT_LAST_HIGH_WATER_MARK_IN_MS 之间

{ "first-high-water-mark-ms",           "first chance to wakeup read_thread",
 OPTION_OFFSET(dcc.first_high_water_mark_in_ms),
 OPTION_INT(DEFAULT_FIRST_HIGH_WATER_MARK_IN_MS,
            DEFAULT_FIRST_HIGH_WATER_MARK_IN_MS,
            DEFAULT_LAST_HIGH_WATER_MARK_IN_MS) },
{ "next-high-water-mark-ms",            "second chance to wakeup read_thread",
 OPTION_OFFSET(dcc.next_high_water_mark_in_ms),
 OPTION_INT(DEFAULT_NEXT_HIGH_WATER_MARK_IN_MS,
            DEFAULT_FIRST_HIGH_WATER_MARK_IN_MS,
            DEFAULT_LAST_HIGH_WATER_MARK_IN_MS) },
{ "last-high-water-mark-ms",            "last chance to wakeup read_thread",
 OPTION_OFFSET(dcc.last_high_water_mark_in_ms),
 OPTION_INT(DEFAULT_LAST_HIGH_WATER_MARK_IN_MS,
            DEFAULT_FIRST_HIGH_WATER_MARK_IN_MS,
            DEFAULT_LAST_HIGH_WATER_MARK_IN_MS) },

DEFAULT_FIRST_HIGH_WATER_MARK_IN_MS = 100ms DEFAULT_LAST_HIGH_WATER_MARK_IN_MS = 5000ms 我设置的是10000ms自然会报错,改为5000ms后不再报错且生效。 为什么会有上述限制还要进一步的寻找答案,目前猜和代码的逻辑有关系。