探索实现一个轻量可控的HTML iOS解析渲染器

1,675 阅读5分钟

本文涉及到的实例代码在这里: SimpleHTMLParser

背景

随着互联网的发展HTML数据的展示早已经超出了浏览器,可以在各移动终端平台进行展示与渲染。HTML可以看成是这些终端平台的脚本语言。常见的移动终端平台都内置了原生的引擎可以实现HTML的解析,这里也引发了一个问题,以iOS平台为例,将HTML数据解析成富文本对象的过程是比较耗时的,而官方的API说明了这个过程只能在主线程中进行。为什么这么耗时呢?就算加载一段如下HTML文本:

<p>段落展示</p>

解析器会加载JS以及CSS引擎为HTML加载做准备,实际上在我们的项目中需要展示的HTML是有限的,基本上也很少使用JS与CSS去控制,大多数情况下都是换行、段落、文本颜色字体等轻量的操作。

思考

高效快速的实现业务的落地,切合业务实际应用场景去实现效果。使用了很长一段时间的系统API来实现HTML的解析,随着用户量与消息量与各种不可控的数据增加,带来的某些场景下的卡顿已经不可忽视了。在这样的背景下需要对HTML的解析做出优化,但是系统API可配置可优化的空间实在有限,思考能否自己实现一个轻量的HTML解析器呢?

实现方案

要实现HTML的展示分为两个阶段,第一步是解析,第二步是渲染。 解析可以通过XML或者扫描的方式可以实现HTML的解析,由于HTML比起XML严格的定义更随意,所以接下来谈谈通过扫描的方式来实现HTML的解析。 使用以下的样本说明解析HTML的实现原理:

<p style='color#00fffffont-size30 '>正常文字。</p><em>13123<h1 style='color: #00ff00;'>sdfasfa</h1></em><br/>

第一步:

创建一个解析上下文,用于记录解析所必要的数据,这里必要的数据主要有两个,当前解析的位置(游标)以及解析时的栈(用于处理递归解析)

class SimpleHTMLParserContext {
    /// 是否支持自闭合标签<br/><img>等,默认支持
    var isSupportSelfClosingTag = true
    var rawHTML = ""
    var cur = 0
    var parseStack: [SimpleHTMLElement] = .init()
    
}

第二步:

创建一个根节点,然后通过递归的方式解析子节点

let rootElement = SimpleHTMLElement.init()
rootElement.type = .root
rootElement.rawHTML = html
rootElement.defaultTextColor = defaultFontColor
rootElement.defaultFontSize = defaultFontSize

将根节点压入栈,即之后解析的为其子节点

 self.context.pushElement(rootElement)

接着,开始解析根节点的子节点,将需要被解析HTML原始数据

rootElement.children = self.parseChildren()

第二步:

说干就干!接下来谈谈parseChildren

private func parseChildren() -> [SimpleHTMLElement] {
  var children = [SimpleHTMLElement]()
        while !self.context.isEnd {
            guard let source = self.context.readCurrentSource(), source.count > 0 else { break }
            var element: SimpleHTMLElement= nil
            /// 标签的开头
            if source.hasPrefix("<") {
                if source.hasPrefix("</") {
                    /// 结束标签
                    let startCur = self.context.cur
                    element = self.parseElement(.tail)
                    let endCur = self.context.cur
                    if let element = element {
                        let subString = self.context.rawHTML.subString(start: startCur, length: endCur - startCur)
                        element.rawHTML = String(subString)
                    }
                    /// 解析完当前标签,将当前标签从栈顶弹出
                    self.context.popElement()
                    break
                } else {
                    /// 开始标签
                    let startCur = self.context.cur
                    element = self.parseElement(.head)
                    if let element = element {
                        if !element.isSelfClosingTag && element.type.canLayEggs() {
                            self.context.pushElement(element)
                            element.children = self.parseChildren()
                        }
                    }
                    let endCur = self.context.cur
                    let subString = self.context.rawHTML.subString(start: startCur, length: endCur - startCur)
                    element?.rawHTML = String(subString)
                }
            } else {
                /// 不是标签的开头,则解析成纯文本数据
                element = self.parseTextElement()
            }
            if let element = element {
                children.append(element)
            }
        }
        return children
    }

1.判断起始位置非空格的第一个字符,如果是<或者是</则表示解析到标签的开始或者结束,如果不是则将接下来的一段字符串作为一个纯文本的数据进行解析,并生成一个TextElement节点,解析纯文本节点的方法如下:

  private func parseTextElement() -> SimpleHTMLElement? {
        guard let parent = self.context.topElement(), let source = self.context.readCurrentSource() else { return nil }
        /// 匹配所有的字符,除了'<', '>'
        guard let regularExp = compileRegularExpression("^[\s\S]([^<>])*"),
              let result = regularExp.firstMatch(in: source, options: .init(rawValue: 0), range: .init(location: 0, length: source.count)) else { fatalError() }
        var text = source.subString(range: result.range)
        self.context.advance(by: result.range)
        /// 路过异常的标签数据
        while let source = self.context.readCurrentSource(),
              source.hasPrefix("<<"|| source.hasPrefix(">>"|| source.hasPrefix("<>"|| source.hasPrefix("><") {
            text.append(source.subString(start: 0, length: 1))
            self.context.advancd(by: 1)
        }
        return .buildTextElement(text, parent: parent)
    }

2.如果判断开头是<则表示开始解析一下新的节点,此时进入常规节点解析方法,如下:

private func parseElement(_ locationSimpleHTMLElementLocation) -> SimpleHTMLElement? {
        var element: SimpleHTMLElement= nil
        guard let parent = self.context.topElement(), let source = self.context.readCurrentSource() else { return nil }
        /// 匹配'<'(开始标签)或'</'(结束标签)开头的字符串,中间不能有空格,也不能出现'/','>','<','='
        guard let regularExp = compileRegularExpression("^<\/?([a-z][^\t\r\n /><=]*)"else { fatalError() }
        guard let result = regularExp.firstMatch(in: source, options: .init(rawValue: 0), range: .init(location: 0, length: source.count)) else { return nil }
        let tagContent = source.subString(range: result.range)
        self.context.advance(by: result.range)
        let tagName = readTagName(from: tagContent, location: location)
        if location == .head {
            /// 开始解析标签属性
            element = .init()
            element?.parent = parent
            let attributes = self.parseAttributes()
            self.context.trimLeftSpace()
            var isSelfClosing = false
            var validTag = false
            /// 属性解析完成,判断开始标签是否解析正常
            if let source = self.context.readCurrentSource() {
                if source.hasPrefix(">") {
                    isSelfClosing = false
                    validTag = true
                    self.context.advancd(by: 1)
                } else if source.hasPrefix("/>") {
                    isSelfClosing = true
                    validTag = true
                    self.context.advancd(by: 2)
                }
            }
            if validTag {
                element?.attributes = attributes
                element?.isSelfClosingTag = isSelfClosing
                element?.tagName = tagName
                element?.type = getSimpleHTMLElementTagType(by: tagName)
            }
        } else {
            /// 开始解析结束标签
            self.context.trimLeftSpace()
            if let source = self.context.readCurrentSource() {
                if tagName == parent.tagName && source.hasPrefix(">") {
                    /// 结束标签的名称与当前正在解析的标签名称一致,正常结束
                    parent.isSelfClosingTag = false
                    self.context.advancd(by: 1)
                } else {
                    /// 异常的标签,将数据作为纯文本展示
                    element = .buildTextElement(tagContent, parent: parent)
                }
            }
        }
        return element
    }

通过正则表达式匹配标签的开头,解析到标签的名称,同时继续解析标签的属性直到开始标签结束符>,解析的具体看一下代码就明白了很简单,解析属性也一样的简单。解析完开始标签需要判断是否为自闭会标签,如果不是自闭合标签则需要递归解析子标签,当解析到</时表示解析到结束标签,判断当前栈顶的标签(正在解析的标签)名称是否与结束一样,同时将栈顶标签元素弹出,此时一轮标签解析完成,判断是否解析完所有的HTML,如果没有则重复上面的逻辑直到HTML解析到最后一个字符。 整个逻辑总结下来就4步:
1.解析标签的开头与属性
2.解析子标签
3.解析结束标签
4.重复上面的操作\

渲染

解析完成了就是将解析后的AST翻译生成对应平台的富文本对象,在iOS平台里是NSAttributedString,富文本对象也是一棵树,将对应的AST翻译过来就可以了。HTML解析后的标签节点可以分为两类,渲染标签功能标签。比如:

<strong>123</strong>

在解析之后会生成两个节点strongtext节点,strong是功能节点,它本身不参与渲染,而是在解析的时候生成的,目的是对其子节点增加加粗的功能,text则需要进行渲染。HTML的属性是可以继承的,即父节点的样式会被子节点继承,有了以上的原则,生成富文本对象就很简单了,下面是文本节点的富文本对象创建逻辑:

let textRender: SimpleHTMLElementRender = {
    
    let range: NSRange = .init(location: 0, length: $0.value.count)
    let attributedString = NSMutableAttributedString.init(string: $0.value)
    
    let fontSize = $0.getFontSize()
    var font = UIFont.systemFont(ofSize: fontSize)
    var traits = font.fontDescriptor.symbolicTraits
    if $0.getIsItalic() { traits.insert(.traitItalic) }
    if $0.getIsBold() { traits.insert(.traitBold) }
    if let nFontDescriptor = font.fontDescriptor.withSymbolicTraits(traits) {
        font = UIFont.init(descriptor: nFontDescriptor, size: fontSize)
    }
    attributedString.addAttribute(.font, value: font, range: range)
    if let textColor = $0.getTextColor() {
        attributedString.addAttribute(.foregroundColor, value: textColor, range: range)
    }
    if traits.contains(.traitItalic) {
        attributedString.addAttribute(.obliqueness, value: 0.2, range: range)
    }
    if let linkUrl = $0.getLinkUrl() {
        attributedString.addAttribute(.link, value: linkUrl, range: range)
    }
    if $0.containsInParagraph() {
        let paragraphStyle = NSMutableParagraphStyle.init()
        paragraphStyle.alignment = .natural
        paragraphStyle.lineSpacing = 0
        paragraphStyle.paragraphSpacing = 12
        attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: range)
        attributedString.append(NSAttributedString.init(string: "\n"))
    }
    return attributedString
}

这里只对有限的属性进行了解析与生成,满足了当下的业务场景即可,有兴趣的小伙伴可以自己扩展。
下面是br标签创建富文本的代码:

let brRender: SimpleHTMLElementRender = { _ in
    return NSAttributedString.init(string: "\n")
}

下面是img标签创建富文本的代码:

let imgRender: SimpleHTMLElementRender = {
    guard let imgUrl = $0.getImgUrl(),
          let url = URL.init(string: imgUrl),
          let data = try? Data.init(contentsOf: url),
          let image = UIImage.init(data: data) else { return .init(string: "[img]")}
    let attachment = NSTextAttachment.init()
    attachment.image = image
    let imgAttributedString = NSAttributedString.init(attachment: attachment)
    return imgAttributedString
}

由于这里解析创建可以在子线程中进行,则直接对图片进行了简单的处理,实际项目当中这一块的实现是被替换的,有兴趣的小伙伴也可以自己探索通过继承NSTextAttachment将图片的生成与展示封闭在内部进行。 最后看一下测试样本的效果:

总结

解析渲染HTML总共分成两个大步骤:
1.解析: 通过逐步解析消耗的方式从头解析到尾,标签递归解析标签完成整个AST的构建。
2.渲染: 通过将标签分成两类构建出用于平台渲染的富文本对象

轻量级的HTML渲染只是一个探索,也许你还有更好的方案也可以一起分享讨论。
公众号: 程序猿搬砖