
1. 屏幕刷新率切换这件事为什么值得单独拿出来讲Android 10也就是 Android QAPI 29在显示系统里引入了一个挺关键的变化应用终于可以主动向系统申请一个偏好刷新率了。在这之前刷新率的切换基本是系统或者厂商自己说了算普通应用层几乎插不上手。你要么接受默认的 60Hz要么在部分厂商的定制 ROM 上碰运气看有没有私有的接口能调。Android 10 把这个能力正式收进了WindowManager.LayoutParams加了一个preferredDisplayModeId字段配合Display.getSupportedModes()和Display.Mode这套 API开发者第一次有了一个相对标准的方式去表达“我希望这个界面跑在 90Hz 或者 120Hz 上”。这个变化看起来只是加了个字段但它背后牵扯的东西不少。高刷新率意味着每秒多渲染几十帧功耗、发热、掉帧、画面撕裂这些问题都会跟着来。系统不可能让所有应用无脑跑最高刷新率所以它设计了一套策略应用提出偏好系统根据当前场景、电量、温度、前台应用状态来决定到底给不给。你要做的不是“强制设置刷新率”而是“提出一个合理的请求然后接受系统的裁决”。这个心态上的转变很重要很多刚接触这块的开发者一开始都想着怎么锁死 120Hz结果发现行为跟预期对不上就是因为没理解这套协商机制。这篇文章适合谁看如果你正在做视频播放器、游戏、高刷适配、系统工具类应用或者你只是单纯发现自己的应用在 120Hz 手机上跑起来跟 60Hz 没区别想搞清楚原因那这篇内容应该能帮到你。我会从 API 的用法讲起把Display.Mode的结构、preferredDisplayModeId的设置时机、系统策略的影响因素、以及实际调试中踩过的坑都过一遍。代码部分基于 Android 10 的官方 API不依赖任何厂商私有接口保证你能直接拿去用。先说一个基本结论免得你看到后面才发现方向不对Android 10 提供的刷新率切换能力是“请求式”的不是“命令式”的。你设置preferredDisplayModeId之后系统会在合适的时机去切换但不保证立刻生效也不保证一定按你给的 mode 来。理解这一点后面的很多“奇怪现象”就都能解释了。2. 核心概念拆解Display.Mode 和 preferredDisplayModeId 到底怎么配合2.1 Display.Mode 里到底存了什么要谈刷新率切换先得把Display.Mode这个类搞清楚。它描述的是显示设备支持的一种“模式”一个模式包含了几组参数分辨率宽高、刷新率、以及一些其他的物理属性。你可以通过Display.getSupportedModes()拿到当前屏幕支持的所有模式列表然后遍历找出你想要的刷新率。Display display getWindowManager().getDefaultDisplay(); Display.Mode[] modes display.getSupportedModes(); for (Display.Mode mode : modes) { Log.d(RefreshRate, modeId mode.getModeId() refreshRate mode.getRefreshRate() size mode.getPhysicalWidth() x mode.getPhysicalHeight()); }这里有几个点需要注意。getModeId()返回的是这个模式在系统里的唯一标识后面设置preferredDisplayModeId时用的就是这个 id不是刷新率数值本身。getRefreshRate()返回的是浮点数常见的有 60.0、90.0、120.0但有些设备会出现 59.94、119.88 这种值这是为了匹配某些视频帧率标准做判断的时候不要用直接比给个容差范围更稳。还有一个容易忽略的地方同一个刷新率可能对应多个 mode。比如某台设备在 1080p 下有 60Hz 和 120Hz 两个 mode在 1440p 下只有 60Hz 一个 mode。如果你只按刷新率筛选可能会选到一个分辨率跟你当前窗口不匹配的 mode导致画面被拉伸或者系统直接拒绝你的请求。所以筛选逻辑最好是“先看刷新率再看分辨率是否匹配当前显示尺寸”。2.2 preferredDisplayModeId 的设置位置和时机preferredDisplayModeId是WindowManager.LayoutParams的一个字段也就是说它是挂在窗口上的不是全局的。这一点很关键意味着你可以给不同的窗口设置不同的偏好比如视频播放窗口要 60Hz 匹配片源游戏窗口要 120Hz 追求流畅系统会根据当前哪个窗口在前台来决策。设置的方式很直接WindowManager.LayoutParams params getWindow().getAttributes(); params.preferredDisplayModeId targetModeId; getWindow().setAttributes(params);但时机有讲究。如果你在onCreate里就设置此时窗口还没真正 attach 到 WindowManager设置可能会被后续的默认值覆盖。比较稳妥的做法是在onResume或者onWindowFocusChanged里设置这时候窗口已经存在属性变更能正常生效。我实测下来在onResume里设置的成功率最高onCreate里设置偶尔会失效尤其是冷启动场景。另外设置完之后不要指望马上就能通过display.getRefreshRate()读到新值。系统的切换是异步的中间可能隔几十到几百毫秒。如果你想确认切换结果可以注册DisplayListener在onDisplayChanged回调里再去读当前刷新率。DisplayManager dm (DisplayManager) getSystemService(DISPLAY_SERVICE); dm.registerDisplayListener(new DisplayManager.DisplayListener() { Override public void onDisplayAdded(int displayId) {} Override public void onDisplayRemoved(int displayId) {} Override public void onDisplayChanged(int displayId) { if (displayId Display.DEFAULT_DISPLAY) { float current getWindowManager().getDefaultDisplay().getRefreshRate(); Log.d(RefreshRate, now it is current); } } }, null);注意onDisplayChanged的触发频率不低别在里面做重操作打个日志或者更新个状态就够了。2.3 系统策略在背后做了什么前面说了这是“请求式”的那系统到底怎么裁决根据 Android 10 的框架设计大致有这么几个影响因素前台窗口的偏好系统会看当前获得焦点的窗口设置了什么preferredDisplayModeId以此作为主要参考。省电模式如果设备开了省电模式系统通常会强制降到最低刷新率你的请求会被忽略。热状态温度过高时系统会主动降频降刷新率来控温这时候你的请求同样无效。内容类型某些场景下系统会自己判断比如播放 24fps 视频时可能切到 48Hz 或 60Hz 来匹配而不是你请求的 120Hz。厂商定制不同厂商在这套机制上加了各自的策略有的激进有的保守这也是为什么同一份代码在不同机器上表现不一样。理解这些之后你就不会因为“设置了没生效”而抓狂了。正确的做法是设置偏好然后监听实际结果根据实际刷新率来调整你的渲染策略而不是假设它一定按你想的来。3. 实操一步步实现刷新率切换与状态监听3.1 筛选目标 Display.Mode 的完整逻辑先写一个工具方法从支持的模式里挑出最合适的那个。我的筛选策略是优先匹配当前分辨率再在其中选刷新率最高的如果没有匹配分辨率的就退而求其次选刷新率最高的。public static Display.Mode findBestMode(Display display, float targetRefreshRate) { Display.Mode[] modes display.getSupportedModes(); Display.Mode current display.getMode(); int curW current.getPhysicalWidth(); int curH current.getPhysicalHeight(); Display.Mode best null; float bestDiff Float.MAX_VALUE; for (Display.Mode mode : modes) { boolean sameSize (mode.getPhysicalWidth() curW mode.getPhysicalHeight() curH); if (!sameSize) continue; float diff Math.abs(mode.getRefreshRate() - targetRefreshRate); if (diff bestDiff) { bestDiff diff; best mode; } } if (best null) { // 没有同分辨率的退而求其次 for (Display.Mode mode : modes) { float diff Math.abs(mode.getRefreshRate() - targetRefreshRate); if (diff bestDiff) { bestDiff diff; best mode; } } } return best; }这里用Math.abs算差值而不是直接相等就是为了处理 59.94 这种非整数刷新率。容差不用单独设因为我们是找“最接近”的差值最小自然就是最合适的。3.2 在正确的生命周期节点应用设置拿到 mode 之后在onResume里应用Override protected void onResume() { super.onResume(); applyRefreshRate(120.0f); } private void applyRefreshRate(float target) { Display display getWindowManager().getDefaultDisplay(); Display.Mode mode findBestMode(display, target); if (mode null) { Log.w(RefreshRate, no suitable mode found); return; } WindowManager.LayoutParams params getWindow().getAttributes(); if (params.preferredDisplayModeId ! mode.getModeId()) { params.preferredDisplayModeId mode.getModeId(); getWindow().setAttributes(params); Log.d(RefreshRate, request mode mode.getModeId() mode.getRefreshRate()); } }加了个判断避免重复设置同一个 mode减少不必要的系统调用。虽然重复设置一般也不会出问题但能省则省。3.3 监听实际生效的刷新率设置是一回事生效是另一回事。前面提到的DisplayListener要记得注册和反注册放在onResume和onPause里配对private DisplayManager.DisplayListener listener; Override protected void onResume() { super.onResume(); applyRefreshRate(120.0f); registerDisplayListener(); } Override protected void onPause() { super.onPause(); unregisterDisplayListener(); } private void registerDisplayListener() { if (listener ! null) return; DisplayManager dm (DisplayManager) getSystemService(DISPLAY_SERVICE); listener new DisplayManager.DisplayListener() { Override public void onDisplayAdded(int displayId) {} Override public void onDisplayRemoved(int displayId) {} Override public void onDisplayChanged(int displayId) { if (displayId ! Display.DEFAULT_DISPLAY) return; float rate getWindowManager().getDefaultDisplay().getRefreshRate(); onRefreshRateChanged(rate); } }; dm.registerDisplayListener(listener, null); } private void unregisterDisplayListener() { if (listener null) return; DisplayManager dm (DisplayManager) getSystemService(DISPLAY_SERVICE); dm.unregisterDisplayListener(listener); listener null; }onRefreshRateChanged里你可以做实际的事情比如调整动画时长、切换渲染管线、更新帧率显示。这里的关键是你的渲染逻辑要能适应刷新率的变化而不是假设它固定不变。比如你用Choreographer做帧同步它本身会跟着刷新率走不用你手动改但如果你自己写了个固定 16ms 的定时器那在 120Hz 下就会显得卡顿因为每帧只更新一次但屏幕刷新了两次。3.4 一个完整的 Activity 示例骨架把上面的东西拼起来一个最小可用的实现大概长这样public class HighRefreshActivity extends Activity { private static final float TARGET_REFRESH_RATE 120.0f; private DisplayManager.DisplayListener displayListener; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main); } Override protected void onResume() { super.onResume(); requestRefreshRate(TARGET_REFRESH_RATE); startListening(); } Override protected void onPause() { stopListening(); super.onPause(); } private void requestRefreshRate(float target) { Display display getWindowManager().getDefaultDisplay(); Display.Mode mode findBestMode(display, target); if (mode null) return; WindowManager.LayoutParams lp getWindow().getAttributes(); lp.preferredDisplayModeId mode.getModeId(); getWindow().setAttributes(lp); } private void startListening() { DisplayManager dm (DisplayManager) getSystemService(DISPLAY_SERVICE); displayListener new DisplayManager.DisplayListener() { Override public void onDisplayAdded(int id) {} Override public void onDisplayRemoved(int id) {} Override public void onDisplayChanged(int id) { if (id Display.DEFAULT_DISPLAY) { float r getWindowManager().getDefaultDisplay().getRefreshRate(); Log.i(RefreshRate, actual r); } } }; dm.registerDisplayListener(displayListener, null); } private void stopListening() { if (displayListener null) return; DisplayManager dm (DisplayManager) getSystemService(DISPLAY_SERVICE); dm.unregisterDisplayListener(displayListener); displayListener null; } private static Display.Mode findBestMode(Display display, float target) { // 同 3.1 的实现 return null; } }这个骨架可以直接抄进项目里改。实际用的时候TARGET_REFRESH_RATE不一定要写死 120可以根据场景动态传比如视频播放传片源帧率游戏传屏幕支持的最高值。4. 常见问题与排查技巧实录4.1 设置了 preferredDisplayModeId 但刷新率没变这是最常见的问题原因通常有这几类我整理成一张表方便对照现象可能原因排查方法完全没变化省电模式开启检查PowerManager.isPowerSaveMode()完全没变化设备温度过高看系统日志里有没有 thermal 相关降频记录完全没变化厂商 ROM 不支持换台设备或看getSupportedModes()是否只有一个 mode变化了但不是我想要的系统策略覆盖对比请求的 modeId 和实际getMode()偶尔生效偶尔不生效设置时机不对挪到onResume或onWindowFocusChanged切换后画面异常mode 分辨率不匹配检查 mode 的宽高和当前窗口是否一致排查的时候第一步永远是打印getSupportedModes()的全部内容确认设备到底支持哪些模式。有些设备看起来是 120Hz 屏但getSupportedModes()里只有 60Hz 的 mode那说明厂商没把高刷模式暴露给应用层这种情况下你做什么都没用。第二步是确认你的请求有没有被系统接受。可以在设置之后隔几百毫秒读一次getRefreshRate()如果还是旧值基本就是被策略拦了。这时候可以试试关掉省电模式、让设备凉下来再测。4.2 高刷新率下动画反而更卡了这个坑我踩过。原因是你的动画逻辑还是按 60Hz 的节奏写的比如用ValueAnimator设了固定 16ms 的更新间隔或者自己写了个Handler.postDelayed循环。在 120Hz 下屏幕每 8.3ms 刷新一次但你的动画每 16ms 才更新一次结果就是每两帧才变一次看起来反而比 60Hz 更顿。解决办法是让动画跟着Choreographer走或者用ValueAnimator的默认插值器它会自动适配当前刷新率。如果你必须自己控制帧节奏那就动态读取当前刷新率来算间隔float rate display.getRefreshRate(); long frameIntervalNs (long) (1_000_000_000L / rate);这样在 60Hz 下是 16.6ms在 120Hz 下是 8.3ms动画就能跟上了。4.3 视频播放场景下的刷新率匹配视频播放是个特殊场景。如果你的片源是 24fps屏幕跑 120Hz 的话每帧画面会被重复显示 5 次虽然看起来还行但会有轻微的抖动感judder。理想的做法是让屏幕刷新率变成 24 的整数倍比如 48Hz 或 120Hz。Android 10 的这套 API 允许你这么做float videoFps 24.0f; float target videoFps * Math.round(120.0f / videoFps); // 取最接近的整数倍 requestRefreshRate(target);但要注意不是所有设备都支持 48Hz 这种非标准刷新率。如果getSupportedModes()里没有那就只能退到 60Hz 或者 120Hz。实测下来120Hz 播 24fps 的抖动比 60Hz 要小因为 120/245 是整数而 60/242.5 不是整数后者会产生 3-2-3-2 的显示节奏抖动更明显。4.4 多窗口和分屏下的行为分屏模式下两个应用可能同时可见但只有一个能获得焦点。系统通常按焦点窗口的偏好来定刷新率。如果你的应用在分屏里不是焦点那你的preferredDisplayModeId基本不会生效。这个行为是合理的因为屏幕只有一个刷新率不可能同时满足两个应用的不同需求。做分屏适配的时候别指望能锁住高刷接受系统的调度就好。4.5 调试时怎么快速验证最直接的办法是打开开发者选项里的“显示刷新率”浮层能实时看到当前刷新率。然后配合adb shell dumpsys display看更详细的信息里面会列出所有 display 的当前 mode、支持的 mode 列表、以及一些策略状态。我调试的时候经常用这两招比打日志快多了。adb shell dumpsys display | grep -A 20 mDisplayId0输出里找mCurrentDisplayMode和mSupportedModes这两段能直接看到当前生效的是哪个 mode。提示不同厂商的 dumpsys 输出格式可能不一样如果 grep 不到就先把完整输出导出来再找。5. 策略层面的思考什么时候该请求高刷什么时候不该5.1 按场景区分刷新率需求不是所有界面都需要高刷。我的经验是按场景分三档必须高刷游戏、手写笔绘图、高帧率视频播放、滚动列表。这些场景下高刷带来的流畅度提升是肉眼可见的。可以高刷普通 UI 浏览、动画过渡。高刷能提升质感但不是刚需。不需要高刷静态阅读、设置页、后台服务。这些场景跑高刷纯属浪费电。所以我的做法是给不同 Activity 或 Fragment 设置不同的目标刷新率而不是全局锁一个值。比如阅读页请求 60Hz游戏页请求 120Hz这样在体验和功耗之间取个平衡。5.2 功耗和体验的权衡高刷的功耗代价是实打实的。同样一块电池120Hz 比 60Hz 大概多耗 15% 到 25% 的电具体看屏幕材质和使用场景。所以如果你的应用是长时间前台运行的比如导航、阅读、社交无脑请求 120Hz 会让用户很快发现电量掉得快然后给你差评。一个折中的策略是在用户主动交互时请求高刷静止一段时间后降回 60Hz。比如列表滚动时 120Hz停止滚动 2 秒后切回 60Hz。这个逻辑用Handler做个延迟切换就行实现不复杂但体验和功耗的平衡会好很多。private static final long IDLE_TIMEOUT_MS 2000L; private final Handler handler new Handler(Looper.getMainLooper()); private final Runnable idleTask () - requestRefreshRate(60.0f); private void onUserScroll() { requestRefreshRate(120.0f); handler.removeCallbacks(idleTask); handler.postDelayed(idleTask, IDLE_TIMEOUT_MS); }这个模式我在几个项目里用过实测下来用户基本感知不到切换但功耗曲线明显比一直 120Hz 要平缓。5.3 兼容性处理低版本和低端设备Android 10 以下没有preferredDisplayModeId这个字段直接访问会编译不过如果 compileSdk 低于 29或者运行时无效。所以代码里要做版本判断if (Build.VERSION.SDK_INT Build.VERSION_CODES.Q) { // 使用 preferredDisplayModeId } else { // 降级处理或者什么都不做 }低端设备可能只有一个 60Hz 的 modefindBestMode会返回它设置上去也没坏处就是没效果而已。所以兼容逻辑不用太复杂能设就设设不了就算了。5.4 厂商差异的应对前面提过不同厂商对这套 API 的实现有差异。有的厂商在省电模式下直接忽略请求有的厂商会在温度升高时强制降刷还有的厂商压根没把高刷 mode 暴露出来。应对办法只有一个不要假设要实测。在目标机型上跑一遍看实际行为然后针对性地调整策略。如果某台设备就是不支持那就优雅降级别硬刚。我一般会在代码里加个开关允许远程配置是否启用高刷请求这样遇到问题机型可以快速关掉不用发版。6. 我踩过的几个坑和最后的经验第一个坑是在onCreate里设置preferredDisplayModeId不生效。当时我以为是 API 用错了查了半天才发现是时机问题。窗口还没 attach设置被覆盖了。挪到onResume就好了。这个坑让我养成了一个习惯凡是跟窗口属性相关的设置都放到onResume或之后。第二个坑是用getRefreshRate()的返回值做精确比较。有台设备返回的是 59.94我代码里写的是if (rate 60.0f)结果判断永远为 false。后来改成容差比较Math.abs(rate - 60.0f) 0.5f才正常。这个教训是浮点数比较永远别用。第三个坑是忽略了DisplayListener的反注册。有次忘了在onPause里反注册结果 Activity 销毁后回调还在触发导致内存泄漏。后来我把它封装成一个独立的类用LifecycleObserver自动管理注册和反注册省心多了。最后一个经验是关于测试的。刷新率这东西模拟器上测不出来必须用真机。而且最好准备两台设备一台高刷一台普通对比着看行为差异。测试用例要覆盖冷启动、热启动、前后台切换、分屏、省电模式、锁屏解锁。这几个场景下刷新率的行为都不一样只测一个场景很容易漏掉问题。如果你正在做高刷适配我的建议是先把getSupportedModes()的输出摸清楚再决定你的策略。别一上来就写代码先搞清楚设备支持什么、系统允许什么然后再动手。这样能少走很多弯路。