淺析iOS-Cordova

窗前有月光發表於2018-11-20

前言:

這兩年一直在做Cordova工程的專案,目前我們基於Cordova的jsBridge進行兩端的互動,通過載入本地JS優化渲染時間和白屏問題,Cordova給我們帶來了互動的外掛化,可配置等優點,可以說Cordova為我們進行Hybrid應用的構建提供了一個優秀的平臺,總結一下Cordova實現,下面主要基於native端部分的原始碼進行一下分析和學習,本篇不具體分析Cordova的原始碼,旨在總結Cordova的編碼思想。

目錄

  • 1.viewDidLoad
  • 2.載入配置檔案
  • 3.配置webview
  • 4.webViewEngine實現分析
  • 5.js與native互動以及native與js互動
  • 6.native外掛具體呼叫過程

一、viewDidLoad

cordova入口

- (void)viewDidLoad
{
    [super viewDidLoad];

    1.載入配置在config.xml中的配置檔案,具體做了哪些下面分析。
    // Load settings
    [self loadSettings];

    2.這一塊主要是對cordova的一些配置
    NSString* backupWebStorageType = @"cloud"; // default value

    id backupWebStorage = [self.settings cordovaSettingForKey:@"BackupWebStorage"];
    if ([backupWebStorage isKindOfClass:[NSString class]]) {
        backupWebStorageType = backupWebStorage;
    }
    [self.settings setCordovaSetting:backupWebStorageType forKey:@"BackupWebStorage"];
    [CDVLocalStorage __fixupDatabaseLocationsWithBackupType:backupWebStorageType];

    // // Instantiate the WebView ///////////////

    3.配置Cordova的Webview,具體怎麼配置的下面分析
    if (!self.webView) {
        [self createGapView];
    }
    
    4.對config.xml檔案中,配置了onload為true的外掛提前載入
    if ([self.startupPluginNames count] > 0) {
        [CDVTimer start:@"TotalPluginStartup"];

        for (NSString* pluginName in self.startupPluginNames) {
            [CDVTimer start:pluginName];
            [self getCommandInstance:pluginName];
            [CDVTimer stop:pluginName];
        }

        [CDVTimer stop:@"TotalPluginStartup"];
    }

    // /////////////////
    5.配置url
    NSURL* appURL = [self appUrl];

    6.配置webView的userAgent,加鎖,載入url
    [CDVUserAgentUtil acquireLock:^(NSInteger lockToken) {
        ...
        載入url的程式碼省略了
        ...
    }];
}
複製程式碼

viewDidload裡面已經將整個呼叫過程走完了,到這裡我們也就能夠看到我們在cordova上面載入的頁面了,所以我們在使用的時候可以直接繼承自CDVViewController來實現我們自己的邏輯,然後我們對這一過程進行逐步分析,viewDidload裡面究竟具體做了哪些工作。

二、載入配置檔案

首先載入配置檔案,還是看程式碼:

- (void)loadSettings
{
    1.config.xml配置檔案解析具體實現類
    CDVConfigParser* delegate = [[CDVConfigParser alloc] init];
    [self parseSettingsWithParser:delegate];
    
    2.將解析後的結果給self,也就是CDVViewController,其中pluginsMap的儲存所有我們在xml中配置的外掛字典,
    key為我們配置的feature,value為外掛類名。startupPluginNames儲存了我們所有配置了onload為true的外掛,用來幹嘛的後面說,
    settings儲存了我們在xml中對web的一些配置,後續也會用到。
    // Get the plugin dictionary, whitelist and settings from the delegate.
    self.pluginsMap = delegate.pluginsDict;
    self.startupPluginNames = delegate.startupPluginNames;
    self.settings = delegate.settings;

    3.預設wwwFolderName為www,wwwFolderName幹什麼用後面會說。
    // And the start folder/page.
    if(self.wwwFolderName == nil){
        self.wwwFolderName = @"www";
    }
    
    4.startPage外面有沒有設定,如果沒有設定就在xml裡面取,
    如果配置檔案沒有配置預設為index.html。
    if(delegate.startPage && self.startPage == nil){
        self.startPage = delegate.startPage;
    }
    if (self.startPage == nil) {
        self.startPage = @"index.html";
    }

    // Initialize the plugin objects dict.
    self.pluginObjects = [[NSMutableDictionary alloc] initWithCapacity:20];
}
複製程式碼

初始化我們在config.xml配置的類名、外掛提前載入還是使用的時候再建立等資訊。

三、配置webview

配置Cordova的webview,這一塊比較重要著重分析。

- (UIView*)newCordovaViewWithFrame:(CGRect)bounds
{
    1.預設的webView抽象類,實際上CDVViewController中是沒有webView的具體實現等程式碼的,
    他們的實現都是在這個抽象類裡面。當然這個抽象類也可以我們自己去配置,然後在我們自己的抽象類裡面去做具體實現,
    比如說我們現在專案使用的是UIWebView那麼就完全可以使用框架內不提供的預設實現,如果我們升級WKWebView,就可以直接修改了。
    NSString* defaultWebViewEngineClass = @"CDVUIWebViewEngine";
    NSString* webViewEngineClass = [self.settings cordovaSettingForKey:@"CordovaWebViewEngine"];

    if (!webViewEngineClass) {
        webViewEngineClass = defaultWebViewEngineClass;
    }

    2.尋找我們配置的webView
    if (NSClassFromString(webViewEngineClass)) {
        self.webViewEngine = [[NSClassFromString(webViewEngineClass) alloc] initWithFrame:bounds];
    3.如果webEngine返回nil,沒有遵循protocol,不能載入配置的url,滿足其一,都會載入框架預設的。
        // if a webView engine returns nil (not supported by the current iOS version) or doesn't conform to the protocol, or can't load the request, we use UIWebView
        if (!self.webViewEngine || ![self.webViewEngine conformsToProtocol:@protocol(CDVWebViewEngineProtocol)] || ![self.webViewEngine canLoadRequest:[NSURLRequest requestWithURL:self.appUrl]]) {
            self.webViewEngine = [[NSClassFromString(defaultWebViewEngineClass) alloc] initWithFrame:bounds];
        }
    } else {
        self.webViewEngine = [[NSClassFromString(defaultWebViewEngineClass) alloc] initWithFrame:bounds];
    }
    4.初始化webView
    if ([self.webViewEngine isKindOfClass:[CDVPlugin class]]) {
        [self registerPlugin:(CDVPlugin*)self.webViewEngine withClassName:webViewEngineClass];
    }
    5.返回webView
    return self.webViewEngine.engineWebView;
}
複製程式碼

這一塊稍微有點抽象,實際上是基於面向協議的程式設計思想對介面和試圖做了一個抽離,id webViewEngine,實際上它指向的是一個id型別並且遵循了CDVWebViewEngineProtocol協議的物件,也就是說它可以實現CDVWebViewEngineProtocol報漏出來的介面,這樣我們只要讓抽象類遵循了這個協議,那麼就可以實現協議裡面定義的方法和屬性,從而實現介面分離,如果哪天我們使用WKWebView那麼就可以直接再定義一套介面出來完全不需要修改框架,同理webViewEngine抽象類表面上看是個webview實際上是將webView抽離出來,實現試圖分離,達到解耦合。

四、webViewEngine實現分析

webViewEngine實際上是webView的一層抽象類,為什麼封裝了webViewEngine作為中間層上面也提到了不再分析了,下面主要看一下它的具體實現。

- (instancetype)initWithFrame:(CGRect)frame
{
    self = [super init];
    if (self) {
        Class WebClass = NSClassFromString(@"DLPanableWebView");
        if ([[WebClass class] isSubclassOfClass:[UIWebView class]]) {
            self.engineWebView = [[WebClass alloc] initWithFrame:frame];
        } else {
            self.engineWebView = [[UIWebView alloc] initWithFrame:frame];
        }
        NSLog(@"Using UIWebView");
    }

    return self;
}
複製程式碼

這裡就是剛才說的抽離具體的WebView,所以說框架不需要關心具體使用的是哪一個webView,比如說DLPanableWebView就是我們自定義的webView,那麼我們完全可以將web的工作拿到DLPanableWebView裡面去做,完全不會影響框架功能。

webViewEngine初始化配置

- (void)pluginInitialize
{
    // viewController would be available now. we attempt to set all possible delegates to it, by default
    1.首先拿到我們上面配置的web。
    UIWebView* uiWebView = (UIWebView*)_engineWebView;
    
    2.看一下我們外面配置的實現Controller是否自己實現了UIWebView的代理,
    如果實現了,那麼配置一下,在web回撥的時候會傳到我們自己的controller裡面做
    一下我們自己的事情。
    if ([self.viewController conformsToProtocol:@protocol(UIWebViewDelegate)]) {
        self.uiWebViewDelegate = [[CDVUIWebViewDelegate alloc] initWithDelegate:(id <UIWebViewDelegate>)self.viewController];
        uiWebView.delegate = self.uiWebViewDelegate;
    } else {
        3.如果外部controller沒有實現,那麼配置代理具體實現。
        比如說這裡我們在專案裡配置了HWebViewDelegate,那麼我
        們web攔截的時候其他處理就可以在子類裡面做了,比如新增白
        名單設定等。
        self.navWebViewDelegate = [[CDVUIWebViewNavigationDelegate alloc] initWithEnginePlugin:self];

        Class TheClass = NSClassFromString(@"HWebViewDelegate");
        if ([TheClass isSubclassOfClass:[CDVUIWebViewDelegate class]]) {
            self.uiWebViewDelegate = [[TheClass alloc] initWithDelegate:self.navWebViewDelegate];
        } else {
            self.uiWebViewDelegate = [[CDVUIWebViewDelegate alloc] initWithDelegate:self.navWebViewDelegate];
        }
        // end
        uiWebView.delegate = self.uiWebViewDelegate;
    }

    [self updateSettings:self.commandDelegate.settings];
}
複製程式碼

五、js與native互動以及native與js互動

到這裡為止,我們外掛配置與載入完成了,webView的具體實現與代理的設定也完成了,那麼接下來說一下native與js的具體互動吧,主要說一下native端都做了什麼。這是在CDVUIWebViewNavigationDelegate類中對web代理的實現,也是在上面配置webView的時候將它配置為代理的。這裡的實現就是互動的重中之重了,那麼詳細看下。

淺析iOS-Cordova

- (BOOL)webView:(UIWebView*)theWebView shouldStartLoadWithRequest:(NSURLRequest*)request navigationType:(UIWebViewNavigationType)navigationType
{
    1.拿到url
    NSURL* url = [request URL];
    2.拿到我們的實現類
    CDVViewController* vc = (CDVViewController*)self.enginePlugin.viewController;
    
    3.看url的scheme是不是gap,gap來源於cordova.js,
    cordova.js裡面建立了一個不可見的iframe注入當前html,
    src為gap://ready,用於webView攔截
    if ([[url scheme] isEqualToString:@"gap"]) {
    4.如果是就進行攔截,具體攔截後幹了啥下面說。
        [vc.commandQueue fetchCommandsFromJs];
        [vc.commandQueue executePending];
        return NO;
    }
    ...省略了一些程式碼
    return NO;
}
複製程式碼

到這裡native已經收到了js的呼叫了,webView的攔截可以看到只傳遞了一個gap://ready並沒有詳細的引數,詳細的引數實際上是在fetchCommandsFromJs中取到的,具體的下面說,這樣一來shouldStartLoadWithRequest:就不需要關心js端傳的是什麼,要怎麼解析等情況,它只負責攔截,然後將任務交給commandQueue去處理,好比快遞員給你打電話說你快遞到了去取一下,那麼你取回來的可能是鞋子,也可能是衣服,但是具體是什麼快遞員並不關心,他只需要告訴你你快遞到了,他的任務就結束了。

到這裡著重分析兩個方法,fetchCommandsFromJs:和executePending:,也是我們攔截的具體實現。

- (void)fetchCommandsFromJs
{
    __weak CDVCommandQueue* weakSelf = self;
    NSString* js = @"cordova.require('cordova/exec').nativeFetchMessages()";
    1.通過jsBridge呼叫js方法,js端會以字串的形式返回外掛資訊
    [_viewController.webViewEngine evaluateJavaScript:js
                                    completionHandler:^(id obj, NSError* error) {
        if ((error == nil) && [obj isKindOfClass:[NSString class]]) {
            NSString* queuedCommandsJSON = (NSString*)obj;
            CDV_EXEC_LOG(@"Exec: Flushed JS->native queue (hadCommands=%d).", [queuedCommandsJSON length] > 0);
            2.解析字串。
            [weakSelf enqueueCommandBatch:queuedCommandsJSON];
            // this has to be called here now, because fetchCommandsFromJs is now async (previously: synchronous)
           3.呼叫外掛
            [self executePending];
        }
    }];
}
複製程式碼

evaluateJavaScript:做了一個native呼叫js的操作,實際上cordova.js裡面也有一個commandQueue這樣一個資料結構,在呼叫native之前,就已經將本次呼叫所有的引數儲存在了commandQueue中,好比快遞櫃一樣,快遞已經放到櫃子裡了,自己來拿。native呼叫js還是基於jsBridge的stringByEvaluatingJavaScriptFromString:實現的。到這裡,js呼叫native和native呼叫js已經全部結束了,接下來就是native拿到引數呼叫native端的外掛程式碼了。

- (void)enqueueCommandBatch:(NSString*)batchJSON
{
    1.做個保護。
    if ([batchJSON length] > 0) {
        NSMutableArray* commandBatchHolder = [[NSMutableArray alloc] init];
        2.新增到queue中。
        [_queue addObject:commandBatchHolder];
        3.如果json串小於4M同步執行,如果大於就放到子執行緒中非同步執行。
        if ([batchJSON length] < JSON_SIZE_FOR_MAIN_THREAD) {
            4.將字典存入commandBatchHolder資料中。
            [commandBatchHolder addObject:[batchJSON cdv_JSONObject]];
        } else {
            dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^() {
                NSMutableArray* result = [batchJSON cdv_JSONObject];
                 5.因為非同步執行可能會發生執行緒安全的問題所以加互斥鎖做個執行緒保護。
                @synchronized(commandBatchHolder) {
                    [commandBatchHolder addObject:result];
                }
                6.回撥到主執行緒執行executePending
                [self performSelectorOnMainThread:@selector(executePending) withObject:nil waitUntilDone:NO];
            });
        }
    }
}
複製程式碼

六、native外掛具體呼叫過程

到這裡為止我們拿到了配置好的外掛,webView,js端傳遞過來的引數,還剩下最後一步,引數拿到了怎麼呼叫到外掛的呢?

- (void)executePending
{
    1.因為executePending函式會在多個地方呼叫,避免重複呼叫。
    if (_startExecutionTime > 0) {
        return;
    }
    @try {
        _startExecutionTime = [NSDate timeIntervalSinceReferenceDate];
      2.遍歷queue中的所有外掛資訊,也就是我們上面攔截到新增的。
        while ([_queue count] > 0) {
            NSMutableArray* commandBatchHolder = _queue[0];
            NSMutableArray* commandBatch = nil;
            @synchronized(commandBatchHolder) {
                // If the next-up command is still being decoded, wait for it.
                if ([commandBatchHolder count] == 0) {
                    break;
                }
                commandBatch = commandBatchHolder[0];
            }
            3.遍歷queue中的第一個外掛。
            while ([commandBatch count] > 0) {
                4.記憶體優化。
                @autoreleasepool {
                    5.返回外掛陣列並刪除,目的讓遍歷只走一次。
                    NSArray* jsonEntry = [commandBatch cdv_dequeue];
                    if ([commandBatch count] == 0) {
                        6.從佇列中刪除此外掛。
                        [_queue removeObjectAtIndex:0];
                    }
                    7.將引數儲存在CDVInvokedUrlCommand型別的例項物件中,這也就是我們定義外掛的時候
                    為什麼形參型別為CDVInvokedUrlCommand的原因了。
                    CDVInvokedUrlCommand* command = [CDVInvokedUrlCommand commandFromJson:jsonEntry];
                    CDV_EXEC_LOG(@"Exec(%@): Calling %@.%@", command.callbackId, command.className, command.methodName);
                    8.執行外掛具體函式。
                    if (![self execute:command]) {
#ifdef DEBUG
                            NSString* commandJson = [jsonEntry cdv_JSONString];
                            static NSUInteger maxLogLength = 1024;
                            NSString* commandString = ([commandJson length] > maxLogLength) ?
                                [NSString stringWithFormat : @"%@[...]", [commandJson substringToIndex:maxLogLength]] :
                                commandJson;

                            DLog(@"FAILED pluginJSON = %@", commandString);
#endif
                    }
                }
                9.利用runloop做的優化,具體可以參考一下runloop的知識,目的是為了保證UI流暢進行了優化。
                // Yield if we're taking too long.
                if (([_queue count] > 0) && ([NSDate timeIntervalSinceReferenceDate] - _startExecutionTime > MAX_EXECUTION_TIME)) {
                    [self performSelector:@selector(executePending) withObject:nil afterDelay:0];
                    return;
                }
            }
        }
    } @finally
    {
        _startExecutionTime = 0;
    }
}
複製程式碼

Yield if we're taking too long.執行時間太長了,這一塊涉及到runloop的知識,如果執行的時間過長,避免主執行緒堵塞,造成卡頓,向runloop中新增了一個timer來喚醒runloop繼續幹活,防止休眠。

- (BOOL)execute:(CDVInvokedUrlCommand*)command
{
    if ((command.className == nil) || (command.methodName == nil)) {
        NSLog(@"ERROR: Classname and/or methodName not found for command.");
        return NO;
    }

    1.找到native端的類並返回例項物件。
    CDVPlugin* obj = [_viewController.commandDelegate getCommandInstance:command.className];
    2.是否繼承與CDVPlugin。
    if (!([obj isKindOfClass:[CDVPlugin class]])) {
        NSLog(@"ERROR: Plugin '%@' not found, or is not a CDVPlugin. Check your plugin mapping in config.xml.", command.className);
        return NO;
    }
    BOOL retVal = YES;
    double started = [[NSDate date] timeIntervalSince1970] * 1000.0;
    // Find the proper selector to call.
    NSString* methodName = [NSString stringWithFormat:@"%@:", command.methodName];
    3.生成對應的選擇子。
    SEL normalSelector = NSSelectorFromString(methodName);
    4.發訊息執行。
    if ([obj respondsToSelector:normalSelector]) {
        // [obj performSelector:normalSelector withObject:command];
        ((void (*)(id, SEL, id))objc_msgSend)(obj, normalSelector, command);
    } else {
        // There's no method to call, so throw an error.
        NSLog(@"ERROR: Method '%@' not defined in Plugin '%@'", methodName, command.className);
        retVal = NO;
    }
    double elapsed = [[NSDate date] timeIntervalSince1970] * 1000.0 - started;
    if (elapsed > 10) {
        NSLog(@"THREAD WARNING: ['%@'] took '%f' ms. Plugin should use a background thread.", command.className, elapsed);
    }
    return retVal;
}
複製程式碼

到這裡,整個外掛的呼叫過程就結束了,生成plugin這裡,框架是基於工廠的設計模式,通過不同的類名返回繼承了CDVPlugin的不同物件,然後在對應的plugin物件上執行對應的方法,到這裡整個呼叫過程全部結束了。

注:本文屬於原創,轉載註明出處。圖片資源來源網際網路。