diff --git a/ios/Platform/Platform/ContentView.swift b/ios/Platform/Platform/ContentView.swift index 90ed19b..61d28fe 100644 --- a/ios/Platform/Platform/ContentView.swift +++ b/ios/Platform/Platform/ContentView.swift @@ -94,7 +94,10 @@ struct MainTabView: View { } .task { 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() } } diff --git a/ios/Platform/Platform/Features/Reader/Views/ArticleWebView.swift b/ios/Platform/Platform/Features/Reader/Views/ArticleWebView.swift index 8d1285f..99666b7 100644 --- a/ios/Platform/Platform/Features/Reader/Views/ArticleWebView.swift +++ b/ios/Platform/Platform/Features/Reader/Views/ArticleWebView.swift @@ -10,20 +10,34 @@ final class ArticleRenderer { let webView: WKWebView private init() { + let t0 = CFAbsoluteTimeGetCurrent() + print("[WARMUP] ArticleRenderer.init started") + let config = WKWebViewConfiguration() 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.backgroundColor = .clear webView.scrollView.isScrollEnabled = true - // Pre-warm with realistic content. An empty - // only warms the WebContent process. Loading the real CSS template with - // sample text forces WebKit to also parse CSS, load/measure system fonts, - // initialize the layout engine, and spin up the compositing pipeline. - // This shifts the ~3s first-render cost to app launch (background) so the - // first article open doesn't stall. + // Attach to the key window briefly to force GPU process launch. + // The WKWebView must be in a real window hierarchy for WebKit to + // spin up the GPU compositor. At .zero frame or detached, it skips + // GPU init — causing a ~3s stall on first real display. + if let window = UIApplication.shared.connectedScenes + .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( title: "Warming up", url: nil, @@ -38,8 +52,34 @@ final class ArticleRenderer {

Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.

Quoted text for blockquote styling.
code { display: block; }
+ """ ), 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") + } + } } }