UIViewRepresentable 当其 SwiftUI 偏移属性更改导致性能问题时不必要地重新创建视图

问题描述 投票:0回答:1

我正在为 SwiftUI(最低目标 iOS 15.0)编写一个具有缩放和平移功能的环绕式轮播组件。

想法如下:

  1. 有两个缓冲区:一个包含三个图像并显示在屏幕上,称为
    onScreenBuffer
    ,另一个用于为其准备更新,大小相同,称为
    offScreenBuffer
  2. 用户可以在组件上执行拖动手势。当拖动结束时,如果手指水平拖动足够长的时间,
    offScreenBuffer
    将从头开始重新创建,“居中”在新索引上。
  3. 在交换缓冲区之前,表示
    onScreenBuffer
    中下一个图像的索引(称为
    offsetImageSelector
    )的值会根据拖动方向进行更新。
  4. 当动画结束并且组件空闲时,在幕后
    offScreenBuffer
    会被复制到
    onScreenBuffer
    上,并且
    offsetImageSelector
    会重置为 0。

我将包含一个图像来直观地表示我所表达的概念,从组件首次渲染时开始。

enter image description here

现在,为了讨论将要发生的情况,让我们考虑以下场景:

向右滑动(dragOffset > 0)

当用户向右拖动足够长的时间时,组件应该显示上一张图像而不是当前显示的图像。这就是它的实现方式:

  1. 更新
    offScreenBuffer
    offsetImageSelector
    (后者用于触发滑动动画)。请注意,轮播组件的偏移量为
    (dragOffset - containerWidth)*(1-offsetImageSelector)
    ,因此空闲时呈现的图像为
    onScreenBuffer[1]
    ;向右滑动可显示
    onScreenBuffer[0]
    ,向左滑动可显示
    onScreenBuffer[2]

enter image description here

  1. offsetImageSelector
    0
    1
    的变化会导致以下过渡的动画:

enter image description here

  1. 当动画 @2 完成时,
    offScreenBuffer
    会覆盖
    onScreenBuffer
    并且
    offsetImageSelector
    会重置为 0,如下所示。

enter image description here

代码

我省略了向左滑动,因为它是完全对称的。这是我当前的实现(MRE,实际组件肯定比这更复杂):

PinchableViewRepresentable.swift

import SwiftUI
import Combine

struct PinchableViewRepresentable<Content: View>: UIViewRepresentable {
    var id: String
    private var content: () -> Content
    internal var minimumZoomScale: CGFloat = 1.0
    internal var maximumZoomScale: CGFloat = 20.0
    internal var bouncesZoom: Bool = true
    internal var resetZoomOnDoubleTap: Bool = true
    internal var onDoubleTap: (() -> Void)?
    internal var onSingleTap: (() -> Void)?
        
    @Binding internal var zoomObserver: CGFloat
    @Binding internal var scrollObserver: CGPoint
        
    init(
        id: String = UUID().uuidString,
        zoomObserver: Binding<CGFloat> = .constant(0),
        scrollObserver: Binding<CGPoint> = .constant(.zero),
        @ViewBuilder content: @escaping () -> Content,
        onSingleTap: (()->Void)? = nil,
        onDoubleTap: (()->Void)? = nil
    ) {
        self._zoomObserver = zoomObserver
        self._scrollObserver = scrollObserver
        self.content = content
        self.onSingleTap = onSingleTap
        self.onDoubleTap = onDoubleTap
        self.id = id
        print("PinchableViewRepresentable#\(id) init")
    }
    
    func makeUIView(context: Context) -> UIScrollView {
        // set up the UIScrollView
        let scrollView = UIScrollView()
        scrollView.delegate = context.coordinator  // for viewForZooming(in:)
        scrollView.maximumZoomScale = self.maximumZoomScale
        scrollView.minimumZoomScale = self.minimumZoomScale
        scrollView.bouncesZoom = self.bouncesZoom

        // create a UIHostingController to hold our SwiftUI content
        let hostedView = context.coordinator.hostingController.view!
        hostedView.translatesAutoresizingMaskIntoConstraints = true
        hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        hostedView.frame = scrollView.bounds
        scrollView.addSubview(hostedView)
        hostedView.backgroundColor = .clear
        context.coordinator.scrollView = scrollView
        
        context.coordinator.onSingleTap = self.onSingleTap
        context.coordinator.onDoubleTap = self.onDoubleTap
        
        if self.resetZoomOnDoubleTap {
            let doubleTapGestureRecognizer = UITapGestureRecognizer(
                target: context.coordinator,
                action: #selector(context.coordinator.handleDoubleTap(_:))
            )
            
            doubleTapGestureRecognizer.numberOfTapsRequired = 2
            scrollView.addGestureRecognizer(doubleTapGestureRecognizer)
            context.coordinator.doubleTapRecognizer = doubleTapGestureRecognizer
            
            let singleTapGestureRecognizer = UITapGestureRecognizer(
                target: context.coordinator,
                action: #selector(context.coordinator.handleSingleTap(_:))
            )
            
            singleTapGestureRecognizer.numberOfTapsRequired = 1
            scrollView.addGestureRecognizer(singleTapGestureRecognizer)
            context.coordinator.singleTapRecognizer = singleTapGestureRecognizer
            
            singleTapGestureRecognizer.require(toFail: doubleTapGestureRecognizer)
        }
        
        context.coordinator.setZoomObserver(self._zoomObserver)
        context.coordinator.setScrollObserver(self._scrollObserver)
        
        return scrollView
    }

    func makeCoordinator() -> Coordinator {
        return Coordinator(hostingController: UIHostingController(rootView: self.content()))
    }

    func updateUIView(_ uiView: UIScrollView, context: Context) {
        
        print("PinchableViewRepresentable#\(id) updateUIView")
        context.coordinator.scrollView = uiView
        context.coordinator.hostingController.rootView = self.content()
        
        let updateTask = {
            context.coordinator.setZoomObserver(self._zoomObserver)
            context.coordinator.setScrollObserver(self._scrollObserver)
            
         //   uiView.zoomScale = self.zoomObserver
         //   uiView.contentOffset = self.scrollObserver
        }
        
        DispatchQueue.main.async {
            updateTask()
        }

        assert(context.coordinator.hostingController.view.superview == uiView)
    }

    // MARK: - Coordinator
    class Coordinator: NSObject, UIScrollViewDelegate {
        var hostingController: UIHostingController<Content>
        var scrollView: UIScrollView? = nil
        var onDoubleTap: (() -> Void)? = nil
        var onSingleTap: (() -> Void)? = nil
        
        @Binding var zoomObserver: CGFloat
        @Binding var scrollObserver: CGPoint
        
        var doubleTapRecognizer: UITapGestureRecognizer!
        var singleTapRecognizer: UITapGestureRecognizer!
        var lastFiredZoom: Date = .distantPast
        var lastFiredScroll: Date = .distantPast
        
        init(
            hostingController: UIHostingController<Content>,
            onSingleTap: (() -> Void)? = nil,
            onDoubleTap: (() -> Void)? = nil,
            zoomObserver: Binding<CGFloat> = .constant(.zero),
            scrollObserver: Binding<CGPoint> = .constant(.zero)
        ) {
            self.hostingController = hostingController
            self.onSingleTap = onSingleTap
            self.onDoubleTap = onDoubleTap
            self._zoomObserver = zoomObserver
            self._scrollObserver = scrollObserver
        }

        func viewForZooming(in scrollView: UIScrollView) -> UIView? {
          return hostingController.view
        }
        
        func scrollViewDidZoom(_ scrollView: UIScrollView) {
            if #unavailable(iOS 16.0) {
                DispatchQueue.main.async { [weak self] in
                    guard let self = self else { return }
                    if self.lastFiredZoom.distance(to: Date.now) > 1.0/90.0 {
                        zoomObserver = scrollView.zoomScale
                        self.lastFiredZoom = Date.now
                    }
                }
            } else {
                zoomObserver = scrollView.zoomScale
            }
        }
        
        func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
            zoomObserver = scrollView.zoomScale
        }
        
        func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
            scrollObserver = scrollView.contentOffset
        }
        
        func scrollViewDidScroll(_ scrollView: UIScrollView) {
            if #unavailable(iOS 16.0) {
                DispatchQueue.main.async { [weak self] in
                    guard let self = self else { return }
                    if self.lastFiredScroll.distance(to: Date.now) > 1.0/90.0 {
                        self.scrollObserver = scrollView.contentOffset
                        self.lastFiredScroll = Date.now
                    }
                }
            } else {
                self.scrollObserver = scrollView.contentOffset
            }
        }
        
        func setZoomObserver(_ zoomObserver: Binding<CGFloat>) {
            DispatchQueue.main.async {
                self._zoomObserver = zoomObserver
            }
        }
        
        func setScrollObserver(_ scrollObserver: Binding<CGPoint>) {
            DispatchQueue.main.async {
                self._scrollObserver = scrollObserver
            }
        }
        
        @objc dynamic func handleDoubleTap(_ sender: UITapGestureRecognizer) {
            guard let scrollView = self.scrollView else { return }
            
            let zoomScale = scrollView.zoomScale
            
            switch sender.state {
                case .ended:
                    scrollView.setZoomScale(1.0, animated: true)
                    
                if zoomScale == 1.0 {
                    let imageView = scrollView.subviews.first!
                    let point = doubleTapRecognizer.location(in: imageView)
                    
                    let scrollSize = scrollView.frame.size
                    let size = CGSize(width: scrollSize.width / (scrollView.maximumZoomScale/2.0),
                                      height: scrollSize.height / (scrollView.maximumZoomScale/2.0))
                    let origin = CGPoint(x: point.x - size.width / 2,
                                         y: point.y - size.height / 2)
                    scrollView.zoom(to:CGRect(origin: origin, size: size), animated: true)
                    
                    
                    self.onDoubleTap?()
                }
                
                default:
                    break
            }
        }
        
        @objc dynamic func handleSingleTap(_ sender: UITapGestureRecognizer) {
            guard let onSingleTap = self.onSingleTap else { return }
            onSingleTap()
        }
        
    }
}

CGSize + sizeThatFits.swift

import CoreGraphics

extension CGSize {
    static func sizeThatFits(containerSize: CGSize, containedAR: CGFloat) -> CGSize {
        if containedAR.isZero || containedAR.isNaN || containedAR.isInfinite {
            #if DEBUG
                print("Unexpected input parameter `containedAR` value: \(containedAR)")
            #endif
            return .zero
        } else {
            var proposedSize: CGSize = .zero
            
            if containedAR > containerSize.width / containerSize.height {
                proposedSize.width = containerSize.width
                proposedSize.height = containerSize.width / containedAR
            } else {
                proposedSize.height = containerSize.height
                proposedSize.width = containerSize.height * containedAR
            }
            
            #if DEBUG
            if proposedSize.width > containerSize.width || proposedSize.height > containerSize.height {
                print("Computed size \(proposedSize) unexpectedly exceeds container size \(containerSize)")
            }
            #endif
            
            return proposedSize
        }
    }
}

ZTronImageModel.swift:

import Foundation

class ZTronImageModel: ObservableObject {
    
    private var imageID: String
    private var position: Int
    var id: String
    
    init(image: String, position: Int) {
        self.imageID = image
        self.position = position
        self.id = imageID
    }

    static func == (lhs: ZTronImageModel, rhs: ZTronImageModel) -> Bool {
        return lhs.id == rhs.id
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(imageID)
    }
    
    func getName() -> String {
        return self.imageID
    }
    
    func getPosition() -> Int {
        return self.position
    }
    
    static var zero: ZTronImageModel = ZTronImageModel(
        image: "YadaYadaYada",
        position: 0
    )
}

ZTronCarouselModel.swift

import SwiftUI

class ZtronCarouselModel: ObservableObject {
    internal var onScreenBuffer: [BufferedZTronImageModel] = []
    private var offScreenBuffer: [BufferedZTronImageModel] = []
    internal var currentImage: Int = 0
    @Published internal var offsetImageSelector: Int = 0
        
    //MARK: - Images set
    @Published internal var imagesIDs: [ZTronImageModel] = [] {
        didSet {
            guard imagesIDs.count > 0 else { return }
            self.makeBuffer(for: self.currentImage, buffer: &self.onScreenBuffer)
            self.makeBuffer(for: self.currentImage, buffer: &self.offScreenBuffer)
        }
    }
        
    init(){
        imagesIDs = [
            ZTronImageModel(image: "Police", position: 0),
            ZTronImageModel(image: "Shutter", position: 1),
            ZTronImageModel(image: "Depot", position: 2),
            ZTronImageModel(image: "Cakes", position: 3),
            ZTronImageModel(image: "Sign", position: 4)
        ]
    }
            
    //MARK: - ViewController
    internal func makeBuffer(for centralIdx: Int, buffer: inout [BufferedZTronImageModel])  {
        precondition(centralIdx >= 0 && centralIdx < self.imagesIDs.count)
        var tempBuffer: [BufferedZTronImageModel] = []
        
        if tempBuffer.count < 3 {
            tempBuffer = [BufferedZTronImageModel].init(repeating: .zero, count: 3)
        }
        
        for i in -1...1 {
            let next = (centralIdx + i + imagesIDs.count) % imagesIDs.count
            tempBuffer[i+1] = BufferedZTronImageModel(imageModel: imagesIDs[next], position: i+1)
        }
        
        buffer = tempBuffer
    }
    
    func prepareForNext(forward: Bool = true) {
        let nextIdx = forward ? (currentImage+1)%imagesIDs.count : (currentImage-1+imagesIDs.count) % imagesIDs.count
        
        self.offsetImageSelector += forward ? -1 : 1
                
        assert(nextIdx >= 0 && nextIdx <= imagesIDs.count)
        
        self.makeBuffer(for: nextIdx, buffer: &self.offScreenBuffer)
        currentImage = nextIdx
        
    }
    
    func swapOnscreenBuffer() {
        self.onScreenBuffer = Array(self.offScreenBuffer)
        self.offsetImageSelector = 0
    }
    
    internal struct BufferedZTronImageModel: Identifiable {
        var imageModel: ZTronImageModel
        var position: Int
        var id: String
        
        init(imageModel: ZTronImageModel, position: Int) {
            self.imageModel = imageModel
            self.position = position
            //self.id = "\(imageModel.id)\(position)"
            self.id = imageModel.id
        }
        
        public static let zero = BufferedZTronImageModel(imageModel: .zero, position: .zero)
    }
}

ZtronCarouselViewAlt.swift

import SwiftUI

struct ZTronCarouselViewAlt: View {
    @StateObject private var carouselModel = ZtronCarouselModel()
    @State private var dragOffset: CGFloat = 0

    var body: some View {
        GeometryReader { geo in
            let expectedImageVFraction = CGSize.sizeThatFits(containerSize: geo.size, containedAR: 16.0/9.0).height / geo.size.height
            let needsVerticalLayout = expectedImageVFraction <= 0.75

            VStack(alignment: .leading, spacing: 0) {
                VStack(alignment: .leading, spacing: 0) {
                    HStack(spacing: 0) {
                        ForEach(carouselModel.onScreenBuffer, id: \.id) { bufferedImage in
                            PinchableViewRepresentable(id: bufferedImage.imageModel.getName()) {
                                Image(bufferedImage.imageModel.getName())
                                    .resizable()
                            }
                            .frame(width: geo.size.width + geo.safeAreaInsets.leading + geo.safeAreaInsets.trailing, alignment: .leading)
                            .overlay {
                                Text("\(carouselModel.offsetImageSelector)")
                                    .foregroundStyle(.white)
                            }
                        }
                    }
                    .zIndex(1.0)
                    .offset(
                        x: (dragOffset - (geo.size.width + geo.safeAreaInsets.leading + geo.safeAreaInsets.trailing)) * CGFloat((1 - carouselModel.offsetImageSelector))
                    )
                    .aspectRatio(16.0/9.0, contentMode: .fit)
                    .gesture(
                        DragGesture(minimumDistance: 20)
                            .onChanged { value in
                                if carouselModel.offsetImageSelector == 0 {
                                    dragOffset = value.translation.width
                                } else {
                                    dragOffset = 0
                                }
                            }
                            .onEnded { _ in
                                onDragEnded(galleryWidth: geo.size.width + geo.safeAreaInsets.leading + geo.safeAreaInsets.trailing)
                            }
                    )
                    .frame(maxWidth: geo.size.width + geo.safeAreaInsets.leading + geo.safeAreaInsets.trailing, alignment: .leading)
                    .contentShape(Rectangle())
                    .clipped()
                }
                .frame(maxHeight: .infinity, alignment: .center)
                .ignoresSafeArea(.container, edges: [.leading, .trailing])
                .fixedSize(horizontal: false, vertical: needsVerticalLayout)
            }
        }
    }

    private func onDragEnded(galleryWidth: CGFloat) {
        let swipePercentage = abs(dragOffset) / galleryWidth

        if swipePercentage > 0.2 {
            withAnimation(.easeOut(duration: 0.25)) {
                carouselModel.prepareForNext(forward: dragOffset < 0)
                dragOffset = 0
            }
            

            DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
                carouselModel.swapOnscreenBuffer()
            }

        } else {
            print("\(#function) path 1.2")
            withAnimation {
                self.dragOffset = 0
            }
        }
    }
}

问题

正如我在此处记录的那样,在拖动手势期间,PinchableViewRepresentable组件会被重新创建(

.init() invoked
)并更新
updateUIView()
多次,并且在界面方向更改期间也会发生同样的情况,导致动画效果不佳(在真机iPhone上测试) 6S(带 iOS 15.8)和 iPhone 15 Pro 模拟器)。
使用 SwiftUI 工具,看起来组件会重新渲染,因为 Binding

和 Binding

发生了变化,即使在此 MRE 中,zoomObserver

scrollObserver
使用默认常量值。
使用 Self._printChanges() 我只得到以下内容:

ZTronCarouselViewAlt: @self, @identity, _carouselModel, _dragOffset changed. ZTronCarouselViewAlt: _dragOffset changed. [...] ZTronCarouselViewAlt: _dragOffset changed. ZTronCarouselViewAlt: _carouselModel changed.

因此,在第一次渲染 @identity 更改期间,之后,当发生冗余更新时,只有 
_dragOffset

发生变化。

实际的 

PinchableViewRepresentable

内容也比这复杂得多,并且它有一个依赖于

zoomObserver
scrollObserver
的覆盖层,因此问题变得非常严重,主要是在旋转期间。
为了自己进行测试,您可以将任意一组名为“Police”、“Shutter”、“Depot”、“Cakes”、“Sign”(具有确切的大小写)的 16:9 图像添加到您的资产文件夹中。 

编辑

如果我将

PinchableViewRepresentable

减少为:

,问题仍然存在:
struct PinchableViewRepresentable<Content: View>: UIViewRepresentable { private var content: Content init( @ViewBuilder content: @escaping () -> Content ) { self.content = content() print("PinchableViewRepresentable#\(#function)") } func makeUIView(context: Context) -> UIScrollView { // set up the UIScrollView let scrollView = UIScrollView() scrollView.maximumZoomScale = 1.0 scrollView.minimumZoomScale = 20.0 scrollView.bouncesZoom = true // create a UIHostingController to hold our SwiftUI content let hostedView = UIHostingController(rootView: content).view! hostedView.translatesAutoresizingMaskIntoConstraints = true hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight] hostedView.frame = scrollView.bounds scrollView.addSubview(hostedView) hostedView.backgroundColor = .clear return scrollView } func updateUIView(_ uiView: UIScrollView, context: Context) { print("PinchableViewRepresentable#\(#function)") } }


performance optimization swiftui uikit
1个回答
0
投票
这是针对 DLP 策略上传 .txt 文件的测试

© www.soinside.com 2019 - 2024. All rights reserved.