这篇文章是搜狐技术团队整理的高效计算字符串高度新方案,我自己翻译了swift-oc,文章并非原创,出处

为什么要写这篇文章

一、最近在对我们自己的项目中的feed流进行优化,在使用Instruments的Time Profiler对项目进行分析时,发现这个方法比较耗时,所以想要对这个方法的调用进行优化,所以对多行Label高度计算进行了一些研究。

//========== swift ========

@available(iOS 7.0, *)

    open func boundingRect(with size: CGSize, options: NSStringDrawingOptions = [], attributes: [NSAttributedString.Key : Any]? = nil, context: NSStringDrawingContext?) -> CGRect

//========= objective-c =======

- (CGRect)boundingRectWithSize:(CGSize)size options:(NSStringDrawingOptions)options attributes:(nullable NSDictionary<NSAttributedStringKey, id> *)attributes context:(nullable NSStringDrawingContext *)context NS_AVAILABLE(10_11, 7_0);

二、本文中“根据字体来计算字符串总宽度,从而得到多行Label高度”的方法是我自己想的,也比较新颖,网上也没有找到资料,有些业务场景我所在的项目组可能没有接触到,但是其他项目组的技术同学在日常开发中可能会碰到,如果这篇文章能够给他们造成一些启发,也能够让这篇文章发挥价值。

优化历史

单次异步调用boundingRect方法

一开始是在这篇文章里面看到有说到异步调用boundingRect方法,里面的代码我般过来了:

+(void)textBoundingRectWithString:(NSString *)string maxHeight:(CGFloat)maxHeight maxWidth:(CGFloat)maxWidth textFont:(UIFont *)textFont Block:(void (^)(CGSize obj))block

{

    /* 如果传入内容有误,直接返回结果到当前线程*/

    if (!textFont || [self isBlankString:string] == YES) {

        if (block) {

            block(CGSizeMake(0, 0));

        }

      return;

    }

    /* 异步执行计算操作*/

    dispatch_async(dispatch_get_global_queue(0, 0), ^{

        CGSize lastSize;

        if (maxHeight == 0) {

            CGSize size = [string boundingRectWithSize:CGSizeMake(maxWidth, CGFLOAT_MAX) options:NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName:textFont} context:nil].size;

            lastSize = CGSizeMake(ceilf(size.width), ceilf(size.height));

        }else

        {

            CGSize size = [string boundingRectWithSize:CGSizeMake(maxWidth, maxHeight) options:NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName:textFont} context:nil].size;

            lastSize = CGSizeMake(ceilf(size.width), ceilf(size.height));

        }

        /* 计算完成后再主线程中回调数据,因为一般拉倒值后会直接设置UI控件属性。 */

        dispatch_async(dispatch_get_main_queue(), ^{

            if (block) {

                block(lastSize);

            }

        });

    });

}

在项目中完整的流程是这样的

  1. 网络请求获取数据,对JSON数据进行序列化得到模型数组modelList
  2. 对模型数组modelList进行遍历
  3. 在遍历过程中,调用boundingRect方法,计算每个model对应cell的label高度,label高度计算完成后得到cell高度,切换到主线程完成对model的height的赋值操作
  4. 遍历完成后对tableView进行刷新

流程图如下:

这种方法与上面的方法相比,把整个遍历过程放在子线程中进行,遍历完成后再切回到主线程刷新tableview,防止tableview刷新时,model中的height还没有异步计算完,造成显示的高度不正确,也是我们相对较好的的做法,但是它依然无法解决boundingRect方法计算比较慢的问题,尤其是需要处理的数据比较多的时候,这样的做法只是避免了掉帧(因为比较耗时的数据处理不在主线程中),但是由于需要在线程对高度计算完之后才进行tableview刷新,会导致用户等待刷新的时间过长。

计算Label高度的新方法

所以我们就观察Label的构成,以下是一个在iOS平台上以font为system font,size为16,style为regular显示的Label。

我们可以发现,其实当字体固定时,所有的中文字符所占用的宽度值是固定的,每个数字,大写字母,小写字母,常见的符号宽度进行计算并缓存在一个widthDictionary中,然后对Label进行高度计算时,对Label应该显示的string进行遍历,得到每个字符占位的宽度,然后得到string对应的总宽度,根据label的最大宽度,计算得到label的高度。

流程图如下:

代码如下:

import Foundation

import UIKit



class StringCalculateManager {

    static let shared = StringCalculateManager()

    //fontDictionary是一个Dictionary,例如{".SFUIText-Semibold-16.0": {"0":10.3203125, "Z":10.4140625, "中":16.32, "singleLineHeight":19.09375}},

    //fontDictionary的key是以字体的名字和大小拼接的String,例如".SFUIText-Semibold-16.0"

    //fontDictionary的value是一个Dictionary,存储对应字体的各种字符对应的宽度及字体的单行高度,例如{"0":10.3203125, "Z":10.4140625, "中":16.32, "singleLineHeight":19.09375}

    var fontDictionary = [String: [String: CGFloat]]()

    var numsNeedToSave = 0//更新的数据的条数

    var fileUrl: URL = {//fontDictionary在磁盘中的存储路径

        let manager = FileManager.default

        var filePath = manager.urls(for: .documentDirectory, in: .userDomainMask).first

        filePath!.appendPathComponent("font_dictionary.json")

        print("font_dictionary.json的路径是===\(filePath!)")

        return filePath!

    }()



    init() {

        readFontDictionaryFromDisk()

        NotificationCenter.default.addObserver(self, selector: #selector(saveFontDictionaryToDisk), name: UIApplication.didEnterBackgroundNotification, object: nil)

        NotificationCenter.default.addObserver(self, selector: #selector(saveFontDictionaryToDisk), name: UIApplication.willTerminateNotification, object: nil)

    }

    deinit {

        NotificationCenter.default.removeObserver(self)

    }

    //第一次使用字体时预先计算该字体中各种字符的宽度

    func createNewFont(font: UIFont) -> [String: CGFloat] {

        let array: [String] = ["中", "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P",  "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "a", "b", "c", "d", "e",  "f",  "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "“", ";", "?", ",", "[", "]", "、", "【", "】", "?", "!", ":", "|"]

        var widthDictionary = [String: CGFloat]()

        var singleWordRect = CGRect.zero

        for string in array {

            singleWordRect = string.boundingRect(with: CGSize(width: 100, height: 100),

                                                 options: .usesLineFragmentOrigin,

                                                 attributes: [NSAttributedString.Key.font: font],

                                                 context: nil)

            widthDictionary[string] = singleWordRect.size.width

        }

        widthDictionary["singleLineHeight"] = singleWordRect.size.height

        let fontKey = "\(font.fontName)-\(font.pointSize)"

        fontDictionary[fontKey] = widthDictionary

        numsNeedToSave = array.count//代表有更新,需要存入到磁盘

        saveFontDictionaryToDisk()//存入本地json

        return widthDictionary

    }

    //计算Label的bounds

    func calculateSize(withString string: String, size: CGSize, font: UIFont) -> CGRect {

        var widthDictionary = [String: CGFloat]()

        let fontKey = "\(font.fontName)-\(font.pointSize)"

        if let dictionary =  StringCalculateManager.shared.fontDictionary[fontKey] {

            widthDictionary = dictionary

        } else {

            widthDictionary = StringCalculateManager.shared.createNewFont(font: font)

        }

        var totalWidth: CGFloat = 0

        let chineseWidth = widthDictionary["中"]!

        for character in string {

            if "\u{4E00}" <= character  && character <= "\u{9FA5}" {//中文

                totalWidth += chineseWidth

            } else if let width = widthDictionary[String(character)]  {//数字,小写字母,大写字母,及常见符号

                totalWidth += width

            } else {//符号及其他没有预先计算好的字符,对它们进行计算并且缓存到宽度字典中去

                let tempString = String(character)

                let width = tempString.boundingRect(with: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude),

                                                    options: .usesLineFragmentOrigin,

                                                    attributes: [NSAttributedString.Key.font: font],

                                                    context: nil).size.width

                totalWidth += width

                widthDictionary[tempString] = width

                numsNeedToSave += 1

            }

        }

        fontDictionary[fontKey] = widthDictionary

        if numsNeedToSave > 10 {

            saveFontDictionaryToDisk()

        }

        let singleLineHeight = widthDictionary["singleLineHeight"]!

        let numsOfLine = ceil(totalWidth/size.width)//行数

        let resultwidth = numsOfLine <= 1 ? totalWidth : size.width//小于最大宽度时,取实际宽度的值

        let resultHeight = (singleLineHeight*numsOfLine) > size.height ? size.height : (singleLineHeight*numsOfLine)//计算结果超出最大高度时,取最大高度的值

        return CGRect.init(x: 0, y: 0, width: resultwidth, height: resultHeight)

    }



    let queue = DispatchQueue(label: "com.StringCalculateManager.queue")

    //存储fontDictionary到磁盘

    @objc func saveFontDictionaryToDisk() {

        guard numsNeedToSave > 0 else {

            return

        }

        numsNeedToSave = 0

        queue.async {//防止多线程同时写入造成冲突

            do {

                var data: Data?

                if #available(iOS 11.0, *) {

                    data = try? JSONSerialization.data(withJSONObject: self.fontDictionary, options: .sortedKeys)

                } else {

                    data = try? JSONSerialization.data(withJSONObject: self.fontDictionary, options: .prettyPrinted)

                }

                try data?.write(to: self.fileUrl)

                print("font_dictionary存入磁盘,font_dictionary=\(self.fontDictionary)")

            }  catch {

                print("font_dictionary存储失败error=\(error)")

            }

        }

    }

    //从磁盘中读取缓存

    func readFontDictionaryFromDisk() {

        do {

            let data = try Data.init(contentsOf: fileUrl)

            let json = try JSONSerialization.jsonObject(with: data, options: .allowFragments)

            guard let dict = json as? [String: [String: CGFloat]] else {

                return

            }

            fontDictionary = dict

            print(fontDictionary)

            print("font_dictionarys读取成功,font_dictionarys=\(fontDictionary)")

        } catch {

            print("font_dictionary读取失败")

        }

    }

}



extension String {

    func boundingRectFast(withMaxSize size: CGSize, font: UIFont) -> CGRect {

        let rect = StringCalculateManager.shared.calculateSize(withString: self, size: size, font: font)

        return rect

    }

}

外部调用的示例:

let title = "iOS性能优化之计算多行Label高度的新方法"

let rect = title.boundingRectFast(withMaxSize: constraintRect, font: UIFont.boldSystemFont(ofSize: 16))

这里献上Demo,具体的实现细节都在demo里面,欢迎大家使用。

最后

这篇文章是搜狐技术产品团队发出的,我在这里进行了一个整理,原链接
根据文章最后介绍,新办法可以将效率提升到新高度,特别是针对大量文本内容的时候。还有,这个轮子是采用swift语言编写的,我也翻译了一遍OC的在自己的github上,欢迎大家审阅,OC-Demo


你远道而来这人世间,想必也是因为热爱吧 ||