Android Developer

Android TinyDancer 라이브러리 분석 (FPS, 프레임률 측정)

졸려질려 2022. 12. 28. 17:47
반응형

 프레임율(FPS, Frame Per Second) 이란, 초당 렌더링 되는 프레임의 개수를 의미한다. 여기서 프레임이란 하나의 사진, 화면을 뜻한다. 즉, 초당 그려지는 사진이나 화면의 개수를 프레임율이라 하며, 성능을 확인하며 게임을 하는 유저들에게는 아주 익숙한 용어일 것이다. 그만큼 애플리케이션이나 기기의 성능을 테스트할 때, 기본적으로 확인하는 요소이다.

 

GitHub - friendlyrobotnyc/TinyDancer: An android library for displaying fps from the choreographer and percentage of time with t

An android library for displaying fps from the choreographer and percentage of time with two or more frames dropped - GitHub - friendlyrobotnyc/TinyDancer: An android library for displaying fps fro...

github.com

 안드로이드 앱에서도 프레임율을 측정할 수 있는 코드를 구현하는 다양한 방법들이 존재한다. 그 중에서 "TinyDancer" 라는 라이브러리를 활용하여 프레임율을 측정하는 방법에 대해 기술해보고자 한다. 해당 라이브러리는 마지막 커밋이 2019년에 이루어졌으며, 현재는 레포지토리가 아카이브 된 상태라 읽기만 가능하다. 하지만, 단순히 FPS 를 측정하는 것이 아니라, 화면 주사율(Refresh Rate) 을 고려한 FPS 를 측정하는 코드를 구현한 라이브러리이기에 선택하게 되었다.

 우선, TinyDancer 살펴보기에 앞서, 화면 주사율(Refresh Rate) 과 프레임율(FPS, Frame Per Second) 에 대해 알아본다.


1. FPS vs Refresh Rate

 FPS(프레임율) 와 Refresh Rate(화면 주사율) 는 그래픽 관련 측정값이라는 공통점을 가지고 있다. 둘 다 그래픽이 얼마나 부드럽게 렌더링되는지에 대한 측정 요소이며, 두 값 모두 크면 클수록 좋은 그래픽 성능이라는 것을 뜻한다. 하지만, 두 요소는 "측정하는 위치" 가 다르다는 것에서 차이점을 보인다.

 우선, FPS(Frame Per Second) GPU 에서 디스플레이로 보내는 프레임의 수가 초당 몇 개인지를 뜻한다. GPU(Graphic Processing Unit) 는 보통 "그래픽 카드" 라 불리는 부품이며, 그래픽과 관련된 연산만을 처리하는 역할을 수행한다. 우리가 컴퓨터(PC, 모바일 등)에서 만드는 그래픽 혹은 UI 들은 GPU 에서 만들어져, 디스플레이(모니터) 를 통해 볼 수 있는 것이다. 해당 과정의 중간에서 GPU 가 만드는 한 장의 그래픽을 "프레임" 이라 칭하며, 이를 1초마다 많이 보낼수록 사용자는 더 부드러운 화면을 볼 수 있다. 단위는 초당 프레임 수이므로, [FPS] 이다.

 반면에, Refresh Rate 는 디스플레이가 1초당 화면을 갱신하는 횟수를 의미한다. FPS 와 비슷하게, 1초당 화면을 더 많이 갱신 할수록 더 부드러운 움직임을 볼 수 있는 것이다. 화면을 갱신할 때마다 GPU 에서 보내주는 프레임으로 갱신하므로, FPS 와 Refresh Rate 는 둘 다 좋아져야 그래픽이 좋아진다. 단위는 초당 화면이 갱신되는 횟수이므로, [Hz] 를 사용한다.

 FPS 와 Refresh Rate 의 차이점을 이해하기 어렵다면, 플립북(Flip Book) 을 생각하면 좋다.

출처 : https://giphy.com/gifs/KWRG5ATZaiBOjCCjVO

 위의 GIF 를 보면, 여러 장의 그림으로 된 묶음을 빠르게 넘기면서 하나의 애니메이션이 되는 것을 볼 수 있다. 이를 플립북이라 부르는데, 플립북을 구성하는 여러 그림들이 서로 미세한 차이를 두게 된다면 더욱 더 부드러운 움직임을 만들 수 있을 것이다. 여기서 한 장의 그림을 프레임(Frame) 이라 하면, 더 세밀하게 연속되는 그림을 그리는 것을 FPS 라 볼 수 있다. 반면에, 플립북에서 애니메이션을 보기 위해서는 손을 이용해서 빠르게 그림 묶음을 넘겨줘야한다. 빠르게 넘기면 넘길수록 더 부드러운 움직임을 볼 수 있기 때문이다. 여기서 그림들을 넘기는 행동을 Refresh Rate 라 볼 수 있다.

 아무리 플립북의 그림들을 더 세밀하고 많이 그렸다하더라도, 느리게 그림들을 넘긴다면 부드러운 움직임을 볼 수 없을 것이다. 이처럼 GPU 에서 초당 생산하는 프레임(FPS)이 아무리 많더라도, 디스플레이에서 화면을 갱신하는 속도가 이를 받쳐주지 못한다면 GPU 의 프레임율에 비해 기대하는 결과 화면을 볼 수 없다.

 즉, 요약하자면 FPS 는 GPU 에서 디스플레이로 보내는 초당 프레임의 개수를 의미하며, Refresh Rate 는 디스플레이가 초당 화면을 갱신하는 횟수를 뜻한다. GPU 에서 보내는 프레임은 디스플레이에서 화면 갱신을 통해 볼 수 있으며, FPS 와 Refresh Rate 가 동시에 좋아져야 더 좋은 그래픽 성능을 경험할 수 있다.


2. TinyDancer

 이제 본론으로 돌아와서, TinyDancer 라이브러리에 대해 알아본다. TinyDancer 의 동작 예시는 다음과 같다.

출처 : https://github.com/friendlyrobotnyc/TinyDancer

 위 이미지에서 TinyDancer 부분은 화면 상에 떠있는 플로팅 버튼 같이 생긴 UI 이다. TinyDancer 는 FPS 를 실시간으로 출력해주며, UI 또한 기본적으로 제공해준다. 다만, UI 를 기본 형태에서 색상이나 기본 위치 커스텀은 가능하나, UI 없이 값만 받을 수 있는 구현은 불가능하다. 그래서, TinyDancer 의 로직 부분만을 발췌하여 살펴보기로 한다. 다음 코드들은 TinyDancer 의 코드들 중, UI 와 관련된 요소들은 제외하고 최소의 파일 개수로 정리한 것이다.

 첫번째로, FPS 계산이 끝나면 값을 전달해줄 콜백 메소드이다.

// FpsMonitorCallback.java

public interface FpsMonitorCallback {
    void onFpsCaptured(float capturedFps);
}

 두번째는, FPS 계산에 필요한 메소드들을 static 으로 선언한 FpsCalculation 클래스이다.

// FpsCalculation.java

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

public class FpsCalculation {
    public static List<Integer> getDroppedSet(float deviceRefreshRateInMs, List<Long> dataSet) {
        List<Integer> droppedSet = new ArrayList<>();
        long start = -1;
        for (Long value : dataSet) {
            if (start == -1) {
                start = value;
                continue;
            }

            int droppedCount = droppedCount(start, value, deviceRefreshRateInMs);
            if (droppedCount > 0) {
                droppedSet.add(droppedCount);
            }
            start = value;
        }
        return droppedSet;
    }

    public static int droppedCount(long startNs, long endNs, float devRefreshRate) {
        int count = 0;
        long diffNs = endNs - startNs;

        long diffMs = TimeUnit.MILLISECONDS.convert(diffNs, TimeUnit.NANOSECONDS);
        long dev = Math.round(devRefreshRate);

        if (diffMs > dev) {
            long droppedCount = (diffMs / dev);
            count = (int) droppedCount;
        }

        return count;
    }

    public static float getCalculatedFps(float refreshRate, float deviceRefresh, List<Long> dataSet, List<Integer> droppedSet) {
        long timeInNs = dataSet.get(dataSet.size() - 1) - dataSet.get(0);
        long size = getNumberOfFramesInSet(timeInNs, deviceRefresh);

        int dropped = 0;

        for (Integer k : droppedSet) {
            dropped += k;
        }

        float multiplier = refreshRate / size;
        float answer = multiplier * (size - dropped);

        return answer;
    }

    private static long getNumberOfFramesInSet(long realSampleLengthNs, float deviceRefresh) {
        float realSampleLengthMs = TimeUnit.MILLISECONDS.convert(realSampleLengthNs, TimeUnit.NANOSECONDS);
        float size = realSampleLengthMs / deviceRefresh;
        return Math.round(size);
    }
}

 마지막으로, FPS 계산을 제어하고 결과값을 전달해줄 FpsMonitor 클래스이다. 해당 클래스는 Choreographer.FrameCallback 인터페이스를 상속 받아 구현했다.

// FpsMonitor.java

import android.content.Context;
import android.view.Choreographer;
import android.view.Display;
import android.view.WindowManager;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

public class FpsMonitor implements Choreographer.FrameCallback {
    private final Context context;
    private List<Long> dataSet;
    private float refreshRate = 60.0f;
    private float deviceRefreshRateInMs = 16.6f;
    private final long sampleTimeInMs = 736;
    private long startSampleTimeInNs = 0;
    private FpsMonitorCallback callback;

    public FpsMonitor(Context context, FpsMonitorCallback callback) {
        this.context = context;
        this.callback = callback;
        dataSet = new ArrayList<>();
    }

    public void startFpsMonitor() {
        setFrameRate(context);
        Choreographer.getInstance().postFrameCallback(this);
    }

    public void stopFpsMonitor() {
        Choreographer.getInstance().removeFrameCallback(this);
    }

    private void setFrameRate(Context context) {
        Display display = ((WindowManager) context.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay();
        deviceRefreshRateInMs = 1000f / display.getRefreshRate(); // Hz 를 ms 로 변환한 것
        refreshRate = display.getRefreshRate(); // 화면 주사율 (Hz)
    }

    @Override
    public void doFrame(long frameTimeNanos) {
        if (startSampleTimeInNs == 0) {
            startSampleTimeInNs = frameTimeNanos;
        }

        if (isFinishedWithSample(frameTimeNanos)) {
            collectSampleAndSend(frameTimeNanos);
        }

        dataSet.add(frameTimeNanos);

        Choreographer.getInstance().postFrameCallback(this);
    }

    private void collectSampleAndSend(long frameTimeNanos) {
        List<Long> dataSetCopy = new ArrayList<>(dataSet);
        List<Integer> droppedSet = FpsCalculation.getDroppedSet(deviceRefreshRateInMs, dataSetCopy);
        callback.onFpsCaptured(FpsCalculation.getCalculatedFps(refreshRate, deviceRefreshRateInMs, dataSetCopy, droppedSet));
        dataSet.clear();
        startSampleTimeInNs = frameTimeNanos;
    }

    private boolean isFinishedWithSample(long frameTimeNanos) {
        return frameTimeNanos - startSampleTimeInNs > getSampleTimeInMs();
    }

    private long getSampleTimeInMs() {
        return TimeUnit.NANOSECONDS.convert(sampleTimeInMs, TimeUnit.MILLISECONDS);
    }
}

 이제 FpsCalcultaion 과 FpsMonitor 코드를 하나씩 살펴보면서 TinyDancer 의 FPS 측정 방법에 대해 분석해본다. FpsMonitorCallback 은 인터페이스 파일이니 생략한다.


3. TinyDancer - FpsMonitor

 FpsMonitor 클래스는 원본 라이브러리에서 "TinyDancerBuilder.java" 와 "FPSFrameCallback.java" 파일의 코드를 하나의 파일로 섞은 것이다. 원래는 View 단에서 TinyDancerBuilder 를 인스턴스화하고, TinyDancerBuilder 는 생성과 동시에 FPSFrameCallback 을 인스턴스화 하여 프레임 측정을 시작한다. TinyDancerBuilder 코드 안에는 UI 와 관련된 코드들도 상당부분 있어서, UI 관련 코드들을 지우고 FPSFrameCallback 의 코드를 붙여넣어서 FpsMonitor.java 로 만들었다.

// FpsMonitor.java

import android.content.Context;
import android.view.Choreographer;
import android.view.Display;
import android.view.WindowManager;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

public class FpsMonitor implements Choreographer.FrameCallback {
    private final Context context;
    private List<Long> dataSet;
    private float refreshRate = 60.0f;
    private float deviceRefreshRateInMs = 16.6f;
    private final long sampleTimeInMs = 736;
    private long startSampleTimeInNs = 0;
    private FpsMonitorCallback callback;

    public FpsMonitor(Context context, FpsMonitorCallback callback) {
        this.context = context;
        this.callback = callback;
        dataSet = new ArrayList<>();
    }

    public void startFpsMonitor() {
        setFrameRate(context);
        Choreographer.getInstance().postFrameCallback(this);
    }

    public void stopFpsMonitor() {
        Choreographer.getInstance().removeFrameCallback(this);
    }

    private void setFrameRate(Context context) {
        Display display = ((WindowManager) context.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay();
        deviceRefreshRateInMs = 1000f / display.getRefreshRate(); // Hz 를 ms 로 변환한 것
        refreshRate = display.getRefreshRate(); // 화면 주사율 (Hz)
    }

    @Override
    public void doFrame(long frameTimeNanos) {
        if (startSampleTimeInNs == 0) {
            startSampleTimeInNs = frameTimeNanos;
        }

        if (isFinishedWithSample(frameTimeNanos)) {
            collectSampleAndSend(frameTimeNanos);
        }

        dataSet.add(frameTimeNanos);

        Choreographer.getInstance().postFrameCallback(this);
    }

    private void collectSampleAndSend(long frameTimeNanos) {
        List<Long> dataSetCopy = new ArrayList<>(dataSet);
        List<Integer> droppedSet = FpsCalculation.getDroppedSet(deviceRefreshRateInMs, dataSetCopy);
        callback.onFpsCaptured(FpsCalculation.getCalculatedFps(refreshRate, deviceRefreshRateInMs, dataSetCopy, droppedSet));
        dataSet.clear();
        startSampleTimeInNs = frameTimeNanos;
    }

    private boolean isFinishedWithSample(long frameTimeNanos) {
        return frameTimeNanos - startSampleTimeInNs > getSampleTimeInMs();
    }

    private long getSampleTimeInMs() {
        return TimeUnit.NANOSECONDS.convert(sampleTimeInMs, TimeUnit.MILLISECONDS);
    }
}

 이제 위 코드에 대해 차근차근 알아보도록 한다.

3-1) Choreographer.FrameCallback

 FpsMonitor 코드에서 가장 먼저 알아볼 부분은 Choreographer.FrameCallback 이다. FpsMonitor 코드 내에서 오버라이드한 하나의 메소드가 보일 것이다. doFrame() 메소드만이 오버라이드 되어 있는데, 해당 메소드가 Choreographer.FrameCallback 의 메소드이다. doFrame() 메소드는 프레임이 새로 렌더링 되는 순간에 콜백하여, 렌더링 되는 시간을 nanosecond 로 전달해준다. 그 외에 더 자세한 사항은 공식 문서를 통해 확인할 수 있다.

3-2) setFrameRate(Context context)

private void setFrameRate(Context context) {
    Display display = ((WindowManager) context.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay();
    deviceRefreshRateInMs = 1000f / display.getRefreshRate();
    refreshRate = display.getRefreshRate();
}

 위 메소드는 deviceRefreshRateInMs 와 refreshRate 필드 값을 초기화 한다. 핵심이라 할 수 있는 부분은 Display.getRefreshRate() 일 것이다. 두 필드 값이 해당 메소드의 결과값을 통해 초기화 되고 있기 때문이다. Display.getRefreshRate() 는 현재 앱을 실행하는 Android 기기의 화면 주사율을 반환해주는 API 이다. 보통 Android OS 를 사용하는 스마트폰, TV 등 기기들은 60[Hz] 의 주사율로 보여주므로, Display.getRefreshRate() 의 값은 60[Hz] 라 생각해도 될 것이다.

 refreshRate 는 화면 주사율(Hz) 값을 저장하는 전역 변수이며, Display.getRefreshRate() 의 반환값을 그대로 저장한다. 반면에, deviceRefreshRateInMs 는 1000 을 Display.getRefreshRate() 의 반환값으로 나눈 결과를 저장한다.

 화면 주사율의 단위인 Hz(헤르츠) 는 주파수 단위를 의미한다. 주파수란, "1초(sec)에 몇 번 발생하는가" 를 뜻한다. 그렇다면 반대로, "1번 발생했을 때, 몇 초가 걸리는가" 는 어떻게 나타낼 수 있을까? 이는 주파수(Hz) 값을 역수로 취해주면, 그에 대한 시간 값이 나오게된다. 즉, 주파수(Hz) 는 1초에 몇 번 발생하는지(Hz)를 의미하고, 주파수를 역수로 취해주면 1번 발생하는 데 걸리는 시간(sec)으로 바뀌게 된다.

 다시 deviceRefreshRateInMs 값을 초기화 하는 부분으로 돌아오면, 해당 값은 1000 을 화면 주사율 값으로 나눈 것의 결과이다. 그런데, 나눈다는 것은 나누는 값을 역수로 바꿔주면 곱하는 것으로 바꿀 수 있다. 즉, "1000f / display.getRefreshRate()" 는 "1000f * (1/display.getRefreshRate())" 와 같은 것이다. 이제 1000f 를 곱해준 이유는, [Hz] 를 역수로 취했을 때 얻는 시간 값은 [sec] 단위 이므로, [sec] 단위를 [milli-sec] 로 변환하기 위해 곱해준 것이다. [milli] 는 10 의 (-3) 제곱을 의미하기 때문에, 값에는 10 의 3 제곱을 곱해줘야 한다.

 다시 정리해보자면, deviceRefreshRateInMs 는 화면이 1번 갱신되는 데 걸리는 시간을 [millisec] 단위로 가지고 있는 필드 값이고, refreshRate 는 1초에 화면이 갱신되는 횟수를 [Hz] 단위로 가지고 있는 필드 값이다.

3-3) doFrame(long frameTimeNanos)

@Override
public void doFrame(long frameTimeNanos) {
    if (startSampleTimeInNs == 0) {
        startSampleTimeInNs = frameTimeNanos;
    }

    if (isFinishedWithSample(frameTimeNanos)) {
        collectSampleAndSend(frameTimeNanos);
    }

    dataSet.add(frameTimeNanos);

    Choreographer.getInstance().postFrameCallback(this);
}

 앞서 설명했듯이, doFrame() 메소드는 Choregrapher.FrameCallback 인터페이스의 메소드로 프레임이 새로 렌더링 될 때 콜백된다. 그 안에 코드는 새로 프레임이 렌더링된 시간으로 특정 조건(isFinishedWithSample) 이 갖춰질 경우, collectSampleAndSend 메소드를 호출하고, dataSet 에 프레임이 렌더링된 시간을 축적한다. Choreographer.getInstance().postFrameCallback() 을 호출하여, 다음 프레임의 렌더링 시간을 요청하기도 한다.

3-4) isFinishedWithSample(frameTimeNanos)

private boolean isFinishedWithSample(long frameTimeNanos) {
    return frameTimeNanos - startSampleTimeInNs > getSampleTimeInMs();
}

private long getSampleTimeInMs() {
    return TimeUnit.NANOSECONDS.convert(sampleTimeInMs, TimeUnit.MILLISECONDS);
}

 isFinishedWithSample() 메소드의 내부 코드는 위와 같다. 먼저, getSampleTimeInMs() 메소드는 sampleTimeInMs 값을 NanoSecond 단위로 변환하는 기능을 수행한다. sampleTimeInMs 의 값은 736 인데, 이는 샘플링 시간이 736[ms] 임을 의미한다. 샘플링이란, 특정 구간을 선정하여 모인 데이터를 분석하는 것을 뜻한다. 위 코드에서는 736[ms] 만큼만 분석하겠다는 것을 뜻한다. 이제 isFinishedWithSample 메소드를 살펴보면, frameTimeNanos 값에서 startSampleTimeInNs 값을 뺀 결과가 샘플링 시간(736[ms]) 보다 클 경우 true 를 반환하게 된다. frameTimeNanos 는 최근에 프레임이 렌더링된 시각을 의미하고, startSampleTimeInNs 는 샘플링을 시작하는 시각을 의미한다. 즉, 샘플링에 처음으로 렌더링된 시각에서 최근에 프레임이 렌더링 된 시각까지의 시간이 샘플링 시간을 초과하게 될 경우 true 를 반환하고, 초과하지 않는다면 false 를 반환하는 메소드이다.

 isFinishedWithSample() 메소드가 true 를 반환하면, collectSampleAndSend() 메소드가 호출된다.

3-5) collectSampleAndSend(long frameTimeNanos)

private void collectSampleAndSend(long frameTimeNanos) {
    List<Long> dataSetCopy = new ArrayList<>(dataSet);
    List<Integer> droppedSet = FpsCalculation.getDroppedSet(deviceRefreshRateInMs, dataSetCopy);
    callback.onFpsCaptured(FpsCalculation.getCalculatedFps(refreshRate, deviceRefreshRateInMs, dataSetCopy, droppedSet));
    dataSet.clear();
    startSampleTimeInNs = frameTimeNanos;
}

프레임이 렌더링된 시각을 모아놓은 dataSet 의 복사본을 생성하고, FpsCalculation.getDroppedSet() 과 FpsCalculation.getCalculatedFps() 메소드를 거쳐 FPS 를 View 단으로 콜백해주게 된다. 콜백한 직후에는 dataSet 을 초기화하고, startSampleTimeInNs 의 값을 최근에 렌더링된 시각으로 바꾸어준다.

 간단하게 정리해보자면,  736[ms] 동안만 프레임이 렌더링된 시각들을 모아두고, 다 모였을 때 FPS 를 계산하여 View 단으로 보내준다고 볼 수 있다. 이제 FpsCalculation 클래스의 각 메소드를 분석한다면, 위 코드를 모두 이해할 수 있게 될 것이다.


4. TinyDancer - FpsCalculation

 FpsCalculation 클래스는 TinyDancer 라이브러리에서 "Calculation.java" 코드를 그대로 복사한 클래스이다.

// FpsCalculation.java

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

public class FpsCalculation {
    public static List<Integer> getDroppedSet(float deviceRefreshRateInMs, List<Long> dataSet) {
        List<Integer> droppedSet = new ArrayList<>();
        long start = -1;
        for (Long value : dataSet) {
            if (start == -1) {
                start = value;
                continue;
            }

            int droppedCount = droppedCount(start, value, deviceRefreshRateInMs);
            if (droppedCount > 0) {
                droppedSet.add(droppedCount);
            }
            start = value;
        }
        return droppedSet;
    }

    public static int droppedCount(long startNs, long endNs, float devRefreshRate) {
        int count = 0;
        long diffNs = endNs - startNs;

        long diffMs = TimeUnit.MILLISECONDS.convert(diffNs, TimeUnit.NANOSECONDS);
        long dev = Math.round(devRefreshRate);

        if (diffMs > dev) {
            long droppedCount = (diffMs / dev);
            count = (int) droppedCount;
        }

        return count;
    }

    public static float getCalculatedFps(float refreshRate, float deviceRefresh, List<Long> dataSet, List<Integer> droppedSet) {
        long timeInNs = dataSet.get(dataSet.size() - 1) - dataSet.get(0);
        long size = getNumberOfFramesInSet(timeInNs, deviceRefresh);

        int dropped = 0;

        for (Integer k : droppedSet) {
            dropped += k;
        }

        float multiplier = refreshRate / size;
        float answer = multiplier * (size - dropped);

        return answer;
    }

    private static long getNumberOfFramesInSet(long realSampleLengthNs, float deviceRefresh) {
        float realSampleLengthMs = TimeUnit.MILLISECONDS.convert(realSampleLengthNs, TimeUnit.NANOSECONDS);
        float size = realSampleLengthMs / deviceRefresh;
        return Math.round(size);
    }
}

 위 클래스는 모아진 프레임 렌더링 시각들을 사용하여 FPS 를 계산하는 데 필요한 로직들을 가지고 있다. 크게 getDroppedSet() 과 getCalculatedFps() 로 나누어서 살펴본다.

4-1) getDroppedSet(float, List<Long>)

public static List<Integer> getDroppedSet(float deviceRefreshRateInMs, List<Long> dataSet) {
    List<Integer> droppedSet = new ArrayList<>();
    long start = -1;
    for (Long value : dataSet) {
        if (start == -1) {
            start = value;
            continue;
        }

        int droppedCount = droppedCount(start, value, deviceRefreshRateInMs);
        if (droppedCount > 0) {
            droppedSet.add(droppedCount);
        }
        start = value;
    }
    return droppedSet;
}

public static int droppedCount(long startNs, long endNs, float devRefreshRate) {
    int count = 0;
    long diffNs = endNs - startNs;

    long diffMs = TimeUnit.MILLISECONDS.convert(diffNs, TimeUnit.NANOSECONDS);
    long dev = Math.round(devRefreshRate);

    if (diffMs > dev) {
        long droppedCount = (diffMs / dev);
        count = (int) droppedCount;
    }

    return count;
}

 먼저, getDroppedSet() 메소드의 인자값은 deviceRefreshRateInMsdataSet 이다. deviceRefreshRateInMs 는 화면 주사율을 역수로 취한, 화면 1번 갱신에 걸리는 시간 값을 가지고 있다. dataSet샘플링 시간동안 모인 프레임 렌더링 시각들의 묶음이다. dataSet 의 크기만큼 forEach 문을 수행하여, dataSet 안에서 연속되는 두 개의 값을 start 와 value 로 선정하여 droppedCount() 메소드의 파라미터로 전달하여 호출한다. droppedCount() 메소드의 반환값이 0 보다 클 경우, droppedSet 에 모아두고, start 값을 갱신하여 다음 반복을 수행한다.

 droppedCount() 메소드의 파라미터로 전달되는 startNs 와 endNs 는 샘플링 시간동안 모은 프레임 렌더링 시각들 중 연속되는 두 개의 값을 선정하여 보낸 값이다. 그리고, devRefreshRate 는 방금 언급했던 기기의 화면 갱신이 1번 발생하는데 걸리는 시간이다. 즉, 위 사진에서 보여지는 droppedCount() 메소드는 연이은 두 개의 프레임 렌더링 시간 사이에서 화면의 갱신이 발생했음에도 예정된 렌더링이 되지 않은 프레임의 개수를 파악하는 것이다.

 화면 주사율 60[Hz] 와 프레임율 60[FPS] 는 단위가 다르지만 실제 값은 똑같다. 디스플레이는 1초에 화면이 60번 갱신되고, GPU 에서는 1초에 60개의 프레임을 생산하여 보내는 것을 뜻한다. 그렇다면, 이론적으로 생각했을 때, 디스플레이가 한 번 갱신 될 때, GPU 에서는 동시에 1개의 프레임을 보내줘야한다. 그런데, 예기치 못하게 디스플레이가 한 번 갱신 되었음에도 GPU 에서 프레임을 보내주지 않은 경우가 발생할 때가 있다. 그래서, TinyDancer 는 FPS 를 계산할 때, 이론적으로 계산한 프레임율에서 예기치 못하게 렌더링 되지 못한 프레임의 개수를 차감하여 실제 프레임율을 출력해주는 로직을 채택했다. 그래서, 위에 DroppedSet 에 모인 렌더링 되지 못한 프레임의 개수는 FpsCalculation.getCalculatedFps 에서 사용하게 된다.

4-2) getCalculatedFps(float, float, List<Long>, List<Integer>)

public static float getCalculatedFps(float refreshRate, float deviceRefresh, List<Long> dataSet, List<Integer> droppedSet) {
    long timeInNs = dataSet.get(dataSet.size() - 1) - dataSet.get(0);

    long size = getNumberOfFramesInSet(timeInNs, deviceRefresh);

    int dropped = 0;

    for (Integer k : droppedSet) {
        dropped += k;
    }

    float multiplier = refreshRate / size;
    float answer = multiplier * (size - dropped);

    return answer;
}

private static long getNumberOfFramesInSet(long realSampleLengthNs, float deviceRefresh) {
    float realSampleLengthMs = TimeUnit.MILLISECONDS.convert(realSampleLengthNs, TimeUnit.NANOSECONDS);
    float size = realSampleLengthMs / deviceRefresh;
    return Math.round(size);
}

 먼저, getCalculatedFps() 메소드로 전달되는 파라미터의 각 값들은 다음과 같다.

  • refreshRate : 1초에 화면이 갱신되는 횟수, 화면 주사율(Hz) 값
  • deviceRefresh : 화면 주사율을 역수로 취한, 1번 화면 갱신에 필요한 시간
  • dataSet : 샘플링 시간동안 모아둔 프레임 렌더링 시각들의 리스트
  • droppedSet : 화면 갱신 시간에 발생하지 않은 프레임의 개수

 timeInNs 는 프레임 렌더링 시각들의 리스트에서 맨 처음과 맨 마지막의 시간을 계산한 값이다. 그리고 timeInNs 값과 deviceRefresh 값을 사용하여 getNumberOfFrameInSet() 메소드를 호출하여 size 값을 반환 받는다.

 getNumberOfFramesInSet() 메소드는 위에서 계산한 timeInNs 값을 MilliSecond 값으로 반환하여, deviceRefresh 값으로 나눈 값을 반환한다. 누적된 프레임 렌더링 시간들 중 처음 것과 마지막 것의 시간차를 기기 화면이 1번 갱신되는데 걸리는 시간으로 나눈 것이다. 프레임 지연이 발생하지 않는 이상, 60[Hz] 의 화면 주사율과 60[fps] 프레임율이므로, 화면 갱신 1번에 프레임도 1번 렌더링이 될 것이다. 즉, size 는 실제로 프레임이 쌓이는 동안 발생한 화면 갱신 횟수이자, 이론적인 프레임 횟수을 의미한다.

 dropped 는 위에서 연산했던, 예기치 못하게 렌더링 되지 못한 프레임의 개수를 총합한 것이다. forEach 문을 사용하여 droppedSet 에 리스트로 저장한 프레임의 개수들을 다 더한다.

 마지막으로, multiplier 는 화면 주사율인 refreshRate 를 실제로 프레임이 쌓일 동안 발생한 화면 갱신 횟수로 나눈 값이다. 그리고, 해당 값을 size 와 dropped 의 차이값에 곱하여 FPS 최종 값을 계산한다. 이론적으로 발생해야하는 프레임 개수인 size 에서 실제로 발생하지 못한 프레임 개수인 dropped 를 뺀 후에, 가중치인 multiplier 를 곱하여 FPS 를 계산한 것이다.


5. 실행 결과

 원래의 TinyDancer 에서는 UI 커스텀이 불가능하여, 표지판처럼 생긴 플로팅 UI 가 무조건 떠있어야한다. 하지만, 위와 같이 로직 부분만 빼내어 반환 받을 수 있게 한다면, 원하는 UI 에 넣을 수 있게 된다. 필자가 적용한 실행 모습을 첨부하며 글을 마친다.

반응형