从 VVeboTableViewDemo 到 YYAsyncLayer(一)

1,073 阅读8分钟
原文链接: www.jianshu.com

前言

知道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版本

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

VVeboTableViewDemo.swift