為什麼會有這樣一個想法?
- 一個人做專案的時間有點久了,有時候為了修復一個小BUG 或者為更新一點內容就得去app store 稽核,這個過程太漫長了,覺得煩躁了。
- 再就是有時候伺服器的更新不及時,或者想自己控制app 內容。
- 考慮過引入ReactNative,但是這個東西,我自己覺得太過笨重了吧。
- 用現有的方式來寫Native 要方便控制,方便更新,容易編寫,考慮使用HTML,CSS,JS。
新的開發方式
為了解決以上問題,算是獨闢蹊徑,實現了一個新穎,並且可能容易被接受的構建iOS 原生app 的方式,這個方式有以下特點:
1. 不需要專門的伺服器!!!
2. 非常方便進行app 的更新,隨時更改app 的功能!!!
3. 容易擴充套件新的元件,實現自己的解析方式或者相容現有的HTML 標準!!!
4. 使用HTML,CSS,JS來編寫原生功能,Flex佈局。
複製程式碼
在講述如何構建這樣一種新穎的開發方式之前,上兩張圖,用這種方式實現的原生功能
開始搭建框架
要想製作這樣一個框架,必須做到下面這些:
- 解析HTML,生成一個DOM 樹
- 根據HTML 的相應標籤,下載CSS,JS檔案
- 解析CSS,把樣式表合併到相應的Node上
- 根據DOM 樹使用OC 或者Swift 建立檢視
- 佈局系統使用前端的Flex 佈局,Facebook 出的yoga 可以幫助我們
- 想要互動必須得執行JS,這樣需要JS 和Native 通訊的能力
具體的實現原始碼可以檢視TokenHybrid原始碼
Step 1 – 解析HTML
推薦用蘋果原生的NSXMLParser,但是NSXMLParser有一些坑
- 不能解析非閉合標籤比如
<meta>
,應該是<meta>/<meta>
- 當掃描到標籤內部的文字的時候,如果文字太長,可能一次掃描不完,需要自己做記錄(不算是坑)
為了避開上面的非閉合標籤的坑,你得尋找所有的非閉合標籤,並補完全,使其成為閉合標籤。
這裡需要用到正規表示式
下面是我尋找所有的自閉和標籤並補全的程式碼
-(void)parserHTML:(NSString *)html
{
dispatch_async(tokenXMLParserQueue(), ^{
NSString *closedHTML = [self handleSimeClosedTagWithTagNameArray:@[@"meta",@"input"] html:html];
NSData *data = [closedHTML dataUsingEncoding:NSUTF8StringEncoding];
_parser = [[NSXMLParser alloc] initWithData:data];
_parser.delegate = self;
[_parser parse];
});
}
-(NSString *)handleSimeClosedTagWithTagNameArray:(NSArray *)tagNameArray html:(NSString *)html{
__block NSString *temp = html;
for (NSString *tagName in tagNameArray) {
NSString *testString = @"<".token_append(tagName);
NSString *closedString = [NSString stringWithFormat:@"</%@>",tagName];
if ([html containsString:testString]) {
//檢測是否閉合
NSString *pattern = [NSString stringWithFormat:@"<%@(.*?)>",tagName];
NSRegularExpression *exp = [NSRegularExpression regularExpressionWithPattern:pattern options:0 error:nil];
NSArray<NSTextCheckingResult *> *results = [exp matchesInString:html options:0 range:NSMakeRange(0, html.length)];
[results enumerateObjectsUsingBlock:^(NSTextCheckingResult * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
NSString *matchString = [html substringWithRange:obj.range];
NSString *nextString = [html substringWithRange:NSMakeRange(obj.range.length+obj.range.location, tagName.length+3)];
if (![nextString isEqualToString:closedString]) {
temp = temp.token_replace(matchString,matchString.token_append(closedString));
}
}];
}
}
return temp;
}
複製程式碼
HTML 解析的同時,如果有<script>,<style>,<link>
等標籤,需要啟動下載器去下載相應的檔案
下面只展示下載CSS檔案
你要做到如下:
- HTML 解析完畢,你才能合併CSS 到CSS 選擇器匹配的Node上
- 以及如何匹配CSS 選擇器到Node 上
- 根據DOM 樹構建相應的
UIView
層次結構 - 有可能涉及到執行緒同步的問題
[nodes enumerateObjectsUsingBlock:^(TokenXMLNode * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
NSString *linkURL = obj.innerAttributes[@"href"];
if (linkURL == nil || linkURL.length == 0) return;
NSString *absoluteLinkURL = [NSString token_completeRelativeURLString:linkURL
withAbsoluteURLString:_document.sourceURL];
HybridLog(@"開始下載CSS檔案");
TokenNetworking.networking()
.sendRequest(^NSURLRequest *(TokenNetworking *netWorking) {
return NSMutableURLRequest.token_requestWithURL(absoluteLinkURL)
.token_setPolicy(NSURLRequestReloadIgnoringLocalCacheData);
}).transform(^id(TokenNetworking *netWorking, id responsedObj) {
HybridLog(@"CSS檔案下載完成");
NSString *cssText = [netWorking HTMLTextSerializeWithData:responsedObj];
NSDictionary *rules = [TokenCSSParser parserCSSWithString:cssText];
if (rules.allKeys.count) {
[_document addCSSRuels:rules];
}
self.styleAndLinkNodeCount -= 1;
return cssText;
}).finish(nil, ^(TokenNetworking *netWorkingObj, NSError *error) {
self.styleAndLinkNodeCount -= 1;
HybridLog(@"CSS檔案下載錯誤: %@",error);
[_document addFailedCSSURL:absoluteLinkURL];
});
}];
複製程式碼
Step 2 – 解析CSS
Step 2.1 -將CSS 解析為 NSDictionary
如果你可以解析CSS,那麼你可以自己實現一些諸如CSS裡面的函式calc()等,是不是非常激動。你得做到以下兩點
- 計算字串數學表示式
- 去掉CSS 裡面的註釋
計算NSString 數學表示式
NSString *mathExp = @"7+8*3";
NSExpression *expression = [NSExpression expressionWithFormat:mathExp];
id value = [expression expressionValueWithObject:nil context:nil];
value 就是一個NSNumber 值為31
複製程式碼
下面是去掉註釋並解析為NSDictionary
的程式碼
//我為NSString 增加的正規表示式方法 下面的cssString.token_replaceWithRegExp(commentRegExp,@"")
-(TokenStringReplaceWithRegExpBlock)token_replaceWithRegExp{
return ^NSString *(NSString *regExp,NSString *newString) {
__block NSString *temp = [self copy];
NSRegularExpression *exp = [NSRegularExpression regularExpressionWithPattern:regExp options:0 error:nil];
NSArray<NSTextCheckingResult *> *result = [exp matchesInString:temp options:0 range:NSMakeRange(0, temp.length)];
[result enumerateObjectsUsingBlock:^(NSTextCheckingResult * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
NSString *stringWillBeReplaced = [self substringWithRange:obj.range];
temp = [temp stringByReplacingOccurrencesOfString:stringWillBeReplaced withString:newString];
}];
return temp;
};
}
//參考了DTCoreText
+(NSDictionary *)parserCSSWithString:(NSString *)cssString{
if (cssString == nil) return @{};
NSMutableDictionary *styleSheets = @{}.mutableCopy;
NSString *commentRegExp = @"(?<!:)\/\/.*|\/\*(\s|.)*?\*\/";
//去掉CSS裡面的評論
NSString *css = cssString.token_replaceWithRegExp(commentRegExp,@"")
.token_replace(@"
",@"")
.token_replace(@"
",@"");
int braceMarker = 0;
NSString *selector;
NSString *rule;
for (int i = 0; i < css.length; i ++) {
unichar c = [css characterAtIndex:i];
if (c == `{`) {
selector = [css substringWithRange:NSMakeRange(braceMarker, i-braceMarker)];
braceMarker = i + 1;
}
if (c == `}`) {
rule = [css substringWithRange:NSMakeRange(braceMarker, i-braceMarker)];
braceMarker = i + 1;
if (selector.length && rule.length) {
NSDictionary *dic = [self converAttrStringToDictionary:rule];
if ([selector hasPrefix:@" "] || [selector hasSuffix:@" "]) {
selector = [selector stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
}
[styleSheets setObject:dic forKey:selector];
}
}
}
return styleSheets;
}
複製程式碼
呼叫 -parserCSSWithString
就會將CSS 檔案解析為一個 NSDictionary
如下
body { --> {
backgroundColor: rgb(120,120,120); @"backgroundColor":@"rgb(120,120,120)",
width:120px; @"width":@"120px"
} }
複製程式碼
Step 2.2 – 匹配CSS 選擇器 支援id選擇器,class 選擇器,簡單的組合選擇器
匹配相應的CSS 選擇器到DOM 上相應的Nodes
匹配的時候你得從選擇器字串的右邊匹配到左邊,這樣會加快匹配的速度,想想為啥?
+(NSSet <TokenXMLNode *> *)matchNodesWithRootNode:(TokenXMLNode *)node selector:(NSString *)selector{
//去掉兩端空格
if ([selector hasPrefix:@" "]) {
selector = [selector stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
}
//用空格分割
NSMutableArray *selectors = NSMutableArray.token_arrayWithArray(selector.token_separator(@" "));
if ([selectors containsObject:@""]) {
[selectors removeObject:@""];
}
NSMutableSet <TokenXMLNode *> *matchNodeSet = [NSMutableSet set];
//先產生一個基本集合
[TokenXMLNode enumerateTreeFromRootToChildWithNode:node block:^(TokenXMLNode *node ,BOOL *stop) {
[matchNodeSet addObject:node];
}];
//對selector 從右往左開始匹配
for (NSInteger i = selectors.count - 1 ; i>= 0; i--) {
NSString *selector = selectors[i];
NSMutableSet *matchNodeSetCopy = [NSMutableSet setWithSet:matchNodeSet];
[matchNodeSet enumerateObjectsUsingBlock:^(TokenXMLNode * node, BOOL * _Nonnull stop) {
//id 選擇器
if ([selector hasPrefix:@"#"]) {
if (![node.innerAttributes[@"id"] isEqualToString:[selector substringWithRange:NSMakeRange(1, selector.length-1)]]) {
[matchNodeSetCopy removeObject:node];
}
}
else if ([selector hasPrefix:@"."]) {
NSString *nodeClass = node.innerAttributes[@"class"];
NSString *selectorToBeMatched = [selector substringWithRange:NSMakeRange(1, selector.length-1)];
if ([nodeClass containsString:@" "]) {//包含多個類
NSArray *nodeClassArray = [nodeClass componentsSeparatedByString:@" "];
if (![nodeClassArray containsObject:selectorToBeMatched]) {
[matchNodeSetCopy removeObject:node];
}
}
else {
//不包含多個類
if (![nodeClass isEqualToString:[selector substringWithRange:NSMakeRange(1, selector.length-1)]]) {
[matchNodeSetCopy removeObject:node];
}
}
}
else {
if (i == selectors.count-1) {
if (![node.name isEqualToString:selector]) {
[matchNodeSetCopy removeObject:node];
}
}
else {
BOOL nodeMatchd = NO;
//開始向上匹配父節點
TokenXMLNode *currentNode = node;
while (currentNode.parentNode) {
//匹配到父節點
if ([currentNode.name isEqualToString:selector]) {
nodeMatchd = YES;
break;
}
currentNode = currentNode.parentNode;
}
if (!nodeMatchd) {
[matchNodeSetCopy removeObject:node];
}
}
}
}];
matchNodeSet = matchNodeSetCopy;
}
return matchNodeSet;
}
複製程式碼
Step 3 – 根據DOM 樹構建UIView 的層次結構
當NSXMLParser 解析到下面這兩個方法的時候可以構建檢視層次
因為HTML 標籤內部的結構和UIView 的層次結構正好對應,都有父子關係,其實就是一顆多叉樹,使用Stack層次遍歷即可。
#pragma mark - XMLParserDelegate
-(void)parserDidStart{
//新建一個棧
_viewStack = [[TokenHybridStack alloc] init];
}
-(void)parser:(TokenXMLParser *)parser didStartNodeWithinBodyNode:(TokenPureNode *)node{
//根據相應的node 建立相應的Native 元件
TokenPureComponent *view = [UIView token_produceViewWithNode:node];
if (view == nil) {
view = [[TokenPureComponent alloc] init];
}
view.associatedNode = node;
node.associatedView = view;
[_viewStack push:view];
}
-(void)parser:(TokenXMLParser *)parser didEndNodeWithinBodyNode:(TokenXMLNode *)node{
//在End調整UIView層次結構
UIView *currentView = [_viewStack pop];
UIView *parentView = [_viewStack top];
[parentView addSubview:currentView];
}
複製程式碼
Step 4 – 設定UIView 的相應的屬性
如何設定,其實很簡單
因為上文中,生成的UIView
都持有一個Node
,根據Node
的裡面解析的資料就可以設定,你可以寫總結的方法,推薦你為UIView
寫一個 Category
增加一個方法專門設定Node
屬性到UIView
屬性的方法。裡面可能遇到很多if-else
,本人水平有限,希望有人能幫助簡化if-else
下面是我寫的方法
//
// UIView+Attributes.m
// TokenHybrid
//
// Created by 陳雄 on 2017/11/9.
// Copyright © 2017年 com.feelings. All rights reserved.
//
@implementation UIView (Attributes)
...
-(void)token_updateAppearanceWithNormalDictionary:(NSDictionary *)dictionary{
NSDictionary *d = dictionary;
if(d[@"borderRadius"]) { self.layer.cornerRadius = [d[@"borderRadius"] floatValue];}
if(d[@"zIndex"]) { self.layer.zPosition = [d[@"zIndex"] floatValue];}
if(d[@"borderWidth"]) { self.layer.borderWidth = [d[@"borderWidth"] floatValue];}
if(d[@"borderColor"]) { self.layer.borderColor = [UIColor ss_colorWithString:d[@"borderColor"]].CGColor;}
if(d[@"backgroundColor"]) { self.backgroundColor = [UIColor ss_colorWithString:d[@"backgroundColor"]];}
NSString *hidden = d[@"hidden"];
if(hidden) {self.hidden = hidden.token_turnBoolStringToBoolValue(); }
}
@end
複製程式碼
Step 5 – JS 和OC/Swift 的互動
我說說我的做法
模型:TokenDomcument,TokenXMLNode,TokenTool
工具類:TokenViewBuilder,TokenJSContext
TokenViewBuilder
用來作為XMLParser
的delegate,並且構建DOM 樹,下載JS,CSS,生成渲染樹TokenDomcument
用來模仿瀏覽器的document,裡面包含整個DOM 樹,並且使用JSExport 導給JS使用TokenXMLNode
節點的父類,也遵循JSExport 協議,導給JS使用,並且通過它控制Native 元件TokenTool
用來給JS 提供各種Native API 如:定位,獲取照片,彈出提示框,等等TokenJSContext
提供給JS 額外注入,並且執行JS 的環境- 並且如何互動的基礎,請看非常容易懂得JS和OC互動
我自己根據這樣一個思路做了一份原始碼TokenHybrid原始碼希望大家能多給一點意見!