新的iOS开发方式,无需服务器,做自己的前端转原生iOS app的框架
为什么会有这样一个想法?
- 一个人做项目的时间有点久了,有时候为了修复一个小BUG 或者为更新一点内容就得去app store 审核,这个过程太漫长了,觉得烦躁了。
- 有时候一个H5页面,用webView展示,首屏加载时间慢,各种CSS,JS脚本都要加载。
- 再就是有时候服务器的更新不及时,或者想自己控制app 内容。
- 考虑过引入ReactNative,但是这个东西,我自己觉得太过笨重了吧。
- 用现有的方式来写Native 要方便控制,方便更新,容易编写,考虑使用HTML,CSS,JS。
新的开发方式
为了解决以上问题,算是独辟蹊径,实现了一个新颖,并且可能容易被接受的构建iOS 原生app 的方式,这个方式有以下特点:
1. 不需要专门的服务器!!!
2. 首屏加载速度快,第二次以后均从缓存加载,缓存甚至包括原生的视图坐标信息。
3. 非常方便进行app 的更新,随时更改app 的功能!!!
4. 容易扩展新的组件,实现自己的解析方式或者兼容现有的HTML 标准!!!
5. 使用HTML,CSS,JS来编写原生功能,Flex布局。
该想法已经实现,点击github链接查看TokenHybrid源码
目前我已将这种方式放进我们团队的app -掌上理工大 (app store 可以搜索)里面使用啦。
在讲述如何构建这样一种新颖的开发方式之前,上两张图,用这种方式实现的原生功能
开始搭建框架
要想制作这样一个框架,必须做到下面这些:
- 解析HTML,生成一个DOM 树
- 根据HTML 的相应标签,下载CSS,JS文件
- 解析CSS,把样式表合并到相应的Node上
- 根据DOM 树使用OC 或者Swift 创建视图
- 布局系统使用前端的Flex 布局,Facebook 出的yoga 可以帮助我们
- 想要交互必须得执行JS,这样需要JS 和Native 通信的能力
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(@"\n",@"")
.token_replace(@"\r",@"");
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源码希望大家能多给一点意见!