1. 从 Android 10 开始,刷新率不再是一个“只读属性”
如果你在 Android 9 及以前做过显示相关的开发,大概率会有这样一个印象:屏幕刷新率是系统底层和硬件之间的事,应用层能做的事情非常有限。大多数情况下,你只能通过Display.getRefreshRate()读到一个固定值,比如 60Hz,然后所有和帧率相关的逻辑都围绕这个数字来设计。但从 Android 10(也就是 Android Q,API 29)开始,这套认知需要彻底更新了。
Android 10 在框架层引入了一套可切换的显示刷新率机制,允许系统根据当前运行场景动态调整屏幕刷新率,同时也给应用层开放了“表达偏好”的能力。这意味着,一台 90Hz 或 120Hz 的高刷设备,并不是永远跑在最高刷新率上,而是会在不同场景下切换,比如看视频时降到 60Hz 甚至 24Hz,滑动列表时拉满到 90Hz,静止阅读时再降下来省电。这套机制的核心目标是在流畅度和功耗之间取得平衡。
这篇文章主要面向三类读者:一是做 Android 系统层或 Framework 开发的工程师,需要理解刷新率切换的底层逻辑;二是做高性能应用(如游戏、视频、动画密集型 App)的开发者,需要知道怎么让自己的应用正确表达刷新率偏好;三是对显示子系统感兴趣、想搞清楚“为什么我的高刷手机有时候不流畅”的技术爱好者。我会从 Android 10 的刷新率切换机制讲起,拆解Display.Mode、preferredDisplayModeId、Surface.setFrameRate()等关键 API,再结合系统策略、应用层实践和常见踩坑,把这块内容讲透。
需要提前说明的是,Android 10 只是这套机制的起点,后续版本(Android 11、12、13)在 API 和策略上都有演进。本文聚焦 Android 10 的行为,但在必要处会指出后续版本的变化,避免你在新系统上照搬旧结论。
2. Android 10 刷新率切换的底层机制拆解
2.1 Display.Mode 与物理刷新率的映射关系
要理解刷新率切换,首先要搞清楚Display.Mode这个概念。在 Android 的显示框架里,一个物理显示屏可能支持多种“模式”,每种模式对应一组显示参数,包括分辨率、刷新率、色彩深度等。Display.getSupportedModes()会返回一个Display.Mode[]数组,里面列出了当前屏幕支持的所有模式。
在 Android 10 之前,这个数组通常只有一个元素,或者虽然有多个但系统不会主动切换。Android 10 之后,高刷设备上你会看到类似这样的模式列表:
| Mode ID | 分辨率 | 刷新率 | 典型用途 |
|---|---|---|---|
| 1 | 1080x2340 | 60Hz | 默认/省电场景 |
| 2 | 1080x2340 | 90Hz | 高刷场景 |
| 3 | 1080x2340 | 120Hz | 游戏/极致流畅 |
每个模式都有一个唯一的modeId。系统当前使用的模式可以通过Display.getMode()获取,而应用可以通过WindowManager.LayoutParams.preferredDisplayModeId来“建议”系统使用某个模式。注意这里的措辞是“建议”而不是“强制”,因为最终决定权在系统手里,系统会综合考虑功耗、热、当前前台应用等因素。
这里有一个容易被忽略的细节:preferredDisplayModeId设置的是模式 ID,而不是刷新率数值。你不能直接说“我要 90Hz”,而是要先遍历getSupportedModes(),找到刷新率最接近 90Hz 的那个模式的 ID,再把它设进去。如果设备不支持你想要的刷新率,设置会被忽略,系统回退到默认模式。
2.2 系统如何在多个刷新率之间做决策
Android 10 的刷新率切换策略并不是简单地“谁请求高刷就给谁”,而是有一套多层次的决策逻辑。这套逻辑大致可以分为三个层面:
第一层是硬件能力层。Display 驱动会向框架报告支持的模式列表,以及每种模式的功耗特性。有些设备在不同刷新率下的功耗差异很大,系统会把这些信息纳入考量。
第二层是系统策略层。Android 10 在DisplayManagerService中引入了刷新率切换的管理逻辑。系统会监听当前前台窗口的preferredDisplayModeId,同时结合一个“默认刷新率”配置(通常定义在设备 overlay 里,比如config_defaultRefreshRate)。当没有应用明确表达偏好时,系统使用默认刷新率。
第三层是应用偏好层。应用可以通过preferredDisplayModeId或后续版本引入的Surface.setFrameRate()来表达自己的需求。但系统不会无条件服从,而是会做冲突仲裁。比如一个应用请求 120Hz,但设备当前温度过高,系统可能会降回 60Hz。
这里的关键点是:Android 10 的切换是“模式级”的,不是“帧级”的。也就是说,系统切换的是整个显示模式,而不是针对某一帧动态调整。这带来一个副作用:切换刷新率时,屏幕可能会短暂黑屏或闪烁,因为显示时序发生了变化。这也是为什么系统倾向于减少切换频率,而不是每帧都切。
2.3 preferredDisplayModeId 的实际生效路径
很多开发者第一次用preferredDisplayModeId时会困惑:为什么我设置了,但Display.getRefreshRate()返回的还是 60Hz?这涉及到设置的生效路径问题。
当你给一个Window设置preferredDisplayModeId后,这个偏好会随着窗口的WindowManager.LayoutParams传递到WindowManagerService。WMS 会把这个信息同步给DisplayManagerService。DMS 在收到新的偏好后,会触发一次刷新率重新评估。评估过程是异步的,不是立即生效的。所以你在设置完的下一行代码就去读getRefreshRate(),大概率读到的还是旧值。
正确的做法是注册DisplayListener,监听onDisplayChanged()回调,在回调里再去读当前模式。或者更简单粗暴一点,延迟几百毫秒再读。但延迟读不是可靠方案,因为系统可能因为其他原因拒绝你的请求。
还有一个坑:preferredDisplayModeId是设置在Window上的,不是设置在Activity或View上的。如果你在Activity.onCreate()里通过getWindow().getAttributes()修改,需要确保在setContentView()之前或之后正确调用setAttributes()使修改生效。很多示例代码漏掉了setAttributes()这一步,导致设置根本没生效。
// 正确设置 preferredDisplayModeId 的示例 Window window = getWindow(); WindowManager.LayoutParams params = window.getAttributes(); Display display = getDisplay(); Display.Mode[] modes = display.getSupportedModes(); int targetModeId = -1; for (Display.Mode mode : modes) { if (Math.abs(mode.getRefreshRate() - 90f) < 1f) { targetModeId = mode.getModeId(); break; } } if (targetModeId != -1) { params.preferredDisplayModeId = targetModeId; window.setAttributes(params); }这段代码的逻辑是:先找到最接近 90Hz 的模式,然后把它的 ID 设进去。注意getRefreshRate()返回的是 float,比较时要用一个容差范围,不要用==直接比。
3. 应用层表达刷新率偏好的几种方式与选型
3.1 通过 WindowManager.LayoutParams 设置模式偏好
这是 Android 10 最直接的方式,也是官方在 API 29 中正式开放的接口。适用场景是:你的整个 Activity 或窗口需要固定运行在某个刷新率下,比如一个视频播放器希望锁定 60Hz,或者一个游戏希望拉满 120Hz。
这种方式的优点是简单、声明式,设置一次就持续生效,直到窗口销毁或你主动修改。缺点是粒度粗,只能控制整个窗口,不能针对某个 Surface 或某段动画单独设置。而且如前所述,系统不保证一定满足。
在实际项目中,我通常会在Activity的onResume()里设置偏好,在onPause()里恢复默认。这样做的理由是:当用户离开你的应用时,不应该继续占用高刷新率资源,否则会影响其他应用和系统整体功耗。
@Override protected void onResume() { super.onResume(); applyRefreshRatePreference(90f); } @Override protected void onPause() { super.onPause(); // 恢复默认,让系统自己决定 WindowManager.LayoutParams params = getWindow().getAttributes(); params.preferredDisplayModeId = 0; // 0 表示无偏好 getWindow().setAttributes(params); }这里preferredDisplayModeId = 0是一个约定,表示清除偏好,让系统使用默认策略。这个细节在很多文档里没写清楚,但实测有效。
3.2 Surface.setFrameRate 的引入与适用边界
Android 10 还在Surface层面引入了一个新方法:setFrameRate(float frameRate, int compatibility)。这个方法允许你针对单个 Surface 设置期望的帧率,而不是整个显示模式。这比preferredDisplayModeId更细粒度,适合视频播放、游戏渲染等场景。
compatibility参数有两个可选值:FRAME_RATE_COMPATIBILITY_DEFAULT和FRAME_RATE_COMPATIBILITY_FIXED_SOURCE。前者表示系统可以灵活调整,后者表示内容源帧率固定,系统应尽量匹配。比如播放 24fps 的电影时,用FIXED_SOURCE可以让系统把刷新率切到 24Hz 的整数倍(如 48Hz 或 72Hz),避免抖动。
但要注意,Surface.setFrameRate()在 Android 10 上的支持并不完整。很多设备厂商在这个版本上还没有完全实现这个 API 的底层通路,调用后可能没有任何效果。我在实际测试中发现,Pixel 4 在 Android 10 上对setFrameRate的支持相对较好,但一些第三方厂商的设备基本是空实现。所以如果你的应用需要兼容多厂商,不能完全依赖这个 API,还是要以preferredDisplayModeId为主。
3.3 两种方式的对比与选型建议
| 对比维度 | preferredDisplayModeId | Surface.setFrameRate |
|---|---|---|
| 作用范围 | 整个窗口 | 单个 Surface |
| 粒度 | 模式级 | 帧率级 |
| Android 10 支持度 | 较好 | 参差不齐 |
| 适用场景 | 整体 UI 刷新率控制 | 视频、游戏渲染 |
| 是否保证生效 | 否 | 否 |
| 设置时机 | onResume/onPause | Surface 创建后 |
选型建议很直接:如果你的需求是“整个界面跑在高刷下”,用preferredDisplayModeId;如果是“视频按内容帧率匹配”,优先尝试setFrameRate,但要做好降级处理。两者可以同时使用,系统会做仲裁,但不要设置互相矛盾的偏好,否则行为不可预测。
4. 系统策略、厂商定制与真实设备上的差异
4.1 AOSP 默认策略与厂商 overlay 的关系
AOSP 在 Android 10 中提供的刷新率切换策略是一个“基础框架”,而不是一套完整的、开箱即用的策略。真正的行为很大程度上取决于设备厂商的 overlay 配置和 HAL 实现。
在 AOSP 中,DisplayManagerService会读取一个名为config_defaultRefreshRate的配置项,以及config_supportRefreshRateSwitching这样的开关。厂商可以通过 overlay 覆盖这些值。比如三星可能在 One UI 里定义了自己的默认刷新率策略,小米在 MIUI 里又有另一套逻辑。
这就导致一个现实问题:同一段设置preferredDisplayModeId的代码,在 Pixel 上可能正常工作,在小米上可能被忽略,在华为上可能触发不同的切换时机。作为应用开发者,你不能假设所有设备的行为一致。
我的经验是:永远不要硬编码刷新率数值,也不要假设设置一定生效。正确的做法是读取getSupportedModes(),从中选择最合适的模式,设置后通过DisplayListener验证是否真的切换成功。如果没成功,要有降级方案,比如接受当前刷新率继续运行,而不是崩溃或卡死。
4.2 高刷设备上常见的“锁帧”现象分析
很多用户和开发者都遇到过这样的情况:明明买的是 90Hz 手机,但在某些应用里感觉只有 60Hz。这背后可能有多种原因。
第一种可能是应用没有请求高刷,系统默认跑在 60Hz。Android 10 的默认策略通常是保守的,如果应用不主动表达偏好,系统倾向于使用较低刷新率省电。
第二种可能是应用请求了,但被系统拒绝。拒绝的原因可能是热限制、电量低于某个阈值、或者当前前台有多个窗口冲突。
第三种可能是应用本身渲染性能不足,虽然屏幕是 90Hz,但应用只能产出 60fps 的内容,视觉上还是 60Hz 的流畅度。这种情况不是刷新率切换的问题,而是渲染性能的问题,需要从布局优化、减少过度绘制、使用硬件加速等角度解决。
第四种可能是厂商的“智能刷新率”策略在起作用。有些厂商会在检测到用户没有触摸操作时自动降帧,触摸时再升上去。这种策略对省电有利,但会让开发者困惑,因为getRefreshRate()的返回值会频繁变化。
排查这类问题时,我通常会用adb shell dumpsys display查看当前显示模式,用adb shell dumpsys SurfaceFlinger查看各层的帧率信息。这两个命令能提供很多底层细节,比在应用层打印日志更可靠。
4.3 切换刷新率时的闪烁与黑屏问题
刷新率切换不是免费的。当显示模式发生变化时,显示控制器需要重新配置时序参数,这个过程可能导致屏幕短暂黑屏或闪烁。在 Android 10 的早期实现中,这个问题比较明显,尤其是从 60Hz 切到 90Hz 再切回来时。
系统为了减少这种视觉干扰,会尽量合并切换请求,避免频繁切换。但作为应用开发者,你可以通过一些方式减少切换带来的影响:
- 避免在动画进行中切换刷新率,尽量在页面稳定后再设置偏好。
- 如果必须在运行时切换,考虑用一个短暂的淡入淡出遮罩掩盖闪烁。
- 不要频繁地在 onResume/onPause 之外的地方修改
preferredDisplayModeId。
我在一个视频应用的项目中遇到过这个问题:用户在播放列表中滑动时,我们想拉高刷新率让滑动更流畅,但每次切换都有轻微闪烁。后来改成只在进入播放页时设置一次偏好,退出时恢复,闪烁问题基本消失。这个经验说明,刷新率切换要“少而准”,不要“多而频”。
5. 实操中踩过的坑与验证方法
5.1 设置后读不到新刷新率的排查链路
这是最常见的问题。完整的排查链路应该是这样的:
第一步,确认设备是否真的支持多刷新率。执行adb shell dumpsys display | grep -i "mSupportedModes",看看输出的模式列表里有没有多个刷新率。如果只有一个,那设备本身不支持切换,后面都不用查了。
第二步,确认你的设置代码是否真的执行了。在setAttributes()之后加日志,打印params.preferredDisplayModeId的值,确认不是 -1 或 0。
第三步,确认设置是否被系统接受。注册DisplayListener,在onDisplayChanged()里打印display.getMode().getRefreshRate()。如果回调一直不触发,说明系统没有处理你的请求。
第四步,检查是否有其他窗口覆盖。如果当前有 Dialog、悬浮窗、或者系统弹窗在前台,你的窗口可能不是焦点窗口,偏好不会被采纳。
第五步,检查厂商限制。有些厂商在开发者选项里有一个“强制高刷新率”的开关,如果没打开,应用层的请求可能被忽略。这个不是标准 API,但实际存在。
这套排查链路我用了很多次,基本能定位 90% 以上的问题。剩下 10% 可能是 HAL 层或驱动层的问题,那就需要抓取更多底层日志了。
5.2 不同 Android 版本上的行为差异对照
虽然本文聚焦 Android 10,但实际开发中你必然要面对多版本兼容。下面这张表整理了从 Android 10 到 Android 13 在刷新率 API 上的主要变化:
| 版本 | 关键变化 | 对开发者的影响 |
|---|---|---|
| Android 10 | 引入 preferredDisplayModeId 和 Surface.setFrameRate | 基础能力,但支持不完整 |
| Android 11 | 完善 setFrameRate,引入 FrameRateCategory | 视频类应用可更精细控制 |
| Android 12 | 引入 setFrameRate 的更多兼容模式 | 游戏和视频受益 |
| Android 13 | 引入 Display.getSupportedModes 的增强 | 模式信息更丰富 |
在 Android 10 上,你基本只能依赖preferredDisplayModeId。到了 Android 11 及以上,Surface.setFrameRate()才真正可用。所以如果你的 minSdk 是 29,需要做版本判断,在低版本上用模式偏好,在高版本上优先用帧率 API。
5.3 用 dumpsys 和 systrace 验证切换是否真正发生
光看应用层日志不够,因为应用层看到的getRefreshRate()可能和实际硬件刷新率不一致。要确认切换是否真正发生,需要用系统工具。
adb shell dumpsys display会输出当前显示设备的详细信息,包括mActiveModeId、mDefaultModeId、mSupportedModes等。对比设置前后的mActiveModeId,就能知道系统是否真的切换了模式。
adb shell dumpsys SurfaceFlinger会输出各层的合成信息,包括每层的期望帧率和实际帧率。如果某层的frameRate和你设置的不一致,说明 SurfaceFlinger 没有采纳。
更底层的验证可以用 systrace 或 perfetto,抓取display和surfaceflinger的 trace,看刷新率切换事件的时间点和持续时间。这个对性能优化很有帮助,但上手门槛较高,适合系统层开发者。
提示:在验证刷新率切换时,一定要在真实设备上测试,模拟器不支持多刷新率模式,所有相关 API 在模拟器上都会返回单一模式。
6. 把刷新率控制做成可复用的工程实践
6.1 封装一个刷新率管理工具类
在实际项目中,把刷新率相关的逻辑散落在各个 Activity 里是很糟糕的做法。更好的方式是封装一个工具类,统一处理模式查找、偏好设置、监听回调和降级逻辑。
public class RefreshRateHelper { private final Display display; private final Window window; private DisplayListener listener; private float targetRate; public RefreshRateHelper(Window window) { this.window = window; this.display = window.getWindowManager().getDefaultDisplay(); } public boolean requestRefreshRate(float rate) { Display.Mode[] modes = display.getSupportedModes(); int bestModeId = -1; float bestDelta = Float.MAX_VALUE; for (Display.Mode mode : modes) { float delta = Math.abs(mode.getRefreshRate() - rate); if (delta < bestDelta) { bestDelta = delta; bestModeId = mode.getModeId(); } } if (bestModeId == -1 || bestDelta > 5f) { return false; // 没有足够接近的模式 } WindowManager.LayoutParams params = window.getAttributes(); params.preferredDisplayModeId = bestModeId; window.setAttributes(params); targetRate = rate; return true; } public void clearPreference() { WindowManager.LayoutParams params = window.getAttributes(); params.preferredDisplayModeId = 0; window.setAttributes(params); } public float getCurrentRefreshRate() { return display.getRefreshRate(); } }这个工具类的核心逻辑是:遍历所有模式,找到和目标刷新率最接近的那个,设置它的 ID。如果最接近的差距超过 5Hz,就认为设备不支持,返回 false 让调用方降级。
6.2 在 Activity 生命周期中正确接入
有了工具类之后,接入就很简单了。在onResume里请求,在onPause里清除。但要注意,onResume可能被多次调用,所以工具类内部要做一个幂等判断,避免重复设置相同的模式。
另外,如果你的应用有多个 Activity,每个都设置偏好可能会互相干扰。更好的做法是在 Application 级别或者用一个统一的 BaseActivity 来管理,确保同一时间只有一个偏好生效。
还有一个细节:当应用进入后台但还在播放音频或画中画时,是否应该保持高刷偏好?这取决于具体场景。如果是画中画视频,保持偏好是合理的;如果是纯音频,应该清除偏好省电。这个判断逻辑最好放在业务层,而不是工具类里写死。
6.3 监控切换结果并做降级处理
设置偏好之后,一定要验证结果。我通常会在工具类里加一个回调接口,当DisplayListener触发onDisplayChanged时,对比当前刷新率和目标刷新率,如果差距较大,通知调用方。
public interface RefreshRateCallback { void onRefreshRateChanged(float actualRate, boolean matched); }调用方收到matched = false时,可以选择降级方案,比如降低动画复杂度、减少帧率敏感的操作,或者直接提示用户当前设备不支持高刷。这种“请求-验证-降级”的模式,比单纯设置一次然后假设成功要可靠得多。
注意:不要在
DisplayListener的回调里做耗时操作,这个回调运行在系统线程,阻塞会影响显示系统的正常响应。
7. 一些个人体会和后续可以深挖的方向
刷新率这块内容,我从 Android 10 刚发布时就开始跟进,踩过的坑确实不少。最大的体会是:不要和系统策略对抗。应用层能做的只是“表达偏好”,最终决策权在系统。与其花大量精力试图强制某个刷新率,不如把精力放在让应用在不同刷新率下都能稳定运行上。
另一个体会是,刷新率切换和渲染性能是两件事。很多开发者以为设置了高刷就能让应用变流畅,但如果应用本身掉帧,高刷只会让掉帧更明显。先把渲染性能优化好,再考虑刷新率控制,顺序不能反。
后续可以深挖的方向包括:Android 11 及以上版本的Surface.setFrameRate完整实现、可变刷新率(VRR)在游戏中的应用、以及多窗口场景下刷新率冲突的仲裁策略。这些内容每一个都值得单独写一篇,本文先把 Android 10 的基础打牢,后续再逐步展开。