語音社交app開發,如何實現介面優化?

雲豹科技程式設計師發表於2021-12-08
在語音社交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的電子槍掃描過程如下圖所示:

語音社交app開發,如何實現介面優化?

雖然現在的顯示器基本都是液晶螢幕了,但其原理基本一致。
介面渲染的流程如下:CPU計算 -> GPU渲染 -> 幀緩衝區 -> 視訊控制器讀取幀緩衝區的資料 -> 顯示器,如下圖:

語音社交app開發,如何實現介面優化?

雙緩衝機制+VSync
如果GPU渲染後,因為某些原因沒有存入語音社交app開發的幀緩衝區,這樣就形成了等待,為了解決了這個問題,就產生了雙緩衝機制,也就是前幀和後幀。
當GPU渲染完一幀後就會存入幀快取區,然後視訊控制器去讀取緩衝幀快取,同時GPU去渲染另一幀並存入另一個幀快取區,這樣來回的切換讀取來顯示介面,如下圖:
語音社交app開發,如何實現介面優化?

這個切換也不是任意時間切的,我們常見的都是一秒渲染60幀,也就是VSync每隔16.67ms傳送一次訊號 所以,語音社交app開發中的視訊控制器會按照VSync訊號逐幀讀取幀緩衝區的資料

卡頓

卡頓產生原理
我們知道VSync每隔16.67ms傳送一次訊號,兩次傳送訊號之間cpu進行了計算,gpu渲染後存入幀緩衝區,那麼如果計算的步驟花的時間比較長,那麼存入幀快取的渲染部分就比較少了,當視訊控制器讀取幀快取時沒有讀全,此時語音社交app開發就產生了丟幀,也就是卡頓。
卡頓過程如下圖:

語音社交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,它的載入流程如下:
語音社交app開發,如何實現介面優化?

Data Buffer(資料緩衝區)解碼後快取到Image Buffer(影響緩衝區),然後存入幀緩衝區再進行渲染。 語音社交app開發解碼的過程是比較消耗資源的,所以可以將解碼工作放到子執行緒,提前做好一些解碼工作 圖片解碼具體的做法可以參考SDWebImage中的處理,而音視訊的解碼可以參考FFmpeg
按需載入
按需載入顧名思義就是語音社交app開發需要時再載入,例如TableView,在滑動時每出現一個cell就會走cellForRow裡的賦值方法,有些cell剛出現後又馬上在介面消失,像這種可以監聽滑動的狀態,當滑動停止時根據tableView的visibleCells獲取當前可見cell,然後對這些cell進行賦值,這樣也節省了很多的開銷。
非同步渲染
非同步渲染就是在語音社交app開發中子執行緒把需要繪製的圖形提前處理好,然後將處理好的影像資料直接返給主執行緒使用,這樣可以降低主執行緒的壓力。
非同步渲染操作的是layer層,將展示的內容通過UIGraphics畫成一張image然後展示在layer.content上
我們知道繪製會執行drawRect:方法,在方法中檢視堆疊得知:
語音社交app開發,如何實現介面優化?

在堆疊中得知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開發,如何實現介面優化?
在語音社交app開發中非同步渲染處理起來相對要複雜些,具體的實踐可以參照其他非同步渲染框架。
本文轉載自網路,轉載僅為分享乾貨知識,如有侵權歡迎聯絡雲豹科技進行刪除處理 原文連結:


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69996194/viewspace-2846526/,如需轉載,請註明出處,否則將追究法律責任。

相關文章