视频播放优化

前言

视频播放优化是通过跳转到视频页面能够瞬间播放、无缝播放、播放器横竖屏的动画、进度条UISlider滑动和返回手势以及scrollView的冲突造成进行拖拽体验不好这几个方面来进行的优化,在优化的过程中, 也遇到很多问题,踩了很多坑,因此分享一下优化的经验和心得,以及遇到的坑

视频页面播放速度优化
视频无缝播放

在跳转到视频页面播放之前, 通常会分为播放中和未播放的状态, 这时我们需要对这两种状态分别来进行处理, 比如首页一般视频都是动态图,这种场景我们就可以在首页获取到播放URL进行预加载视频,提前下载前五秒的视频数据, 并且获取到播放所需要的数据传递进去,加到视频播放列表数组里, 直接刷新播放列表,进行播放,然后再去请求后台其它视频的数据,这样就可以实现无感播放,基本上可以连封面都看不到,这里需要注意的是,跳转到视频页面播放之前,不要做任何延时、耗时的操作,并且调用播放的时机要在拿到数据刷新列表后就调用

1.缓存预加载5s视频数据

// 缓存一个视频
- (void)startPreloadVideo:(TVideo *)video {
    
    dispatch_set_target_queue(_concurrentQueue, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_LOW, 0));
    MJWeakSelf
    dispatch_async_limit(_concurrentQueue, _limitSemaphoreCount, ^{
        NSURL *proxyURL = [KTVHTTPCache proxyURLWithOriginalURL:[NSURL URLWithString:video.iosUrl]];
        if (proxyURL.absoluteString == nil) {
            return;
        }
        TPreLoaderVideoModel *preLoader;
        @synchronized (weakSelf.preloadAllTask) { //缓存中去取 有的话任务已经创建
            preLoader = [weakSelf.preloadAllTask objectForKeyNotNil:proxyURL.absoluteString];
        }
        if (preLoader != nil) { //缓存有 就重新开启
            [weakSelf taskResumeWithPreloadTask:preLoader];
        }else { //缓存没有 就需要创建
            [weakSelf createTaskWithUrl:proxyURL video:video];
        }
    });
    
}

预加载这一块儿,是专门封装的一个工具类,具体实现代码就不贴出来了,实现思路通过GCD的信号量来进行下载队列顺序的维护,通过KTVHTTPCache第三方库来进行缓存,当离开屏幕后停止预加载,缓存多长时间可以通过接口返回视频的大小来计算可以对应时长缓存的大小,超过对应时长的缓存体积就停止当前视频的预加载

  1. 数据传递并刷新列表播放
- (void)setFeed:(TFeed *)feed {
    _feed = feed;
    if (feed.author && feed.author.ID > 0 && feed.video) { 
        _firistFeedLayout = [[TLearningCircleFeedSVlogLayout alloc] initWithFeed:feed];
        [self.layouts removeAllObjects];
        [self.layouts addObject:_firistFeedLayout];
        [self.tableView reloadData];
        // 拿到数据后立即调用播放,减少接口调用时间,从而达到无感播放
        [self.tableView findTheBestToPlayVideoCell];
    }
}

这样未播放的视频无感播放就可以了,现在来说一下,正在播放的视频跳转到视频详情或者视频列表进行无缝播放,有两种实现方案,可以针对不同的场景去选择

  • 跳转只需要续播,不要动画
    我们的项目里将播放器的播放、暂停等逻辑通过一个单例来进行管理,当我们从正在播放的视频跳转,要续播的话,就不能将播放器暂停,当跳转到下一个页面,去切换播放器显示的View就可以实现续播, 我们使用的是阿里云的播放器,通过调用下面这行代码就可以实现播放器视图的切换
[TPlayerManager manager].player.playerView = self.viewPlayer

这时跳转续播就实现了, 现在是返回到跳转前的页面,也需要续播, 这时候有一个问题, 如果将切换播放器显示的View的时机放在viewWillAppear就会导致,上一个页面返回会出现黑屏的问题, 这时我们就会考虑放在viewDidAppear, 但回到当前页面会出现黑屏,所以最终我们在跳转到下一个播放器之前, 将封面加到视频上, 然后返回到这个页面,开始播放,将封面隐藏,就不会出现黑屏的问题,今日头条和哔哩哔哩也是采用的这种方式,还有一些坑, 比如手势返回来回滑动导致无法续播等,没啥难度,就是通过对应的场景去处理就好了

  • 跳转需要续播且需要动画
    整体的实现思路是通过播放的视图传递到下一个页面,通过播放的视图做一个转场的动画,转场动画的整体思路就是将播放View的Frame转换为Window上的Frame, 通过系统的present 的 Custom来实现动画, 整体思路可以参考下面播放器横竖屏转换的动画
播放器横竖屏转换的动画
播放器横竖屏切换动画

播放器横竖屏切换之前实现是通过系统的横竖屏切换, 再刷新UI布局来实现,这次产品提出的优化是,列表播放,竖屏转横屏其它视频保持竖屏状态,只有点击的视频切换到横屏,这时系统的横竖屏转换的方案就不行了,具体产品效果可以参照今日头条或爱奇艺,大体的实现思路可以参考字节跳动技术团队的这篇文章
iOS端一次视频全屏需求的实现

看完你会发现,由于iOS13的setStatusBarOrientation方法被系统废弃,所以只能参考方案一实现旋转的动画,还需要去解决状态栏显示的问题,有的同学可能和我想的一样,隐藏状态栏不就可以解决了, 哈哈,但是你转到横屏下拉菜单栏,就会发现,系统的菜单栏(WIFI、通知等)拉不下来,只能从横屏的左边,也就是竖屏的顶部才可以拉,因为这是实际上,你还是竖屏

Google看到一篇文章 iOS播放器全屏旋转实现, 通过播放器View旋转+竖屏Window ,可以解决基于字节跳动技术团队方案一横竖屏切换状态栏的问题, 就是通过播放器View旋转+竖屏Window来控制状态栏的旋转和显示, 这里有个坑需要注意一下, 完成竖屏切横屏的动画,需要将系统的window屏幕旋转方向切换为只允许竖屏,不然把手机方向重力感应,来回晃动, 会再次触发切换到横屏,造成UI错位

  • 竖屏转横屏:
    // 记录全屏前的parentView 和 Frame, 供横屏转竖屏用
    self.viewVideo.videoPlayParentView = self.viewVideo.superview;
    self.viewVideo.videoPlayHalfFrame = self.viewVideo.frame;

    CGRect rectInWindow = [self.viewVideo convertRect:self.viewVideo.bounds toView:[UIApplication sharedApplication].keyWindow];
    // 建立一个新的viewVideo添加到WIndow用作动画
    self.viewVideo.frame = rectInWindow;
    [self.viewVideo removeFromSuperview];
    [[UIApplication sharedApplication].keyWindow addSubview:self.viewVideo];
    self.viewVideo.isHorizontalScreen = YES;

    // 允许横屏
    AppDelegate *appDelegate = (AppDelegate *)[UIApplication sharedApplication].delegate;
    appDelegate.allowRotation = 1;

    TTangVideoSceneVC *scene = [[TTangVideoSceneVC alloc] init];
    // 将状态栏旋转成横屏
    scene.interfaceOrientationMask = UIInterfaceOrientationMaskLandscapeRight;
    UIWindow *videoWindow = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
    videoWindow.rootViewController = scene;
    
    // 执行竖屏转横屏的动画
    [UIView animateKeyframesWithDuration:0.35 delay:0 options:UIViewKeyframeAnimationOptionLayoutSubviews animations:^{
        self.viewVideo.transform = CGAffineTransformMakeRotation(M_PI_2);
        self.viewVideo.bounds = CGRectMake(0, 0, CGRectGetHeight(self.viewVideo.superview.bounds), CGRectGetWidth(self.viewVideo.superview.bounds));
        self.viewVideo.center = CGPointMake(CGRectGetMidX(self.viewVideo.superview.bounds), CGRectGetMidY(self.viewVideo.superview.bounds));
        // 更新横屏UI布局和数据
        [self.viewVideo layoutRefresh:TVideoPlayViewTypeSVlog isFull:isFull];
        [self.viewVideo updateUserInfoWithFeed:self.layout.feed];
    } completion:^(BOOL finished) {
        //  动画完成后只允许竖屏,解决横屏后,屏幕自动旋转导致UI错位的问题
        AppDelegate *appDelegate = (AppDelegate *)[UIApplication sharedApplication].delegate;
        appDelegate.allowRotation = 0;
    }];
  • 横屏转竖屏:
    [self.viewVideo layoutRefresh:TVideoPlayViewTypeSVlog isFull:isFull];

    CGRect frame = [self.viewVideo.videoPlayParentView convertRect:self.viewVideo.videoPlayHalfFrame toView:[UIApplication sharedApplication].keyWindow];

    // 允许竖屏
    AppDelegate *appDelegate = (AppDelegate *)[UIApplication sharedApplication].delegate;
    appDelegate.allowRotation = 0;
    
    // 状态栏转成竖屏
    TTangVideoSceneVC *scene = [[TTangVideoSceneVC alloc] init];
    scene.interfaceOrientationMask = UIInterfaceOrientationMaskPortrait;
    UIWindow *videoWindow = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
    videoWindow.rootViewController = scene;

    [[UIApplication sharedApplication].keyWindow addSubview:self.viewVideo];

    // 还原
    [UIView animateKeyframesWithDuration:0.35 delay:0 options:UIViewKeyframeAnimationOptionLayoutSubviews animations:^{
        self.viewVideo.transform = CGAffineTransformIdentity;
        self.viewVideo.frame = frame;
    } completion:^(BOOL finished) {
        [self.viewVideo removeFromSuperview];
        self.viewVideo.frame = self.viewVideo.videoPlayHalfFrame;
        [self.viewVideo.videoPlayParentView addSubview:self.viewVideo];
        [self setPerform:isFull];
    }];

竖屏Window的rootViewController代码如下:

NS_ASSUME_NONNULL_BEGIN

@interface TTangVideoSceneVC : UIViewController

@property (nonatomic, assign) UIInterfaceOrientationMask interfaceOrientationMask;

@end

NS_ASSUME_NONNULL_END
#import "TTangVideoSceneVC.h"

@interface TTangVideoSceneVC ()

@end

@implementation TTangVideoSceneVC

- (void)viewDidLoad {
    [super viewDidLoad];

}

- (BOOL)shouldAutorotate {
    return YES;
}

- (UIInterfaceOrientationMask)supportedInterfaceOrientations {
    
    return self.interfaceOrientationMask;
}

@end

这样横竖屏的旋转以及状态栏、菜单栏就可以正常显示了,但是又发现下拉菜单栏完之后,然后转成竖屏,所有页面的UI都往右偏移了, 最后定位到是系统的iOS13以上的bug, iOS 13 UITableView ContentView 变化了, 这篇文章也提供了一个解决方案, 重写layoutSubviews,但只适用页面少的情况,现在这里是很多页面,不能一个一个页面改,最后一个偶然的场景,发现执行了present, 页面就都正常了, 原理不知道( 有知道的同学可以下边留言帮我解答一下),效果还是可以的,所以在切换到竖屏过程中,先present空的VC,再miss, 问题就解决了,有些 性能差了机型偶尔会闪白, 所以加了iOS13及以上的机型并且下拉了菜单栏才执行present操作,产品还是可以接受的,哈哈~~~

    // iOS13及以上的机型并且下拉了菜单栏
    if (TRuntimeData.sharedInstance.tangVideoFullScreenSystemToolbarShow && @available(iOS 13.0, *)) {
        // 取消动画是为了解决present会造出旋转过程中顶部闪白的问题
        [UIView setAnimationsEnabled:NO];
        UIViewController *vc = [[UIViewController alloc] init];
        TNavigationController *nav = [[TNavigationController alloc] initWithRootViewController:vc];
        nav.modalPresentationStyle = UIModalPresentationFullScreen;
        // present是为了解决横竖屏旋转下拉出现工具栏造出iOS13之后的系统修改了content的width,导致全局各种UI错位
        [[UIViewController getCurrentVC] presentViewController:nav animated:NO completion:nil];
        [[UIViewController getCurrentVC] dismissViewControllerAnimated:NO completion:nil];

        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
            [UIView setAnimationsEnabled:YES];
        });
    }

最后还有一个小问题,因为本质上还是竖屏, 就是横屏状态下,我们这个页面有toast、弹窗等都还是竖屏的布局,所以我们在横屏状态下, 是通过将toast、弹窗来进入旋转90°来实现的,这样效果就都OK了

进度条UISlider滑动和返回手势以及scrollView的冲突

这个相对来说比较简单, 网上一查有很多解决方案,但实现还是有一些坑,所以也记录一下, 分享给刚好遇到这个问题的同学, 问题就是在滑动视频的进度条的过程中,总是会触发手势右滑返回,导致体验特别不好,这时大部分方案都是先在右滑返回的代理中判断触摸的是否是UISlider滑块,是的话就不执行返回手势, 看了一下我们的代码,确实是这样写的,但还是有这个问题, 所以排除了这个原因


// 触发之后是否响应手势事件
// 处理侧滑返回与UISlider的拖动手势冲突
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch{
    // 如果手势是触摸的UISlider滑块触发的,侧滑返回手势就不响应
    if (gestureRecognizer == self.panGestureRecognizer && ([touch.view isKindOfClass:[UISlider class]] || !self.popGestureEnable)) {
        gestureRecognizer.state = UIGestureRecognizerStateFailed;
        return NO;
    }
    return YES;
}

通过测试发现,只有在Iphone6机型上才有这个手势冲突的问题,最终定位到,由于我们的滑动是继承UISlider重写UIResponder的touchesBegan、touchesMoved等方法来实现滑块,刚好iphone6上系统就有坑,所以我重写了滑块的实现方案,通过给UISlider增加滑动和点击的手势来进行实现,并且通过trackRectForBounds来设置滑动的尺寸,就很顺滑了, 贴上对应滑块实现的代码,也是别人分享的,出处找不到了

#import 

@protocol SSTTapSliderDelegate;

@interface SSTTapSlider : UISlider 

@property (weak, nonatomic) idtapSliderDelegate;

@end

@protocol SSTTapSliderDelegate 

@optional

- (void)tapSlider:(SSTTapSlider *)tapSlider valueDidChange:(float)value;
- (void)tapSlider:(SSTTapSlider *)tapSlider tapEndedWithValue:(float)value;
- (void)tapSlider:(SSTTapSlider *)tapSlider panBeganWithValue:(float)value;
- (void)tapSlider:(SSTTapSlider *)tapSlider panEndedWithValue:(float)value;

@end
#import "SSTTapSlider.h"

@implementation SSTTapSlider

#pragma mark - Initialization
#pragma mark -

- (id)initWithFrame:(CGRect)aRect {
    self = [super initWithFrame:aRect];
    if (self) {
        [self initializeTapSlider];
    }
    return self;
}

- (id)initWithCoder:(NSCoder*)aDecoder {
    self = [super initWithCoder:aDecoder];
    if (self) {
        [self initializeTapSlider];
    }
    return self;
}

#pragma mark - User Actions
#pragma mark -

- (void)sliderTapGestureRecognized:(UIGestureRecognizer *)recognizer {
    [self handleSliderGestureRecognizer:recognizer];

    switch (recognizer.state) {
        case UIGestureRecognizerStateEnded:
            if ([self.tapSliderDelegate respondsToSelector:@selector(tapSlider:tapEndedWithValue:)]) {
                [self.tapSliderDelegate tapSlider:self tapEndedWithValue:self.value];
            }
            break;
        default:
            break;
    }
}

- (void)sliderPanGestureRecognized:(UIGestureRecognizer *)recognizer {
    [self handleSliderGestureRecognizer:recognizer];

    switch (recognizer.state) {
        case UIGestureRecognizerStateBegan:
            if ([self.tapSliderDelegate respondsToSelector:@selector(tapSlider:panBeganWithValue:)]) {
                [self.tapSliderDelegate tapSlider:self panBeganWithValue:self.value];
            }
            break;
        case UIGestureRecognizerStateEnded:
            if ([self.tapSliderDelegate respondsToSelector:@selector(tapSlider:panEndedWithValue:)]) {
                [self.tapSliderDelegate tapSlider:self panEndedWithValue:self.value];
            }
            break;
        default:
            break;
    }
}

#pragma mark - Private
#pragma mark -

- (void)initializeTapSlider {
    [self modifySlider:self];
}

- (void)modifySlider:(UISlider *)slider {
    UITapGestureRecognizer *tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(sliderTapGestureRecognized:)];
    UIPanGestureRecognizer *panGestureRecognizer = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(sliderPanGestureRecognized:)];
    panGestureRecognizer.delegate = self;
    slider.gestureRecognizers = @[tapGestureRecognizer, panGestureRecognizer];

    [slider addTarget:self action:@selector(sliderValueChanged:) forControlEvents:UIControlEventValueChanged];
}

- (void)sliderValueChanged:(id)sender {
    if ([self.tapSliderDelegate respondsToSelector:@selector(tapSlider:valueDidChange:)]) {
        [self.tapSliderDelegate tapSlider:self valueDidChange:self.value];
    }
}

- (void)handleSliderGestureRecognizer:(UIGestureRecognizer *)recognizer {
    if ([recognizer.view isKindOfClass:[UISlider class]]) {
        UISlider *slider = (UISlider *)recognizer.view;
        CGPoint point = [recognizer locationInView:recognizer.view];
        CGFloat width = CGRectGetWidth(slider.frame);
        CGFloat percentage = point.x / width;

        // new value is based on the slider's min and max values which
        // could be different with each slider
        float newValue = ((slider.maximumValue - slider.minimumValue) * percentage) + slider.minimumValue;
        [slider setValue:newValue animated:TRUE];
    }

    if ([self.tapSliderDelegate respondsToSelector:@selector(tapSlider:valueDidChange:)]) {
        [self.tapSliderDelegate tapSlider:self valueDidChange:self.value];
    }
}

/// 设置track(滑条)尺寸
- (CGRect)trackRectForBounds:(CGRect)bounds {
    return CGRectMake(0, 19, self.width, 2);
}

// 解决汤视频详情拖动进度滑竿不走pan回调的问题
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer {
    return YES;
}

感言

感谢以下同学技术文章的分享~~~
iOS端一次视频全屏需求的实现
iOS播放器全屏旋转实现
iOS 13 UITableView ContentView 变化了

你可能感兴趣的:(视频播放优化)