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

本文涉及到的实例代码在这里: 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><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(_ location: SimpleHTMLElementLocation) -> 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将图片的生成与展示封闭在内部进行。 最后看一下测试样本的效果:

image

总结

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

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

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

推荐阅读更多精彩内容