@onekeyfe/react-native-image 3.0.155 → 3.0.157-alpha.248

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +3 -0
  2. package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImage.kt +356 -22
  3. package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageCache.kt +34 -4
  4. package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageMemoryVariants.kt +119 -0
  5. package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageReusableView.kt +2 -0
  6. package/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyImageMemoryVariantsTest.kt +149 -0
  7. package/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyImageRequestSignatureTest.kt +14 -1
  8. package/ios/OneKeyImage.swift +273 -43
  9. package/ios/OneKeyImageCache.swift +40 -3
  10. package/ios/OneKeyImageMemoryVariants.swift +137 -0
  11. package/ios/OneKeyImageRequestContext.swift +51 -9
  12. package/ios/tests/OneKeyImageInfrastructureTests.swift +255 -18
  13. package/lib/module/index.js +26 -3
  14. package/lib/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.cpp +18 -0
  15. package/lib/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.hpp +4 -0
  16. package/lib/nitrogen/generated/android/c++/views/JHybridOneKeyImageStateUpdater.cpp +10 -0
  17. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/onekeyimage/HybridOneKeyImageSpec.kt +12 -0
  18. package/lib/nitrogen/generated/ios/c++/HybridOneKeyImageSpecSwift.hpp +14 -0
  19. package/lib/nitrogen/generated/ios/c++/views/HybridOneKeyImageComponent.mm +12 -0
  20. package/lib/nitrogen/generated/ios/swift/HybridOneKeyImageSpec.swift +2 -0
  21. package/lib/nitrogen/generated/ios/swift/HybridOneKeyImageSpec_cxx.swift +48 -0
  22. package/lib/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.cpp +4 -0
  23. package/lib/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.hpp +4 -0
  24. package/lib/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.cpp +4 -0
  25. package/lib/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.hpp +6 -0
  26. package/lib/nitrogen/generated/shared/json/OneKeyImageConfig.json +2 -0
  27. package/lib/typescript/src/OneKeyImage.nitro.d.ts +8 -0
  28. package/lib/typescript/src/index.d.ts +1 -1
  29. package/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.cpp +18 -0
  30. package/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.hpp +4 -0
  31. package/nitrogen/generated/android/c++/views/JHybridOneKeyImageStateUpdater.cpp +10 -0
  32. package/nitrogen/generated/android/kotlin/com/margelo/nitro/onekeyimage/HybridOneKeyImageSpec.kt +12 -0
  33. package/nitrogen/generated/ios/c++/HybridOneKeyImageSpecSwift.hpp +14 -0
  34. package/nitrogen/generated/ios/c++/views/HybridOneKeyImageComponent.mm +12 -0
  35. package/nitrogen/generated/ios/swift/HybridOneKeyImageSpec.swift +2 -0
  36. package/nitrogen/generated/ios/swift/HybridOneKeyImageSpec_cxx.swift +48 -0
  37. package/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.cpp +4 -0
  38. package/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.hpp +4 -0
  39. package/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.cpp +4 -0
  40. package/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.hpp +6 -0
  41. package/nitrogen/generated/shared/json/OneKeyImageConfig.json +2 -0
  42. package/package.json +2 -2
  43. package/src/OneKeyImage.nitro.ts +8 -0
  44. package/src/index.tsx +25 -1
@@ -7,9 +7,13 @@ import UIKit
7
7
  private final class OneKeyImageHostView: SDAnimatedImageView {
8
8
  var onLayout: (() -> Void)?
9
9
  var onWindowChanged: ((Bool) -> Void)?
10
+ var round = false {
11
+ didSet { updateRoundMask() }
12
+ }
10
13
 
11
14
  override func layoutSubviews() {
12
15
  super.layoutSubviews()
16
+ updateRoundMask()
13
17
  onLayout?()
14
18
  }
15
19
 
@@ -17,6 +21,10 @@ private final class OneKeyImageHostView: SDAnimatedImageView {
17
21
  super.didMoveToWindow()
18
22
  onWindowChanged?(window != nil)
19
23
  }
24
+
25
+ private func updateRoundMask() {
26
+ layer.cornerRadius = round ? min(bounds.width, bounds.height) / 2 : 0
27
+ }
20
28
  }
21
29
 
22
30
  private final class OneKeyImageSkeletonView: UIView {
@@ -90,6 +98,7 @@ private final class OneKeyImageSkeletonIndicator: NSObject, SDWebImageIndicator
90
98
 
91
99
  final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
92
100
  private enum DisplayState { case loading, image, error, fallback }
101
+ private enum MemoryProbeResult { case miss, preview, exact }
93
102
  private static let fadeDelayThreshold: CFTimeInterval = 0.1
94
103
  private static let fadeDuration: TimeInterval = 0.14
95
104
 
@@ -107,12 +116,19 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
107
116
  private var requestActive = false
108
117
  private var isResetting = false
109
118
  private var displayState = DisplayState.loading
119
+ /// `.loading` with the previous image kept on screen as the placeholder
120
+ /// (see `showLoading(preservedImage:)`).
121
+ private var isPreservingDisplayedImage = false
122
+ private var isShowingMemoryPreview = false
123
+ private var scalesCenterMemoryPreview = false
110
124
  private var requestStartedAt: CFTimeInterval?
111
125
 
112
126
  var view: UIView { hostView }
113
127
 
114
128
  var sourceUri: String? {
115
- didSet { if sourceUri != oldValue { identityDidChange() } }
129
+ didSet {
130
+ if sourceUri != oldValue { identityDidChange() }
131
+ }
116
132
  }
117
133
  var sourceHeadersJson: String? {
118
134
  didSet { if sourceHeadersJson != oldValue { identityDidChange() } }
@@ -134,11 +150,17 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
134
150
  var optimizeTos: Bool? = true {
135
151
  didSet { if optimizeTos != oldValue { identityDidChange() } }
136
152
  }
153
+ var round: Bool? = false
137
154
  var resizeWidth: Double? {
138
155
  didSet {
139
156
  if !Self.equalOptionalDouble(resizeWidth, oldValue) { identityDidChange() }
140
157
  }
141
158
  }
159
+ var resizeHeight: Double? {
160
+ didSet {
161
+ if !Self.equalOptionalDouble(resizeHeight, oldValue) { identityDidChange() }
162
+ }
163
+ }
142
164
  var overscan: Double? = 1.1 {
143
165
  didSet {
144
166
  if !Self.equalOptionalDouble(overscan, oldValue) { identityDidChange() }
@@ -185,6 +207,12 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
185
207
  }
186
208
 
187
209
  func afterUpdate() {
210
+ // Fabric can reset `round` before clearing `sourceUri` while removing a view.
211
+ // Apply the shape once per committed prop batch so the outgoing frame keeps
212
+ // its clipping, while mounted and recycled views still receive the new value.
213
+ if let sourceUri, !sourceUri.isEmpty {
214
+ hostView.round = round == true
215
+ }
188
216
  scheduleLoad()
189
217
  }
190
218
 
@@ -229,7 +257,7 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
229
257
  self.cancelCurrentRequest(invalidateGeneration: true)
230
258
  self.lastRequestSignature = nil
231
259
  if let source = self.sourceUri, !source.isEmpty {
232
- if !self.showMemoryCachedImageIfAvailable(requestIsActive: false) {
260
+ if self.showMemoryCachedImageIfAvailable(requestIsActive: false) == .miss {
233
261
  self.showLoading(requestIsActive: false, letLibraryStartIndicator: false)
234
262
  }
235
263
  } else {
@@ -245,7 +273,10 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
245
273
  lastRequestSignature = nil
246
274
  // A memory-cached image shown from `identityDidChange` before layout
247
275
  // stays up; layout re-runs this load with real bounds and confirms it.
248
- if displayState != .image {
276
+ if !Self.shouldPreserveDisplayedImage(
277
+ isShowingImage: displayState == .image,
278
+ hasImage: hostView.image != nil
279
+ ) {
249
280
  showLoading(requestIsActive: false, letLibraryStartIndicator: false)
250
281
  }
251
282
  return
@@ -267,6 +298,7 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
267
298
  contentFit?.stringValue ?? "cover",
268
299
  String(optimizeTos ?? true),
269
300
  resizeWidth.map(String.init(describing:)) ?? "",
301
+ resizeHeight.map(String.init(describing:)) ?? "",
270
302
  String(overscan ?? 1.1),
271
303
  String(Int(hostView.bounds.width.rounded())),
272
304
  String(Int(hostView.bounds.height.rounded())),
@@ -281,8 +313,21 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
281
313
  requestStartedAt = CACurrentMediaTime()
282
314
  onLoadStart?()
283
315
  guard Self.isCurrentRequestGeneration(generation, current: requestGeneration) else { return }
284
- if showMemoryCachedImageIfAvailable(requestIsActive: true) { return }
285
- showLoading(requestIsActive: true, letLibraryStartIndicator: true)
316
+ if showMemoryCachedImageIfAvailable(requestIsActive: true) == .exact { return }
317
+ // The pre-layout probe may have shown this identity from another thumbnail
318
+ // key (a preload's, or an earlier layout size). Keep it on screen as the
319
+ // placeholder while the exact rendition loads: blanking to a skeleton and
320
+ // redrawing is worse than the skeleton it replaced.
321
+ let preservedImage =
322
+ Self.shouldPreserveDisplayedImage(
323
+ isShowingImage: displayState == .image,
324
+ hasImage: hostView.image != nil
325
+ ) ? hostView.image : nil
326
+ showLoading(
327
+ requestIsActive: true,
328
+ letLibraryStartIndicator: true,
329
+ preservedImage: preservedImage
330
+ )
286
331
 
287
332
  if let violation = OneKeyImageSafetyPolicy.preflight(source: rawString) {
288
333
  finishWithError(violation, generation: generation)
@@ -298,8 +343,11 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
298
343
  }
299
344
 
300
345
  let hasCustomIdentity = OneKeyImageRequestContext.headers(from: sourceHeadersJson) != nil
301
- let displaySize = resizeWidth.flatMap { $0.isFinite && $0 > 0 ? CGFloat($0) : nil }
302
- ?? max(hostView.bounds.width, hostView.bounds.height)
346
+ let displaySize = OneKeyImageDecodeSizing.tosDisplaySize(
347
+ bounds: hostView.bounds.size,
348
+ resizeWidth: resizeWidth,
349
+ resizeHeight: resizeHeight
350
+ ) ?? 0
303
351
  let requestURL =
304
352
  (optimizeTos ?? true)
305
353
  ? OneKeyTosURL.optimized(
@@ -314,7 +362,8 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
314
362
  url: requestURL,
315
363
  rawURL: rawURL,
316
364
  generation: generation,
317
- mayFallbackToRaw: requestURL != rawURL
365
+ mayFallbackToRaw: requestURL != rawURL,
366
+ placeholderImage: preservedImage
318
367
  )
319
368
  }
320
369
 
@@ -322,7 +371,8 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
322
371
  url: URL,
323
372
  rawURL: URL,
324
373
  generation: UInt64,
325
- mayFallbackToRaw: Bool
374
+ mayFallbackToRaw: Bool,
375
+ placeholderImage: UIImage? = nil
326
376
  ) {
327
377
  guard Self.isCurrentRequestGeneration(generation, current: requestGeneration) else { return }
328
378
  let rawScreenScale: CGFloat = hostView.window?.screen.scale ?? UIScreen.main.scale
@@ -350,7 +400,9 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
350
400
  )
351
401
  hostView.sd_internalSetImage(
352
402
  with: url,
353
- placeholderImage: nil,
403
+ // SDWebImage resets the image view when it starts a load; hand it the
404
+ // preserved image so the view keeps showing it until the load lands.
405
+ placeholderImage: placeholderImage,
354
406
  options: OneKeyImageRequestContext.renderOptions,
355
407
  context: context,
356
408
  setImageBlock: { [weak self] image, _, _, _ in
@@ -386,21 +438,43 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
386
438
  url: rawURL,
387
439
  rawURL: rawURL,
388
440
  generation: generation,
389
- mayFallbackToRaw: false
441
+ mayFallbackToRaw: false,
442
+ placeholderImage: self.hostView.image
390
443
  )
391
444
  return
392
445
  }
393
446
  self.finishWithError(safetyViolation ?? error, generation: generation)
394
447
  return
395
448
  }
449
+ if let image,
450
+ Self.canUseAsMemoryPreview(image),
451
+ let thumbnailPixelSize,
452
+ self.cachePolicy == .memory || self.cachePolicy == .memoryDisk || self.cachePolicy == nil
453
+ {
454
+ OneKeyImageMemoryVariantRegistry.shared.record(
455
+ OneKeyImageMemoryVariant(
456
+ requestURL: url,
457
+ thumbnailPixelSize: thumbnailPixelSize
458
+ ),
459
+ for: OneKeyImageMemoryFamilyKey(
460
+ rawURL: rawURL,
461
+ headersJson: self.sourceHeadersJson
462
+ )
463
+ )
464
+ }
396
465
  guard self.claimTerminal(generation) else { return }
466
+ let replacesMemoryPreview = self.clearMemoryPreviewPresentation()
397
467
  self.requestActive = false
398
468
  self.displayState = .image
469
+ self.isPreservingDisplayedImage = false
399
470
  self.skeletonIndicator.stopAnimatingIndicator()
400
471
  self.fallbackLayer.isHidden = true
401
472
  self.hostView.backgroundColor = .clear
402
473
  let resolvedCacheType = Self.cacheType(cacheType)
403
- self.applyLoadedImageTransition(cacheType: resolvedCacheType)
474
+ self.applyLoadedImageTransition(
475
+ cacheType: resolvedCacheType,
476
+ replacingMemoryPreview: replacesMemoryPreview
477
+ )
404
478
  self.applyAutoplay()
405
479
  let onLoad = self.onLoad
406
480
  let onLoadEnd = self.onLoadEnd
@@ -502,7 +576,9 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
502
576
  autoplay = true
503
577
  recyclingKey = nil
504
578
  optimizeTos = true
579
+ round = false
505
580
  resizeWidth = nil
581
+ resizeHeight = nil
506
582
  overscan = 1.1
507
583
  loadingStrategy = .static
508
584
  placeholderColor = nil
@@ -533,22 +609,50 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
533
609
  guard Self.isCurrentRequestGeneration(generation, current: requestGeneration) else {
534
610
  return
535
611
  }
612
+ // Also the placeholder write path (SDWebImage hands the preserved image
613
+ // back through setImageBlock when the load starts), so the preservation
614
+ // flag is NOT touched here; it ends with the terminal callbacks.
536
615
  hostView.image = image
537
616
  }
538
617
 
539
618
  private func showLoading(
540
619
  requestIsActive: Bool,
541
- letLibraryStartIndicator: Bool
620
+ letLibraryStartIndicator: Bool,
621
+ preservedImage: UIImage? = nil
542
622
  ) {
543
623
  displayState = .loading
544
624
  requestActive = requestIsActive
545
625
  resetImageTransition()
546
- hostView.image = nil
547
626
  fallbackLayer.isHidden = true
627
+ if let preservedImage {
628
+ // Same identity, different thumbnail key: the image already on screen
629
+ // stands in for the placeholder, so no blank frame and no indicator.
630
+ // The indicator is unmounted, not just stopped: SDWebImage restarts a
631
+ // mounted `sd_imageIndicator` when the load begins, and `applyVariant`
632
+ // must not reinstall it while this state lasts.
633
+ isPreservingDisplayedImage = true
634
+ hostView.image = preservedImage
635
+ skeletonIndicator.stopAnimatingIndicator()
636
+ if hostView.sd_imageIndicator != nil {
637
+ hostView.sd_imageIndicator = nil
638
+ }
639
+ hostView.backgroundColor = .clear
640
+ return
641
+ }
642
+ clearMemoryPreviewPresentation()
643
+ isPreservingDisplayedImage = false
644
+ hostView.image = nil
548
645
  applyLoadingAppearance(letLibraryStartIndicator: letLibraryStartIndicator)
549
646
  }
550
647
 
551
- private func showMemoryCachedImageIfAvailable(requestIsActive: Bool) -> Bool {
648
+ /// Whether the image currently on screen should stay up while a load for the
649
+ /// SAME identity runs (bounds not laid out yet, or the laid-out request key
650
+ /// differs from the one the pre-layout probe hit). Pure for testability.
651
+ static func shouldPreserveDisplayedImage(isShowingImage: Bool, hasImage: Bool) -> Bool {
652
+ isShowingImage && hasImage
653
+ }
654
+
655
+ private func showMemoryCachedImageIfAvailable(requestIsActive: Bool) -> MemoryProbeResult {
552
656
  // JS leaves `cachePolicy` unset for the common case and Fabric then hands
553
657
  // Nitro a nil, which the request path already treats as memory-disk; the
554
658
  // probe must agree, otherwise a nil policy skips the memory lookup entirely
@@ -558,13 +662,16 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
558
662
  guard effectivePolicy == .memory || effectivePolicy == .memoryDisk,
559
663
  let sourceUri,
560
664
  let rawURL = URL(string: sourceUri) else {
561
- return false
665
+ return .miss
562
666
  }
563
667
  let rawScreenScale: CGFloat = hostView.window?.screen.scale ?? UIScreen.main.scale
564
668
  let screenScale = min(max(rawScreenScale, 1), 3)
565
669
  let hasCustomIdentity = OneKeyImageRequestContext.headers(from: sourceHeadersJson) != nil
566
- let displaySize = resizeWidth.flatMap { $0.isFinite && $0 > 0 ? CGFloat($0) : nil }
567
- ?? max(hostView.bounds.width, hostView.bounds.height)
670
+ let displaySize = OneKeyImageDecodeSizing.tosDisplaySize(
671
+ bounds: hostView.bounds.size,
672
+ resizeWidth: resizeWidth,
673
+ resizeHeight: resizeHeight
674
+ ) ?? 0
568
675
  let requestURL =
569
676
  (optimizeTos ?? true)
570
677
  ? OneKeyTosURL.optimized(
@@ -575,20 +682,21 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
575
682
  hasCustomIdentity: hasCustomIdentity
576
683
  )
577
684
  : rawURL
578
- // Before layout the bounds are empty; fall back to the `resizeWidth` hint so
579
- // the probe key matches the prewarm / post-layout request instead of
580
- // missing on every mount frame.
581
- let thumbnailPixelSize = OneKeyImageDecodeSizing.probeViewSize(
582
- bounds: hostView.bounds.size,
583
- resizeWidth: resizeWidth
584
- ).flatMap {
585
- OneKeyImageDecodeSizing.thumbnailPixelSize(
586
- viewSize: $0,
685
+ // The thumbnail size is part of the cache key. Laid out, it is this view's
686
+ // own request key; before layout it comes from the resize hints, under
687
+ // this view's contentFit and under `.cover` (a preload's key). With no
688
+ // bounds and no hint the un-thumbnailed key is the only thing to try.
689
+ let thumbnailCandidates: [CGSize?] = {
690
+ let sizes = OneKeyImageDecodeSizing.probeThumbnailPixelSizes(
691
+ bounds: hostView.bounds.size,
692
+ resizeWidth: resizeWidth,
693
+ resizeHeight: resizeHeight,
587
694
  scale: screenScale,
588
695
  contentFit: contentFit ?? .cover
589
696
  )
590
- }
591
- func memoryCachedImage(for url: URL) -> UIImage? {
697
+ return sizes.isEmpty ? [nil] : sizes
698
+ }()
699
+ func memoryCachedImage(for url: URL, thumbnailPixelSize: CGSize?) -> UIImage? {
592
700
  let context = OneKeyImageRequestContext.make(
593
701
  headersJson: sourceHeadersJson,
594
702
  cachePolicy: cachePolicy ?? .memoryDisk,
@@ -603,24 +711,100 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
603
711
  }
604
712
  return cache.imageFromMemoryCache(forKey: key)
605
713
  }
606
- guard let image = memoryCachedImage(for: requestURL)
607
- ?? (requestURL == rawURL ? nil : memoryCachedImage(for: rawURL)) else {
608
- return false
714
+
715
+ var exactProbes: [(variant: OneKeyImageMemoryVariant?, url: URL, size: CGSize?)] = []
716
+ for url in requestURL == rawURL ? [requestURL] : [requestURL, rawURL] {
717
+ for size in thumbnailCandidates {
718
+ exactProbes.append(
719
+ (
720
+ variant: size.map {
721
+ OneKeyImageMemoryVariant(requestURL: url, thumbnailPixelSize: $0)
722
+ },
723
+ url: url,
724
+ size: size
725
+ )
726
+ )
727
+ }
609
728
  }
729
+ let family = OneKeyImageMemoryFamilyKey(rawURL: rawURL, headersJson: sourceHeadersJson)
730
+ let exactVariants = Set(exactProbes.compactMap(\.variant))
731
+ for probe in exactProbes {
732
+ guard let image = memoryCachedImage(for: probe.url, thumbnailPixelSize: probe.size) else {
733
+ if let variant = probe.variant {
734
+ OneKeyImageMemoryVariantRegistry.shared.remove(variant, for: family)
735
+ }
736
+ continue
737
+ }
738
+ if let variant = probe.variant, Self.canUseAsMemoryPreview(image) {
739
+ OneKeyImageMemoryVariantRegistry.shared.record(variant, for: family)
740
+ }
741
+ displayMemoryCachedImage(
742
+ image,
743
+ requestIsActive: requestIsActive,
744
+ terminal: true,
745
+ scalesCenterPreview: false
746
+ )
747
+ return .exact
748
+ }
749
+
750
+ if let target = exactProbes.compactMap(\.variant).first {
751
+ let candidates = OneKeyImageMemoryVariantRegistry.shared.candidates(
752
+ for: family,
753
+ target: target,
754
+ excluding: exactVariants
755
+ )
756
+ for candidate in candidates {
757
+ guard
758
+ let image = memoryCachedImage(
759
+ for: candidate.requestURL,
760
+ thumbnailPixelSize: candidate.thumbnailPixelSize
761
+ ),
762
+ Self.canUseAsMemoryPreview(image)
763
+ else {
764
+ OneKeyImageMemoryVariantRegistry.shared.remove(candidate, for: family)
765
+ continue
766
+ }
767
+ displayMemoryCachedImage(
768
+ image,
769
+ requestIsActive: false,
770
+ terminal: false,
771
+ scalesCenterPreview: Self.shouldScaleCenterMemoryPreview(
772
+ contentFit: contentFit ?? .cover,
773
+ candidate: candidate,
774
+ target: target
775
+ )
776
+ )
777
+ OneKeyImageMemoryVariantRegistry.shared.record(candidate, for: family)
778
+ return .preview
779
+ }
780
+ }
781
+ return .miss
782
+ }
783
+
784
+ private func displayMemoryCachedImage(
785
+ _ image: UIImage,
786
+ requestIsActive: Bool,
787
+ terminal: Bool,
788
+ scalesCenterPreview: Bool
789
+ ) {
610
790
  let generation = requestGeneration
611
- if requestIsActive, !claimTerminal(generation) { return true }
791
+ if terminal, requestIsActive, !claimTerminal(generation) { return }
792
+ isShowingMemoryPreview = !terminal
793
+ self.scalesCenterMemoryPreview = !terminal && scalesCenterPreview
794
+ applyContentFit()
612
795
  displayState = .image
796
+ isPreservingDisplayedImage = false
613
797
  requestActive = false
614
798
  skeletonIndicator.stopAnimatingIndicator()
615
799
  fallbackLayer.isHidden = true
616
800
  resetImageTransition()
617
801
  applyDisplayedImage(image, generation: generation)
618
802
  hostView.backgroundColor = .clear
619
- if requestIsActive {
620
- applyLoadedImageTransition(cacheType: .memory)
803
+ if terminal, requestIsActive {
804
+ applyLoadedImageTransition(cacheType: .memory, replacingMemoryPreview: false)
621
805
  }
622
806
  applyAutoplay()
623
- if requestIsActive {
807
+ if terminal, requestIsActive {
624
808
  let onLoad = onLoad
625
809
  let onLoadEnd = onLoadEnd
626
810
  Self.deliverTerminalCallbacks(
@@ -629,11 +813,32 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
629
813
  },
630
814
  onLoadEnd: { onLoadEnd?() }
631
815
  )
632
- guard requestGeneration == generation else { return true }
816
+ guard requestGeneration == generation else { return }
633
817
  pendingDisplayGeneration = generation
634
818
  schedulePendingDisplayIfNeeded()
635
819
  }
636
- return true
820
+ }
821
+
822
+ private static func canUseAsMemoryPreview(_ image: UIImage) -> Bool {
823
+ !(image is SDAnimatedImage) && image.images == nil
824
+ }
825
+
826
+ static func shouldScaleCenterMemoryPreview(
827
+ contentFit: OneKeyImageContentFit,
828
+ candidate: OneKeyImageMemoryVariant,
829
+ target: OneKeyImageMemoryVariant
830
+ ) -> Bool {
831
+ guard case .center = contentFit else { return false }
832
+ return candidate.pixelWidth != target.pixelWidth || candidate.pixelHeight != target.pixelHeight
833
+ }
834
+
835
+ @discardableResult
836
+ private func clearMemoryPreviewPresentation() -> Bool {
837
+ let wasShowingMemoryPreview = isShowingMemoryPreview
838
+ isShowingMemoryPreview = false
839
+ scalesCenterMemoryPreview = false
840
+ applyContentFit()
841
+ return wasShowingMemoryPreview
637
842
  }
638
843
 
639
844
  private func showError() {
@@ -647,6 +852,8 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
647
852
  private func showTerminalState(_ state: DisplayState) {
648
853
  displayState = state
649
854
  requestActive = false
855
+ isPreservingDisplayedImage = false
856
+ clearMemoryPreviewPresentation()
650
857
  skeletonIndicator.stopAnimatingIndicator()
651
858
  resetImageTransition()
652
859
  hostView.image = nil
@@ -664,6 +871,9 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
664
871
  case .image:
665
872
  break
666
873
  case .loading:
874
+ // A preserved image is the placeholder; re-applying the loading look
875
+ // would cover it with the skeleton / placeholder color again.
876
+ if isPreservingDisplayedImage { break }
667
877
  applyLoadingAppearance(letLibraryStartIndicator: false)
668
878
  case .error, .fallback:
669
879
  let hidesTerminalState = loadingStrategy == .none
@@ -739,12 +949,28 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
739
949
  return [base, highlight]
740
950
  }
741
951
 
742
- private func applyLoadedImageTransition(cacheType: OneKeyImageCacheType) {
952
+ static func shouldAnimateLoadedImageTransition(
953
+ cacheType: OneKeyImageCacheType,
954
+ requestDuration: CFTimeInterval?,
955
+ reduceMotionEnabled: Bool,
956
+ replacingMemoryPreview: Bool
957
+ ) -> Bool {
958
+ !replacingMemoryPreview && cacheType != .memory
959
+ && (requestDuration ?? 0) >= fadeDelayThreshold && !reduceMotionEnabled
960
+ }
961
+
962
+ private func applyLoadedImageTransition(
963
+ cacheType: OneKeyImageCacheType,
964
+ replacingMemoryPreview: Bool
965
+ ) {
743
966
  resetImageTransition()
744
- guard cacheType != .memory,
745
- let requestStartedAt,
746
- CACurrentMediaTime() - requestStartedAt >= Self.fadeDelayThreshold,
747
- !UIAccessibility.isReduceMotionEnabled else {
967
+ let requestDuration = requestStartedAt.map { CACurrentMediaTime() - $0 }
968
+ guard Self.shouldAnimateLoadedImageTransition(
969
+ cacheType: cacheType,
970
+ requestDuration: requestDuration,
971
+ reduceMotionEnabled: UIAccessibility.isReduceMotionEnabled,
972
+ replacingMemoryPreview: replacingMemoryPreview
973
+ ) else {
748
974
  return
749
975
  }
750
976
  hostView.alpha = 0
@@ -789,6 +1015,10 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
789
1015
  }
790
1016
 
791
1017
  private func applyContentFit() {
1018
+ if scalesCenterMemoryPreview, case .center = contentFit ?? .cover {
1019
+ hostView.contentMode = .scaleAspectFit
1020
+ return
1021
+ }
792
1022
  switch contentFit ?? .cover {
793
1023
  case .cover: hostView.contentMode = .scaleAspectFill
794
1024
  case .contain: hostView.contentMode = .scaleAspectFit
@@ -76,6 +76,7 @@ final class HybridOneKeyImageCache: HybridOneKeyImageCacheSpec {
76
76
  SDImageCache.shared.clearMemory()
77
77
  // OneKey patch: Clear the dedicated local-avatar cache with public cache operations.
78
78
  OneKeyAvatarImageLoader.cache.clearMemory()
79
+ OneKeyImageMemoryVariantRegistry.shared.clear()
79
80
  }
80
81
  }
81
82
 
@@ -94,6 +95,7 @@ final class HybridOneKeyImageCache: HybridOneKeyImageCacheSpec {
94
95
  SDImageCache.shared.clearMemory()
95
96
  // OneKey patch: The avatar cache is shared by render and preload requests.
96
97
  OneKeyAvatarImageLoader.cache.clearMemory()
98
+ OneKeyImageMemoryVariantRegistry.shared.clear()
97
99
  return try clearDisk()
98
100
  }
99
101
 
@@ -136,14 +138,49 @@ final class HybridOneKeyImageCache: HybridOneKeyImageCacheSpec {
136
138
  source: source,
137
139
  thumbnailPixelSize: thumbnailPixelSize
138
140
  )
141
+ if result == .success {
142
+ recordMemoryVariant(
143
+ rawURL: rawURL,
144
+ requestURL: requestURL,
145
+ source: source,
146
+ thumbnailPixelSize: thumbnailPixelSize
147
+ )
148
+ return true
149
+ }
139
150
  if case .failure = result, requestURL != rawURL {
140
- return await loadAttempt(
151
+ let fallbackResult = await loadAttempt(
141
152
  url: rawURL,
142
153
  source: source,
143
154
  thumbnailPixelSize: thumbnailPixelSize
144
- ) == .success
155
+ )
156
+ if fallbackResult == .success {
157
+ recordMemoryVariant(
158
+ rawURL: rawURL,
159
+ requestURL: rawURL,
160
+ source: source,
161
+ thumbnailPixelSize: thumbnailPixelSize
162
+ )
163
+ return true
164
+ }
145
165
  }
146
- return result == .success
166
+ return false
167
+ }
168
+
169
+ private static func recordMemoryVariant(
170
+ rawURL: URL,
171
+ requestURL: URL,
172
+ source: OneKeyImagePreloadSource,
173
+ thumbnailPixelSize: CGSize
174
+ ) {
175
+ let policy = source.cachePolicy ?? .memoryDisk
176
+ guard policy == .memory || policy == .memoryDisk else { return }
177
+ OneKeyImageMemoryVariantRegistry.shared.record(
178
+ OneKeyImageMemoryVariant(
179
+ requestURL: requestURL,
180
+ thumbnailPixelSize: thumbnailPixelSize
181
+ ),
182
+ for: OneKeyImageMemoryFamilyKey(rawURL: rawURL, headersJson: source.headersJson)
183
+ )
147
184
  }
148
185
 
149
186
  private static func loadAttempt(