Android 计算滑动帧率 笔记

10 阅读5分钟

我们可以利用 ViewTreeObserver 来实现滑动帧率的监控,但需要明确一点:ViewTreeObserver 本身并不直接提供全局滑动监听,而是通过其 OnScrollChangedListener 来监听单个 View 的滚动事件。因此,我们可以通过将监听器附加到滑动容器(如 ScrollViewNestedScrollViewListView 等)上,来感知滑动的开始和结束,进而结合 Choreographer 计算滑动期间的帧率。

下面将介绍一种统一的实现思路,并提供可复用的代码。


1. 核心思路

  1. 检测滑动状态:通过目标 View 的 ViewTreeObserver 注册 OnScrollChangedListener,在滚动回调中判断滑动状态的变化。
  2. 利用 Choreographer 统计帧:在滑动开始时开启帧回调,统计滑动期间每一帧的到达时间;滑动结束时计算平均帧率和掉帧数。
  3. 统一封装:创建一个工具类,可以应用到任何支持滚动监听的 View 上,自动管理生命周期。

注意RecyclerView 并不通过 ViewTreeObserver 触发滚动回调,它有自己的 addOnScrollListener 方法。因此,我们的统一方案需要同时兼容两种场景,或者让调用者根据 View 类型选择合适的方式。这里我们提供一个抽象,并针对常用容器分别处理。


2. 代码实现

2.1 定义滑动帧率监听器接口

public interface ScrollFpsListener {
    void onScrollFps(double avgFps, int droppedFrames, long scrollDurationMs);
}

2.2 核心监控类(利用 Choreographer)

public class ScrollFpsTracker {
    private Choreographer choreographer;
    private Choreographer.FrameCallback frameCallback;
    private boolean isTracking = false;      // 是否正在跟踪(Choreographer是否注册)
    private boolean isScrolling = false;     // 是否正在滑动中

    // 统计数据
    private long lastFrameTimeNs;
    private int frameCount;
    private long totalDurationNs;
    private long scrollStartTimeNs;

    private ScrollFpsListener listener;

    public ScrollFpsTracker(ScrollFpsListener listener) {
        this.choreographer = Choreographer.getInstance();
        this.listener = listener;
        initFrameCallback();
    }

    private void initFrameCallback() {
        frameCallback = new Choreographer.FrameCallback() {
            @Override
            public void doFrame(long frameTimeNanos) {
                if (!isTracking || !isScrolling) {
                    // 如果不在跟踪状态或未在滑动,不统计,但仍需继续注册以便后续恢复
                    if (isTracking) {
                        choreographer.postFrameCallback(this);
                    }
                    lastFrameTimeNs = frameTimeNanos;
                    return;
                }

                if (lastFrameTimeNs != 0) {
                    long intervalNs = frameTimeNanos - lastFrameTimeNs;
                    totalDurationNs += intervalNs;
                    frameCount++;
                }
                lastFrameTimeNs = frameTimeNanos;

                // 继续监听下一帧
                choreographer.postFrameCallback(this);
            }
        };
    }

    // 开始跟踪(通常在View附加到窗口时调用)
    public void startTracking() {
        if (!isTracking) {
            isTracking = true;
            resetStats();
            choreographer.postFrameCallback(frameCallback);
        }
    }

    // 停止跟踪(View从窗口移除时调用)
    public void stopTracking() {
        if (isTracking) {
            isTracking = false;
            choreographer.removeFrameCallback(frameCallback);
            resetStats();
        }
    }

    // 滑动开始
    public void onScrollStarted() {
        if (!isTracking) return;
        isScrolling = true;
        resetStats();
        scrollStartTimeNs = System.nanoTime();
    }

    // 滑动结束
    public void onScrollStopped() {
        if (!isTracking || !isScrolling) return;
        isScrolling = false;

        long now = System.nanoTime();
        long scrollDurationNs = now - scrollStartTimeNs;
        if (scrollDurationNs > 0 && frameCount > 0) {
            double avgFps = (double) frameCount / (scrollDurationNs / 1_000_000_000.0);
            // 假设目标帧率为60FPS,计算掉帧数
            long expectedFrames = (long) (scrollDurationNs / (1_000_000_000.0 / 60));
            int droppedFrames = (int) (expectedFrames - frameCount);
            if (listener != null) {
                listener.onScrollFps(avgFps, Math.max(0, droppedFrames), scrollDurationNs / 1_000_000);
            }
        }

        resetStats();
    }

    private void resetStats() {
        lastFrameTimeNs = 0;
        frameCount = 0;
        totalDurationNs = 0;
    }
}

2.3 统一适配器:根据 View 类型添加滑动监听

我们需要一个辅助类,能够智能地为不同的滑动容器添加监听,并将状态变化转发给 ScrollFpsTracker

public class ScrollFpsMonitor {
    private ScrollFpsTracker tracker;
    private View targetView;
    private Object scrollListener; // 用于保存可能注册的监听器,方便移除

    public ScrollFpsMonitor(View view, ScrollFpsListener listener) {
        this.targetView = view;
        this.tracker = new ScrollFpsTracker(listener);
    }

    // 开始监控(通常在 onResume 或视图可见时调用)
    public void start() {
        tracker.startTracking();
        attachScrollListener();
    }

    // 停止监控(通常在 onPause 或视图销毁时调用)
    public void stop() {
        tracker.stopTracking();
        detachScrollListener();
    }

    private void attachScrollListener() {
        if (targetView == null) return;

        if (targetView instanceof RecyclerView) {
            RecyclerView recyclerView = (RecyclerView) targetView;
            RecyclerView.OnScrollListener listener = new RecyclerView.OnScrollListener() {
                @Override
                public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
                    if (newState == RecyclerView.SCROLL_STATE_DRAGGING || newState == RecyclerView.SCROLL_STATE_SETTLING) {
                        tracker.onScrollStarted();
                    } else if (newState == RecyclerView.SCROLL_STATE_IDLE) {
                        tracker.onScrollStopped();
                    }
                }
            };
            recyclerView.addOnScrollListener(listener);
            scrollListener = listener; // 保存以便移除
        } else {
            // 对于 ScrollView、NestedScrollView、ListView 等,使用 ViewTreeObserver.OnScrollChangedListener
            // 注意:这些 View 的滚动事件需要配合 View.getScrollY() 的变化来判断开始和结束。
            // 这里简单起见,我们使用一个延迟任务来模拟滑动结束(实际复杂场景需要更精确的判断)。
            // 更精确的方法是:在 onScrollChanged 中记录上次滚动时间,配合 handler 判断是否停止。
            ViewTreeObserver.OnScrollChangedListener onScrollChangedListener = new ViewTreeObserver.OnScrollChangedListener() {
                private int lastScrollY = targetView.getScrollY();
                private boolean scrolling = false;
                private Runnable stopCheck = new Runnable() {
                    @Override
                    public void run() {
                        if (scrolling) {
                            scrolling = false;
                            tracker.onScrollStopped();
                        }
                    }
                };
                private Handler handler = new Handler(Looper.getMainLooper());

                @Override
                public void onScrollChanged() {
                    int scrollY = targetView.getScrollY();
                    if (scrollY != lastScrollY) {
                        if (!scrolling) {
                            scrolling = true;
                            tracker.onScrollStarted();
                        }
                        lastScrollY = scrollY;
                        // 延迟重置停止状态
                        handler.removeCallbacks(stopCheck);
                        handler.postDelayed(stopCheck, 100); // 100ms 无滚动则认为停止
                    }
                }
            };
            targetView.getViewTreeObserver().addOnScrollChangedListener(onScrollChangedListener);
            scrollListener = onScrollChangedListener;
        }
    }

    private void detachScrollListener() {
        if (targetView == null || scrollListener == null) return;

        if (targetView instanceof RecyclerView && scrollListener instanceof RecyclerView.OnScrollListener) {
            ((RecyclerView) targetView).removeOnScrollListener((RecyclerView.OnScrollListener) scrollListener);
        } else if (scrollListener instanceof ViewTreeObserver.OnScrollChangedListener) {
            targetView.getViewTreeObserver().removeOnScrollChangedListener((ViewTreeObserver.OnScrollChangedListener) scrollListener);
        }
        scrollListener = null;
    }
}

2.4 使用示例

在 Activity 或 Fragment 中,对目标滑动容器进行监控:

public class MainActivity extends AppCompatActivity {
    private ScrollFpsMonitor fpsMonitor;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // 假设我们有一个 NestedScrollView
        NestedScrollView scrollView = findViewById(R.id.scroll_view);
        fpsMonitor = new ScrollFpsMonitor(scrollView, new ScrollFpsListener() {
            @Override
            public void onScrollFps(double avgFps, int droppedFrames, long scrollDurationMs) {
                Log.i("ScrollFps", String.format("平均FPS: %.2f, 掉帧: %d, 滑动时长: %dms",
                        avgFps, droppedFrames, scrollDurationMs));
            }
        });
    }

    @Override
    protected void onResume() {
        super.onResume();
        fpsMonitor.start();
    }

    @Override
    protected void onPause() {
        super.onPause();
        fpsMonitor.stop();
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        if (fpsMonitor != null) {
            fpsMonitor.stop();
            fpsMonitor = null;
        }
    }
}

3. 原理说明

  • Choreographer.FrameCallback:每次系统准备渲染新帧时回调,我们利用它记录帧的时间戳,从而计算帧间隔和帧数。
  • 滑动状态检测
    • 对于 RecyclerView,使用其自带的 OnScrollListener,状态清晰。
    • 对于其他基于 View 的滚动容器(如 ScrollViewNestedScrollView),通过 ViewTreeObserver.OnScrollChangedListener 监听滚动变化,并根据 scrollY 的变化和停滞时间判断滑动开始和结束。
  • 统计范围:只在 isScrolling = true 期间记录帧,避免静止页面的帧率为0影响平均值。

4. 优缺点

优点

  • 统一接口,可适用于多种滑动容器。
  • 无需侵入业务代码,只需在页面生命周期内启动/停止监控。
  • 可以获取每次滑动片段的帧率数据,便于精细化分析。

缺点

  • 对于非 RecyclerView 的滑动结束判断依赖于定时器,可能存在一定误差(如快速连续滑动可能被误判为多次滑动)。
  • 无法区分惯性滑动和手指拖动,但两者都属于滑动过程,通常我们希望统计整个滑动过程。
  • ViewTreeObserver 监听需要谨慎管理,避免内存泄漏(示例中已处理移除)。

5. 优化建议

  • 更精确的滑动结束检测:可以使用 View.OnScrollChangeListener (API 23+) 结合 VelocityTracker 等,但复杂度增加。
  • 对于 RecyclerView,可以直接复用 RecyclerView.OnScrollListener,无需 ViewTreeObserver
  • 如果希望监控整个页面的所有滑动(包括嵌套滚动),可以递归遍历视图树添加监听,但需注意性能。

通过上述方案,你可以利用 ViewTreeObserver 并结合 Choreographer 统一实现对滑动帧率的监控。根据实际场景选择适合的滑动检测方式即可。