Jetpack Compose 实用指南 - 实现一个精准、多模式的FPS监控器

1,190 阅读5分钟

先看效果吸引各位:

2022-07-13 16-38-38.gif “实时”准确但是不平稳

“x ms”相对平滑,视频中有一个“刚打开时数字特大”的bug,不过代码中已修复

“每x帧”这个是顺便实现的,统计最后x帧的平均帧率,这种统计方式个人不喜欢,但可能某些时候用得到?

前言

compose实现一个 FPS监控 还是很简单的。 因为androidx.compose.runtime包中就提供了诸如withFrameMilliswithFrameNanos 等多个suspend函数。

——当然,我直接说简单你们肯定不信,而且咱自己初上手就整FPS显示功能,一不小心还会有不准确 / 性能存在问题的实现。

——试想一下,你明明是来统计FPS的,结果这组件一写,FPS哗哗地掉,你统计个锤子哟!

那诸君,来吧,跟着我来走一番!

明确帧数统计方法

方法无非那么几种,百度一下:懒得百度就点这里

于是根据万能的百度,我们可以定义一个统计方式的enum class

/**
 * 帧数统计方式
 */
enum class FPSCountMethod {
    /**
     * 统计固定x帧的平均用时,反推帧数
     */
    FixedInterval,
    
    /**
     * 统计一段时间内生成了多少帧,得到帧数
     */
    FixedFrameCount,
    
    /**
     * 根据最近两帧的时间间隔,得出帧数
     */
    RealTime
}

别问为什么我选择了这三种统计方式。问就是我乐意~

查看关键函数的接口

先查看withFrameMillis()接口的定义——

原文原文

看不懂还有中文版本image.png

得知其中的onFrame()回调返回的并非时间戳

我们需要为三种方式实现统计,可能他们的核心逻辑是可以写在一个onFrame()块内的。

但写代码,得一样一样来。

明确协程的调用方式

首先,既然withFrameMillis()是个suspend函数,显然我们需要为它准备一个协程。

个人而言,在compose中使用协程要注意两件事:

  1. 使用rememberCoroutineScope()LaunchEffect(),它们可以保证协程的生命周期和compose生命周期一致——所以也要注意协程的生命周期!
  2. LaunchEffect会在组合阶段被调用,且其默认coroutineContext所指向的线程是组合(composition)所在的线程。(组合所在的线程未必是主线程,但现阶段还是主线程,之后compose会启用多线程组合

然后rememberCoroutineScope()一般用在一个没有@Composable注解的block内。

此处我们需要用到的自然是LaunchEffect()


然后整个帧数的统计功能,我选择:

在对应的协程中定义数据结构,只有界面显示必要的参数才直接在@Composable函数中创建。

具体怎么做别着急,我们先定义协程内部代码应该怎么写。

实现三种统计方式

懒得水了,核心逻辑相信各位大佬一看就明白了。

首先定义基本变量

创建单独的kt文件,这些基本变量直接作为const val毫无问题——

private const val fpsUpdDelay = 250L // 界面x毫秒更新一次fps结果
private const val frameCount = 10 // x帧缓冲区
private const val greenFPS = 57 // 帧数小于等于x显示为红色

关于变量fpsUpdDelay再解释一下:

withFrameMillis()会每次刷新一帧后回调一个相对时间。如果我们直接在该block内进行界面更新,意味着我们的帧数监控器根本没法监控到界面卡顿! 所以我们必须另外launch一个协程块去定时更新。

根据实测,250ms是一个比较均衡的参数,它既能快速响应变化,又不至于快到人眼看不过来。(意思是你可以自己按需再改一个喜欢的参数啦。)

然后创建@Composable函数

@Composable
fun FPSMonitor(modifier: Modifier = Modifier) {    
    // ...界面不着急
    
    // 个人习惯这里填Unit,你填任意常量比如true/false/0/1都行
    // 这里填任意常量都表示此LaunchEffect 在生命周期内、只在第一次组合时执行一次
    LaunchedEffect(Unit) { 
        // 我不想在组合所在的线程做任何事,免得增加它的负担
        launch(Dispatchers.Default) { 
            // 两个任务,一个负责统计,一个负责更新与界面绑定的数据
            val countTask: suspend CoroutineScope.() -> Unit = { ... }
            val updDataTask: suspend CoroutineScope.() -> Unit = { ... }
            // 分别launch两个平行任务
            launch(block = countTask)
            launch(block = updDataTask)
        }
    }
}

之后实现具体统计功能

@Composable
fun FPSMonitor(modifier: Modifier = Modifier) {
    // ...界面后面讲
    
    LaunchedEffect(Unit) {
        launch(Dispatchers.Default) {
            val fpsArray = FloatArray(frameCount) { 0f }
            
            var fpsCount = 0 // FixedInterval统计方式
            var avgFPS = 0  // FixedFrameCount统计方式
            var lastWriteIndex = 0 // RealTime统计方式
            
            val countTask: suspend CoroutineScope.() -> Unit = {
                var lastUpdTime = 0L
                var writeIndex = 0
                while (true) withFrameMillis { frameTimeMillis ->
                    fpsCount++ // FixedInterval统计方式
                    
                    fpsArray[writeIndex] = 1000f / (frameTimeMillis - lastUpdTime)
                    lastUpdTime = frameTimeMillis
                    
                    lastWriteIndex = writeIndex // RealTime统计方式
                    
                    writeIndex++
                    if (writeIndex >= fpsArray.size) {
                        avgFPS = fpsArray.average().roundToInt() // FixedFrameCount统计方式
                        writeIndex = 0
                    }
                }
            }
            
            val updDataTask: suspend CoroutineScope.() -> Unit = { /* 更新界面不着急 */ }
            
            launch(block = countTask)
            launch(block = updDataTask)
        }
    }
}

实现数据更新功能

上一段的代码可以使得我们三种统计方式显示的数据可以这样得到——

val updDataTask: suspend CoroutineScope.() -> Unit = {
    while (true) {
        delay(fpsUpdDelay) // 每x毫秒更新一次
       
        displayedFPS = when (fpsCountMethod) {
            FPSCountMethod.FixedInterval   -> fpsCount * 1000 / fpsUpdDelay.toInt()
            FPSCountMethod.FixedFrameCount -> avgFPS
            FPSCountMethod.RealTime        -> fpsArray[lastWriteIndex].roundToInt()
        }
        fpsCount = 0
    }
}

根据👆代码,发现一个此前未定义过的变量:displayedFPS,显然,它和界面显示相关,界面显示的逻辑需要它才能完善。 只能把它定义到@Composable内了

最终kt文件内的全部代码

private const val fpsUpdDelay = 250L // x毫秒更新一次fps结果
private const val frameCount = 10 // x帧缓冲区
private const val greenFPS = 57 // 帧数小于等于x显示为红色

/**
 * 帧数统计方式
 */
enum class FPSCountMethod {
    /**
     * 统计固定x帧的平均用时,反推帧数
     */
    FixedInterval,
    
    /**
     * 统计一段时间内生成了多少帧,得到帧数
     */
    FixedFrameCount,
    
    /**
     * 根据最近两帧的时间间隔,得出帧数
     */
    RealTime
}

@Composable
fun FPSMonitor(modifier: Modifier = Modifier) { // 传入Modifier方便之后定制它的布局/显示,用默认参数使得不用显式传入
    var displayedFPS by remember { mutableStateOf(0) }
    var textContent by remember { mutableStateOf("FPS:") } // 默认显示"FPS:"
    var fpsCountMethod by remember { mutableStateOf(FPSCountMethod.RealTime) } // 默认为RealTime模式
    
    val textColor by remember {
        derivedStateOf {
            when (fpsCountMethod) {
                FPSCountMethod.RealTime        -> {
                    textContent = "FPS(实时):${displayedFPS}"
                    if (displayedFPS > greenFPS) Color.Green else Color.Red
                }
                FPSCountMethod.FixedInterval   -> {
                    textContent = "FPS(最后${fpsUpdDelay}ms):${displayedFPS}"
                    if (displayedFPS > greenFPS) Color.Cyan else Color.Magenta
                }
                FPSCountMethod.FixedFrameCount -> {
                    textContent = "FPS(每${frameCount}帧):${displayedFPS}"
                    if (displayedFPS > greenFPS) Color.Cyan else Color.Magenta
                }
            }
        }
    }
    
    Text(
        text = textContent,
        modifier = modifier.clickable {
            fpsCountMethod = when (fpsCountMethod) {
                FPSCountMethod.FixedInterval   -> FPSCountMethod.FixedFrameCount
                FPSCountMethod.FixedFrameCount -> FPSCountMethod.RealTime
                FPSCountMethod.RealTime        -> FPSCountMethod.FixedInterval
            }
        },
        color = textColor
    )
    
    LaunchedEffect(Unit) {
        launch(Dispatchers.Default) {
            val fpsArray = FloatArray(frameCount) { 0f }
            
            var fpsCount = 0 // FixedInterval统计方式
            var avgFPS = 0  // FixedFrameCount统计方式
            var lastWriteIndex = 0 // RealTime统计方式
            
            val countTask: suspend CoroutineScope.() -> Unit = {
                var lastUpdTime = 0L
                var writeIndex = 0
                while (true) withFrameMillis { frameTimeMillis ->
                    fpsCount++ // FixedInterval统计方式
                    
                    fpsArray[writeIndex] = 1000f / (frameTimeMillis - lastUpdTime) //
                    lastUpdTime = frameTimeMillis
                    
                    lastWriteIndex = writeIndex // RealTime统计方式
                    
                    writeIndex++
                    if (writeIndex >= fpsArray.size) {
                        avgFPS = fpsArray.average().roundToInt() // FixedFrameCount统计方式
                        writeIndex = 0
                    }
                }
            }
            
            val updDataTask: suspend CoroutineScope.() -> Unit = {
                while (true) {
                    delay(fpsUpdDelay)
                    displayedFPS = when (fpsCountMethod) {
                        FPSCountMethod.FixedInterval   -> fpsCount * 1000 / fpsUpdDelay.toInt()
                        FPSCountMethod.FixedFrameCount -> avgFPS
                        FPSCountMethod.RealTime        -> fpsArray[lastWriteIndex].roundToInt()
                    }
                    fpsCount = 0
                }
            }
            
            launch(block = countTask)
            launch(block = updDataTask)
        }
    }
}

最后讲讲代码中的细节


  • Text()函数其参数text直接显示的内容需要对displayedFPS处理后得到
    • Text()其参数color对应的颜色需要对displayedFPSfpsCountMethod两个state变量建立观察并实时响应。这时候我们有两种方法去实现这个功能:

        1. remember(displayedFPS,fpsCountMethod){ mutableStateOf( /* 在这里处理 */ ) }
        1. remember { derivedStateOf { /* 在这里处理 */ } }
    • derivedStateOf{}的计算结果不变的情况下不会触发recompose

      • 此处只用到了其"在state对象改变时自动触发其calculation{}重新运行"的特性
    • 所以关于derivedStateOf{},请见官方文档:derivedStateOf: convert one or multiple state objects into another state


  • 所有的界面改变都是因为derivedStateOf{}被触发
    1. 首先是displayedFPSfpsCountMethod的改变被derivedStateOf{}监测到
    2. 然后是derivedStateOf{}计算过程中,更新了作为参数传入到Text()textContent
    3. derivedStateOf{}计算完毕后同时也会使得textColor被更新
    4. textColortextContent任一更新都会导致Text()重组

仅仅是displayedFPS改变的话,不会引起重组

不信我们可以试一下:把textColor处代码改成这样——

val textColor by remember {
        derivedStateOf {
            Color.Red
        }
    }
// 或者这样
val textColor by remember {
        Color.Red
    }
// 甚至这样
val textColor = Color.Red.copy(Random(System.currentTimeMillis()).nextFloat())

// 然后加个Log
Log.i("test","update")
Log.i("test","update${fpsCountMethod}")

然后你会发现,不管displayedFPS再怎么更新,这两个log也不会触发——除非你通过click改变fpsCountMethod的值,那它们会同时触发。

不是说state对象的改变会触发重组吗?

这就是下一期的内容了~