Skip to content

Commit ed9b317

Browse files
Pyinerclaude
andcommitted
Merge TASK-1083 iOS home off-main jank fixes (reviewed PASS #TASK-1093)
Move main-thread data work off-main: per-thread SSE flush transcript mapping, widget snapshot serialization, reconcile/backoff, and avatar decode all relocated to background actors; main only applies prepared results. No interaction gates or freeze patches. Pure architectural data-off-main change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C1AGnTqtoKW2D28743rrnq
2 parents d2e46ec + 4b00055 commit ed9b317

19 files changed

Lines changed: 1395 additions & 349 deletions

mobile/garyx-mobile/App/GaryxMobile/GaryxGatewaySwitcherViews.swift

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@ import SwiftUI
55
/// long-press lists saved gateways for one-step switching. Gateway
66
/// management (add/edit/delete/token) stays in Settings -> Gateway.
77
struct GaryxSidebarGatewayIdentityControl: View {
8-
@EnvironmentObject private var model: GaryxMobileModel
8+
let identity: GaryxGatewaySwitcherIdentity
9+
let rows: [GaryxGatewaySwitcherRow]
10+
let onSwitch: (GaryxGatewaySwitcherRow) -> Void
11+
let onManageGateways: () -> Void
12+
@Binding var debugShowsGatewaySwitcher: Bool
913
@State private var showsSwitcher = false
1014

1115
var body: some View {
12-
let identity = model.gatewaySwitcherIdentity
1316
if identity.isInteractive {
1417
Menu {
1518
switcherMenuItems
@@ -28,7 +31,7 @@ struct GaryxSidebarGatewayIdentityControl: View {
2831
.onAppear {
2932
presentDebugSwitcherIfNeeded()
3033
}
31-
.onChange(of: model.debugShowsGatewaySwitcher) { _, _ in
34+
.onChange(of: debugShowsGatewaySwitcher) { _, _ in
3235
presentDebugSwitcherIfNeeded()
3336
}
3437
#endif
@@ -75,9 +78,9 @@ struct GaryxSidebarGatewayIdentityControl: View {
7578

7679
@ViewBuilder
7780
private var switcherMenuItems: some View {
78-
ForEach(model.gatewaySwitcherRows) { row in
81+
ForEach(rows) { row in
7982
Button {
80-
switchTo(row)
83+
onSwitch(row)
8184
} label: {
8285
GaryxMenuSelectionLabel(
8386
title: row.title,
@@ -90,23 +93,12 @@ struct GaryxSidebarGatewayIdentityControl: View {
9093
Divider()
9194

9295
Button {
93-
model.openSettings(tab: .gateway)
96+
onManageGateways()
9497
} label: {
9598
Label("Manage Gateways", systemImage: "gearshape")
9699
}
97100
}
98101

99-
private func switchTo(_ row: GaryxGatewaySwitcherRow) {
100-
if row.isCurrent {
101-
if !model.isGatewayConnectionReady {
102-
Task { await model.connectAndRefresh() }
103-
}
104-
return
105-
}
106-
guard let profile = model.gatewayProfiles.first(where: { $0.id == row.profileId }) else { return }
107-
Task { await model.activateGatewayProfile(profile) }
108-
}
109-
110102
private func accessibilityText(for identity: GaryxGatewaySwitcherIdentity) -> String {
111103
if let subtitle = identity.subtitle {
112104
return "Gateway \(identity.title), \(subtitle)"
@@ -116,8 +108,8 @@ struct GaryxSidebarGatewayIdentityControl: View {
116108

117109
#if DEBUG
118110
private func presentDebugSwitcherIfNeeded() {
119-
guard model.debugShowsGatewaySwitcher else { return }
120-
model.debugShowsGatewaySwitcher = false
111+
guard debugShowsGatewaySwitcher else { return }
112+
debugShowsGatewaySwitcher = false
121113
showsSwitcher = true
122114
}
123115
#endif

mobile/garyx-mobile/App/GaryxMobile/GaryxMobileAgentPickerComponents.swift

Lines changed: 9 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ struct GaryxChannelLogoView: View {
77
let label: String
88
let iconDataUrl: String?
99
var diameter: CGFloat = 30
10-
@State private var asyncDecodedImage: UIImage?
11-
@State private var asyncDecodedImageKey: String?
1210

1311
var body: some View {
1412
ZStack {
@@ -39,44 +37,13 @@ struct GaryxChannelLogoView: View {
3937
.stroke(Color.primary.opacity(0.06), lineWidth: 1)
4038
}
4139
.accessibilityHidden(true)
42-
.task(id: dataURLDecodeKey) {
43-
await updateDecodedImage()
44-
}
4540
}
4641

4742
private var visibleDecodedImage: UIImage? {
48-
if asyncDecodedImageKey == dataURLDecodeKey, let asyncDecodedImage {
49-
return asyncDecodedImage
50-
}
51-
return GaryxDataURLImageCache.cachedImage(from: iconDataUrl, maxPixelSize: decodeMaxPixelSize)
52-
}
53-
54-
private var dataURLDecodeKey: String {
55-
guard let raw = iconDataUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
56-
!raw.isEmpty else {
57-
return ""
58-
}
59-
return "\(raw.count):\(raw.hashValue):\(Int(decodeMaxPixelSize.rounded(.up)))"
60-
}
61-
62-
private var decodeMaxPixelSize: CGFloat {
63-
max(48, diameter * 3)
64-
}
65-
66-
@MainActor
67-
private func updateDecodedImage() async {
68-
let key = dataURLDecodeKey
69-
guard asyncDecodedImageKey != key else { return }
70-
asyncDecodedImage = nil
71-
asyncDecodedImageKey = key
72-
guard !key.isEmpty else { return }
73-
if let cached = GaryxDataURLImageCache.cachedImage(from: iconDataUrl, maxPixelSize: decodeMaxPixelSize) {
74-
asyncDecodedImage = cached
75-
return
76-
}
77-
let image = await GaryxDataURLImageCache.imageAsync(from: iconDataUrl, maxPixelSize: decodeMaxPixelSize)
78-
guard !Task.isCancelled, asyncDecodedImageKey == key else { return }
79-
asyncDecodedImage = image
43+
GaryxDataURLImageCache.cachedImage(
44+
from: iconDataUrl,
45+
maxPixelSize: GaryxDataURLImageCache.channelIconMaxPixelSize
46+
)
8047
}
8148

8249
private var builtInFallbackImage: UIImage? {
@@ -148,8 +115,6 @@ struct GaryxAgentAvatarView: View {
148115
let providerType: String
149116
var builtIn: Bool = false
150117
var diameter: CGFloat = 34
151-
@State private var asyncDecodedImage: UIImage?
152-
@State private var asyncDecodedImageKey: String?
153118

154119
var body: some View {
155120
ZStack {
@@ -185,42 +150,14 @@ struct GaryxAgentAvatarView: View {
185150
.stroke(Color.primary.opacity(0.06), lineWidth: 1)
186151
}
187152
.accessibilityHidden(true)
188-
.task(id: dataURLDecodeKey) {
189-
await updateDecodedImage()
190-
}
191153
}
192154

193155
private var visibleDecodedImage: UIImage? {
194-
if asyncDecodedImageKey == dataURLDecodeKey, let asyncDecodedImage {
195-
return asyncDecodedImage
196-
}
197-
return GaryxDataURLImageCache.cachedImage(from: avatarDataUrl, maxPixelSize: decodeMaxPixelSize)
198-
}
199-
200-
private var dataURLDecodeKey: String {
201-
let raw = avatarDataUrl.trimmingCharacters(in: .whitespacesAndNewlines)
202-
guard !raw.isEmpty, remoteAvatarURL == nil else { return "" }
203-
return "\(raw.count):\(raw.hashValue):\(Int(decodeMaxPixelSize.rounded(.up)))"
204-
}
205-
206-
private var decodeMaxPixelSize: CGFloat {
207-
max(96, diameter * 3)
208-
}
209-
210-
@MainActor
211-
private func updateDecodedImage() async {
212-
let key = dataURLDecodeKey
213-
guard asyncDecodedImageKey != key else { return }
214-
asyncDecodedImage = nil
215-
asyncDecodedImageKey = key
216-
guard !key.isEmpty else { return }
217-
if let cached = GaryxDataURLImageCache.cachedImage(from: avatarDataUrl, maxPixelSize: decodeMaxPixelSize) {
218-
asyncDecodedImage = cached
219-
return
220-
}
221-
let image = await GaryxDataURLImageCache.imageAsync(from: avatarDataUrl, maxPixelSize: decodeMaxPixelSize)
222-
guard !Task.isCancelled, asyncDecodedImageKey == key else { return }
223-
asyncDecodedImage = image
156+
guard remoteAvatarURL == nil else { return nil }
157+
return GaryxDataURLImageCache.cachedImage(
158+
from: avatarDataUrl,
159+
maxPixelSize: GaryxDataURLImageCache.agentAvatarMaxPixelSize
160+
)
224161
}
225162

226163
private var remoteAvatarURL: URL? {

mobile/garyx-mobile/App/GaryxMobile/GaryxMobileAgentsViews.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -928,6 +928,8 @@ private struct GaryxAvatarEditorSection: View {
928928

929929
guard let generated = await onGenerate(stylePrompt) else { return }
930930
guard canApplyCurrentResult(requestId: requestId, fingerprint: generationFingerprint) else { return }
931+
await GaryxDataURLImageCache.predecodeAgentAvatar(from: generated)
932+
guard canApplyCurrentResult(requestId: requestId, fingerprint: generationFingerprint) else { return }
931933
avatarDataUrl = generated
932934
}
933935

@@ -955,6 +957,8 @@ private struct GaryxAvatarEditorSection: View {
955957
try GaryxMobileAvatarImageNormalizer.normalizedDataUrl(fromImageData: data)
956958
}.value
957959
guard canApplyCurrentResult(requestId: requestId, fingerprint: uploadFingerprint) else { return }
960+
await GaryxDataURLImageCache.predecodeAgentAvatar(from: prepared)
961+
guard canApplyCurrentResult(requestId: requestId, fingerprint: uploadFingerprint) else { return }
958962
avatarDataUrl = prepared
959963
} catch is CancellationError {
960964
return

mobile/garyx-mobile/App/GaryxMobile/GaryxMobileComponents.swift

Lines changed: 99 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,20 @@ extension EnvironmentValues {
2626
}
2727

2828
enum GaryxDataURLImageCache {
29+
static let channelIconMaxPixelSize: CGFloat = 128
30+
static let agentAvatarMaxPixelSize: CGFloat = 288
31+
2932
private static let cache: NSCache<NSString, UIImage> = {
3033
let cache = NSCache<NSString, UIImage>()
3134
cache.countLimit = 128
3235
cache.totalCostLimit = 32 * 1024 * 1024
3336
return cache
3437
}()
38+
private static let predecodeQueue = DispatchQueue(
39+
label: "com.garyx.mobile.data-url-image-cache.predecode",
40+
qos: .utility
41+
)
42+
private static let predecodeState = GaryxDataURLImagePredecodeState()
3543

3644
static func cachedImage(from rawValue: String?, maxPixelSize: CGFloat) -> UIImage? {
3745
guard let raw = normalizedRawValue(rawValue) else { return nil }
@@ -51,21 +59,33 @@ enum GaryxDataURLImageCache {
5159
return image
5260
}
5361

54-
static func imageAsync(from rawValue: String?, maxPixelSize: CGFloat) async -> UIImage? {
55-
guard let raw = normalizedRawValue(rawValue) else { return nil }
56-
let cacheKey = cacheKey(for: raw, maxPixelSize: maxPixelSize)
57-
if let cached = cache.object(forKey: cacheKey) {
58-
return cached
59-
}
60-
return await Task.detached(priority: .utility) {
61-
if let cached = cache.object(forKey: cacheKey) {
62-
return cached
63-
}
64-
guard let image = decodedImage(from: raw, maxPixelSize: maxPixelSize) else {
65-
return nil
62+
static func predecodeAgentAvatars(from rawValues: [String?]) {
63+
predecode(rawValues, maxPixelSize: agentAvatarMaxPixelSize)
64+
}
65+
66+
static func predecodeAgentAvatar(from rawValue: String?) async {
67+
await predecodeOne(rawValue, maxPixelSize: agentAvatarMaxPixelSize)
68+
}
69+
70+
static func predecodeChannelIcons(from rawValues: [String?]) {
71+
predecode(rawValues, maxPixelSize: channelIconMaxPixelSize)
72+
}
73+
74+
static func predecode(_ rawValues: [String?], maxPixelSize: CGFloat) {
75+
let jobs = rawValues.compactMap { predecodeJob(for: $0, maxPixelSize: maxPixelSize) }
76+
guard !jobs.isEmpty else { return }
77+
78+
predecodeQueue.async {
79+
for job in jobs {
80+
performPredecode(job, maxPixelSize: maxPixelSize)
6681
}
67-
cache.setObject(image, forKey: cacheKey, cost: raw.utf8.count)
68-
return image
82+
}
83+
}
84+
85+
private static func predecodeOne(_ rawValue: String?, maxPixelSize: CGFloat) async {
86+
guard let job = predecodeJob(for: rawValue, maxPixelSize: maxPixelSize) else { return }
87+
await Task.detached(priority: .utility) {
88+
performPredecode(job, maxPixelSize: maxPixelSize)
6989
}.value
7090
}
7191

@@ -81,6 +101,40 @@ enum GaryxDataURLImageCache {
81101
return NSString(string: "full|\(raw)")
82102
}
83103

104+
private static func predecodeJob(for rawValue: String?, maxPixelSize: CGFloat) -> PredecodeJob? {
105+
guard let raw = normalizedRawValue(rawValue),
106+
!isRemoteURL(raw) else {
107+
return nil
108+
}
109+
let keyString = cacheKey(for: raw, maxPixelSize: maxPixelSize) as String
110+
guard cache.object(forKey: NSString(string: keyString)) == nil else { return nil }
111+
guard reserveScheduledKey(keyString) else { return nil }
112+
return PredecodeJob(raw: raw, keyString: keyString)
113+
}
114+
115+
private static func performPredecode(_ job: PredecodeJob, maxPixelSize: CGFloat) {
116+
let cacheKey = NSString(string: job.keyString)
117+
autoreleasepool {
118+
if cache.object(forKey: cacheKey) == nil,
119+
let image = decodedImage(from: job.raw, maxPixelSize: maxPixelSize) {
120+
cache.setObject(image, forKey: cacheKey, cost: cost(for: image, raw: job.raw))
121+
}
122+
}
123+
releaseScheduledKey(job.keyString)
124+
}
125+
126+
private static func reserveScheduledKey(_ key: String) -> Bool {
127+
predecodeState.reserve(key)
128+
}
129+
130+
private static func releaseScheduledKey(_ key: String) {
131+
predecodeState.release(key)
132+
}
133+
134+
private static func isRemoteURL(_ raw: String) -> Bool {
135+
raw.hasPrefix("http://") || raw.hasPrefix("https://")
136+
}
137+
84138
private static func decodedImage(from raw: String, maxPixelSize: CGFloat?) -> UIImage? {
85139
let encoded = raw.split(separator: ",", maxSplits: 1).last.map(String.init) ?? raw
86140
guard let data = Data(base64Encoded: encoded) else {
@@ -91,6 +145,37 @@ enum GaryxDataURLImageCache {
91145
}
92146
return UIImage(data: data)
93147
}
148+
149+
private static func cost(for image: UIImage, raw: String) -> Int {
150+
if let cgImage = image.cgImage {
151+
return max(raw.utf8.count, cgImage.bytesPerRow * cgImage.height)
152+
}
153+
return raw.utf8.count
154+
}
155+
156+
private struct PredecodeJob: Sendable {
157+
var raw: String
158+
var keyString: String
159+
}
160+
}
161+
162+
private final class GaryxDataURLImagePredecodeState: @unchecked Sendable {
163+
private let lock = NSLock()
164+
private var scheduledKeys = Set<String>()
165+
166+
func reserve(_ key: String) -> Bool {
167+
lock.lock()
168+
defer { lock.unlock() }
169+
guard !scheduledKeys.contains(key) else { return false }
170+
scheduledKeys.insert(key)
171+
return true
172+
}
173+
174+
func release(_ key: String) {
175+
lock.lock()
176+
scheduledKeys.remove(key)
177+
lock.unlock()
178+
}
94179
}
95180

96181
struct GaryxPanelScaffold<Content: View, Actions: View>: View {

0 commit comments

Comments
 (0)