戴铭的博客 - 星光社

HTML 转原生 HTN 项目开发记录

字数统计: 2.4k阅读时长: 10 min
2017/10/16 Share

前言

本文主要是记录 HTN 项目开发的过程。关于这个项目先前在 Swift 开发者大会上我曾经演示过,不过当时项目结构不完善,不易扩展,也没有按照标准来。所以这段时间,我研究了下 W3C 的标准和 WebKit 的一些实现,对于这段时间的研究也写了篇文章深入剖析 WebKit。重构了下这个项目,我可以先说下已经完成的部分,最后列下后面的规划。项目已经放到了 Github 上:https://github.com/ming1016/HTN 后面可以对着代码看。

项目使用介绍

通过解析 html 生成 DOM 树,解析 CSS,生成渲染树,计算布局,最终生成原生 Textrue 代码。下面代码可以看到完整的过程的各个方法。

1
2
3
4
5
6
7
8
let treeBuilder = HTMLTreeBuilder(htmlStr) //htmlStr 就是 需要转的 html 代码
_ = treeBuilder.parse() //解析 html 生成 DOM 树
let cssStyle = CSSParser(treeBuilder.doc.allStyle()).parseSheet() //解析 CSS
let document = StyleResolver().resolver(treeBuilder.doc, styleSheet: cssStyle) //生成渲染树
//转 Textrue
let layoutElement = LayoutElement().createRenderer(doc: document) //计算布局
_ = HTMLToTexture(nodeName:"Flexbox").converter(layoutElement); //生成原生 Textrue 代码

比如有下面的 html

04

在浏览器里显示是这样

06

通过 HTN 生成的原生代码

05

在 iPhone X 模拟器的效果如下

07

下面详细介绍下具体的实现关键点

HTML

这部分最关键的部分是在 HTML/HTMLTokenizer.swift 里。首先会根据 W3C 里的 Tokenization 的标准 https://dev.w3.org/html5/spec-preview/tokenization.html 来定义一个状态的枚举,如下,可以目前完成这些状态的情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
//枚举
enum S: HTNStateType {
case DataState //half done
case CharacterReferenceInDataState
case RCDATAState
case CharacterReferenceInRCDATAState
case RAWTEXTState
case ScriptDataState
case PLAINTEXTState
case TagOpenState //half done
case EndTagOpenState
case TagNameState //half done
case RCDATALessThanSignState
case RCDATAEndTagOpenState
case RCDATAEndTagNameState
case RAWTEXTLessThanSignState
case RAWTEXTEndTagOpenState
case RAWTEXTEndTagNameState
//Script
case ScriptDataLessThanSignState
case ScriptDataEndTagOpenState
case ScriptDataEndTagNameState
case ScriptDataEscapeStartState
case ScriptDataEscapeStartDashState
case ScriptDataEscapedState
case ScriptDataEscapedDashState
case ScriptDataEscapedDashDashState
case ScriptDataEscapedLessThanSignState
case ScriptDataEscapedEndTagOpenState
case ScriptDataEscapedEndTagNameState
case ScriptDataDoubleEscapeStartState
case ScriptDataDoubleEscapedState
case ScriptDataDoubleEscapedDashState
case ScriptDataDoubleEscapedDashDashState
case ScriptDataDoubleEscapedLessThanSignState
case ScriptDataDoubleEscapeEndState
//Tag
case BeforeAttributeNameState
case AttributeNameState //half done
case AfterAttributeNameState
case BeforeAttributeValueState
case AttributeValueDoubleQuotedState //half done
case AttributeValueSingleQuotedState
case AttributeValueUnquotedState
case CharacterReferenceInAttributeValueState
case AfterAttributeValueQuotedState //half done
case SelfClosingStartTagState
case BogusCommentState
case ContinueBogusCommentState
case MarkupDeclarationOpenState //half done
//Comment
case CommentStartState //half done
case CommentStartDashState
case CommentState
case CommentEndDashState //half done
case CommentEndState //half done
case CommentEndBangState
//DOCTYPE
case DOCTYPEState //half done
case BeforeDOCTYPENameState //half done
case DOCTYPENameState
case AfterDOCTYPENameState //half done
case AfterDOCTYPEPublicKeywordState //half done
case BeforeDOCTYPEPublicIdentifierState //half done
case DOCTYPEPublicIdentifierDoubleQuotedState //half done
case DOCTYPEPublicIdentifierSingleQuotedState
case AfterDOCTYPEPublicIdentifierState //half done
case BetweenDOCTYPEPublicAndSystemIdentifiersState
case AfterDOCTYPESystemKeywordState
case BeforeDOCTYPESystemIdentifierState
case DOCTYPESystemIdentifierDoubleQuotedState
case DOCTYPESystemIdentifierSingleQuotedState
case AfterDOCTYPESystemIdentifierState
case BogusDOCTYPEState
case CDATASectionState
case CDATASectionRightSquareBracketState
case CDATASectionDoubleRightSquareBracketState
}

处理这些状态采用的是状态机原理。根据状态机数学模型提取出需要的状态集合,事件集合,事件集合在这里是所遇字符的集合做了一个状态机,具体实现在 HTNFundation/HTNStateMachine.swift。状态转移函数我定义的是 func listen(_ event: E, transit fromState: S, to toState: S, callback: @escaping (HTNTransition) -> Void) ,这里的 block 是在状态转移时需要做的事情定义 。为了能够减少状态转移太多太碎,也多写了几个函数来处理比如一组来源状态到同一个转移状态和针对某些事件状态不变的函数。

有了状态机后面的处理就会很方便,这里的事件就是一个一个的字符,不同字符在不同的状态下的处理。下面可以举个多状态转同一状态的实现,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let anglebracketRightEventFromStatesArray = [S.DOCTYPEState,
S.CommentEndState,
S.TagOpenState,
S.EndTagOpenState,
S.AfterAttributeValueQuotedState,
S.BeforeDOCTYPENameState,
S.AfterDOCTYPEPublicIdentifierState]
stateMachine.listen(E.AngleBracketRight, transit: anglebracketRightEventFromStatesArray, to: S.DataState) { (t) in
if t.fromState == S.TagOpenState || t.fromState == S.EndTagOpenState {
if self._bufferStr.count > 0 {
self._bufferToken.data = self._bufferStr.lowercased()
}
}
self.addHTMLToken()
self.advanceIndexAndResetCurrentStr()
}

W3C 也定义每个状态的处理,非常详细完整,WebKit 基本把这些定义都实现了,HTN 目前只实现了能够满足构建 DOM 树的部分。W3C 的定义可以举个 StartTags 的状态如下图
01

在进入构建 DOM 树之前我们需要设计一些类和结构来记录我们的内容,这里采用了 WebKit 类似的类结构设计,下图是 WebKit 的 DOM 树相关的类设计图
02

完成了这些状态处理,接下来就可以根据这些 HTMLToken 来组装我们的 DOM 树了。这部分的实现在 HTML/HTMLTreeBuilder.swift 里。构建 DOM 树同样使用了先前的写的状态机,只是这里的状态集和事件集不同而已,W3C 也定义一些状态可以用

1
2
3
4
5
6
7
8
9
10
enum S: HTNStateType {
case InitialModeState
case BeforeHTMLState
case BeforeHeadState
case InHeadState
case AfterHeadState
case InBodyState
case AfterBodyState
case AfterAfterBodyState
}

从名字就能很方便的看出每个状态的意思。这里的事件集使用的是 HTMLToken 里的类型,根据不同类型来放置到合适的位置。树的父级子级是通过定义的一个堆栈来控制,具体构建实现可以看 func parse() -> [HTMLToken] 这个函数。

CSS

解析 CSS 需要先了解下 CSS 的 BNF,它的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
ruleset
: selector [ ',' S* selector ]*
'{' S* declaration [ ';' S* declaration ]* '}' S*
;
selector
: simple_selector [ combinator selector | S+ [ combinator selector ] ]
;
simple_selector
: element_name [ HASH | class | attrib | pseudo ]*
| [ HASH | class | attrib | pseudo ]+
;
class
: '.' IDENT
;
element_name
: IDENT | '*'
;
attrib
: '[' S* IDENT S* [ [ '=' | INCLUDES | DASHMATCH ] S*
[ IDENT | STRING ] S* ] ']'
;
pseudo
: ':' [ IDENT | FUNCTION S* [IDENT S*] ')' ]
;

根据 BNF 来确定状态集和事件集。下面是我定义的状态集和事件集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
enum S: HTNStateType {
case UnknownState //
case SelectorState // 比如 div p, #id
case PropertyKeyState // 属性的 key
case PropertyValueState // 属性的 value
//TODO:以下后期支持,优先级2
case PseudoClass // :nth-child(2)
case PseudoElement // ::first-line
//TODO:以下后期支持,优先级3
case PagePseudoClass
case AttributeExact // E[attr]
case AttributeSet // E[attr|="value"]
case AttributeHyphen // E[attr~="value"]
case AttributeList // E[attr*="value"]
case AttributeContain // E[attr^="value"]
case AttributeBegin // E[attr$="value"]
case AttributeEnd
//TODO:@media 这类 @规则 ,后期支持,优先级4
}
enum E: HTNEventType {
case SpaceEvent //空格
case CommaEvent // ,
case DotEvent // .
case HashTagEvent // #
case BraceLeftEvent // {
case BraceRightEvent // }
case ColonEvent // :
case SemicolonEvent // ;
}

同样在状态的处理过程中也需要一个合理的类结构关系设计来满足,这里也参考了 WebKit 里的设计,如下:
03

布局

布局处理目前 HTN 主要是将样式属性和 DOM 树里的 Element 对应上。具体实现是在 Layout/StyleResolver.swift 里。思路是先将所有 CSSRule 和对应的 CSSSelector 做好映射,接着在递归 DOM 树的过程中与每个 Element 对应上。主要代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
public func resolver(_ doc:Document, styleSheet:CSSStyleSheet) -> Document{
//样式映射表
//这种结构能够支持多级 Selector
var matchMap = [String:[String:[String:String]]]()
for rule in styleSheet.ruleList {
for selector in rule.selectorList {
guard let matchLast = selector.matchList.last else {
continue
}
var matchDic = matchMap[matchLast]
if matchDic == nil {
matchDic = [String:[String:String]]()
matchMap[matchLast] = matchDic
}
//这里可以按照后加入 rulelist 的优先级更高的原则进行覆盖操作
if matchMap[matchLast]![selector.identifier] == nil {
matchMap[matchLast]![selector.identifier] = [String:String]()
}
for a in rule.propertyList {
matchMap[matchLast]![selector.identifier]![a.key] = a.value
}
}
}
for elm in doc.children {
self.attach(elm as! Element, matchMap: matchMap)
}
return doc
}
//递归将样式属性都加上
func attach(_ element:Element, matchMap:[String:[String:[String:String]]]) {
guard let token = element.startTagToken else {
return
}
if matchMap[token.data] != nil {
//TODO: 还不支持 selector 里多个标签名组合,后期加上
addProperty(token.data, matchMap: matchMap, element: element)
}
//增加 property 通过处理 token 里的属性列表里的 class 和 id 在 matchMap 里找
for attr in token.attributeList {
if attr.name == "class" {
addProperty("." + attr.value.lowercased(), matchMap: matchMap, element: element)
}
if attr.name == "id" {
addProperty("#" + attr.value.lowercased(), matchMap: matchMap, element: element)
}
}
if element.children.count > 0 {
for element in element.children {
self.attach(element as! Element, matchMap: matchMap)
}
}
}
func addProperty(_ key:String, matchMap:[String:[String:[String:String]]], element:Element) {
guard let dic = matchMap[key] else {
return
}
for aDic in dic {
var selectorArr = aDic.key.components(separatedBy: " ")
if selectorArr.count > 1 {
//带多个 selector 的情况
selectorArr.removeLast()
if !recursionSelectorMatch(selectorArr, parentElement: element.parent as! Element) {
continue
}
}
guard let ruleDic = dic[aDic.key] else {
continue
}
//将属性加入 element 的属性列表里
for property in ruleDic {
element.propertyMap[property.key] = property.value
}
}
}

这里通过 recursionSelectorMatch 来按照 CSS Selector 从右到左的递归出是否匹配路径,具体实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
//递归找出匹配的多路径
func recursionSelectorMatch(_ selectors:[String], parentElement:Element) -> Bool {
var selectorArr = selectors
guard var last = selectorArr.last else {
//表示全匹配了
return true
}
guard let parent = parentElement.parent else {
return false
}
var isMatch = false
if last.hasPrefix(".") {
last.characters.removeFirst()
//TODO:这里还需要考虑attribute 空格多个 class 名的情况
guard let startTagToken = parentElement.startTagToken else {
return false
}
if startTagToken.attributeDic["class"] == last {
isMatch = true
}
} else if last.hasPrefix("#") {
last.characters.removeFirst()
guard let startTagToken = parentElement.startTagToken else {
return false
}
if startTagToken.attributeDic["id"] == last {
isMatch = true
}
} else {
guard let startTagToken = parentElement.startTagToken else {
return false
}
if startTagToken.data == last {
isMatch = true
}
}
if isMatch {
//匹配到会继续往前去匹配
selectorArr.removeLast()
}
return recursionSelectorMatch(selectorArr, parentElement: parent as! Element)
}

转原生

已完成一部分简单布局属性转换 Texture 原生代码。具体实现部分可以参看 HTMLToTexture.swift 文件。

已完成

  • 解析 HTML 构建 DOM 树,解析 CSS 构建渲染树
  • CSS Selector 的 Tag 路径支持,Tag 和 class,id 的组合选择。
  • flexbox 属性,margin 和 padding 映射 Texture 原生代码

规划

  • 支持图片标签,支持 CSS background 背景属性
  • html 的 class 属性还不支持空格多个 class 名
  • text-transform 属性的支持
  • em 转 pt,em 是相对父元素值的乘积值。
  • 支持CSS选择器的 :before 和 :after

  • HTN 的 Objective-C 版。

  • 支持转 Objective-C 的原生代码。
  • 解析转换器内嵌在应用程序内部,支持服务器下发 h5 代码转换。
  • 应用内转换时的缓存的处理,将render树结构体进行缓存的处理

  • HTML 内 JS 解析,支持逻辑控制 HTML

CATALOG
  1. 1. 前言
  2. 2. 项目使用介绍
  3. 3. HTML
  4. 4. CSS
  5. 5. 布局
  6. 6. 转原生
  7. 7. 已完成
  8. 8. 规划