新的iOS開發方式,無需伺服器,做自己的前端轉原生iOS app的框架

feelings發表於2019-03-04

為什麼會有這樣一個想法?

  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圖
新的iOS開發方式,無需伺服器,做自己的前端轉原生iOS app的框架

開始搭建框架

要想製作這樣一個框架,必須做到下面這些:

  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原始碼希望大家能多給一點意見!

相關文章