iOSアプリのフリーズ検出:FPSとRunLoopの監視

FPS監視

iOSデバイスのスクリーンは、一般的に60Hzのリフレッシュレートを持ちます。これは1秒間に60回のVSyncシグナルが送られ、各フレームの表示間隔は約16.67ミリ秒(1000ms / 60)であることを意味します。もし、この16.67ミリ秒のウィンドウ内で次のフレームのデータが準備できなければ、フレームドロップ(カクつき)が発生します。

CADisplayLinkクラスは、ディスプレイのリフレッシュレートと同期してコールバックを呼び出すことができるため、FPS(Frames Per Second)の監視に最適です。一定時間内のフレーム数をカウントし、その時間で割ることでFPSを計算できます。


import UIKit

class PerformanceMonitorView: UILabel {

    private var displayLink: CADisplayLink!
    private var frameCounter: Int = 0
    private var lastTimestamp: CFTimeInterval = 0
    private var currentFPS: Double = 0

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.setupUI()
        self.setupDisplayLink()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        self.setupUI()
        self.setupDisplayLink()
    }

    private func setupUI() {
        self.textColor = .white
        self.textAlignment = .center
        self.font = UIFont(name: "Menlo", size: 12)
        self.backgroundColor = .lightGray
    }

    private func setupDisplayLink() {
        displayLink = CADisplayLink(target: self, selector: #selector(updateDisplay(_:)))
        displayLink.add(to: .main, forMode: .common)
    }

    @objc private func updateDisplay(_ link: CADisplayLink) {
        // 初回のタイムスタンプを記録
        if lastTimestamp == 0 {
            lastTimestamp = link.timestamp
            return
        }

        frameCounter += 1
        let timeDifference = link.timestamp - lastTimestamp

        // 1秒間の経過を確認
        guard timeDifference >= 1.0 else {
            return
        }

        lastTimestamp = link.timestamp
        currentFPS = Double(frameCounter) / timeDifference
        frameCounter = 0

        let fpsText = String(format: "%.1f FPS", currentFPS)
        let statusColor = self.determineStatusColor(for: currentFPS)

        let attributedString = NSMutableAttributedString(string: fpsText)
        attributedString.addAttribute(.foregroundColor, value: statusColor, range: NSRange(location: 0, length: fpsText.count - 3))
        attributedString.addAttribute(.foregroundColor, value: .white, range: NSRange(location: fpsText.count - 3, length: 3))

        DispatchQueue.main.async {
            self.attributedText = attributedString
        }
    }

    private func determineStatusColor(for fps: Double) -> UIColor {
        if fps >= 55.0 {
            return .green // スムーズ
        } else if fps >= 50.0 {
            return .yellow // 良好
        } else {
            return .red // カクつき
        }
    }

    deinit {
        displayLink.invalidate()
    }
}

RunLoop監視

主スレッドのRunLoopの状態を監視することで、長時間実行されている処理(ブロッキング)を検出できます。特に、`kCFRunLoopBeforeSources`(ソースの処理開始前)と`kCFRunLoopAfterWaiting`(待機後)の状態の遷移時間を測定します。これらの状態間の遷移が長時間かかると、UIの応答性が低下している可能性があります。

この監視は、通常サブスレッドで行われます。CFRunLoopObserverを利用して、RunLoopの各アクティビティを監視し、特定の状態遷移の時間を計測します。信号(semaphore)を使用して、状態の変化を待機し、タイムアウトを検出します。


#import "RunLoopStallDetector.h"

@interface RunLoopStallDetector ()
@property (nonatomic, strong) dispatch_semaphore_t activitySemaphore;
@property (nonatomic, assign) NSUInteger stallCounter;
@property (nonatomic, assign) CFRunLoopActivity lastActivity;
@end

@implementation RunLoopStallDetector

+ (instancetype)sharedInstance {
    static RunLoopStallDetector *instance = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        instance = [[self alloc] init];
    });
    return instance;
}

- (void)startMonitoring {
    [self registerRunLoopObserver];
    [self startStallDetectionLoop];
}

- (void)registerRunLoopObserver {
    CFRunLoopObserverContext context = {0, (__bridge void *)self, NULL, NULL};
    // 全てのアクティビティを監視
    self.runLoopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,
                                                kCFRunLoopAllActivities,
                                                YES,
                                                NSIntegerMax,
                                                RunLoopStallDetectorObserverCallback,
                                                &context);
    CFRunLoopAddObserver(CFRunLoopGetMain(), self.runLoopObserver, kCFRunLoopCommonModes);
}

- (void)startStallDetectionLoop {
    self.activitySemaphore = dispatch_semaphore_create(0);
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        while (YES) {
            // 1秒間待機。シグナルが来なければタイムアウト
            long waitResult = dispatch_semaphore_wait(self.activitySemaphore, dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC));
            
            if (waitResult != 0) {
                // タイムアウトが発生
                if (self.lastActivity == kCFRunLoopBeforeSources || self.lastActivity == kCFRunLoopAfterWaiting) {
                    self.stallCounter++;
                    if (self.stallCounter > 2) {
                        NSLog(@"警告: 連続して3回以上のカクつきが検出されました。");
                        self.stallCounter = 0; // カウンターをリセット
                    }
                }
            } else {
                // 正常な状態遷移
                self.stallCounter = 0;
            }
        }
    });
}

static void RunLoopStallDetectorObserverCallback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) {
    RunLoopStallDetector *detector = (__bridge RunLoopStallDetector *)info;
    detector.lastActivity = activity;
    // シグナルを送信して待機を解除
    dispatch_semaphore_signal(detector.activitySemaphore);
}

- (void)dealloc {
    CFRunLoopObserverInvalidate(self.runLoopObserver);
    CFRelease(self.runLoopObserver);
}
@end

タグ: iOS Swift Objective-C CADisplayLink CFRunLoop

6月27日 22:21 投稿