ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • NSAttributedString Performance Optimization
    Swift 2025. 12. 6. 08:59

    안녕하세요. 그린입니다 🍏

    UITextView나 UILabel에서 수천 줄의 텍스트를 다룰 때 앱이 버벅이거나 메모리가 터지는 경험 있으신가요?

    이번 포스팅에서는 NSAttributedString의 성능 최적화에 대해 다뤄보겠습니다.

    Apple 엔지니어의 공식 답변을 토대로 방법들을를 정리해봤어요.


    Attribute Dictionary는 자동으로 중복 제거

    가장 먼저 알아야 할 사실은 NSAttributedString은 내부적으로 동일한 attribute dictionary를 자동으로 uniquify합니다.

    Apple 엔지니어의 공식 답변

    2015년, Apple의 TextKit 엔지니어 Aki Inoue가 공식 확인해준 대목이 있습니다.

     

    Memory optimization of NSAttributedString

    Discussion: Memory optimization of NSAttributedString

    cocoa-dev.apple.narkive.com

     

    "Yes, NSAttributedString does unique attribute dictionaries."

     

     

    실험으로 확인해볼까요?
    func testAttributeUniquification() {
        var array = [NSAttributedString]()
        let fontManager = NSFontManager.shared
        
        // 30개의 NSAttributedString 생성 - 동일한 속성 반복
        for index in 1...30 {
            var font = NSFont(name: "Times", size: 12.0)!
            font = fontManager.convert(font, toHaveTrait: .boldFontMask)
            
            if index % 3 == 0 {
                font = fontManager.convert(font, toHaveTrait: .italicFontMask)
            }
            
            let para = NSMutableParagraphStyle()
            para.lineSpacing = 1.4
            para.alignment = .center
            
            let attrStr = NSAttributedString(
                string: "Para \(index)", 
                attributes: [.font: font, .paragraphStyle: para]
            )
            array.append(attrStr)
        }
        
        // 중복 확인
        var uniqueFonts = Set<NSFont>()
        var uniqueParas = Set<NSParagraphStyle>()
        
        for attrStr in array {
            attrStr.enumerateAttributes(in: NSRange(location: 0, length: attrStr.length), 
                                       options: []) { attrs, _, _ in
                if let font = attrs[.font] as? NSFont { uniqueFonts.insert(font) }
                if let para = attrs[.paragraphStyle] as? NSParagraphStyle { uniqueParas.insert(para) }
            }
        }
        
        print("Fonts: \(uniqueFonts.count), Paragraphs: \(uniqueParas.count)")
        // 결과: Fonts: 2, Paragraphs: 1
        // 30개가 아니라 2개/1개만 생성됨!
    }

     

    동일한 attributes를 반복 생성해도 시스템이 알아서 재사용하므로 별도 캐싱 불필요합니다.

     


    NSTextStorage 메모리 증가 방지

    NSTextStorage를 subclass할 때 가장 흔한 실수입니다.

     

    이슈 코드

    // ❌ 메모리 증가
    class MyTextStorage: NSTextStorage {
        private var storage = NSMutableAttributedString()  // ⚠️ 이슈
        
        override var string: String {
            return storage.string
        }
        
        override func attributes(at location: Int, effectiveRange range: NSRangePointer?) 
            -> [NSAttributedString.Key : Any] {
            return storage.attributes(at: location, effectiveRange: range)
        }
        
        override func replaceCharacters(in range: NSRange, with str: String) {
            storage.replaceCharacters(in: range, with: str)
            edited(.editedCharacters, range: range, changeInLength: str.count - range.length)
        }
        
        override func setAttributes(_ attrs: [NSAttributedString.Key : Any]?, range: NSRange) {
            storage.setAttributes(attrs, range: range)
            edited(.editedAttributes, range: range, changeInLength: 0)
        }
    }

     

    20KB 이상 텍스트 → 메모리 기가바이트 단위 증가

     

    해결 방법

    // ✅ 솔루션
    class MyTextStorage: NSTextStorage {
        private var storage = NSTextStorage()  // NSTextStorage를 backing store로
        
        override var string: String {
            return storage.string
        }
        
        override func attributes(at location: Int, effectiveRange range: NSRangePointer?) 
            -> [NSAttributedString.Key : Any] {
            return storage.attributes(at: location, effectiveRange: range)
        }
        
        override func replaceCharacters(in range: NSRange, with str: String) {
            beginEditing()  // 필수
            storage.replaceCharacters(in: range, with: str)
            edited(.editedCharacters, range: range, changeInLength: (str as NSString).length - range.length)
            endEditing()    // 필수
        }
        
        override func setAttributes(_ attrs: [NSAttributedString.Key : Any]?, range: NSRange) {
            beginEditing()
            storage.setAttributes(attrs, range: range)
            edited(.editedAttributes, range: range, changeInLength: 0)
            endEditing()
        }
    }

     

    핵심 3가지가 아래와 같아요.

    1. NSTextStorage를 backing store로 사용 (NSAttributedString ❌)
    2. beginEditing()/endEditing() 반드시 호출
    3. String → NSString 캐스팅 (Emoji 등 처리)

    beginEditing/endEditing로 레이아웃 최적화

    이슈

    // ❌ 비효율적 - 매번 레이아웃 재계산
    func applySyntaxHighlighting() {
        setAttributes([.foregroundColor: UIColor.blue], range: keywordRange1)   // 레이아웃 계산
        setAttributes([.foregroundColor: UIColor.blue], range: keywordRange2)   // 레이아웃 계산
        setAttributes([.foregroundColor: UIColor.green], range: stringRange)    // 레이아웃 계산
        setAttributes([.foregroundColor: UIColor.gray], range: commentRange)    // 레이아웃 계산
    }

     

    최적화

    // ✅ 효율적 - 한 번만 레이아웃 계산
    func applySyntaxHighlighting() {
        beginEditing()  // "편집 시작" 플래그
        
        setAttributes([.foregroundColor: UIColor.blue], range: keywordRange1)
        setAttributes([.foregroundColor: UIColor.blue], range: keywordRange2)
        setAttributes([.foregroundColor: UIColor.green], range: stringRange)
        setAttributes([.foregroundColor: UIColor.gray], range: commentRange)
       
        endEditing()  // 이제 한 번만 레이아웃 재계산
    }

     

    성능 차이: 4번 계산 → 1번 계산 = 4배 빠름

     


    Syntax Highlighting 커서 점프 해결

    NSTextStorage에서 syntax highlighting 구현 시 커서가 문서 끝으로 점프하는 문제가 있습니다.

     

    이슈 원인

    class HighlightingTextStorage: NSTextStorage {
        override func processEditing() {
            applySyntaxHighlighting()  // ⚠️ 여기서 attributes 변경
            super.processEditing()     // → 커서 점프
        }
    }
    processEditing() 안에서 attributes를 변경하면 두 번째 layout pass가 시작되며 selection이 엉뚱한 곳으로 이동합니다.

     

    해결 방법: didChangeNotification 활용

    class SyntaxHighlighter {
        private weak var textView: NSTextView?
        private var subscription: NSObjectProtocol?
        
        init(textView: NSTextView) {
            self.textView = textView
            
            // 편집 완료 후에 highlighting
            self.subscription = NotificationCenter.default.addObserver(
                forName: NSText.didChangeNotification,
                object: textView,
                queue: nil
            ) { [weak self] _ in
                self?.applyHighlighting()
            }
        }
        
        func applyHighlighting() {
            guard let textStorage = textView?.textStorage else { return }
            
            textStorage.beginEditing()
            // Syntax highlighting 수행
            textStorage.endEditing()
        }
    }

     

    • processEditing() 밖에서 실행 → 별도 layout pass
    • Selection 점프 없음
    • 코드가 더 명확

    enumerateAttribute 최적화

    // ❌ 느림 - 모든 attributes 조회
    attributedString.enumerateAttributes(in: range, options: []) { attrs, range, stop in
        if let font = attrs[.font] as? UIFont {
            // font 처리
        }
    }
    
    // ✅ 빠름 - font만 조회
    attributedString.enumerateAttribute(.font, in: range, options: []) { value, range, stop in
        if let font = value as? UIFont {
            // font 처리
            stop.pointee = true  // 찾았으면 종료!
        }
    }

     


    HTML 변환은 Background에서

    // ✅ UI 블로킹 방지
    func loadHTML(_ htmlString: String, completion: @escaping (NSAttributedString?) -> Void) {
        DispatchQueue.global(qos: .userInitiated).async {
            let data = htmlString.data(using: .utf8)
            let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
                .documentType: NSAttributedString.DocumentType.html
            ]
            
            let attrString = try? NSAttributedString(data: data!, 
                                                    options: options, 
                                                    documentAttributes: nil)
            
            DispatchQueue.main.async {
                completion(attrString)
            }
        }
    }

     


    Copy의 핵심

    // Mutable → Immutable 변환
    let mutableStr = NSMutableString(string: "Hello")
    let immutableCopy = mutableStr.copy() as! NSString
    
    // 이제 mutate 불가능
    (immutableCopy as! NSMutableString).append("World")  // 💥 크래시!
    copy attribute는 안전성 보장이 목적 (성능 최적화 아님)

     


    Conclusion

    NSAttributedString 가장 중요화된 최적화 핵심은 네가지로 함축할 수 있습니다.

    1. Attribute Dictionary 자동 중복 제거 - 별도 캐싱 불필요
    2. NSTextStorage는 NSTextStorage로 delegate - 메모리 폭발 방지
    3. beginEditing/endEditing 활용 - 레이아웃 재계산 최소화
    4. Syntax highlighting은 didChangeNotification - 커서 점프 방지
    만약 텍스트 성능 문제가 생기면 이 부분을 먼저 확인해보면 어떨까요?

     


    References

     

    WWDC18 - Videos - Apple Developer

     

    developer.apple.com

     

    NSTextStorage | Apple Developer Documentation

    The fundamental storage mechanism of TextKit that contains the text managed by the system.

    developer.apple.com

     

    NSAttributedString | Apple Developer Documentation

    A string of text that manages data, layout, and stylistic information for ranges of characters to support rendering.

    developer.apple.com

     

    Memory optimization of NSAttributedString

    Discussion: Memory optimization of NSAttributedString

    cocoa-dev.apple.narkive.com

     

    Sub-Classing NSTextStorage Causes Significant Memory Issues

    I have a custom UITextView that takes advantage of Apple's TextKit by defining a custom NSTextStorage class, however, when I use my subclass for the custom text view's, text storage (as implemented...

    stackoverflow.com

     

    NSAttributedString performance is worse under iOS 8

    Under iOS 8 (and 8.1 beta) the performance of creating an NSAttributedString is much worse than 7 (2-3x). This is especially noticeable if you're using multiple instances on the same view, loading 4

    stackoverflow.com

Designed by Tistory.