ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Android 10 刷新率切换机制详解:从 Display.Mode 到应用层实践

2026/9/23 12:40:49 拓冰建站 浏览量
Android 10 刷新率切换机制详解:从 Display.Mode 到应用层实践 1. 从 Android 10 开始刷新率不再是一个“只读属性”如果你在 Android 9 及以前做过显示相关的开发大概率会有这样一个印象屏幕刷新率是系统底层和硬件之间的事应用层能做的事情非常有限。大多数情况下你只能通过Display.getRefreshRate()读到一个固定值比如 60Hz然后所有和帧率相关的逻辑都围绕这个数字来设计。但从 Android 10也就是 Android QAPI 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分辨率刷新率典型用途11080x234060Hz默认/省电场景21080x234090Hz高刷场景31080x2340120Hz游戏/极致流畅每个模式都有一个唯一的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 两种方式的对比与选型建议对比维度preferredDisplayModeIdSurface.setFrameRate作用范围整个窗口单个 Surface粒度模式级帧率级Android 10 支持度较好参差不齐适用场景整体 UI 刷新率控制视频、游戏渲染是否保证生效否否设置时机onResume/onPauseSurface 创建后选型建议很直接如果你的需求是“整个界面跑在高刷下”用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 的基础打牢后续再逐步展开。