winform label字体抖动_iOS性能优化之计算多行Label高度的新方法

83e954d869b4e008efafeae5ccfa29ab.png

来源 | 搜狐技术产品 公众号

作者 | Not Found

为什么要写这篇文章

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

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

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

优化历程

单次异步调用boundingRect方法

一开始是在这篇文章(https://www.jianshu.com/p/2efcf7ad2608?appinstall=0)里面看到有说到异步调用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进行刷新

流程图如下:

038351626082f0ab8470ba3ff20ceb57.png

但是实际测试后发现这样并不可行,对模型数组modelList遍历完成后,tableView进行刷新时,tableView在调用代理方法

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath;

获取高度时,可能异步计算操作并未完成,这个时候返回的cell高度并不是正确的高度,这样在tableView下一次reloadData时,此时异步计算操作已经完成,heightForRowAtIndexPath返回的高度会与此时显示的cell的高度不一致,从而导致tableView的contentSize和contentOffset发生变化,从而发生抖动。

异步处理数据,回主线程刷新列表

这种方法的思路就是:

  1. 网络请求获取数据,对JSON数据进行序列化得到模型数组modelList

  2. 切换到子线程对模型数组modelList进行遍历

  3. 在遍历过程中,调用boundingRect方法,计算每个model对应的cell的label高度,label高度计算完成后得到cell的高度,完成对model的height属性的赋值操作

  4. 遍历完成后切换到主线程对tableView进行刷新

流程图如下:

4332bcda938e3e7d303e232f73c4f467.png

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

计算Label高度的新方法

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

da8d82d638faf6f692d027a4a5199646.png

我们可以发现,其实当字体固定时,所有中文字符的所占的宽度是固定的值,每个数字,大写字母,小写字母,符号所占的宽度也是固定的,而且相对于中文而言,数字,大写字母,小写字母,符号的数量是可控的,所以我就在想,能不能在第一次使用某个字体计算Label高度时,预先把这种字体对应中文字符,数字,大写字母,小写字母,常见的符号的宽度进行计算并缓存在一个widthDictionary中,然后之后对Label进行高度计算时,对Label应该显示的string进行遍历,得到每个字符占位的宽度,然后得到string对应的总宽度,根据label的最大宽度,计算得到Label的高度。

流程图如下:

1781b05902f5d4f710bcc5a6ccf761b7.png

代码如下:

import Foundationimport UIKitclass 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))

为了证明这种计算方法确实优于系统的boundingRect方法,

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

在Demo中进行测试

我写了一个demo(https://github.com/577528249/StringCalculate),来对方法耗时进行测试,代码如下:

import UIKitclass ViewController: UIViewController {    override func viewDidLoad() {        super.viewDidLoad()        oldWay(value: 1)        newWay(value: 1)        oldWay(value: 10)        newWay(value: 10)        oldWay(value: 100)        newWay(value: 100)        oldWay(value: 1000)        newWay(value: 1000)        oldWay(value: 10000)        newWay(value: 10000)        oldWay(value: 100000)        newWay(value: 100000)        oldWay(value: 1000000)        newWay(value: 1000000)        print("测试完成")    }    func oldWay(value: Int) {        let attribute = [NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 16)]        let constraintRect = CGSize.init(width: 100, height: 60)        var title = ""        var rect = CGRect.zero        print("oldWay开始计算了")        let startTime = CFAbsoluteTimeGetCurrent();        for i in 0...value {            title = "iOS性能优化之计算多行Label高度的新方法\(i)"            rect = title.boundingRect(with: constraintRect,                                    options: .usesLineFragmentOrigin,                                    attributes: attribute,                                    context: nil)        }        let duration = (CFAbsoluteTimeGetCurrent() - startTime) * 1000.0;        print("使用oldWay计算\(value)次,总耗时\(duration) ms")    }    func newWay(value: Int) {        let constraintRect = CGSize.init(width: 100, height: 60)        var title = ""        var rect = CGRect.zero        print("newWay开始计算了")        let startTime = CFAbsoluteTimeGetCurrent();        for i in 0...value {            title = "iOS性能优化之计算多行Label高度的新方法\(i)"            rect = title.boundingRectFast(withMaxSize: constraintRect, font: UIFont.boldSystemFont(ofSize: 16))        }        let duration = (CFAbsoluteTimeGetCurrent() - startTime) * 1000.0;        print("使用newWay计算\(value)次,总耗时\(duration) ms")    }}

测试环境:Xcode 10.1,iPhone 7,iOS 12.1

测试输出:

5ad875edef1651a615e37280e01b7414.png

将测试结果汇总后如下:

9a7f9d7e70ea80de2a1eb9552fae333d.png

我们可以看到只执行一次系统方法boundingRect时和我们的新方法boundingRectFast时,时间反而比执行十次的要多,这是因为可能第一次执行系统方法boundingRect时涉及到一些初始化操作,而后面执行十次时不包含这些初始化操作的时间。我们的新方法boundingRectFast,第一次执行比较慢是因为需要初始化StringCalculateManager单例对象,并且从磁盘中读取json文件,我尝试着在测试之前,调用StringCalculateManager单例对象,然后进行测试,发现执行一次我们的新方法boundingRectFast,耗时只有0.21398067474365234ms,这也证明了我们的猜想。

ba49ad08cae50bd1a5f8e58876926ec2.png

对于这种方法的性能比较,我们应该尽可能取调用次数最多,然后来比较,这样才能忽略其他因素,当我们分别执行一百万次系统方法boundingRect和boundingRectFast时,它们的执行总时间分别是128962.29195594788ms和29827.147006988525ms,相除得到,boundingRectFast执行时间是系统方法boundingRect的执行时间的23.12%,所以从性能角度上考虑,还是可以考虑在项目中采用这种方法来计算Label的高度的。

在实际项目中进行测试

因为demo中的运行环境相对比较简单,所以我在我们自己的项目中对着两个方法进行测试,首先为了方便在Instruments中查看运行效果,我写了三个方法

extension String {    //项目中需要计算的地方会统一调用这个方法    func boundingRectFast(withMaxSize size: CGSize, font: UIFont) -> CGRect {        let rect1 = boundingRectFastInOldWay(withMaxSize: size, font: font)        let rect2 = boundingRectFastInNewWay(withMaxSize: size, font: font)        return rect2    }    //使用系统方法计算    func boundingRectFastInOldWay(withMaxSize size: CGSize, font: UIFont) -> CGRect {        return self.boundingRect(with: size,                                options: .usesLineFragmentOrigin,                                attributes: [NSAttributedStringKey.font: font],                                context: nil)    }    //使用新方法计算    func boundingRectFastInNewWay(withMaxSize size: CGSize, font: UIFont) -> CGRect {        let rect = StringCalculateManager.shared.calculateSize(withString: self, size: size, font: font)        return rect    }}

测试环境:Xcode 10.1,iPhone 7,iOS 12.1

测试步骤:把App从手机上删除,然后点击Xcode上的Product->Profile,在App完成安装后选择Timer Profiler对app运行状态进行监测,点击开始,在App启动后,快速滑动feed流页面,然后我截取了从app启动后的50s,测试结果如下:

200704bbd07ed27a41feb1698d025293.png

发现使用boundingRectFastInOldWay方法总共耗时353ms,在主线程中占时2.1%,使用boundingRectFastInNewWay方法总共耗时46ms,在主线程中占时0.2%,所以从性能来说,boundingRectFastInNewWay方法远优于boundingRectFastInOldWay方法,所以在我们自己的项目中,已经采用了这种方法去计算Label的高度。

最后

我觉得“根据字体来计算字符串总宽度,从而得到多行Label高度”这种方法是比较新颖,我们项目中暂时遇到的业务场景是用来计算Label高度,我觉得应该有更多的业务场景可以使用,希望能给大家带来一些启发。

demo地址:

https://github.com/577528249/StringCalculate

1caf0cd181dd0e89ce8339fa80a6f604.png

推荐阅读

Tab Bar 的图标原来还可以这样玩

探索 messageHandler 的传输性能极限

在 iOS 中使用 GLSL 实现抖音特效

谈谈 iOS 网络层设计

iOS App秒开H5优化总结

在看就点点吧 400499ceac6047395673aea590c4afb3.png

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值