@onekeyfe/react-native-image 3.0.156 → 3.0.157-alpha.249

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 (42) hide show
  1. package/README.md +3 -0
  2. package/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImage.kt +293 -20
  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/test/java/com/margelo/nitro/onekeyimage/OneKeyImageMemoryVariantsTest.kt +149 -0
  6. package/ios/OneKeyImage.swift +268 -38
  7. package/ios/OneKeyImageCache.swift +40 -3
  8. package/ios/OneKeyImageMemoryVariants.swift +137 -0
  9. package/ios/OneKeyImageRequestContext.swift +55 -0
  10. package/ios/tests/OneKeyImageInfrastructureTests.swift +218 -0
  11. package/lib/module/index.js +24 -3
  12. package/lib/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.cpp +9 -0
  13. package/lib/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.hpp +2 -0
  14. package/lib/nitrogen/generated/android/c++/views/JHybridOneKeyImageStateUpdater.cpp +5 -0
  15. package/lib/nitrogen/generated/android/kotlin/com/margelo/nitro/onekeyimage/HybridOneKeyImageSpec.kt +6 -0
  16. package/lib/nitrogen/generated/ios/c++/HybridOneKeyImageSpecSwift.hpp +7 -0
  17. package/lib/nitrogen/generated/ios/c++/views/HybridOneKeyImageComponent.mm +6 -0
  18. package/lib/nitrogen/generated/ios/swift/HybridOneKeyImageSpec.swift +1 -0
  19. package/lib/nitrogen/generated/ios/swift/HybridOneKeyImageSpec_cxx.swift +24 -0
  20. package/lib/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.cpp +2 -0
  21. package/lib/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.hpp +2 -0
  22. package/lib/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.cpp +2 -0
  23. package/lib/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.hpp +3 -0
  24. package/lib/nitrogen/generated/shared/json/OneKeyImageConfig.json +1 -0
  25. package/lib/typescript/src/OneKeyImage.nitro.d.ts +6 -0
  26. package/lib/typescript/src/index.d.ts +1 -1
  27. package/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.cpp +9 -0
  28. package/nitrogen/generated/android/c++/JHybridOneKeyImageSpec.hpp +2 -0
  29. package/nitrogen/generated/android/c++/views/JHybridOneKeyImageStateUpdater.cpp +5 -0
  30. package/nitrogen/generated/android/kotlin/com/margelo/nitro/onekeyimage/HybridOneKeyImageSpec.kt +6 -0
  31. package/nitrogen/generated/ios/c++/HybridOneKeyImageSpecSwift.hpp +7 -0
  32. package/nitrogen/generated/ios/c++/views/HybridOneKeyImageComponent.mm +6 -0
  33. package/nitrogen/generated/ios/swift/HybridOneKeyImageSpec.swift +1 -0
  34. package/nitrogen/generated/ios/swift/HybridOneKeyImageSpec_cxx.swift +24 -0
  35. package/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.cpp +2 -0
  36. package/nitrogen/generated/shared/c++/HybridOneKeyImageSpec.hpp +2 -0
  37. package/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.cpp +2 -0
  38. package/nitrogen/generated/shared/c++/views/HybridOneKeyImageComponent.hpp +3 -0
  39. package/nitrogen/generated/shared/json/OneKeyImageConfig.json +1 -0
  40. package/package.json +2 -2
  41. package/src/OneKeyImage.nitro.ts +6 -0
  42. package/src/index.tsx +23 -1
@@ -98,6 +98,7 @@ private final class OneKeyImageSkeletonIndicator: NSObject, SDWebImageIndicator
98
98
 
99
99
  final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
100
100
  private enum DisplayState { case loading, image, error, fallback }
101
+ private enum MemoryProbeResult { case miss, preview, exact }
101
102
  private static let fadeDelayThreshold: CFTimeInterval = 0.1
102
103
  private static let fadeDuration: TimeInterval = 0.14
103
104
 
@@ -115,6 +116,11 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
115
116
  private var requestActive = false
116
117
  private var isResetting = false
117
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
118
124
  private var requestStartedAt: CFTimeInterval?
119
125
 
120
126
  var view: UIView { hostView }
@@ -150,6 +156,11 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
150
156
  if !Self.equalOptionalDouble(resizeWidth, oldValue) { identityDidChange() }
151
157
  }
152
158
  }
159
+ var resizeHeight: Double? {
160
+ didSet {
161
+ if !Self.equalOptionalDouble(resizeHeight, oldValue) { identityDidChange() }
162
+ }
163
+ }
153
164
  var overscan: Double? = 1.1 {
154
165
  didSet {
155
166
  if !Self.equalOptionalDouble(overscan, oldValue) { identityDidChange() }
@@ -246,7 +257,7 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
246
257
  self.cancelCurrentRequest(invalidateGeneration: true)
247
258
  self.lastRequestSignature = nil
248
259
  if let source = self.sourceUri, !source.isEmpty {
249
- if !self.showMemoryCachedImageIfAvailable(requestIsActive: false) {
260
+ if self.showMemoryCachedImageIfAvailable(requestIsActive: false) == .miss {
250
261
  self.showLoading(requestIsActive: false, letLibraryStartIndicator: false)
251
262
  }
252
263
  } else {
@@ -260,7 +271,14 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
260
271
  guard !hostView.bounds.isEmpty else {
261
272
  cancelCurrentRequest(invalidateGeneration: true)
262
273
  lastRequestSignature = nil
263
- showLoading(requestIsActive: false, letLibraryStartIndicator: false)
274
+ // A memory-cached image shown from `identityDidChange` before layout
275
+ // stays up; layout re-runs this load with real bounds and confirms it.
276
+ if !Self.shouldPreserveDisplayedImage(
277
+ isShowingImage: displayState == .image,
278
+ hasImage: hostView.image != nil
279
+ ) {
280
+ showLoading(requestIsActive: false, letLibraryStartIndicator: false)
281
+ }
264
282
  return
265
283
  }
266
284
  guard let rawString = sourceUri, !rawString.isEmpty else {
@@ -280,6 +298,7 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
280
298
  contentFit?.stringValue ?? "cover",
281
299
  String(optimizeTos ?? true),
282
300
  resizeWidth.map(String.init(describing:)) ?? "",
301
+ resizeHeight.map(String.init(describing:)) ?? "",
283
302
  String(overscan ?? 1.1),
284
303
  String(Int(hostView.bounds.width.rounded())),
285
304
  String(Int(hostView.bounds.height.rounded())),
@@ -294,8 +313,21 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
294
313
  requestStartedAt = CACurrentMediaTime()
295
314
  onLoadStart?()
296
315
  guard Self.isCurrentRequestGeneration(generation, current: requestGeneration) else { return }
297
- if showMemoryCachedImageIfAvailable(requestIsActive: true) { return }
298
- 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
+ )
299
331
 
300
332
  if let violation = OneKeyImageSafetyPolicy.preflight(source: rawString) {
301
333
  finishWithError(violation, generation: generation)
@@ -311,8 +343,11 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
311
343
  }
312
344
 
313
345
  let hasCustomIdentity = OneKeyImageRequestContext.headers(from: sourceHeadersJson) != nil
314
- let displaySize = resizeWidth.flatMap { $0.isFinite && $0 > 0 ? CGFloat($0) : nil }
315
- ?? 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
316
351
  let requestURL =
317
352
  (optimizeTos ?? true)
318
353
  ? OneKeyTosURL.optimized(
@@ -327,7 +362,8 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
327
362
  url: requestURL,
328
363
  rawURL: rawURL,
329
364
  generation: generation,
330
- mayFallbackToRaw: requestURL != rawURL
365
+ mayFallbackToRaw: requestURL != rawURL,
366
+ placeholderImage: preservedImage
331
367
  )
332
368
  }
333
369
 
@@ -335,7 +371,8 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
335
371
  url: URL,
336
372
  rawURL: URL,
337
373
  generation: UInt64,
338
- mayFallbackToRaw: Bool
374
+ mayFallbackToRaw: Bool,
375
+ placeholderImage: UIImage? = nil
339
376
  ) {
340
377
  guard Self.isCurrentRequestGeneration(generation, current: requestGeneration) else { return }
341
378
  let rawScreenScale: CGFloat = hostView.window?.screen.scale ?? UIScreen.main.scale
@@ -363,7 +400,9 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
363
400
  )
364
401
  hostView.sd_internalSetImage(
365
402
  with: url,
366
- 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,
367
406
  options: OneKeyImageRequestContext.renderOptions,
368
407
  context: context,
369
408
  setImageBlock: { [weak self] image, _, _, _ in
@@ -399,21 +438,43 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
399
438
  url: rawURL,
400
439
  rawURL: rawURL,
401
440
  generation: generation,
402
- mayFallbackToRaw: false
441
+ mayFallbackToRaw: false,
442
+ placeholderImage: self.hostView.image
403
443
  )
404
444
  return
405
445
  }
406
446
  self.finishWithError(safetyViolation ?? error, generation: generation)
407
447
  return
408
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
+ }
409
465
  guard self.claimTerminal(generation) else { return }
466
+ let replacesMemoryPreview = self.clearMemoryPreviewPresentation()
410
467
  self.requestActive = false
411
468
  self.displayState = .image
469
+ self.isPreservingDisplayedImage = false
412
470
  self.skeletonIndicator.stopAnimatingIndicator()
413
471
  self.fallbackLayer.isHidden = true
414
472
  self.hostView.backgroundColor = .clear
415
473
  let resolvedCacheType = Self.cacheType(cacheType)
416
- self.applyLoadedImageTransition(cacheType: resolvedCacheType)
474
+ self.applyLoadedImageTransition(
475
+ cacheType: resolvedCacheType,
476
+ replacingMemoryPreview: replacesMemoryPreview
477
+ )
417
478
  self.applyAutoplay()
418
479
  let onLoad = self.onLoad
419
480
  let onLoadEnd = self.onLoadEnd
@@ -517,6 +578,7 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
517
578
  optimizeTos = true
518
579
  round = false
519
580
  resizeWidth = nil
581
+ resizeHeight = nil
520
582
  overscan = 1.1
521
583
  loadingStrategy = .static
522
584
  placeholderColor = nil
@@ -547,32 +609,69 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
547
609
  guard Self.isCurrentRequestGeneration(generation, current: requestGeneration) else {
548
610
  return
549
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.
550
615
  hostView.image = image
551
616
  }
552
617
 
553
618
  private func showLoading(
554
619
  requestIsActive: Bool,
555
- letLibraryStartIndicator: Bool
620
+ letLibraryStartIndicator: Bool,
621
+ preservedImage: UIImage? = nil
556
622
  ) {
557
623
  displayState = .loading
558
624
  requestActive = requestIsActive
559
625
  resetImageTransition()
560
- hostView.image = nil
561
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
562
645
  applyLoadingAppearance(letLibraryStartIndicator: letLibraryStartIndicator)
563
646
  }
564
647
 
565
- private func showMemoryCachedImageIfAvailable(requestIsActive: Bool) -> Bool {
566
- guard cachePolicy == .memory || cachePolicy == .memoryDisk,
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 {
656
+ // JS leaves `cachePolicy` unset for the common case and Fabric then hands
657
+ // Nitro a nil, which the request path already treats as memory-disk; the
658
+ // probe must agree, otherwise a nil policy skips the memory lookup entirely
659
+ // and every mount pays the async pipeline round trip (an icon skeleton
660
+ // frame) for an image that is already decoded in memory.
661
+ let effectivePolicy = cachePolicy ?? .memoryDisk
662
+ guard effectivePolicy == .memory || effectivePolicy == .memoryDisk,
567
663
  let sourceUri,
568
664
  let rawURL = URL(string: sourceUri) else {
569
- return false
665
+ return .miss
570
666
  }
571
667
  let rawScreenScale: CGFloat = hostView.window?.screen.scale ?? UIScreen.main.scale
572
668
  let screenScale = min(max(rawScreenScale, 1), 3)
573
669
  let hasCustomIdentity = OneKeyImageRequestContext.headers(from: sourceHeadersJson) != nil
574
- let displaySize = resizeWidth.flatMap { $0.isFinite && $0 > 0 ? CGFloat($0) : nil }
575
- ?? 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
576
675
  let requestURL =
577
676
  (optimizeTos ?? true)
578
677
  ? OneKeyTosURL.optimized(
@@ -583,12 +682,21 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView {
583
682
  hasCustomIdentity: hasCustomIdentity
584
683
  )
585
684
  : rawURL
586
- let thumbnailPixelSize = OneKeyImageDecodeSizing.thumbnailPixelSize(
587
- viewSize: hostView.bounds.size,
588
- scale: screenScale,
589
- contentFit: contentFit ?? .cover
590
- )
591
- func memoryCachedImage(for url: URL) -> UIImage? {
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,
694
+ scale: screenScale,
695
+ contentFit: contentFit ?? .cover
696
+ )
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
+ }
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
+ }
609
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(