给 Tauri(WKWebView)应用解锁 ProMotion 120Hz——从 rAF 恒定 60 到 swizzle 的排除法诊断
给应用加了个性能监视 HUD,用 requestAnimationFrame 心跳每秒结算一次帧率。跑起来在一台 ProMotion 120Hz 的屏幕上,读数稳稳定定在 60。屏幕明明能到 120,CSS 动画(transform/opacity)也肉眼跑得比 60 顺,唯独 rAF 卡在 60。
这篇是把它捅到 120 的完整过程。有意思的地方不在最终那三十行 swizzle,而在于中间试的四五种”看起来该生效”的写法全部无效——排除法把范围一步步逼到”只有创建那一刻能改”这个唯一结论上。
底层是 Tauri(其 WebView 层 wry 在 macOS 上就是 WKWebView),但结论对任何用 WKWebView 的场景都成立:Electron 的 macOS 端另说,但只要你在碰 WKWebView 的 rAF 帧率,思路一样。
现象
WKWebView 默认会把页面渲染更新的节奏(rAF 回调、style/layout 的推进)钳在 ~60fps 省电。注意它钳的是”页面渲染更新”这一路:
- 合成器动画(
transform、opacity这类能上 GPU 合成线程的)不受影响,能跑满刷新率 - 一切 JS 驱动的渲染——rAF 回调、打字机效果、canvas 逐帧重绘、我这个 HUD 心跳——被锁 60
所以现象很割裂:页面滚动、CSS 过渡都很顺,只有你自己用 rAF 测出来的数字是 60。第一反应很容易误判成”HUD 采样写错了”,但换几种测法(performance.now() 差值、掉帧计数)结论一致——就是 rAF 本身每秒只被叫 60 次。
怎么定位到的
起点:那个人尽皆知的私有开关
搜一圈 WKWebView + 120Hz,几乎所有资料都指向同一个私有 SPI:
[webView _setPreferPageRenderingUpdatesNear60FPS:NO];WKWebView 上一个下划线开头的私有方法,设成 NO 就解除 60fps 钳制。看起来一行搞定。但在 macOS 26 上:
if (![webView respondsToSelector:@selector(_setPreferPageRenderingUpdatesNear60FPS:)]) { // ← 走到了这里,这个 selector 不存在了}respondsToSelector: 返回 false。这个方法已经被移除了。网上所有教程在新系统上都会静默失效——不报错,就是没这个方法,你还以为自己调对了。
转折:用 runtime 把新形态挖出来
方法没了,但功能大概率还在,只是换了个入口。WebKit 有一套 _experimentalFeatures / _internalDebugFeatures 的 feature flag 机制,很多老 SPI 后来都被收编成了 feature flag。于是写了个探针,用 ObjC runtime 把 WKPreferences 的方法列表和 experimental features 全枚举出来,按关键词过滤:
// 枚举 WKPreferences 的所有 experimental features,捞和刷新率相关的 keyNSArray *features = [(id)[WKPreferences class] performSelector: NSSelectorFromString(@"_experimentalFeatures")];for (id f in features) { NSString *key = [f performSelector:NSSelectorFromString(@"key")]; NSString *l = key.lowercaseString; if ([l containsString:@"fps"] || [l containsString:@"refresh"] || [key containsString:@"60"] || [l containsString:@"promotion"]) printf("feature: %s\n", key.UTF8String);}一把捞到它的新马甲:PreferPageRenderingUpdatesNear60FPSEnabled。原来的方法名 preferPageRenderingUpdatesNear60FPS 直接搬进了 feature flag 命名空间,末尾加了个 Enabled。
测量方法:六个独立 WKWebView 实验
为了不被主项目里一堆干扰因素带偏,我没在项目里改,而是写了一串独立的最小 WKWebView 程序(单文件 ObjC,clang 直接编),每个只验证一个假设。测帧率的载荷是同一段——注入一个纯 JS 的 rAF 计数器,跑满 2 秒把平均帧率回传:
NSString *html = @"<script>" "let f=0,s=0;" "function loop(t){" " if(!s)s=t; f++;" " if(t-s>=2000)" // 累计 2 秒 " window.webkit.messageHandlers.report.postMessage((f/((t-s)/1000)).toFixed(1));" " else requestAnimationFrame(loop);" "} requestAnimationFrame(loop);""</script>";帧数 / 秒数 就是实测 rAF fps。有了这把尺子,剩下的就是逐个假设跑对照。
逐个假设排除
| 尝试 | 时机 | 结果 |
|---|---|---|
创建 WebView 前,对 configuration.preferences 关掉该 feature | init 之前 | ✅ 60.7 → 110.2 fps |
| 创建后运行时设置同一 feature | 页面已在跑 | ❌ 仍 60 |
| 运行时设置再 reload 页面 | reload 后 | ❌ 仍 60 |
用 NSUserDefaults 写 WebKit<Key> 覆盖 | 进程级 | ❌ 仍 60 |
第一行成了:在 WKWebView 创建之前,从 WKWebViewConfiguration 的 preferences 里把这个 feature 关掉,帧率直接翻倍。关键的一步用 runtime 调私有的 _setEnabled:forFeature::
NSArray *features = [(id)[WKPreferences class] performSelector: NSSelectorFromString(@"_experimentalFeatures")];for (id f in features) { NSString *key = [f performSelector:NSSelectorFromString(@"key")]; if ([key isEqualToString:@"PreferPageRenderingUpdatesNear60FPSEnabled"]) { // 关掉这个"倾向于 60fps"的 feature ((void (*)(id, SEL, BOOL, id))objc_msgSend)(config.preferences, NSSelectorFromString(@"_setEnabled:forFeature:"), NO, f); }}但真正说明问题的是后三行全部失败。同一个 feature,只要不是在创建之前设,怎么弄都回不到高刷:运行时改无效、改完 reload 无效、连用 NSUserDefaults 从进程层面覆盖也无效。
这三个”无效”合起来指向一个很硬的结论:这个 feature 只在 WKWebView 创建的那一刻从 configuration 读一次,之后再不回看。所以解锁的时机窗口只有一个——init 之前。这不是”多试几种写法总有一种对”,而是”除了创建瞬间,别处都没有窗口”。
根因
把能确定的和推测的分开说。
能确定的事实(实验直接证明):
_setPreferPageRenderingUpdatesNear60FPS:在 macOS 26 上已移除(respondsToSelector:返回 false)。- 它的功能以 experimental feature
PreferPageRenderingUpdatesNear60FPSEnabled的形态存在。 - 这个 feature 仅在 WKWebView 创建时刻从
configuration.preferences读取——创建前设生效(110.2fps),创建后运行时设 / reload /NSUserDefaults覆盖全部无效(仍 60)。
我没有验证的部分:为什么 WebKit 默认要开这个”倾向 60fps”、为什么它只在创建时读一次——这是 WebKit 内部实现,我没读源码。合理的猜测是省电(120Hz 意味着渲染管线的功耗和发热都翻倍,对多数网页不值),以及创建时读一次是因为这个 preference 参与了渲染管线的初始化、之后管线定型就不再重配。但这些是推断,修复方案不依赖它们成不成立——我只依赖”创建时刻是唯一窗口”这个实验事实。
修复:为什么 Tauri 里得靠 swizzle
到这一步,原生 AppKit 应用其实已经解决了:自己 new configuration,创建 WebView 前调一下就行。
问题是 Tauri / wry 不把 WKWebViewConfiguration 暴露给你。WebView 是 wry 内部创建的,等 with_webview 之类的钩子能拿到 WKWebView 时,它早就 init 完了——而上面刚证明,init 之后这个窗口就永久关闭了。Tauri 给的所有钩子都在”创建之后”,全都太晚。
唯一能插进”创建那一刻”的办法:method swizzling——把 WKWebView 的指定初始化器 -initWithFrame:configuration: 换成自己的实现,在里面先对传入的 configuration 关掉 feature,再调原始 init。谁创建 WebView 都拦得到,包括 wry。
核心就这么点:
static IMP g_orig_init = NULL;
// 被 swizzle 进去的 init:先动 configuration,再走原始逻辑static id swizzled_init(id self, SEL _cmd, CGRect frame, WKWebViewConfiguration *config) { if (config) disable_sixty_clamp(config); // 创建瞬间关掉 feature return ((id (*)(id, SEL, CGRect, id))g_orig_init)(self, _cmd, frame, config);}
void install_high_refresh_unlock(void) { if (g_orig_init) return; // 幂等 Method m = class_getInstanceMethod([WKWebView class], @selector(initWithFrame:configuration:)); if (!m) return; g_orig_init = method_getImplementation(m); method_setImplementation(m, (IMP)swizzled_init); // 换掉 IMP}disable_sixty_clamp 就是前面那段遍历 features、_setEnabled:NO 的逻辑。swizzle 之后再跑那把 rAF 尺子:112.3 fps。和”原生创建前设”的 110.2 一致,确认 swizzle 拦截等价于在源头改。
工程落地:让它先于一切 WebView 执行
这段 ObjC 得比任何 WebView 创建都早跑。落地方式:ObjC 源文件由 build.rs 经 cc crate 编译进二进制(仅 macOS),然后在 Rust 入口 run() 的第一行装 swizzle:
// build.rs:把 ObjC swizzle 编译进来,链接 WebKit#[cfg(target_os = "macos")]{ cc::Build::new() .file("src/native/high_refresh.m") .flag("-fobjc-arc") .compile("high_refresh"); println!("cargo:rustc-link-lib=framework=WebKit");}// lib.rs:run() 第一件事就装 swizzle,早于 tauri::Builderpub fn run() { #[cfg(target_os = "macos")] unsafe { install_high_refresh_unlock(); } // ← 先于任何 WKWebView 创建
tauri::Builder::default() // ... 后面才轮到窗口/WebView 被创建}顺序是关键:swizzle 必须在 tauri::Builder 跑起来、窗口被创建之前就位,否则第一个 WebView 会漏网。
防御:feature 未来改名不能把 app 搞崩
这整套建立在私有 API 和一个具体 feature key 字符串上,哪天 WebKit 把 PreferPageRenderingUpdatesNear60FPSEnabled 改名或删了,代码不能崩。所以每一步都带存在性检查,key 匹配不上就静默什么都不做:
static void disable_sixty_clamp(WKWebViewConfiguration *config) { SEL featuresSel = NSSelectorFromString(@"_experimentalFeatures"); if (![WKPreferences respondsToSelector:featuresSel]) return; // 机制没了 → 放弃 NSArray *features = [(id)[WKPreferences class] performSelector:featuresSel]; SEL setSel = NSSelectorFromString(@"_setEnabled:forFeature:"); if (![config.preferences respondsToSelector:setSel]) return; // 方法没了 → 放弃 for (id f in features) { NSString *key = [f performSelector:NSSelectorFromString(@"key")]; if ([key isEqualToString:@"PreferPageRenderingUpdatesNear60FPSEnabled"]) { ((void (*)(id, SEL, BOOL, id))objc_msgSend)(config.preferences, setSel, NO, f); return; } } // key 没匹配上 → 什么都不做,安静退回 60fps}最坏情况:解锁失效,帧率回到 60。app 不崩、行为不变。用私有 API 换性能,前提就是”失效必须优雅降级”——这是能不能把它放进生产版的底线。
两个必须知道的坑
解锁完不代表万事大吉,有两件事不写下来一定会被坑(或者坑到别人)。
1. rAF 帧率跟着窗口所在的屏幕走
rAF 的帧率取决于窗口当前在哪块屏幕上,不是你解没解锁。我本机内屏是 120Hz ProMotion,但外接两块 4K 都是 60Hz。窗口拖到外接屏上,HUD 立刻掉回 60——这是那块屏的物理上限,不是解锁失效。
这个坑特别阴,因为 dev 环境经常有个”把窗口挪到副屏”的逻辑,一挪过去看到 60,很容易误判成”swizzle 没生效”,然后白白回去查代码。判断解锁有没有生效,必须确认窗口正落在高刷屏上再看数字。
2. 代价:JS 帧预算直接减半
60fps 时每帧 JS 有 16.7ms 预算,120fps 只剩 8.3ms。所有 rAF 驱动的逻辑现在要在一半的时间里跑完,且每秒被调用的次数翻倍——rAF 相关的 CPU 占用会明显上升。
所以这不是白捡的顺滑。如果你的页面本来 rAF 回调就重(每帧做复杂计算、大量 DOM 读写),解锁后更容易在 8.3ms 里超支、掉帧发热。性能差的页面不该开,或者至少留个回退开关——在这套设计里,回退就是不调用那个 install 函数,什么都不改。
几条能带走的经验
- 私有 API 会换马甲,不会凭空消失。 一个下划线 SPI 在新系统
respondsToSelector:返回 false,先别断定功能没了——大概率被收编进了_experimentalFeatures/_internalDebugFeatures之类的 feature flag。用 runtime 枚举方法列表和 feature key,按关键词捞,比翻过时教程快得多。 - “设了不生效”要区分是没权限还是没赶上时机。 这次四种写法里三种失败,不是 API 用错,而是读取窗口只有创建那一刻。判断一个配置项的生效时机,最干脆的办法就是像这样跑对照实验:创建前 / 创建后 / reload 后 / 进程级覆盖各测一遍,哪个成哪个不成,时机窗口自己就显形了。
- 拿不到创建钩子时,swizzle 初始化器是通用兜底。 框架(wry、各种 WebView 封装)把创建过程藏起来、只给你”创建之后”的对象时,如果你的需求恰好卡在创建瞬间,swizzle 指定初始化器是能插进那个窗口的少数手段之一。代价是依赖私有实现,所以——
- 靠私有 API 的功能,失效路径必须是”优雅降级”而不是崩溃。 每一步
respondsToSelector:/ key 匹配都要有 else 分支,匹配不上就安静退回原行为。这条是私有 API 能不能进生产版的分界线。 - 帧率类指标先确认物理上限再下结论。 多屏、混合刷新率环境下,rAF 帧率跟随窗口所在屏幕。看到 60 先问”这块屏是不是就 60Hz”,再问”是不是没解锁”。