新的iOS开发方式,无需服务器,做自己的前端转原生iOS app的框架

6,916 阅读7分钟

为什么会有这样一个想法?

  1. 一个人做项目的时间有点久了,有时候为了修复一个小BUG 或者为更新一点内容就得去app store 审核,这个过程太漫长了,觉得烦躁了。
  2. 再就是有时候服务器的更新不及时,或者想自己控制app 内容。
  3. 考虑过引入ReactNative,但是这个东西,我自己觉得太过笨重了吧。
  4. 用现有的方式来写Native 要方便控制,方便更新,容易编写,考虑使用HTML,CSS,JS。

新的开发方式

为了解决以上问题,算是独辟蹊径,实现了一个新颖,并且可能容易被接受的构建iOS 原生app 的方式,这个方式有以下特点:

    1. 不需要专门的服务器!!!
    2. 非常方便进行app 的更新,随时更改app 的功能!!!
    3. 容易扩展新的组件,实现自己的解析方式或者兼容现有的HTML 标准!!!
    4. 使用HTML,CSS,JS来编写原生功能,Flex布局。

在讲述如何构建这样一种新颖的开发方式之前,上两张图,用这种方式实现的原生功能

GIF图

开始搭建框架

要想制作这样一个框架,必须做到下面这些:

  1. 解析HTML,生成一个DOM 树
  2. 根据HTML 的相应标签,下载CSS,JS文件
  3. 解析CSS,把样式表合并到相应的Node上
  4. 根据DOM 树使用OC 或者Swift 创建视图
  5. 布局系统使用前端的Flex 布局,Facebook 出的yoga 可以帮助我们
  6. 想要交互必须得执行JS,这样需要JS 和Native 通信的能力

具体的实现源码可以查看TokenHybrid源码

Step 1 - 解析HTML

推荐用苹果原生的NSXMLParser,但是NSXMLParser有一些坑

  1. 不能解析非闭合标签比如 <meta>,应该是<meta>/<meta>
  2. 当扫描到标签内部的文本的时候,如果文本太长,可能一次扫描不完,需要自己做记录(不算是坑)

为了避开上面的非闭合标签的坑,你得寻找所有的非闭合标签,并补完全,使其成为闭合标签。 这里需要用到正则表达式 下面是我寻找所有的自闭和标签并补全的代码


-(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文件

你要做到如下:

  1. HTML 解析完毕,你才能合并CSS 到CSS 选择器匹配的Node上
  2. 以及如何匹配CSS 选择器到Node 上
  3. 根据DOM 树构建相应的UIView 层次结构
  4. 有可能涉及到线程同步的问题
[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()等,是不是非常激动。你得做到以下两点

  1. 计算字符串数学表达式
  2. 去掉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

  1. TokenViewBuilder 用来作为XMLParser的delegate,并且构建DOM 树,下载JS,CSS,生成渲染树
  2. TokenDomcument 用来模仿浏览器的document,里面包含整个DOM 树,并且使用JSExport 导给JS使用
  3. TokenXMLNode 节点的父类,也遵循JSExport 协议,导给JS使用,并且通过它控制Native 组件
  4. TokenTool 用来给JS 提供各种Native API 如:定位,获取照片,弹出提示框,等等
  5. TokenJSContext 提供给JS 额外注入,并且执行JS 的环境
  6. 并且如何交互的基础,请看非常容易懂得JS和OC交互

我自己根据这样一个思路做了一份源码TokenHybrid源码希望大家能多给一点意见!