perf: force GPU process launch during warmup by attaching WKWebView to window
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s

THEORY: WKWebView at frame .zero or detached from window skips GPU
compositor init. First real display triggers GPU process launch (~3s).
FIX: Create WKWebView at screen bounds, attach to key window (alpha=0)
during warmup. WebKit launches GPU process while user is on Home tab.
Remove from window after 2s (GPU process stays alive).

Also: ensureAttachedToWindow() fallback if init runs before window
exists. Called from ContentView.task where window is guaranteed.

Added 1x1 transparent GIF in warmup HTML to force image decoder init.

Kept all debug logging for verification.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-04 00:44:48 -05:00
parent 62f9a2503a
commit 127da8feaa
2 changed files with 51 additions and 8 deletions

View File

@@ -94,7 +94,10 @@ struct MainTabView: View {
} }
.task { .task {
guard showReader else { return } guard showReader else { return }
_ = ArticleRenderer.shared let renderer = ArticleRenderer.shared
// Window is now available ensure WKWebView is attached
// so GPU compositor launches during startup, not first article tap
renderer.ensureAttachedToWindow()
await readerVM.loadInitial() await readerVM.loadInitial()
} }
} }

View File

@@ -10,20 +10,34 @@ final class ArticleRenderer {
let webView: WKWebView let webView: WKWebView
private init() { private init() {
let t0 = CFAbsoluteTimeGetCurrent()
print("[WARMUP] ArticleRenderer.init started")
let config = WKWebViewConfiguration() let config = WKWebViewConfiguration()
config.allowsInlineMediaPlayback = true config.allowsInlineMediaPlayback = true
webView = WKWebView(frame: .zero, configuration: config) // Create at screen size (not .zero) so WebKit initializes the
// GPU compositor during warmup, not on first article open.
let screenBounds = UIScreen.main.bounds
webView = WKWebView(frame: screenBounds, configuration: config)
webView.isOpaque = false webView.isOpaque = false
webView.backgroundColor = .clear webView.backgroundColor = .clear
webView.scrollView.isScrollEnabled = true webView.scrollView.isScrollEnabled = true
// Pre-warm with realistic content. An empty <html><body></body></html> // Attach to the key window briefly to force GPU process launch.
// only warms the WebContent process. Loading the real CSS template with // The WKWebView must be in a real window hierarchy for WebKit to
// sample text forces WebKit to also parse CSS, load/measure system fonts, // spin up the GPU compositor. At .zero frame or detached, it skips
// initialize the layout engine, and spin up the compositing pipeline. // GPU init causing a ~3s stall on first real display.
// This shifts the ~3s first-render cost to app launch (background) so the if let window = UIApplication.shared.connectedScenes
// first article open doesn't stall. .compactMap({ $0 as? UIWindowScene }).first?.windows.first {
webView.alpha = 0 // invisible
window.addSubview(webView)
print("[WARMUP] attached to window")
} else {
print("[WARMUP] no window available yet — will attach later")
}
// Load realistic content to warm CSS parsing, font loading, layout
webView.loadHTMLString(ArticleHTMLBuilder.build( webView.loadHTMLString(ArticleHTMLBuilder.build(
title: "Warming up", title: "Warming up",
url: nil, url: nil,
@@ -38,8 +52,34 @@ final class ArticleRenderer {
<p>Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.</p> <p>Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.</p>
<blockquote>Quoted text for blockquote styling.</blockquote> <blockquote>Quoted text for blockquote styling.</blockquote>
<pre><code>code { display: block; }</code></pre> <pre><code>code { display: block; }</code></pre>
<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" width="100" height="60">
""" """
), baseURL: nil) ), baseURL: nil)
// Remove from window after a delay (GPU process stays alive)
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak webView] in
webView?.removeFromSuperview()
webView?.alpha = 1
print("[WARMUP] detached from window +\(Int((CFAbsoluteTimeGetCurrent()-t0)*1000))ms")
}
print("[WARMUP] init complete +\(Int((CFAbsoluteTimeGetCurrent()-t0)*1000))ms")
}
/// Call this after app window is available if init ran too early
func ensureAttachedToWindow() {
guard webView.window == nil else { return }
if let window = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene }).first?.windows.first {
webView.alpha = 0
window.addSubview(webView)
print("[WARMUP] late-attached to window")
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
self?.webView.removeFromSuperview()
self?.webView.alpha = 1
print("[WARMUP] late-detached from window")
}
}
} }
} }