You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

滚动TableView时自定义CGFont无法释放的问题排查

TableView中自定义CGFont无法释放的内存问题

问题现象

在CollectionView中使用自定义字体管理器加载字体时,内存中留存了全部400个已加载字体的CGFont对象,但使用系统字体UIFont.systemFont(ofSize: 16.0)时,仅留存当前屏幕显示的约33个CGFont对象。字体显示正常,但内存占用远超预期。

代码实现

CollectionView数据源代码

extension FontPickerPageViewController: UICollectionViewDataSource {

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        fontItems.count
    }

    func collectionView(
        _ collectionView: UICollectionView,
        cellForItemAt indexPath: IndexPath
    ) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(
            withReuseIdentifier: FontPickerCollectionViewCell.reuseIdentifier,
            for: IndexPath(item: indexPath.item, section: 0)
        ) as! FontPickerCollectionViewCell

        let fontItem = fontItems[indexPath.item]
        let name = fontItem.family

        cell.label.text = name

        if let font = try? fontManager.getFont(forName: name) {
            cell.label.font = font
        } else if let fontFile = fontItem.files.first(where: { $0.variant == .regular }),
                  let url = URL(string: fontFile.urlString)
        {
            // Download font
            Task {
                do {
                    let data = try await NetworkManager.downloadData(URLRequest(url: url))
                    try await fontManager.registerFont(for: data, name: name)
                } catch {
                    print(error)
                }
            }
        }

        return cell
    }

}

字体管理器代码

final class FontManager {

    enum FontManager2Error: LocalizedError {
        case registerProviderError
        case postScriptNameError
        case fontNotFound

        var errorDescription: String? {
            switch self {
            case .registerProviderError:
                return "Failed to register font with provider"
            case .postScriptNameError:
                return "Failed to get the post script name"
            case .fontNotFound:
                return "Failed to find font"
            }
        }
    }

    // MARK: - Properties

    static let shared = FontManager()


    private var storage = [String: String]() //fontName : postScriptName
    private let lockQueue = DispatchQueue(label: "lock", attributes: .concurrent)

    // MARK: - Initializers

    private init() {}

    // MARK: - Font Management

    private func registerFont(for data: Data, name: String, completion: @escaping (Result<(), Error>) -> Void) {
        guard let provider = CGDataProvider(data: data as CFData),
              let cgFont = CGFont(provider)
        else {
            return completion(.failure(FontManager2Error.registerProviderError))
        }

        guard let postScriptName = cgFont.postScriptName as? String else {
            return completion(.failure(FontManager2Error.postScriptNameError))
        }

        CTFontManagerRegisterGraphicsFont(cgFont, nil)
        lockQueue.async(flags: .barrier) { [weak self] in
            self?.storage[name] = postScriptName
            completion(.success(()))
        }
    }

    func registerFont(for data: Data, name: String) async throws {
        try await withCheckedThrowingContinuation { continuation in
            registerFont(for: data, name: name) { result in
                switch result {
                case .success:
                    continuation.resume()
                case .failure(let error):
                    continuation.resume(throwing: error)
                }
            }
        }
    }

    func isFontRegistered(name: String) -> Bool {
        lockQueue.sync {
            storage[name] != nil
        }
    }

    func getFont(forName name: String) throws -> UIFont {
        try lockQueue.sync {
            guard let postScriptName = storage[name],
                  let uiFont = UIFont(name: postScriptName, size: Config.defaultFontSize)
            else {
                throw FontManager2Error.fontNotFound
            }

            return uiFont
        }
    }

}

原因分析

问题核心在于字体注册的机制差异:

  • 调用CTFontManagerRegisterGraphicsFont注册的自定义字体,会被Core Text框架全局持有,不会随对应UIFont对象的释放而自动卸载。
  • 系统字体由iOS系统统一管理,内置了缓存回收机制,会自动释放不在屏幕显示范围内的字体资源,因此内存占用仅对应当前显示的单元格数量。
  • 你的代码中,字体一旦被下载注册,就会永久留在内存中,不管CollectionViewCell是否被复用或销毁。

解决方案

方案1:按需注册+主动卸载字体

如果不需要长期保留所有字体,可以在字体不再被使用时主动卸载:

  1. 在FontManager中添加卸载方法:
func unregisterFont(forName name: String) throws {
    try lockQueue.sync(flags: .barrier) {
        guard let postScriptName = storage.removeValue(forKey: name),
              let cgFont = CGFont(postScriptName as CFString)
        else {
            throw FontManager2Error.fontNotFound
        }
        CTFontManagerUnregisterGraphicsFont(cgFont, nil)
    }
}
  1. 在CollectionView的didEndDisplayingCell方法中,判断字体是否不再被引用,调用卸载方法。注意:需确保无其他地方使用该字体,避免显示异常。

方案2:使用NSCache缓存字体(推荐)

放弃全局注册,直接通过CGFont创建UIFont,用NSCache管理缓存,内存不足时自动回收:
修改FontManager核心逻辑:

final class FontManager {
    // 保留原有错误定义和单例
    
    private var fontCache = NSCache<NSString, UIFont>()
    private var fontDataCache = NSCache<NSString, Data>()
    private let lockQueue = DispatchQueue(label: "lock", attributes: .concurrent)

    private init() {
        // 设置缓存上限,控制内存占用
        fontCache.countLimit = 50
    }

    func getFont(forName name: String, size: CGFloat = Config.defaultFontSize) async throws -> UIFont {
        // 先查字体缓存
        if let cachedFont = fontCache.object(forKey: name as NSString) {
            return cachedFont
        }

        // 查字体数据缓存
        if let cachedData = fontDataCache.object(forKey: name as NSString) {
            let font = try createFont(from: cachedData, size: size)
            fontCache.setObject(font, forKey: name as NSString)
            return font
        }

        // 下载并创建字体
        guard let fontFile = fontItems.first(where: { $0.family == name })?.files.first(where: { $0.variant == .regular }),
              let url = URL(string: fontFile.urlString)
        else {
            throw FontManager2Error.fontNotFound
        }

        let data = try await NetworkManager.downloadData(URLRequest(url: url))
        fontDataCache.setObject(data, forKey: name as NSString)
        let font = try createFont(from: data, size: size)
        fontCache.setObject(font, forKey: name as NSString)
        return font
    }

    private func createFont(from data: Data, size: CGFloat) throws -> UIFont {
        guard let provider = CGDataProvider(data: data as CFData),
              let cgFont = CGFont(provider)
        else {
            throw FontManager2Error.registerProviderError
        }

        var error: Unmanaged<CFError>?
        guard let ctFont = CTFontCreateWithGraphicsFont(cgFont, size, nil, &error) else {
            throw error!.takeRetainedValue() as Error
        }

        return UIFont(ctFont: ctFont)
    }
}

这种方式下,NSCache会自动回收不常用的字体对象,内存占用仅保留近期使用的字体,符合CollectionView复用场景的需求。

方案3:页面退出时批量清理

如果必须全局注册字体,可在用户退出字体选择页面时批量卸载所有自定义字体:

func unregisterAllCustomFonts() {
    lockQueue.sync(flags: .barrier) {
        for postScriptName in storage.values {
            if let cgFont = CGFont(postScriptName as CFString) {
                CTFontManagerUnregisterGraphicsFont(cgFont, nil)
            }
        }
        storage.removeAll()
    }
}

在页面的viewWillDisappear或deinit方法中调用该方法,清理全局注册的字体。


内容的提问来源于stack exchange,提问作者Kex

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.18 13:17:57