Текстовый маркер делает пользовательский интерфейс медленным/подтормаживающим.

Вопрос или проблема

У меня есть базовый прокручиваемый вид с контентом, и весь контент находится внутри LazyVStack. Когда у меня много подвидов с моим собственным текстовым представлением в формате markdown, прокручиваемый вид становится очень медленным и с фризами/глитчами. Я предполагаю, что это связано с тем, что мой собственный вид требует много ресурсов для разделения каждой части текста и его отображения. Учитывая строку в Swift UI, я просто хочу стилизовать каждый компонент (хэштег, @тег, веб-ссылка) по-разному и обрабатывать клики на каждом из этих компонентов по-разному. Как я могу улучшить свой подход, чтобы это не сказалось негативно на прокручиваемом представлении? Если существует лучший способ подойти к этому, я был бы признателен за помощь.

Как это работает:

Я разделяю каждую строку на массив компонентов внутри init. Затем каждый компонент располагается внутри CustomLayout, который является пользовательским макетом, который располагает подвиды последовательно слева направо, сверху вниз, чтобы воспроизвести непрерывную строку.

код для копирования

import SwiftUI

private let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)

struct MarkDownText: View {
    let text: String
    let links: [NSTextCheckingResult]
    
    init (_ text: String) {
        self.text = text
        let nsText = text as NSString
        let wholeString = NSRange(location: 0, length: nsText.length)
        links = linkDetector.matches(in: text, options: [], range: wholeString)
    }
    
    var body: some View {
        MarkDownTextView(text: text, links: links)
    }
}

struct MarkDownTextView: View {
    @Environment(\.colorScheme) var colorScheme
    
    enum Component {
        case text(String)
        case link(String, URL)
        case username(String)
        case hashtag(String)
    }

    let text: String
    @State var components: [Component]

    init(text: String, links: [NSTextCheckingResult]) {
        self.text = text
        let nsText = text as NSString
        
        var components: [Component] = []
        var index = 0
        for result in links {
            if result.range.location > index {
                let sub_str = nsText.substring(with: NSRange(location: index, length: result.range.location - index))
                let ats = separateTextAndTags(input: sub_str)
                
                ats.forEach { element in
                    if element.hasPrefix("@") && element.count < 13 {
                        components.append(.username(element))
                    } else if element.hasPrefix("#") && element.count < 40 {
                        components.append(.hashtag(element))
                    } else {
                        components.append(.text(element))
                    }
                }
            }
            components.append(.link(nsText.substring(with: result.range), result.url!))
            index = result.range.location + result.range.length
        }
        if index < nsText.length {
            let sub_str = nsText.substring(from: index)
            let ats = separateTextAndTags(input: sub_str)
            
            ats.forEach { element in
                if element.hasPrefix("@") && element.count < 13 {
                    components.append(.username(element))
                } else if element.hasPrefix("#") && element.count < 40 {
                    components.append(.hashtag(element))
                } else {
                    components.append(.text(element))
                }
            }
        }
        _components = State(initialValue: components)
    }
    
    var body: some View {
        CustomLayout(spacing: 0){
            ForEach(Array(components.enumerated()), id: \.offset) { _, component in
                switch component {
                case .text(let text):
                    Text(verbatim: text).font(.subheadline).multilineTextAlignment(.leading)
                case .link(let text, _):
                    Button {
                        if let url = URL(string: text) {
                            UIApplication.shared.open(url, options: [:], completionHandler: nil)
                        }
                    } label: {
                        Text(verbatim: text).foregroundColor(.blue).font(.subheadline)
                            .multilineTextAlignment(.leading)
                    }
                case .username(let text):
                    Button(action: {
                        Text(text)
                            .font(.subheadline).bold()
                            .foregroundStyle(.red)
                    }, label: {
                        Text(text)
                            .font(.subheadline).bold()
                            .multilineTextAlignment(.leading)
                            .foregroundStyle(.orange)
                    })
                case .hashtag(let hash):
                    NavigationLink {
                        Text("\(hash) view")
                    } label: {
                        Text(hash)
                            .italic()
                            .font(.subheadline).bold()
                            .foregroundStyle(.blue)
                    }
                }
            }
        }
    }
}

func separateTextAndTags(input: String) -> [String] {
    var resultArray: [String] = []
    var currentToken = ""

    for character in input {
        if character == "@" || character == "#" {
            if !currentToken.isEmpty {
                resultArray.append(currentToken)
                currentToken = ""
            }
            currentToken += String(character)
        } else if character.isWhitespace {
            if currentToken.count > 1 && (currentToken.hasPrefix("@") || currentToken.hasPrefix("#")) {
                resultArray.append(currentToken)
                currentToken = ""
            }
            currentToken += String(character)
        } else {
            currentToken += String(character)
        }
    }

    if !currentToken.isEmpty {
        resultArray.append(currentToken)
    }

    return resultArray
}

struct CustomLayout: Layout {
    var alignment: Alignment = .leading
    var spacing: CGFloat = 0
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let maxWidth = proposal.width ?? 0
        var height: CGFloat = 0
        let rows = generateRows(maxWidth, proposal, subviews)
        
        for (index, row) in rows.enumerated() {
            if index == (rows.count - 1) {
                height += row.maxHeight(proposal)
            } else {
                height += row.maxHeight(proposal) + spacing
            }
        }
        
        return .init(width: maxWidth, height: height)
    }
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        var origin = bounds.origin
        let maxWidth = bounds.width
        
        let rows = generateRows(maxWidth, proposal, subviews)
        
        for row in rows {
            let leading: CGFloat = bounds.maxX - maxWidth
            let trailing = bounds.maxX - (row.reduce(CGFloat.zero) { partialResult, view in
                let width = view.sizeThatFits(proposal).width
                
                if view == row.last {
                    return partialResult + width
                }
                return partialResult + width + spacing
            })
            let center = (trailing + leading) / 2
            
            origin.x = (alignment == .leading ? leading : alignment == .trailing ? trailing : center)
            
            for view in row {
                let viewSize = view.sizeThatFits(proposal)
                view.place(at: origin, proposal: proposal)
                origin.x += (viewSize.width + spacing)
            }
        
            origin.y += (row.maxHeight(proposal) + spacing)
        }
    }
    
    func generateRows(_ maxWidth: CGFloat, _ proposal: ProposedViewSize, _ subviews: Subviews) -> [[LayoutSubviews.Element]] {
        var row: [LayoutSubviews.Element] = []
        var rows: [[LayoutSubviews.Element]] = []
        
        var origin = CGRect.zero.origin
        
        for view in subviews {
            let viewSize = view.sizeThatFits(proposal)
            
            if (origin.x + viewSize.width + spacing) > maxWidth {
                rows.append(row)
                row.removeAll()
                origin.x = 0
                row.append(view)
                origin.x += (viewSize.width + spacing)
            } else {
                row.append(view)
                origin.x += (viewSize.width + spacing)
            }
        }

        if !row.isEmpty {
            rows.append(row)
            row.removeAll()
        }
        
        return rows
    }
}

Ответ или решение

Оптимизация производительности пользовательского интерфейса при работе с текстовым Markdown в SwiftUI

Ваша проблема со снижением производительности при использовании пользовательского текстового Markdown-вида в LazyVStack связана с высокими затратами на рендеринг и сложными вычислениями в вашем коде. При значительном количестве подвидов, содержащих сложную разметку, в результате возникают "фрейми" и подвисания. Чтобы решить эту проблему, нужно оптимизировать как обработку данных, так и сам интерфейс. Рассмотрим несколько подходов к улучшению производительности.

1. Изменение обработки текста

Сначала необходимо обратить внимание на способ обработки текстовых данных. Ваша текущая реализация разбивает текст на компоненты в init, используя NSDataDetector. Это может быть затратным, особенно при обработке больших объемов текста. Вместо этого рассмотрите возможность предварительной обработки текста в отдельном потоке или использования механизма кэширования для хранения результатов:

@State private var componentsCache: [Component] = []

init(text: String, links: [NSTextCheckingResult]) {
    self.text = text
    if let cachedComponents = componentsCache[text] {
        self.components = cachedComponents
    } else {
        // Обработка текста и добавление в кэш
        self.components = processText(text, links: links)
        componentsCache[text] = self.components
    }
}

private func processText(_ text: String, links: [NSTextCheckingResult]) -> [Component] {
    // Ваша текущая логика обработки текста
}

2. Оптимизация рендеринга компонентов

Каждый компонент вашего MarkDownTextView порождает новую View, что может привести к задержкам при прокрутке. Постарайтесь минимизировать количество создаваемых представлений, используя статические представления или обновляя только те компоненты, которые действительно изменяются:

  • Используйте Identifiable, чтобы SwiftUI мог оптимально обновлять компоненты.
  • Избегайте использования State, если это не нужно. Рассмотрите возможность использования Binding для передачи данных в подвиды.

Пример изменения кода для обеспечения лучшей идентификации:

struct ComponentView: View {
    let component: MarkDownTextView.Component

    var body: some View {
        switch component {
        case .text(let text):
            Text(verbatim: text)
                .font(.subheadline)
        case .link(let text, _):
            Button(action: {/* открыть ссылку */}) {
                Text(text)
                    .foregroundColor(.blue)
            }
        case .username(let text):
            // аналогично для username
        case .hashtag(let hash):
            // аналогично для hashtag
        }
    }
}

3. Использование draw вместо layout

Если вы не нуждаетесь в динамическом изменении расположения элементов (например, если длина текста статична), рассматривайте возможность использования draw метода для отрисовки текста вместо использования сложныхигачи.

4. Проверка и минимизация затрат на Кэширование

Кэширование может значительно улучшить производительность, но если кэш становится слишком большим, он может оказать отрицательное влияние на память. Убедитесь, что ваше кэширование ответственно и содержит только необходимую информацию.

5. Асинхронные операции

Если ваш текст очень длинный, вы можете попробовать обрабатывать тексты в фоновом потоке, используя DispatchQueue.global().async, а затем возвращаться в главный поток для обновления UI:

DispatchQueue.global().async {
    let processedComponents = self.processText(text, links: links)
    DispatchQueue.main.async {
        self.components = processedComponents
    }
}

Заключение

Оптимизация производительности вашего интерфейса при использовании text markdown в SwiftUI требует внимательного подхода к обработке данных и рендерингу компонентов. Использование кэширования, минимизация количества подвидов и отложенная обработка могут помочь значительно улучшить плавность прокрутки и общее восприятие приложения. Важно тщательно анализировать каждый шаг и проверять, как изменения влияют на производительность. Таким образом, вы сможете создать более отзывчивый и эффективный интерфейс для ваших пользователей.

Оцените материал
Добавить комментарий

Капча загружается...