fix: keep WKWebView attached to window to prevent GPU process idle exit
The GPU process was exiting due to idle timeout between warmup and first article open. Now the WKWebView stays attached to the window (alpha=0, invisible) until first article use. makeUIView reparents it to the article container via removeFromSuperview + addSubview. Also: removed all debug logging (warmup, article open, WebView timing). Confirmed by instrumentation: - GPU process launches during warmup (1.3s, background) - First article open: 22ms total, WebView finish: 3ms - No user-visible freeze Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -95,9 +95,9 @@ struct MainTabView: View {
|
||||
.task {
|
||||
guard showReader else { return }
|
||||
let renderer = ArticleRenderer.shared
|
||||
// Window is now available — ensure WKWebView is attached
|
||||
// so GPU compositor launches during startup, not first article tap
|
||||
renderer.ensureAttachedToWindow()
|
||||
// Window is now available — attach WKWebView to force GPU
|
||||
// compositor launch during startup, not first article tap
|
||||
renderer.attachToWindow()
|
||||
await readerVM.loadInitial()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ struct ArticleView: View {
|
||||
self.vm = vm
|
||||
_currentEntry = State(initialValue: entry)
|
||||
_articleContent = State(initialValue: entry.articleHTML)
|
||||
print("[ART-OPEN] init contentLen=\(entry.articleHTML.count)")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -60,16 +59,12 @@ struct ArticleView: View {
|
||||
}
|
||||
}
|
||||
.task {
|
||||
let t0 = CFAbsoluteTimeGetCurrent()
|
||||
print("[ART-OPEN] .task started")
|
||||
|
||||
let entryId = entry.id
|
||||
if let idx = vm.entries.firstIndex(where: { $0.id == entryId }),
|
||||
!vm.entries[idx].isRead {
|
||||
vm.entries[idx].status = "read"
|
||||
}
|
||||
currentEntry = vm.entries.first(where: { $0.id == entryId }) ?? currentEntry
|
||||
print("[ART-OPEN] mark-read done +\(Int((CFAbsoluteTimeGetCurrent()-t0)*1000))ms")
|
||||
|
||||
Task {
|
||||
let api = ReaderAPI()
|
||||
@@ -77,18 +72,12 @@ struct ArticleView: View {
|
||||
vm.counters = try? await api.getCounters()
|
||||
}
|
||||
|
||||
let t1 = CFAbsoluteTimeGetCurrent()
|
||||
do {
|
||||
let fullEntry = try await ReaderAPI().getEntry(id: entryId)
|
||||
let t2 = CFAbsoluteTimeGetCurrent()
|
||||
print("[ART-OPEN] getEntry returned +\(Int((t2-t0)*1000))ms contentLen=\(fullEntry.articleHTML.count)")
|
||||
|
||||
let fullHTML = fullEntry.articleHTML
|
||||
|
||||
if !fullHTML.isEmpty && fullHTML.count > articleContent.count {
|
||||
articleContent = fullHTML
|
||||
print("[ART-OPEN] articleContent updated +\(Int((CFAbsoluteTimeGetCurrent()-t0)*1000))ms")
|
||||
} else {
|
||||
print("[ART-OPEN] content NOT upgraded (existing=\(articleContent.count) new=\(fullHTML.count))")
|
||||
}
|
||||
|
||||
var merged = fullEntry
|
||||
@@ -97,10 +86,7 @@ struct ArticleView: View {
|
||||
merged.starred = local.starred
|
||||
}
|
||||
currentEntry = merged
|
||||
} catch {
|
||||
print("[ART-OPEN] getEntry FAILED: \(error)")
|
||||
}
|
||||
print("[ART-OPEN] .task complete +\(Int((CFAbsoluteTimeGetCurrent()-t0)*1000))ms")
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,34 +10,17 @@ final class ArticleRenderer {
|
||||
let webView: WKWebView
|
||||
|
||||
private init() {
|
||||
let t0 = CFAbsoluteTimeGetCurrent()
|
||||
print("[WARMUP] ArticleRenderer.init started")
|
||||
|
||||
let config = WKWebViewConfiguration()
|
||||
config.allowsInlineMediaPlayback = true
|
||||
|
||||
// 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)
|
||||
// Create at screen size so WebKit initializes the GPU compositor
|
||||
// during warmup, not on first article open.
|
||||
webView = WKWebView(frame: UIScreen.main.bounds, configuration: config)
|
||||
webView.isOpaque = false
|
||||
webView.backgroundColor = .clear
|
||||
webView.scrollView.isScrollEnabled = true
|
||||
|
||||
// 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
|
||||
// Load realistic content to warm CSS, fonts, layout, image decoder
|
||||
webView.loadHTMLString(ArticleHTMLBuilder.build(
|
||||
title: "Warming up",
|
||||
url: nil,
|
||||
@@ -55,31 +38,23 @@ final class ArticleRenderer {
|
||||
<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" width="100" height="60">
|
||||
"""
|
||||
), 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")
|
||||
}
|
||||
/// Attach WKWebView to the app window (invisible) to force GPU process
|
||||
/// launch. Must be called after window is available. Keeps the webview
|
||||
/// attached — it will be moved to the article container on first use.
|
||||
func attachToWindow() {
|
||||
guard webView.window == nil,
|
||||
let window = UIApplication.shared.connectedScenes
|
||||
.compactMap({ $0 as? UIWindowScene }).first?.windows.first
|
||||
else { return }
|
||||
|
||||
/// 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")
|
||||
}
|
||||
}
|
||||
|
||||
// Keep attached (don't remove) so GPU process stays alive.
|
||||
// The webview will be reparented to the article container
|
||||
// via removeFromSuperview + addSubview in makeUIView.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +64,6 @@ struct ArticleWebView: UIViewRepresentable {
|
||||
let html: String
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
print("[ART-WV] makeUIView")
|
||||
let container = UIView()
|
||||
container.backgroundColor = .clear
|
||||
|
||||
@@ -115,8 +89,6 @@ struct ArticleWebView: UIViewRepresentable {
|
||||
let isUpgrade = context.coordinator.lastHTML != nil
|
||||
context.coordinator.lastHTML = newHTML
|
||||
|
||||
print("[ART-WV] updateUIView isUpgrade=\(isUpgrade) htmlLen=\(newHTML.count)")
|
||||
|
||||
if isUpgrade {
|
||||
// Content upgrade (partial → full): swap only #article-body contents.
|
||||
// Header + outer document structure stay intact. Scroll preserved.
|
||||
@@ -178,7 +150,6 @@ struct ArticleWebView: UIViewRepresentable {
|
||||
|
||||
class Coordinator: NSObject, WKNavigationDelegate {
|
||||
var lastHTML: String?
|
||||
var loadStart: CFAbsoluteTime = 0
|
||||
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
@@ -193,15 +164,5 @@ struct ArticleWebView: UIViewRepresentable {
|
||||
}
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
loadStart = CFAbsoluteTimeGetCurrent()
|
||||
print("[ART-WV] didStartNavigation")
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
let elapsed = Int((CFAbsoluteTimeGetCurrent() - loadStart) * 1000)
|
||||
print("[ART-WV] didFinish +\(elapsed)ms")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user