给 Tauri(WKWebView)应用解锁 ProMotion 120Hz——从 rAF 恒定 60 到 swizzle 的排除法诊断

2877 字
14 分钟
给 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 省电。注意它钳的是”页面渲染更新”这一路:

  • 合成器动画(transformopacity 这类能上 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,捞和刷新率相关的 key
NSArray *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 关掉该 featureinit 之前✅ 60.7 → 110.2 fps
创建运行时设置同一 feature页面已在跑❌ 仍 60
运行时设置再 reload 页面reload 后❌ 仍 60
NSUserDefaultsWebKit<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 之前。这不是”多试几种写法总有一种对”,而是”除了创建瞬间,别处都没有窗口”。

根因#

把能确定的和推测的分开说。

能确定的事实(实验直接证明):

  1. _setPreferPageRenderingUpdatesNear60FPS: 在 macOS 26 上已移除(respondsToSelector: 返回 false)。
  2. 它的功能以 experimental feature PreferPageRenderingUpdatesNear60FPSEnabled 的形态存在。
  3. 这个 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.rscc 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::Builder
pub 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”,再问”是不是没解锁”。
Profile Image of the Author
xt53
Hi
分类
标签
站点统计
文章
12
分类
2
标签
22
总字数
27,831
运行时长
0
最后活动
0 天前

目录