天天看點

iOS WebView中的JS互動

前言:

一段時間了,手頭的工作照常進行中,之前有好幾個主題一直想寫,但又擱置了,主要是主題有點大,看樣子路還是要一步一步走的;

前幾天,收到了一份面試通知,一家比較大的網際網路企業吧,想想自己也沒什麼面試經驗,總想着要換個環境,也想換個角度審視一下自己,是以就去了,真心是沒準備什麼,臨行前把幾個常用算法翻出來看了看……這裡就說說我的體會吧(可能會占一定篇幅,誰讓這是我的地盤呢)

到了之後,人力的同學帶我上樓,先是筆試1h,之後是面試,大概又是1h吧;筆試的内容,基礎的比如:weak、assign等關鍵字的使用場景,KVO的實作機制等,const/static的用法,CALayer層的使用,響應者鍊的概念,多線程GCD、NSOperation手寫代碼示例,手寫單例和遞歸函數;還有一些内容比較新,因為有幾個内容是最近一些首頁論壇分享出來的文章,或是一直以來讨論的主題,比如:如何高效的切割圓角,app的性能優化等;

大概答了一些,覺得自己說不明白的也就沒寫,面試官來了,就開始聊我回答的問題,涉及到的其實主要還是記憶體管理,多線程的使用,資料結構的常見問題,比如連結清單,二叉樹周遊,快排算法等一些基礎的算法思想……最後也問道了app架構上,設計模式等等;

其實很多東西都能說一點,但是了解的又不夠深入,總的來說,這是一次失敗的面試,但同時也是一次寶貴的經驗,面試技巧上就不提了,因為我做的也不好,哪能一兩次面試就總結出一堆真知灼見來,不過這次面試的内容,還是引起了我的思考:從開始接觸iOS開發,到今天差不多2年半了,時間并不短,對開發的了解也在逐漸變化,以前覺得實作需求順利就可以了,忽視了軟體開發其實是一個比較完成的體系,簡單的實作可能隻是冰山一角,深入的了解底層的原理,真的非常重要,新的知識也要及時接觸和實踐,在這些方面我做的還明顯不夠,好了,說了一堆廢話,算是與君共勉,接下來我們來看這次的主題;

UIWebView和WKWebView:

iOS8之前,對于webview的展示,我們使用UIWebView,在iOS8之後,蘋果推出了WKWebView,webkit使用WKWebView來代替UIWebView和OS X的WebView,新提供的WKWebView運作JS可以和Safari一樣快,記憶體上也優化了很多,這個大家在使用時可以很明顯的看出來,我就不舉例了;

UIWebView使用NSURLCache緩存,通過setSharedURLCache可以設定成我們自己的緩存,但WKWebview并不支援NSURLCache,相關緩存的清除我一會會給出;

對于簡單的webview加載,其實兩個控件用起來大同小異,都有各自的協定回調相應的頁面加載過程,由于在新項目中已經不再支援iOS8以前的系統了,是以自然就想到了用WKWebView;

需求上需要進行JS的互動,這也就是我本次要讨論的主題,讓我們先來熟悉一下WKWebView;

WKWebView API:

在使用之前需要先導入framework:

iOS WebView中的JS互動

接下來可以看下WKWebView的API:(我隻列舉一部分)

@property (nonatomic, readonly, copy) WKWebViewConfiguration *configuration;
           
- (instancetype)initWithFrame:(CGRect)frame configuration:(WKWebViewConfiguration *)configuration NS_DESIGNATED_INITIALIZER;
           
- (nullable WKNavigation *)loadRequest:(NSURLRequest *)request;
           
@property (nonatomic, readonly, getter=isLoading) BOOL loading;
           
@property (nonatomic, readonly) double estimatedProgress;
           
@property (nonatomic, readonly) BOOL canGoBack;
           
@property (nonatomic, readonly) BOOL canGoForward;
           
- (nullable WKNavigation *)goBack;

- (nullable WKNavigation *)goForward;

- (nullable WKNavigation *)reload;
           
- (void)evaluateJavaScript:(NSString *)javaScriptString completionHandler:(void (^ _Nullable)(_Nullable id, NSError * _Nullable error))completionHandler;
           

這幾個包括了:

WKWebView初始化時的配置屬性;初始化方法;加載url的方法;目前的加載狀态;加載進度;前進/後退操作;解釋執行JS語句的方法;

意思都比較清晰,熟悉就好;

WKWebView的使用:

使用之前先導入頭檔案:

#import <WebKit/WebKit.h>
           

接下來讓我們看一段初始化的代碼:

WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init];
    
    // 設定偏好設定
    config.preferences = [[WKPreferences alloc] init];
    // 預設為0
    config.preferences.minimumFontSize = 10;
    // 預設認為YES
    config.preferences.javaScriptEnabled = YES;
    // 在iOS上預設為NO,表示不能自動通過視窗打開
    config.preferences.javaScriptCanOpenWindowsAutomatically = YES;
    
    // web内容處理池
    config.processPool = [[WKProcessPool alloc] init];
    
    // 通過JS與webview内容互動
    config.userContentController = [[WKUserContentController alloc] init];
    // 注入JS對象名稱createdoctor,當JS通過createdoctor來調用時,
     [config.userContentController addScriptMessageHandler:self name:@"createdoctor"];
    
    self.webView = [[WKWebView alloc] initWithFrame:CGRectMake(0, 0,ScreenWidth , ScreenHeight - 64) configuration:config];
    
    // 添加KVO監聽
    [self.webView addObserver:self
                  forKeyPath:@"loading"
                     options:NSKeyValueObservingOptionNew
                     context:nil];
    [self.webView addObserver:self
                  forKeyPath:@"title"
                     options:NSKeyValueObservingOptionNew
                     context:nil];
    [self.webView addObserver:self
                  forKeyPath:@"estimatedProgress"
                     options:NSKeyValueObservingOptionNew
                     context:nil];
    
    self.webView.UIDelegate = self;
    self.webView.navigationDelegate = self;
    
    [self.webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:url]]];
    
    [self.view addSubview:self.webView];
           

這其中有兩個類值得我們特别關注下:WKWebViewConfiguration和WKUserContentController;

我們注意到WKWebViewConfiguration對應的對象在設定相關屬性之後,作為WKWebView初始化的配置項傳入;WKUserContentController的執行個體作為注冊JS對象的容器存在(有添加就有移除,api裡方法都有);這兩個類的API我就不列在這了,沒幾個方法,大家看下就行;

這段代碼可以分為四個部分:

1.配置執行個體config的初始化,注意頁面的一些設定都是在preferences的屬性中設定的,config執行個體還有一個比較重要的屬性是websiteDataStore(WKWebsiteDataStore),存儲的資訊類似UIWebView的緩存資訊,這個類會在清理緩存的時候用到;

2.WKUserContentController執行個體注冊JS對象;

3.WKWebView初始化及URL加載;

4.KVO監聽加載狀态,頁面标題,加載進度 幾個屬性的變化;

1、3、4相信大家都比較清楚,主要是第2步,那接下來我就做下說明:

app webview調用執行JS,很簡單,使用WKWebView,隻需要用相應的執行個體調用方法即可:

NSString * js = @"alert('Objective-C call js to show alert');window.webkit.messageHandlers.createdoctor.postMessage('要告訴app醫生已經建立成功 你可以進行界面切換了')";
    [self.webView evaluateJavaScript:js completionHandler:nil];
           

就像這樣;

也可以提前注入一個js方法,在需要調用的時候執行相應方法即可,這樣就不用每次都寫一段js代碼來解釋執行了:

//js注入,注入一個測試方法。
    NSString *javaScriptSource = @"function userFunc(){window.webkit.messageHandlers.createdoctor.postMessage( {\"name\":\"HS\"})}";
    WKUserScript *userScript = [[WKUserScript alloc] initWithSource:javaScriptSource injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES];// forMainFrameOnly:NO(全局視窗),yes(隻限主視窗)
    [config.userContentController addUserScript:userScript];
           

但是實際的應用場景要稍複雜些,比如說前後端的傳值(這裡我先把app這裡叫前端,JS端成為後端,這是相對來講的,大家了解就行):

1.如果前端希望後端處理一些場景,但是還需要提供一下前端才有的資料,這是就需要在前端調用執行後端在頁面中提供的js方法,進行傳值,即“前->後”;

2.相應的,在後端處理了一些事物之後,或是需要主動觸發前端的相關操作,就需要把具體的結果通過前端注冊的JS對象,并調用方法,進行傳值,即“後->前”;

前面提到的注冊js對象,對應的就是“後-前”的這種場景,我們可以看下這個方法:

[config.userContentController addScriptMessageHandler:self name:@"createdoctor"];
           

Handler:是遵循WKScriptMessageHandler協定的執行個體對象,通過該協定的回調方法,回去服務端傳過來的參數:

- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message
{
        NSLog(@"\nJS:\nname-%@;\nbody-%@\n",message.name,message.body);

}
           

這裡指定為目前的視圖控制器;

name:指定的值是一個字元串,這個字元串,或被webkit解釋成一個對象,供後端使用,使用的格式形如:

window.webkit.messageHandlers.createdoctor.postMessage('哈哈哈')"

其中createdoctor就是我們注冊時傳入的name,可以注冊多個JS對象(注意有注冊就要有移除)供後端在不同的業務場景中調用,postMessage是一個方法,裡邊傳的是參數;

若使用上邊的代碼那我們在離開這個ViewController的時候,在dealloc中就需要做幾件事情:

一直提到要移除已經注冊的JS對象,是以這裡要調用WKUserContentController的執行個體方法

[self.webView.configuration.userContentController removeScriptMessageHandlerForName:@"createdoctor"];
           

同時還要移除KVO的監聽;

還有一個(如果在意的話),就是清除緩存:

之前提到的WKWebsiteDataStore類是iOS9之後提供的,我們可以用它來清除webview存儲的資料;

NSSet *websiteDataTypes = [NSSet setWithArray:@[
                                                                WKWebsiteDataTypeDiskCache,
                                                                WKWebsiteDataTypeOfflineWebApplicationCache,
                                                                WKWebsiteDataTypeMemoryCache,
                                                                WKWebsiteDataTypeLocalStorage,
                                                                WKWebsiteDataTypeCookies,
                                                                WKWebsiteDataTypeSessionStorage,
                                                                WKWebsiteDataTypeIndexedDBDatabases,
                                                                WKWebsiteDataTypeWebSQLDatabases
                                                                ]];
           

有很多類型是吧,區分不出來,就全删掉好了:

NSSet *websiteDataTypes = [WKWebsiteDataStore allWebsiteDataTypes];
        NSDate *dateFrom = [NSDate dateWithTimeIntervalSince1970:0];
        [[WKWebsiteDataStore defaultDataStore] removeDataOfTypes:websiteDataTypes
                                                   modifiedSince:dateFrom completionHandler:^{
                                                       // code
                                                   }];
           

但是還會有這樣的疑問,既然這個類是iOS9之後才有的,那iOS8中WKWebView的緩存如何清理啊,我在網上找了一段代碼:

//wkwebview緩存清空
        NSString *libraryDir = NSSearchPathForDirectoriesInDomains(NSLibraryDirectory,
                                                                   NSUserDomainMask, YES)[0];
        NSString *bundleId  =  [[[NSBundle mainBundle] infoDictionary]
                                objectForKey:@"CFBundleIdentifier"];
        NSString *webkitFolderInLib = [NSString stringWithFormat:@"%@/WebKit",libraryDir];
        NSString *webKitFolderInCaches = [NSString
                                          stringWithFormat:@"%@/Caches/%@/WebKit",libraryDir,bundleId];
        NSString *webKitFolderInCachesfs = [NSString
                                            stringWithFormat:@"%@/Caches/%@/fsCachedData",libraryDir,bundleId];
        
        NSError *error;
        [[NSFileManager defaultManager] removeItemAtPath:webKitFolderInCaches error:&error];
        [[NSFileManager defaultManager] removeItemAtPath:webkitFolderInLib error:&error];
        [[NSFileManager defaultManager] removeItemAtPath:webKitFolderInCachesfs error:&error];
           

這個我也沒試過好不好用(一會再和大家講為什麼沒試),實質就是去本地找緩存路徑,然後清空指定路徑的資料;

有了這些準備之後,接下來我們來實踐一下,這裡需要借助Safari提供的Web檢查器進行JS的調試;

使用Safari進行web調試:

先看下如何使用Safari進行調試,大家看看這幾張圖就知道了:

iOS WebView中的JS互動
iOS WebView中的JS互動
iOS WebView中的JS互動

現在我用手機打開一個我項目中的url,就可以通過Safari進行調試了:

iOS WebView中的JS互動

在出現的網頁檢查器中刻意直接輸入JS的代碼,執行相關方法進行測試,比如我執行一個alert()方法:

iOS WebView中的JS互動

傳入參數1的話,在我app中相應的webView上就會顯示一個alert彈窗提示“1”;

注意:WKWebView對于這類alert有單獨的代理方法進行處理,如:

- (void)webView:(WKWebView *)webView runJavaScriptAlertPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(void))completionHandler
           

需要在這個方法中,實作UIAlertController否則該彈窗不會顯示;

app端-JS端:

app端給js端傳值,隻需調用evaluateJavaScript方法執行JS提供的方法即可;

目前webview中js提供了一個方法,如fnCommon,他有兩個參數(我們傳的是公參和私有參數,你用的話可以随便傳點什麼,這些方法實作時商定即可)

iOS WebView中的JS互動

在控制台也是可以調用到這個方法的:

iOS WebView中的JS互動

實際調用的話可以這樣:

NSString * js = [NSString stringWithFormat:@"fnCommon(\'%@\',\'%@\')",pubStr,priStr];
    [self.webView evaluateJavaScript:js completionHandler:nil];
           

JS端拿到傳遞的參數,處理即可;

JS端-app端:

這個場景前面我們已經論述過了,這裡直接給出控制台執行的示例:

iOS WebView中的JS互動

執行這句代碼之後,app中的webview就會調用WKScriptMessageHandler協定的代理方法:

- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message
{
        NSLog(@"\nJS:\nname-%@;\nbody-%@\n",message.name,message.body);

}
           

name就是createdoctor;

body就是postMessage傳入的字元串參數;

這裡給出用戶端的log,大家可以試一下;

iOS WebView中的JS互動

無法釋放的問題:

前面我們已經講過,在js端調用app端時需要注冊JS對象:

[config.userContentController addScriptMessageHandler:self name:@"createdoctor"];
           

我們将代理設定為self,注冊了對用的就是移除,我們是在dealloc中做的,但是實際調試過程中,我們發現退出界面後,dealloc方法并沒有執行,這裡貌似是self對象被強引用得不到釋放造成的,這裡我們可以通過自定義一個實作WKScriptMessageHandler協定的對象,如HQScriptMessageHandler:

HQScriptMessageHandler * messageHandle = [[HQScriptMessageHandler alloc]initWithDelegate:self];
    [config.userContentController addScriptMessageHandler:messageHandle name:@"createdoctor"];
           

在這個類中實作相應的回調方法,并處理即可,這樣self就能正常釋放,dealloc方法就會被調用了;

對象釋放的問題:

在執行“window.webkit.messageHandlers.createdoctor.postMessage('要告訴app醫生已經建立成功 你可以進行界面切換了') ”這句代碼之後,雖然在WKScriptMessageHandler協定的代理方法中收到了正常的JS端傳過來的參數,但是出現了crush:在被釋放的對象并沒有被初始化……

iOS WebView中的JS互動

………………這個malloc_error_break咋debug啊?(我隻想說,我的内心是崩潰的>_<,這就是前面iOS8下清緩存的方式我沒試的原因)

沒辦法的辦法:

這個bug調試了一段時間,找了幾個同業的牛人請教了一下,奈何人家寫的code就沒問題;在論壇上提了還沒回複;最終我的解決方案是暫時使用UIWebView實作JS的互動,優先實作功能,這也是沒辦法的辦法;

限于個人能力,沒能将這個問題解決掉,希望有遇到相同情況的同學在看到我的文章之後,能指點一二,萬分謝謝;

我自己也會繼續嘗試解決這個問題,有了進展我會更新上來,友善大家交流;

UIWebView API:

得,又回來看我們的老朋友了:

@property (nullable, nonatomic, assign) id <UIWebViewDelegate> delegate;

- (void)loadRequest:(NSURLRequest *)request;

- (void)reload;
- (void)stopLoading;

- (void)goBack;
- (void)goForward;

@property (nonatomic, readonly, getter=canGoBack) BOOL canGoBack;
@property (nonatomic, readonly, getter=canGoForward) BOOL canGoForward;
@property (nonatomic, readonly, getter=isLoading) BOOL loading;

           

在初始化webView視圖之後,我們可以通過loadrequest方法載入相應的界面

NSString * url = @"http://www.baidu.com";
    NSURLRequest * request = [NSURLRequest requestWithURL:[NSURL URLWithString:url]];
    self.webView.delegate = self;
    [self.webView loadRequest:request];
           

load、reload、stoploading這個幾個方法對應屬性loading的狀态值;

goback方法使用時需要判斷屬性canGoBack的值;goForward方式使用時需要判斷屬性canGoForward的值;這兩個方法是處理webview内連接配接界面切換的;(對于跨域的連結需要手動打開)

看起來是不是和WKWebView差不多;

執行js代碼使用如下方法:

- (nullable NSString *)stringByEvaluatingJavaScriptFromString:(NSString *)script;
           

其他屬性就不看了,協定的代理方法又一個還是需要注意下:

- (BOOL)webView:(UIWebView *)webView shouldStartLoadWithRequest:(NSURLRequest *)request navigationType:(UIWebViewNavigationType)navigationType;
           

通過這個代理方法,也是可以實作原生和js互動的,在這個方法裡面。可以拿到每一個URL,通過對URL的參數字段監測分析,可以實作JS調用OC代碼;

用一個比較形象的詞就是”url重定向“(姑且這樣講吧)

我們要用這種方式嗎?當然不是,iOS7之後,蘋果提供了一個新的庫JavaScriptCore(對應頭檔案#import <JavaScriptCore/JavaScriptCore.h>),用來做JS互動,我們就用這個;

JavaScriptCore:

JavaScriptCore是webkit的一個重要組成部分,主要是對JS進行解析和提供執行環境。使用起來也比較簡單,深入了解的話推薦大家看看這篇文章

這裡,我就直接放代碼了,用起來都差不多;

app端=>JS端:

NSString * pubStr = [pubPara toJSONString];
    NSString * priStr = [priPara toJSONString];
    
    NSString * js = [NSString stringWithFormat:@"fnCommon(\'%@\',\'%@\')",pubStr,priStr];
    [self.webView stringByEvaluatingJavaScriptFromString:js];
           

調用webview方法,直接解釋執行js代碼即可;

或是參考上邊文章中的這種方式

self.context = [[JSContext alloc] init];

    NSString *js = @"function add(a,b) {return a+b}";

    [self.context evaluateScript:js];

    JSValue *n = [self.context[@"add"] callWithArguments:@[@2, @3]];

    NSLog(@"---%@", @([n toInt32]));//---5
           

這種其實和WKWebView的js注入有點像;

JS端=>app端:

首先定義一個對象,并聲明一個繼承自JSExport協定的協定:

.h

#import <Foundation/Foundation.h>
#import <JavaScriptCore/JavaScriptCore.h>

@protocol HQJSProtocal <JSExport>

JSExportAs(postjsaction, -(void)postjsaction:(NSString *)name Scripts:(NSString *)action);
JSExportAs(alert, -(void)alert:(id)message);


@end

@protocol HQJSResultDelegate <NSObject>

-(void)receivejsaction:(NSString *)name Scripts:(NSString *)action;

@end

@interface HQJSInterface : NSObject<HQJSProtocal>

-(instancetype)initWithDelegate:(id<HQJSResultDelegate>)delegate;

@end
           

HQJSResultDelegate這個協定是用來回調傳遞HQJSProtocal協定方法接收到的資料的;

.m

#import "HQJSInterface.h"

@interface HQJSInterface()

@property (nonatomic , weak) id<HQJSResultDelegate> delegate;

@end

@implementation HQJSInterface
-(instancetype)initWithDelegate:(id<HQJSResultDelegate>)delegate{
    if (self = [super init]) {
        _delegate = delegate;
    }
    return self;
}

-(void)postjsaction:(NSString *)name Scripts:(NSString *)action{
    //nb
    if ([self.delegate respondsToSelector:@selector(receivejsaction:Scripts:)]) {
        [self.delegate receivejsaction:name Scripts:action];
    }
}
-(void)alert:(id)message{
    if ([message isKindOfClass:[NSString class]]) {
        NSLog(@"%@",message);

    }
}

@end
           

之後結合UIWebView使用:

- (void)webViewDidFinishLoad:(UIWebView *)webView{
    self.context = [webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];

    HQJSInterface * dtp = [[HQJSInterface alloc]initWithDelegate:self];
    self.context[@"dtp"] = dtp;
}
           

擷取上下文,注冊互動對象,通過HQJSResultDelegate的協定方法,我們就可以得到js調用所傳遞的資料了;

總結:

這種js互動的實踐,到是讓我重新認識了一下js與原生,聯想到了最近很火的ReactNative,移動端的開發對系統api及平台的依賴性是必然的,但是新的程式設計架構也在改變着一些東西,之前有位朋友說他已經連續幾個月一直在用React這種方式程式設計了,不禁感歎技術的跟新如此之快,以至于稍不留神就又被抛出軌道的感覺,一開始的唠叨多少和這個也有些關系,敢于嘗試新的東西,同時深入學習有所專攻,努力成長,迎接即将到來的2017。

參考文章:

http://www.jianshu.com/p/86a1b69bc9a6

http://www.jianshu.com/p/d19689e0ed83

http://www.tuicool.com/articles/qQRrMzY