先看效果吸引各位:
“实时”准确但是不平稳
“x ms”相对平滑,视频中有一个“刚打开时数字特大”的bug,不过代码中已修复
“每x帧”这个是顺便实现的,统计最后x帧的平均帧率,这种统计方式个人不喜欢,但可能某些时候用得到?
前言
compose实现一个 FPS监控 还是很简单的。
因为androidx.compose.runtime包中就提供了诸如withFrameMillis、withFrameNanos 等多个suspend函数。
——当然,我直接说简单你们肯定不信,而且咱自己初上手就整FPS显示功能,一不小心还会有不准确 / 性能存在问题的实现。
——试想一下,你明明是来统计FPS的,结果这组件一写,FPS哗哗地掉,你统计个锤子哟!
那诸君,来吧,跟着我来走一番!
明确帧数统计方法
方法无非那么几种,百度一下:懒得百度就点这里
于是根据万能的百度,我们可以定义一个统计方式的enum class
/**
* 帧数统计方式
*/
enum class FPSCountMethod {
/**
* 统计固定x帧的平均用时,反推帧数
*/
FixedInterval,
/**
* 统计一段时间内生成了多少帧,得到帧数
*/
FixedFrameCount,
/**
* 根据最近两帧的时间间隔,得出帧数
*/
RealTime
}
别问为什么我选择了这三种统计方式。问就是我乐意~
查看关键函数的接口
先查看withFrameMillis()接口的定义——
原文
看不懂还有中文版本
得知其中的onFrame()回调返回的并非时间戳
我们需要为三种方式实现统计,可能他们的核心逻辑是可以写在一个onFrame()块内的。
但写代码,得一样一样来。
明确协程的调用方式
首先,既然withFrameMillis()是个suspend函数,显然我们需要为它准备一个协程。
个人而言,在compose中使用协程要注意两件事:
- 使用
rememberCoroutineScope()或LaunchEffect(),它们可以保证协程的生命周期和compose生命周期一致——所以也要注意协程的生命周期! - 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对应的颜色需要对displayedFPS、fpsCountMethod两个state变量建立观察并实时响应。这时候我们有两种方法去实现这个功能:-
remember(displayedFPS,fpsCountMethod){ mutableStateOf( /* 在这里处理 */ ) }
-
remember { derivedStateOf { /* 在这里处理 */ } }
-
-
但
derivedStateOf{}的计算结果不变的情况下不会触发recompose- 此处只用到了其"在state对象改变时自动触发其calculation{}重新运行"的特性
-
所以关于
derivedStateOf{},请见官方文档:derivedStateOf: convert one or multiple state objects into another state
-
- 所有的界面改变都是因为
derivedStateOf{}被触发- 首先是displayedFPS或fpsCountMethod的改变被
derivedStateOf{}监测到 - 然后是
derivedStateOf{}计算过程中,更新了作为参数传入到Text()的textContent derivedStateOf{}计算完毕后同时也会使得textColor被更新- textColor和textContent任一更新都会导致
Text()被重组
- 首先是displayedFPS或fpsCountMethod的改变被
仅仅是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的值,那它们会同时触发。