iOS 애니메이션 및 효율성 조정

게시 됨: 2022-03-11

훌륭한 앱을 빌드하는 것은 모양이나 기능이 전부가 아니라 성능이 얼마나 좋은가도 중요합니다. 모바일 장치의 하드웨어 사양이 빠른 속도로 개선되고 있지만 성능이 저조한 앱, 모든 화면 전환 시 끊김 현상 또는 슬라이드쇼와 같은 스크롤은 사용자 경험을 망치고 좌절의 원인이 될 수 있습니다. 이 기사에서는 iOS 앱의 성능을 측정하고 효율성을 위해 조정하는 방법을 살펴보겠습니다. 이 기사의 목적을 위해, 우리는 이미지와 텍스트의 긴 목록으로 간단한 앱을 만들 것입니다.

iOS 애니메이션 및 효율성 조정

성능 테스트를 위해 실제 장치를 사용하는 것이 좋습니다. 앱을 빌드하고 부드러운 iOS 애니메이션을 위해 최적화하는 데 진지한 경우 시뮬레이터는 단순히 앱을 자르지 않습니다. 시뮬레이션은 때때로 현실과 동떨어져 있을 수 있습니다. 예를 들어 시뮬레이터가 Mac에서 실행 중일 수 있습니다. 이는 CPU(중앙 처리 장치)가 iPhone의 CPU보다 훨씬 강력하다는 것을 의미합니다. 반대로 GPU(그래픽 처리 장치)는 장치와 Mac 간에 너무 다르기 때문에 Mac이 실제로 장치의 GPU를 에뮬레이트합니다. 결과적으로 CPU 바운드 작업은 시뮬레이터에서 더 빠른 경향이 있는 반면 GPU 바운드 작업은 느린 경향이 있습니다.

60FPS로 애니메이션

인지된 성능의 한 가지 주요 측면은 애니메이션이 화면의 새로 고침 빈도인 60FPS(초당 프레임 수)로 실행되도록 하는 것입니다. 여기에서 논의하지 않을 몇 가지 타이머 기반 애니메이션이 있습니다. 일반적으로 말해서 50FPS 이상으로 실행하는 경우 앱이 매끄럽고 성능이 좋아 보일 것입니다. 애니메이션이 20~40FPS 사이에서 멈추면 눈에 띄는 끊김 현상이 발생하고 사용자는 전환에서 "거칠기"를 감지하게 됩니다. 20FPS 미만은 앱의 사용성에 심각한 영향을 미칩니다.

시작하기 전에 CPU 바운드 작업과 GPU 바운드 작업의 차이점에 대해 논의하는 것이 좋습니다. GPU는 그래픽 그리기에 최적화된 특수 칩입니다. CPU도 할 수 있지만 훨씬 느립니다. 이것이 우리가 2D 또는 3D 모델에서 이미지를 생성하는 프로세스인 그래픽 렌더링의 많은 부분을 GPU로 오프로드하려는 이유입니다. 그러나 GPU의 처리 능력이 부족하면 CPU가 상대적으로 여유가 있어도 그래픽 관련 성능이 저하되므로 주의해야 합니다.

Core Animation은 앱 내부와 앱 외부 모두에서 애니메이션을 처리하는 강력한 프레임워크입니다. 프로세스를 6가지 주요 단계로 나눕니다.

  1. 레이아웃: 레이어를 정렬하고 색상 및 상대적 위치와 같은 속성을 설정하는 곳

  2. 디스플레이: 배경 이미지가 컨텍스트에 그려지는 곳입니다. drawRect: 또는 drawLayer:inContext: 에서 작성한 모든 루틴은 여기에서 액세스할 수 있습니다.

  3. 준비: 이 단계에서 Core Animation은 그림을 그릴 렌더러에 컨텍스트를 보내려고 하므로 이미지 압축 해제와 같은 몇 가지 필요한 작업을 수행합니다.

  4. 커밋: 여기에서 Core Animation은 이 모든 데이터를 렌더 서버로 보냅니다.

  5. 역직렬화: 이전 4단계는 모두 앱 내에서 이루어졌습니다. 이제 애니메이션이 앱 외부에서 처리되고 패키지된 레이어는 렌더 서버가 이해할 수 있는 트리로 역직렬화됩니다. 모든 것이 OpenGL 기하학으로 변환됩니다.

  6. 그리기: 모양(실제로는 삼각형)을 렌더링합니다.

프로세스 1-4가 CPU 작업이고 5-6이 GPU 작업이라고 추측했을 수 있습니다. 실제로는 처음 2단계만 제어할 수 있습니다. GPU의 가장 큰 킬러는 GPU가 프레임당 동일한 픽셀을 여러 번 채워야 하는 반투명 레이어입니다. 또한 모든 오프스크린 그리기(그림자, 마스크, 둥근 모서리 또는 레이어 래스터화와 같은 여러 레이어 효과로 인해 Core Animation이 화면 밖에 그려짐)도 성능에 영향을 미칩니다. GPU에서 처리하기에는 너무 큰 이미지는 대신 훨씬 느린 CPU에서 처리됩니다. 그림자는 레이어에 직접 두 가지 속성을 설정하여 쉽게 얻을 수 있지만 화면에 그림자가 있는 개체가 많으면 성능이 쉽게 저하될 수 있습니다. 때때로 이러한 그림자를 이미지로 추가하는 것을 고려해 볼 가치가 있습니다.

iOS 애니메이션 성능 측정

5개의 PNG 이미지와 테이블 보기가 있는 간단한 앱으로 시작하겠습니다. 이 앱에서는 기본적으로 5개의 이미지를 로드하지만 10,000행 이상 반복합니다. 이미지와 이미지 옆의 레이블 모두에 그림자를 추가합니다.

 -(UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { CustomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[CustomTableViewCell getCustomCellIdentifier] forIndexPath:indexPath]; NSInteger index = (indexPath.row % [self.images count]); NSString *imageName = [self.images objectAtIndex:index]; NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; UIImage *image = [UIImage imageWithContentsOfFile:filePath]; cell.customCellImageView.image = image; cell.customCellImageView.layer.shadowOffset = CGSizeMake(0, 5); cell.customCellImageView.layer.shadowOpacity = 0.8f; cell.customCellMainLabel.text = [NSString stringWithFormat:@"Row %li", (long)(indexPath.row + 1)]; cell.customCellMainLabel.layer.shadowOffset = CGSizeMake(0, 3); cell.customCellMainLabel.layer.shadowOpacity = 0.5f; return cell; }

라벨은 항상 다른 반면 이미지는 단순히 재활용됩니다. 결과는 다음과 같습니다.

세로로 스와이프하면 보기가 스크롤될 때 끊김 현상이 나타날 가능성이 매우 높습니다. 이 시점에서 메인 스레드에서 이미지를 로드한다는 사실이 문제라고 생각할 수 있습니다. 이것을 배경 스레드로 옮기면 모든 문제가 해결될 수 있습니다.

맹목적으로 추측하기 보다는 직접 사용해보고 성능을 측정해 보겠습니다. 악기를 위한 시간입니다.

Instruments를 사용하려면 "실행"에서 "프로필"로 변경해야 합니다. 또한 시뮬레이터에서 모든 기기를 사용할 수 있는 것은 아닙니다(시뮬레이터에서 성능을 최적화하면 안 되는 또 다른 이유). 실제 장치에 연결해야 합니다. 우리는 주로 "GPU Driver", "Core Animation" 및 "Time Profiler" 템플릿을 사용할 것입니다. 약간 알려진 사실은 다른 기기에서 중지하고 실행하는 대신 여러 기기를 끌어다 놓고 동시에 여러 기기를 실행할 수 있다는 것입니다.

이제 장비를 설정했으므로 측정해 보겠습니다. 먼저 FPS에 정말 문제가 있는지 봅시다.

예, 여기에서 18FPS를 얻는 것 같습니다. 기본 스레드의 번들에서 이미지를 로드하는 것이 실제로 그렇게 비싸고 비용이 많이 듭니까? 렌더러 활용도가 거의 최대치에 도달했음을 알 수 있습니다. 타일러 활용도도 마찬가지입니다. 둘 다 95% 이상입니다. 그리고 그것은 메인 스레드의 번들에서 이미지를 로드하는 것과 아무 관련이 없으므로 여기서 솔루션을 찾지 맙시다.

효율성을 위한 조정

ShouldRasterize라는 속성이 있으며 사람들은 여기에서 사용하는 것이 좋습니다. shouldRasterize는 정확히 무엇을 합니까? 레이어를 병합된 이미지로 캐시합니다. 모든 값비싼 레이어 드로잉은 한 번만 수행해야 합니다. 프레임이 자주 변경되는 경우 매번 다시 생성해야 하므로 캐시를 사용할 필요가 없습니다.

코드를 빠르게 수정하면 다음을 얻습니다.

 -(UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { CustomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[CustomTableViewCell getCustomCellIdentifier] forIndexPath:indexPath]; NSInteger index = (indexPath.row % [self.images count]); NSString *imageName = [self.images objectAtIndex:index]; NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; UIImage *image = [UIImage imageWithContentsOfFile:filePath]; cell.customCellImageView.image = image; cell.customCellImageView.layer.shadowOffset = CGSizeMake(0, 5); cell.customCellImageView.layer.shadowOpacity = 0.8f; cell.customCellMainLabel.text = [NSString stringWithFormat:@"Row %li", (long)(indexPath.row + 1)]; cell.customCellMainLabel.layer.shadowOffset = CGSizeMake(0, 3); cell.customCellMainLabel.layer.shadowOpacity = 0.5f; cell.layer.shouldRasterize = YES; cell.layer.rasterizationScale = [UIScreen mainScreen].scale; return cell; }

그리고 다시 측정합니다.

단 두 줄로 FPS가 2배 향상되었습니다. 이제 평균 40FPS 이상입니다. 하지만 이미지 로딩을 배경 스레드로 옮겼다면 도움이 될까요?

 -(UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { CustomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[CustomTableViewCell getCustomCellIdentifier] forIndexPath:indexPath]; NSInteger index = (indexPath.row % [self.images count]); NSString *imageName = [self.images objectAtIndex:index]; cell.tag = indexPath.row; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; UIImage *image = [UIImage imageWithContentsOfFile:filePath]; dispatch_async(dispatch_get_main_queue(), ^{ if (indexPath.row == cell.tag) { cell.customCellImageView.image = image; } }); }); cell.customCellImageView.layer.shadowOffset = CGSizeMake(0, 5); cell.customCellImageView.layer.shadowOpacity = 0.8f; cell.customCellMainLabel.text = [NSString stringWithFormat:@"Row %li", (long)(indexPath.row + 1)]; cell.customCellMainLabel.layer.shadowOffset = CGSizeMake(0, 3); cell.customCellMainLabel.layer.shadowOpacity = 0.5f; // cell.layer.shouldRasterize = YES; // cell.layer.rasterizationScale = [UIScreen mainScreen].scale; return cell; }

측정 시 성능이 평균 약 18FPS임을 알 수 있습니다.

축하할 가치가 없습니다. 즉, 프레임 속도가 향상되지 않았습니다. 메인 쓰레드를 막는 것은 잘못된 것이지만 우리의 병목 현상이 아니라 렌더링이 문제였기 때문입니다.

더 나은 예로 돌아가서 평균 40FPS 이상으로 이동하면 성능이 눈에 띄게 부드러워집니다. 그러나 우리는 실제로 더 잘할 수 있습니다.

Core Animation Tool에서 "Color Blended Layers"를 확인하면 다음이 표시됩니다.

"Color Blended Layers"는 GPU가 많은 렌더링을 수행하는 화면에 표시됩니다. 녹색은 가장 적은 양의 렌더링 작업을 나타내고 빨간색은 가장 많은 양을 나타냅니다. 하지만 shouldRasterize 를 YES 로 설정했습니다. "Color Blended Layers"가 "Color Hits Green and Misses Red"와 같지 않다는 점을 지적할 가치가 있습니다. 나중에 캐시가 재생성될 때 기본적으로 래스터화된 레이어를 빨간색으로 강조 표시합니다(캐시를 제대로 사용하지 않는지 확인하는 좋은 도구). shouldRasterize를 YES 로 설정하면 불투명하지 않은 레이어의 초기 렌더링에 영향을 주지 않습니다.

이것은 중요한 점이며 잠시 생각을 멈추어야 합니다. ShouldRasterize가 YES 로 설정되었는지 여부에 관계없이 프레임워크를 렌더링하려면 모든 보기를 확인하고 하위 보기가 투명하거나 불투명한지 여부에 따라 혼합(또는 혼합)해야 합니다. UILabel이 불투명하지 않은 것이 합리적일 수 있지만, 가치가 없고 성능이 저하될 수 있습니다. 예를 들어 흰색 배경의 투명한 UILabel은 아마도 가치가 없을 것입니다. 불투명하게 만들자:

이렇게 하면 성능이 향상되지만 앱의 모양이 변경되었습니다. 이제 레이블과 이미지가 불투명하기 때문에 그림자가 이미지 주위로 이동했습니다. 아무도 이 변경 사항을 좋아하지 않을 것입니다. 최고의 성능으로 원래 모양과 느낌을 유지하려면 희망을 잃지 않을 것입니다.

원래 모양을 유지하면서 몇 가지 추가 FPS를 짜내려면 지금까지 무시했던 두 가지 핵심 애니메이션 단계를 다시 방문하는 것이 중요합니다.

  1. 준비하다
  2. 저 지르다

이것들은 우리의 손에서 완전히 벗어난 것처럼 보일지 모르지만 그것은 사실이 아닙니다. 이미지를 로드하려면 압축을 풀어야 한다는 것을 알고 있습니다. 압축 해제 시간은 이미지 형식에 따라 다릅니다. PNG의 경우 압축 해제는 JPEG보다 훨씬 빠르기 때문에(로드 시간이 더 길고 이는 이미지 크기에 따라 달라짐) PNG를 사용하기 위한 올바른 방향에 있었지만 압축 해제 프로세스에 대해서는 아무 것도 하지 않습니다. "그리는 시점"에서 일어나고 있습니다! 이것은 메인 스레드에서 시간을 죽일 수 있는 최악의 장소입니다.

강제로 감압하는 방법이 있습니다. 바로 UIImageView의 이미지 속성으로 설정할 수 있습니다. 그러나 여전히 메인 스레드에서 이미지의 압축을 풉니다. 더 좋은 방법이 있습니까?

하나가 있습니다. 이미지를 그리기 전에 압축을 풀어야 하는 CGContext로 이미지를 그립니다. 백그라운드 스레드에서 CPU를 사용하여 이 작업을 수행할 수 있으며 이미지 보기의 크기에 따라 필요에 따라 범위를 지정할 수 있습니다. 이렇게 하면 메인 스레드에서 작업을 수행하여 이미지 그리기 프로세스를 최적화하고 메인 스레드에서 불필요한 "준비" 계산을 수행하지 않아도 됩니다.

그 동안 이미지를 그릴 때 그림자를 추가하지 않겠습니까? 그런 다음 이미지를 하나의 고정된 불투명 이미지로 캡처(및 캐시)할 수 있습니다. 코드는 다음과 같습니다.

 - (UIImage*)generateImageFromName:(NSString*)imageName { //define a boudns for drawing CGRect imgVwBounds = CGRectMake(0, 0, 48, 48); //get the image NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; UIImage *image = [UIImage imageWithContentsOfFile:filePath]; //draw in the context UIGraphicsBeginImageContextWithOptions(imgVwBounds.size, NO, 0); { //get context CGContextRef context = UIGraphicsGetCurrentContext(); //shadow CGContextSetShadowWithColor(context, CGSizeMake(0, 3.0f), 3.0f, [UIColor blackColor].CGColor); CGContextBeginTransparencyLayer (context, NULL); [image drawInRect:imgVwBounds blendMode:kCGBlendModeNormal alpha:1.0f]; CGContextSetRGBStrokeColor(context, 1.0, 1.0, 1.0, 1.0); CGContextEndTransparencyLayer(context); } image = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); return image; }

그리고 마지막으로:

 -(UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { CustomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[CustomTableViewCell getCustomCellIdentifier] forIndexPath:indexPath]; // NSInteger index = (indexPath.row % [self.images count]); // NSString *imageName = [self.images objectAtIndex:index]; // // cell.tag = indexPath.row; // // dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ // NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; // UIImage *image = [UIImage imageWithContentsOfFile:filePath]; // // dispatch_async(dispatch_get_main_queue(), ^{ // if (indexPath.row == cell.tag) { // cell.customCellImageView.image = image; // } // }); // }); cell.customCellImageView.image = [self getImageByIndexPath:indexPath]; cell.customCellImageView.clipsToBounds = YES; // cell.customCellImageView.layer.shadowOffset = CGSizeMake(0, 5); // cell.customCellImageView.layer.shadowOpacity = 0.8f; cell.customCellMainLabel.text = [NSString stringWithFormat:@"Row %li", (long)(indexPath.row + 1)]; cell.customCellMainLabel.layer.shadowOffset = CGSizeMake(0, 3); cell.customCellMainLabel.layer.shadowOpacity = 0.5f; cell.layer.shouldRasterize = YES; cell.layer.rasterizationScale = [UIScreen mainScreen].scale; return cell; }

결과는 다음과 같습니다.

현재 평균 55FPS 이상이며 렌더링 활용도와 타일러 활용도는 원래의 절반에 가깝습니다.

마무리

초당 몇 프레임을 더 만들기 위해 우리가 무엇을 할 수 있는지 궁금하시다면 더 이상 보지 마십시오. UILabel은 WebKit HTML을 사용하여 텍스트를 렌더링합니다. CATextLayer로 직접 이동하여 거기에서 그림자를 가지고 놀 수도 있습니다.

위의 구현에서 우리는 배경 스레드에서 이미지 로드를 수행하지 않고 대신 캐싱하고 있음을 알아차렸을 것입니다. 단지 5개의 이미지가 있었기 때문에 이것은 정말 빠르게 작동했고 전체 성능에 영향을 미치지 않는 것 같았습니다(특히 스크롤하기 전에 5개의 이미지가 모두 화면에 로드되었기 때문에). 그러나 추가 성능을 위해 이 논리를 백그라운드 스레드로 이동하려고 할 수 있습니다.

효율성을 위한 튜닝은 세계적 수준의 앱과 아마추어 앱의 차이입니다. 특히 iOS 애니메이션의 경우 성능 최적화는 어려운 작업일 수 있습니다. 그러나 Instruments의 도움으로 iOS에서 애니메이션 성능의 병목 현상을 쉽게 진단할 수 있습니다.