语音社交app开发,如何实现界面优化?

在语音社交app开发中经常会出现卡顿的现象(丢帧),给用户的感觉很不好。那么这个现象是怎样产生的,如何检测到掉帧,要怎样去优化呢?本文将针对这几个问题进行分析

界面渲染流程

在语音社交app开发的界面的渲染过程中CPU和GPU起了比较重要的作用

CPU与GPU

CPU全名是Central Processing Unit(中央处理器),语音社交app开发在加载资源、对象的创建和销毁、对象属性的调整、布局计算、Autolayout、文本渲染,文本的计算和排版、图片格式转码和解码、图像的绘制(Core Graphics)时,都需要依赖CPU来执行

GPU全名是Graphics Processing Unit(图像处理器),它是一个专门为图形高并发计算而量身定做的处理单元,比CPU使用更少的电来完成工作并且GPU的浮点计算能力要超出CPU很多。
在语音社交app开发中,相对于CPU来说,GPU能干的事情比较单一:接收提交的纹理(Texture)和顶点描述(三角形),应用变换(transform)、混合(合成)并渲染,然后输出到屏幕上。通常你所能看到的内容,主要也就是纹理(图片)和形状(三角模拟的矢量图形)两类。GPU的渲染性能要比CPU高效很多,同时对系统的负载和消耗也更低一些

在语音社交app开发中,我们应该尽量让CPU负责主线程的UI调动,把图形显示相关的工作交给GPU来处理,当涉及到光栅化等一些工作时,CPU也会参与进来。

渲染过程

在讲渲染原理之前先介绍下CRT显示器原理。

CRT的电子枪从上到下逐行扫描,扫描完成后显示器就呈现一帧画面。然后电子枪回到初始位置进行下一次扫描。为了同步显示器的显示过程和系统的视频控制器,显示器会用硬件时钟产生一系列的定时信号。

当电子枪换行进行扫描时,显示器会发出一个 水平同步信号(horizonal synchronization),简称HSync;

而当语音社交app开发中一帧画面绘制完成后,电子枪回复到原位,准备画下一帧前,显示器会发出一个 垂直同步信号(vertical synchronization),简称VSync。显示器通常以固定频率进行刷新,这个刷新率就是VSync信号产生的频率。

CRT的电子枪扫描过程如下图所示:

在这里插入图片描述

虽然现在的显示器基本都是液晶显示屏了,但其原理基本一致。

界面渲染的流程如下:CPU计算 -> GPU渲染 -> 帧缓冲区 -> 视频控制器读取帧缓冲区的数据 -> 显示器,如下图:

在这里插入图片描述

双缓冲机制+VSync

如果GPU渲染后,因为某些原因没有存入语音社交app开发的帧缓冲区,这样就形成了等待,为了解决了这个问题,就产生了双缓冲机制,也就是前帧和后帧。

当GPU渲染完一帧后就会存入帧缓存区,然后视频控制器去读取缓冲帧缓存,同时GPU去渲染另一帧并存入另一个帧缓存区,这样来回的切换读取来显示界面,如下图:
在这里插入图片描述

这个切换也不是任意时间切的,我们常见的都是一秒渲染60帧,也就是VSync每隔16.67ms发送一次信号
所以,语音社交app开发中的视频控制器会按照VSync信号逐帧读取帧缓冲区的数据

卡顿

卡顿产生原理

我们知道VSync每隔16.67ms发送一次信号,两次发送信号之间cpu进行了计算,gpu渲染后存入帧缓冲区,那么如果计算的步骤花的时间比较长,那么存入帧缓存的渲染部分就比较少了,当视频控制器读取帧缓存时没有读全,此时语音社交app开发就产生了丢帧,也就是卡顿。

卡顿过程如下图:

在这里插入图片描述

卡顿检测

每秒渲染帧数用FPS(Frames Per Second)来表示,通常60fps为最佳,我们可以检测语音社交app开发的FPS来观察语音社交app开发是否流畅。

可以使用以下几种方式检测语音社交app开发的FPS:

CADisplayLink

语音社交app开发系统在每次发送VSync时,就会触发CADisplayLink,我们可以统计每秒发送VSync的数量来查看App的FPS是否稳定,主要代码如下:

@interface ViewController ()
@property (nonatomic, strong) CADisplayLink *link;
@property (nonatomic, assign) NSTimeInterval lastTime; // 每隔1秒记录一次时间
@property (nonatomic, assign) NSUInteger count; // 记录VSync1秒内发送的数量
@end

self.link = [CADisplayLink displayLinkWithTarget:self selector:@selector(linkAction:)];
[_link addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];

- (void)linkAction: (CADisplayLink *)link {
    if (_lastTime == 0) {
        _lastTime = link.timestamp;
        return;
    }
    _count++;    
    NSTimeInterval delta = link.timestamp - _lastTime;
    if (delta < 1) return;
  
    _lastTime = link.timestamp;
    float fps = _count / delta;
    _count = 0;
      
    NSLog(@"🎈 fps : %f ", fps);
}

RunLoop

在 Runloop原理 中,我们详细的分析了Runloop,它的退出和进入实质都是Observer的通知,我们可以监听Runloop的状态,并在相关回调里发送信号,如果在语音社交app开发设定的时间内能够收到信号说明是流畅的;如果在设定的时间内没有收到信号,说明发生了卡顿。具体实现如下:

@interface WSBlockMonitor () {
  CFRunLoopActivity activity;
}

@property (nonatomic, strong) dispatch_semaphore_t semaphore;
@property (nonatomic, assign) NSUInteger timeoutCount;
@end

- (void)registerObserver{
    CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
    //NSIntegerMax : 优先级最小
    CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
                                                            kCFRunLoopAllActivities,
                                                            YES,
                                                            NSIntegerMax,
                                                            &CallBack,
                                                            &context);
    CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
}

static void CallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
{
    WSBlockMonitor *monitor = (__bridge WSBlockMonitor *)info;
    monitor->activity = activity;
    // 发送信号
    dispatch_semaphore_t semaphore = monitor->_semaphore;
    dispatch_semaphore_signal(semaphore);
}

- (void)startMonitor {
    // 创建信号
    _semaphore = dispatch_semaphore_create(0);
    // 在子线程监控时长
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        while (YES)
        {
            // 超时时间是 1 秒,没有等到信号量,st 就不等于 0, RunLoop 所有的任务
            long st = dispatch_semaphore_wait(self->_semaphore, dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC));
            if (st != 0)
            {
                if (self->activity == kCFRunLoopBeforeSources || self->activity == kCFRunLoopAfterWaiting) // 即将处理sources,即将进入休眠
                {
                    if (++self->_timeoutCount < 2) {
                        NSLog(@"timeoutCount==%lu",(unsigned long)self->_timeoutCount);
                        continue;
                    }
                    // 一秒左右的衡量尺度 很大可能性连续来 避免大规模打印!
                    // 可在此处记录卡顿堆栈信息,进行排查
                    NSLog(@"检测到超过两次连续卡顿");
                }
            }
            self->_timeoutCount = 0;
        }
    });
}

// 调用方法
- (void)start{
    [self registerObserver];
    [self startMonitor];
}

主要在主线程监听Runloop即将处理事物和即将休眠两种状态,子线程监控时长,如果连续两次1秒内没有收到信号,说明发生了卡顿,此时可以记录卡顿的堆栈以便于排查。

微信matrix

微信的matrix也是借助runloop实现的,大体流程上面Runloop相同,它使用退火算法优化捕获卡顿的效率,防止连续捕获相同的卡顿,并且通过保存最近的20个主线程堆栈信息,获取最近最耗时堆栈。所以需要准确的分析语音社交app开发卡顿原因可以借助微信matrix来分析卡顿。

滴滴DoraemonKit

DoraemonKit的卡顿检测方案并没有对runloop操作,它也是while循环中根据一定的状态判断,通过语音社交app开发的主线程中不断对发送信号semaphore,循环中等待信号的时间为5秒,等待超时则说明主线程卡顿,并进行相关上报。

优化方案

在检测到语音社交app开发卡顿后,接下来就应该去进行相关的优化,我们可以通过以下几种方案

预排版

在语音社交app开发中,布局我们通常选择用Masonry或SnapKit,他们都是基于AutoLayout来实现的,自动布局通常在赋值过后才决定UI控件的大小。而根据苹果的介绍,相对于AutoLayout,frame产生的消耗要小的多

例如在复杂结构的UITableViewCell中,赋值过后会产生UI控件的大小,如果cell比较多会进行频繁的计算,这样就会消耗性能。这种情况我们可以在请求完数据时,就计算好各个控件的Rect,然后在数据赋值时,也将各个控件的frame进行赋值,这样会大大减少计算。这个方案也叫做预排版,具体代码如下:

数据DataModel代码

@interface DataModel : NSObject
@property (nonatomic, strong) NSString *name;
@end

布局LayoutModel代码

// .h
@interface LayoutModel : NSObject
@property (nonatomic, assign) CGRect nameRect;
@property (nonatomic, strong) DataModel *data;
@property (nonatomic, assign) CGFloat height;

- (instancetype)initWithModel:(DataModel *)model; // 初始化代码
@end

// .m
- (instancetype)initWithModel:(DataModel *)model {
  self = [super init];
  if (self) {
      self.data = model;
      // 根据数据计算相关控件的size
      CGSize size = [self getSizeWithContent:model.name];
      self.nameRect = CGRectMake(15, 100, size.width, size.height);
      self.height = 200; // 计算cell高度
  }
  return self;
}

- (CGSize)getSizeWithContent: (NSString *)content {
    //根据文字计算size ...
    return CGSizeMake(100, 40);
}

layoutModel初始化时,要传入对应的model,然后根据model中的相关字段计算相应的size,然后讲相应的rect赋值给layoutModel中的相关字段。

cell代码

// .h
- (void)configCellWithModel: (LayoutModel *)model;

// .m
@interface TestCell ()
@property (nonatomic, strong) UILabel *nameLbl;
@end

@implementation TestCell
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
    self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
    if (self) {
        self.nameLbl = [UILabel new];
        [self.contentView addSubview:_nameLbl];
    }
    return self;
}

- (void)configCellWithModel:(LayoutModel *)model {
    self.nameLbl.frame = model.nameRect; // frame 赋值
    self.nameLbl.text = model.data.name; // 数据赋值
}

cell中的控件创建完后设置相关的颜色字体,然后在数据赋值时同时对frame进行赋值

VC代码

@property (nonatomic, strong) NSMutableArray<LayoutModel *> *dataSource;

// 模拟网络请求
dispatch_async(dispatch_get_global_queue(0, 0), ^{
    NSDictionary *dataDic;  // 网络请求数据
    NSMutableArray<DataModel *> *modelArray = [NSMutableArray array];
    for (NSDictionary *dic in dataDic[@"data"]) {
        DataModel *model = [DataModel yyModel: dic]; // 相关的json转model方法
        [modelArray addObject:model];
    }
  
    self.dataSource = [NSMutableArray arrayWithCapacity:modelArray.count];
    for (DataModel *model in modelArray) {
        LayoutModel *layout = [[LayoutModel alloc] initWithModel:model]; // 根据数据model,初始化layoutModel,并进行控件的layout计算
        [self.dataSource addObject:layout];
    }
  
    // 计算完成后reloadData
    dispatch_async(dispatch_get_main_queue(), ^{
        [self.tableView reloadData];
    });
});

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return _dataSource.count;
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    NSString *identifier = @"cellID";
    TestCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier];
    if (!cell) {
        cell = [[TestCell alloc] initWithStyle:(UITableViewCellStyleDefault) reuseIdentifier:identifier];
    }
  
    [cell configCellWithModel:self.dataSource[indexPath.row]];
    return cell;
}

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
    LayoutModel *model = self.dataSource[indexPath.row];
    return model.height;
}

vc中主要是网络请求后创建数据model后,然后根据将dataModel创建layoutModel并进行相关计算,完成后再reloadData,这样就一次性将相关的布局计算好,滑动cell时只是进行赋值,无需额外的布局耗时计算

预解码&预渲染

预解码主要是对图像视频类进行优化,例如UIImage,它的加载流程如下:
在这里插入图片描述

Data Buffer(数据缓冲区)解码后缓存到Image Buffer(影响缓冲区),然后存入帧缓冲区再进行渲染。
语音社交app开发解码的过程是比较消耗资源的,所以可以将解码工作放到子线程,提前做好一些解码工作
图片解码具体的做法可以参考SDWebImage中的处理,而音视频的解码可以参考FFmpeg

按需加载

按需加载顾名思义就是语音社交app开发需要时再加载,例如TableView,在滑动时每出现一个cell就会走cellForRow里的赋值方法,有些cell刚出现后又马上在界面消失,像这种可以监听滑动的状态,当滑动停止时根据tableView的visibleCells获取当前可见cell,然后对这些cell进行赋值,这样也节省了很多的开销。

异步渲染

异步渲染就是在语音社交app开发中子线程把需要绘制的图形提前处理好,然后将处理好的图像数据直接返给主线程使用,这样可以降低主线程的压力。

异步渲染操作的是layer层,将展示的内容通过UIGraphics画成一张image然后展示在layer.content上

我们知道绘制会执行drawRect:方法,在方法中查看堆栈得知:
在这里插入图片描述

在堆栈中得知CALayer在调用display方法后回去调用绘制相关的方法,根据流程我们来实现一个简单的绘制:

// WSLyer.m
- (void)display {
    // 创建context
    CGContextRef context = (__bridge CGContextRef)[self.delegate performSelector:@selector(createContext)];
    [self.delegate layerWillDraw:self]; // 绘制的准备工作
    [self drawInContext:context]; //绘制
    [self.delegate displayLayer:self]; // 展示位图
    [self.delegate performSelector:@selector(closeContext)]; // 结束绘制
}


// WSView.m
- (void)drawRect:(CGRect)rect {
    // Drawing code
}
+ (Class)layerClass {
    return [WsLayer class];
}
// 创建context
- (CGContextRef)createContext {
    UIGraphicsBeginImageContextWithOptions(self.bounds.size, self.layer.opaque, self.layer.contentsScale);
    CGContextRef context = UIGraphicsGetCurrentContext();
    return context;
}

- (void)layerWillDraw:(CALayer *)layer {
    // 绘制的准备工作
}

- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx {
    [super drawLayer:layer inContext:ctx];
    // 形状
    CGContextMoveToPoint(ctx, self.bounds.size.width / 2- 20, 20);
    CGContextAddLineToPoint(ctx, self.bounds.size.width / 2 + 20, 20);
    CGContextAddLineToPoint(ctx, self.bounds.size.width / 2 + 40, 60);
    CGContextAddLineToPoint(ctx, self.bounds.size.width / 2 - 40, 60);
    CGContextAddLineToPoint(ctx, self.bounds.size.width / 2 - 20, 20);
    CGContextSetFillColorWithColor(ctx, UIColor.magentaColor.CGColor);
    CGContextSetStrokeColorWithColor(ctx, UIColor.magentaColor.CGColor); // 描边
    CGContextDrawPath(ctx, kCGPathFillStroke);

    // 文字
    [@"无双" drawInRect:CGRectMake(self.bounds.size.width / 2 - 40, 70, 80, 24) withAttributes:@{NSFontAttributeName: [UIFont systemFontOfSize:15],NSForegroundColorAttributeName: UIColor.blackColor}];
    // 图片
    [[UIImage imageNamed:@"buou"] drawInRect:CGRectMake(self.bounds.size.width / 2 - 40, 100, 60, 50)];
}

// 主线程渲染
- (void)displayLayer:(CALayer *)layer {
    UIImage *image = UIGraphicsGetImageFromCurrentImageContext();

    dispatch_async(dispatch_get_main_queue(), ^{
        layer.contents = (__bridge id)(image.CGImage);
    });
}

// 结束context
- (void)closeContext {
    UIGraphicsEndImageContext();
}

在VC中只需要添加view对象即可

// ViewController.m
WsView *view = [[WsView alloc] initWithFrame:CGRectMake(100, 100, 200, 200)];
view.backgroundColor = UIColor.yellowColor;
[self.view addSubview:view];

运行结果和层级关系如下:

在这里插入图片描述

在语音社交app开发中异步渲染处理起来相对要复杂些,具体的实践可以参照其他异步渲染框架。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值