从VVeboTableViewDemo到YYAsyncLayer(一)

前言

知道VVeboTableViewDemo其实很久了,一直想研究一下,最近终于有时间了,将VVeboTableViewDemo用Swift做了一遍(VVeboTableViewDemo.swift),花了两个周对iOS优化的一系列文章通读了至少一遍,发现它们对优化的点总结的很散,而且大多不适合我这样的小菜。
列如这样的问题:

  • 为什么需要60fps?
  • 为什么要减少混合?
  • 为什么要避免离屏渲染?
  • UIView和CALayer的关系?
  • 为什么在4之后Twitter的绘制方案不能提升性能了?
    ......
    在读完一篇关于iOS的优化文章后并不知道这些问题的根本,只知道要这样做。因此,我想把这些问题总结一下,对有用信息进行过滤,以减少大家的学习时间成本,让更多像我这样的iOS小菜也知道如何优化。在文中,我也会推荐相应的技术博客,让你花最少的成本,掌握某项技术。当然,由于知识结构有限,我get到的点可能有误,希望有误的地方你可以指出,我会及时修正。
    好了,让我们开始吧。

VVeboTableViewDemo源码分析

本节关键字

  • Core Graphics
  • Core Text
  • 异步绘制

首先看一下VVeboTableViewDemo的结构(由于我已经把它翻译成了Swift,我下面是用Swift版分析的,和原版的逻辑是一致的。)

OC
Swift

其中DataPrenstenter是我从VVeboTableView中抽离出来的,他其实就是读取数据的,你不用关心。

核心类

  • VVeboLabel(这里面主要使用了本节关键字提到的三种技术)
  • VVeboTableViewCell(这里面主要使用异步绘制技术)
  • VVeboTableView(这里的主要作用是控制了数据绘制的时机,当用户快速滑动时,数据是不会绘制的)

VVeboLabel

VVeboLabel

以上这张图是VVeboLabel中所有的内容,高亮的那个方法是VVeboLabel的核心所在。

  • highlightImageView
    用于显示绘制text的图片

  • highlightImageView
    用于显示绘制text高亮时的图片,会叠在labelImageView上面

  • ** func textDidSet(_ : , oldText: ) // 核心方法**

// 核心方法
    func textDidSet(_ text: String?, oldText: String?) {
        // 当 text为nil或者是empty,加labelImageView和highlightImageView设置为nil,结束
        guard let text = text, !text.isEmpty else {
            labelImageView.image = nil
            highlightImageView.image = nil
            return
        }

        if text == oldText {
            if !highlighting || currentRange.location == -1 {
                return
            }
        }

        if highlighting && labelImageView.image == nil {
            return
        }

        if !highlighting {
            framesDict.removeAll()
            currentRange = NSRange(location: -1, length: -1)
        }

        let flag = drawFlag
        let isHighlight = highlighting

        // 将文本绘制放入全局队列,以减轻主线程压力
        DispatchQueue.global().async {
            let temp = text

            var size = self.frame.size
            size.height += 10

            // 如果有颜色绘制将会绘制颜色
            let isNotClear = self.backgroundColor != .clear

            /// 第一个参数表示所要创建的图片的尺寸;
            /// 第二个参数用来指定所生成图片的背景是否为不透明,如上我们使用true而不是false,则我们得到的图片背景将会是黑色,显然这不是我想要的;
            /// 第三个参数指定生成图片的缩放因子,这个缩放因子与UIImage的scale属性所指的含义是一致的。传入0则表示让图片的缩放因子根据屏幕的分辨率而变化,所以我们得到的图片不管是在单分辨率还是视网膜屏上看起来都会很好。

            /// 注意这个与UIGraphicsEndImageContext()成对出现
            /// iOS10 中新增了UIGraphicsImageRenderer(bounds: _)
            UIGraphicsBeginImageContextWithOptions(size, isNotClear, 0)

            /// 获取绘制画布
            /// 每一个UIView都有一个layer,每一个layer都有个content,这个content指向的是一块缓存,叫做backing store。
            /// UIView的绘制和渲染是两个过程,当UIView被绘制时,CPU执行drawRect,通过context将数据写入backing store
            /// http://vizlabxt.github.io/blog/2012/10/22/UIView-Rendering/
            guard let context = UIGraphicsGetCurrentContext() else { return }

            if isNotClear {
                /// 这句相当于这两句
                /// self.backgroundColor?.setFill() 设置填充颜色
                /// self.backgroundColor?.setStroke() 设置边框颜色
                self.backgroundColor?.set()
            
                /// 绘制一个实心矩形
                /// stroke(_ rect: CGRect) 用这个方法得到的是边框为你设置颜色的空心矩形
                context.fill(CGRect(origin: .zero, size: size))
            }

            /// 坐标反转,固定写法,因为Core Text中坐标起点是左下角
            context.textMatrix = .identity
            context.translateBy(x: 0, y: size.height) //向上平移
            context.scaleBy(x: 1.0, y: -1.0) //在y轴缩放-1相当于沿着x张旋转180
            
            
            
            //MARK: - 这里属于 Core Text技术

            //Set line height, font, color and break mode
            var minimumLineHeight = self.font.pointSize
            var maximumLineHeight = minimumLineHeight
            var linespace = self.lineSpace

            let font = CTFontCreateWithName(self.font.fontName as CFString?, self.font.pointSize, nil)

            var lineBreakMode = CTLineBreakMode.byWordWrapping
            var alignment = CTTextAlignmentFromUITextAlignment(self.textAlignment)
            //Apply paragraph settings

            let alignmentSetting = [
                CTParagraphStyleSetting(spec: .alignment, valueSize: MemoryLayout.size(ofValue: alignment), value: &alignment),
                CTParagraphStyleSetting(spec: .minimumLineHeight, valueSize: MemoryLayout.size(ofValue: minimumLineHeight), value: &minimumLineHeight),
                CTParagraphStyleSetting(spec: .maximumLineHeight, valueSize: MemoryLayout.size(ofValue: maximumLineHeight), value: &maximumLineHeight),
                CTParagraphStyleSetting(spec: .maximumLineSpacing, valueSize: MemoryLayout.size(ofValue: linespace), value: &linespace),
                CTParagraphStyleSetting(spec: .minimumLineSpacing, valueSize: MemoryLayout.size(ofValue: linespace), value: &linespace),
                CTParagraphStyleSetting(spec: .lineBreakMode, valueSize: MemoryLayout.size(ofValue: 1), value: &lineBreakMode)
            ]

            let style = CTParagraphStyleCreate(alignmentSetting, alignmentSetting.count)

            let attributes: [String: Any] = [
                NSFontAttributeName: font,
                NSForegroundColorAttributeName: self.textColor.cgColor,
                NSParagraphStyleAttributeName: style
            ]

            //Create attributed string, with applied syntax highlighting
            let attributedStr = NSMutableAttributedString(string: text, attributes: attributes)

            // 通过正则匹配出需要高亮的子串,设置对应的属性
            let attributedString: CFAttributedString = self.highlightText(attributedStr)

            //Draw the frame
            // 生成framesetter
            // 通过CFAttributedString(NSAttributeString 也可以无缝桥接)进行初始化
            let framesetter = CTFramesetterCreateWithAttributedString(attributedString)

            let rect = CGRect(x: 0, y: 5, width: size.width, height: size.height - 5)

            // 这里应该不需要,因为在Swift中text为let
//            guard temp == text else { return }

            // 确保行高一致,计算所需触摸区域
            // 这里采用的是逐行绘制,因为emoji需要特殊处理(文本高度和间隔不一致)
            self.draw(framesetter: framesetter, attributedString: attributedStr, textRange: CFRangeMake(0, text.length), in: rect, context: context)

            // ???: 上面已经反转
//            context.textMatrix = .identity
//            context.translateBy(x: 0, y: size.height) //向上平移
//            context.scaleBy(x: 1.0, y: -1.0)

            // 新绘制的图
            let screenShotimage = UIGraphicsGetImageFromCurrentImageContext()
            let shotImageSize = screenShotimage?.size ?? .zero
            // 结束绘制
            UIGraphicsEndImageContext()
    
            /// 回到主线程设置绘制文本的图片
            DispatchQueue.main.async {
                attributedStr.mutableString.setString("")

                guard self.drawFlag == flag else { return }

                if isHighlight { //点击高亮进入
                    guard self.highlighting else { return }

                    self.highlightImageView.image = nil

                    if self.highlightImageView.frame.width != shotImageSize.width {
                        self.highlightImageView.frame.size.width = shotImageSize.width
                    }
                    if self.highlightImageView.frame.height != shotImageSize.height {
                        self.highlightImageView.frame.size.height = shotImageSize.height
                    }
                    self.highlightImageView.image = screenShotimage
                } else { //默认状态
                    guard temp == text else { return }
                    if self.labelImageView.frame.width != shotImageSize.width {
                        self.labelImageView.frame.size.width = shotImageSize.width
                    }
                    if self.labelImageView.frame.height != shotImageSize.height {
                        self.labelImageView.frame.size.height = shotImageSize.height
                    }
                    self.highlightImageView.image = nil
                    self.labelImageView.image = nil
                    self.labelImageView.image = screenShotimage
                }
//                self.debugDraw() // 绘制可触摸区域,主要用于调试
            }
        }
    }
  • **func draw(framesetter: CTFramesetter, attributedString: NSAttributedString, textRange: CFRange, in rect: CGRect, context: CGContext) **

这里属于Core Text技术,主要是对文本的特殊处理,采用了逐行绘制

其余方法主要是对文本高亮和清除内容处理,不是重点,可以不关心。

VVeboTableViewCell

VVeboTableViewCell

VVeboTableViewCell中,高亮的方法为核心部分。其实同VVeboLabel的思想是一模一样的,就是将内容异步绘制在一张图上,然后显示出来,到达减少混合,以减小GPU压力。就不贴出源码,下面会放出Demo。

VVeboTableView

这是一个设计很巧妙的类,在开始研究这个类的思路之前,我建议你看看这篇文章。当然如果你对UIScrollView足够熟悉,并且熟悉这个方法func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>),那么对VVeboTableView的思路可以一目了然了。

func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>)

该方法从 iOS 5 引入,在 didEndDragging 前被调用,当 willEndDragging 方法中velocityCGPoin.zero(结束拖动时两个方向都没有速度)时,didEndDragging 中的 decelerate 为 false,即没有减速过程,willBeginDeceleratingdidEndDecelerating 也就不会被调用。反之,当 velocity 不为 CGPoin.zero 时,scroll view 会以 velocity 为初速度,减速直到 targetContentOffset。值得注意的是,这里的 targetContentOffset 是个指针,没错,你可以改变减速运动的目的地,这在一些效果的实现时十分有用。

以上文字来源

微信读书的那种横滑居中效果,除了重写UICollectionViewFlowLayout
也通过控制targetContentOffset就可以实现

VVeboTableView

图中高亮方法为核心部分

//按需加载 - 如果目标行与当前行相差超过指定行数,只在目标滚动范围的前后指定3行加载。
    func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
        guard let cip = indexPathsForVisibleRows?.first,
        let ip = indexPathForRow(at: CGPoint(x: 0, y: targetContentOffset.move().y))
            else { return }
        let skipCount = 8

        // 快速滑动时,显示的第一个与停止位置的那个Cell间隔超过8
        guard labs(cip.row - ip.row) > skipCount else { return }

        let temp = indexPathsForRows(in: CGRect(x: 0, y: targetContentOffset.move().y, width: frame.width, height: frame.height))
        var arr = [temp]
        if velocity.y < 0 { // 下滑动
            if let indexPath = temp?.last, indexPath.row + 3 < datas.count {
                (1...3).forEach() {
                    arr.append([IndexPath(row: indexPath.row + $0, section: 0)])
                }
            }
        } else { // 上滑动
            if let indexPath = temp?.first, indexPath.row > 3 {
                (1...3).reversed().forEach() {
                    arr.append([IndexPath(row: indexPath.row - $0, section: 0)])
                }
            }
        }
        for item in arr {
            guard let item = item else { continue }
            for indexPath in item {
                needLoadArr.append(indexPath)
            }
        }
    }

cell绘制判断逻辑

func draw(cell: VVeboTableViewCell, with indexPath: IndexPath) {
        let data = datas[indexPath.row]
        cell.selectionStyle = .none
        cell.clear()
        cell.data = data
        // needLoadArr不为空,说明用户有快速滑动。当needLoadArr不为空时,不在其中的cell也是需要绘制的
        // 因为在scrollViewWillEndDragging(_: UIScrollView, withVelocity: CGPoint,: UnsafeMutablePointer<CGPoint>)调用之后,tableView(_: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell是会继续执行的。
        // 如果单纯判断needLoadArr不为空,会导致之后的不能绘制
        if !needLoadArr.isEmpty && !needLoadArr.contains(indexPath) {
            cell.clear()
            return
        }
        // 向上滚动过程不绘制
        if scrollToToping {
            return
        }
        cell.draw()
    }
尾巴

以上VVeboTableViewDemo源码已经全部解析完成了,那么你在惊叹作者巧妙思路的同时,肯定也很想知道这种技术的来源,和改进过程。(以下为个人猜想)

通过本文,我觉得应该了解Core TextCore GraphicsHit-Test View异步绘制这几项内容,你可以通过以下推荐的文章来掌握前三种技术,异步绘制在下一节YYAsyncLayer源码分析中,我相信你不知不觉就掌握了这项技术。

异步绘制技术发展过程猜想

最初来源
这种技术的出现是为了减轻GPU的压力,因为图层的混合是GPU做的,而在这是CPU几乎是没事可做的,所以吧GPU的混合移到CPU的func draw(_ rect: CGRect)去完成需求。
此技术的demo fastscrolling

技术淘汰原因
由于retina屏幕的出现,原来单位面积的像素增加,而CPU做的事情也变得多了起来,导致效率反而不及subViews方法。

AsyncDisplayKit YYKit等新技术出现

我觉得VVeboTableViewDemo的出现应该也是遵循以上过程的

推荐文章:

Core Text:
Swift之CoreText排版神器
官方文档

Core Graphics:
iOS绘图教程
Swift之你应该懂点Core Graphics
官方Demo
官方Demo Swift版本
Building Concurrent User Interfaces on iOS

响应链
iOS事件响应链中Hit-Test View的应用
iOS 事件处理 | Hit-Testing

异步绘制
http://www.appcoda.com/ios-concurrency/

VVeboTableViewDemo.swift

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,732评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,496评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,264评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,807评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,806评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,675评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,029评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,683评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,704评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,666评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,773评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,413评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,016评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,204评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,083评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,503评论 2 343

推荐阅读更多精彩内容

  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,019评论 4 62
  • 第293天~ 又开始纠结啦?!你的生日该送你什么?!送还是不送?!是不是做多咯都是多余也是打扰?! 每天反复的问自...
    法斗SEVEN阅读 87评论 0 0
  • 最近同学遇到了个问题,在xib中tableview设置完位置后第一次显示正常,但是重现时,比如push个页面又po...
    soundtravel阅读 413评论 0 1
  • 宝万酣战泱王石, 资本为王剑双刃。 独立自主属正途, 金实苟合鸠鹊巢。
    幽州二少阅读 176评论 0 1
  • .. 人,要么像辣椒一样有脾气,要么像白菜一样有层次,要么像莲藕一样有心眼,可我做不到,我就像一根擀面杖,直,不会...
    守护我的爱阅读 231评论 0 0