iOS開發基礎146-深入解析WKWebView

Mr.陳發表於2024-08-03

WKWebView是蘋果在iOS 8中引入的重要元件,它替代了UIWebView,為開發者提供了高效能、高穩定性的網頁顯示和互動能力。在本文中,我們將深入探討WKWebView的底層架構、關鍵特性、使用方法和高階功能。

一、WKWebView的底層架構

WKWebView基於WebKit框架,採用多程序架構,將頁面渲染和JavaScript執行放在獨立的Web程序中,這樣做的好處是主應用程序與Web內容程序隔離,能顯著提升應用的穩定性和安全性。其架構主要包括以下幾個部分:

1. Web內容程序

負責HTML解析、CSS解析、JavaScript執行、頁面渲染等操作。這些操作都是在獨立的程序中進行,防止網頁崩潰影響整個應用。

2. 網路程序

負責網路請求的管理和快取資料的處理,從資料來源獲取網頁內容,並傳輸給Web內容程序。

3. UI程序

主要負責與使用者的互動,如接收使用者輸入、傳送訊息給Web內容程序等。UI程序與Web內容程序透過IPC(程序間通訊)進行資訊的傳遞。

如下圖所示是WKWebView的架構示意圖:

+------------------+                +------------------+
|                  | <------------> |                  |
|      UI程序       |                |    Web內容程序     |
|                  |    IPC 通訊      |                  |
+------------------+                +------------------+
         ^                                   ^
         |                                   |
         v                                   v
+------------------+                +------------------+
|                  |                |                  |
|     WKWebView    |                |     頁面引擎      |
|                  |                |                  |
+------------------+                +------------------+

二、WKWebView的基本使用

1. 初始化WKWebView

要使用WKWebView,首先需要進行基本初始化和配置工作。不同於UIWebView,初始化WKWebView時需指定其配置屬性。

#import <WebKit/WebKit.h>

@interface ViewController ()
@property (nonatomic, strong) WKWebView *webView;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    // 建立配置物件
    WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init];
    
    // 初始化WKWebView
    self.webView = [[WKWebView alloc] initWithFrame:self.view.bounds configuration:configuration];
    
    // 設定內邊距
    [self.view addSubview:self.webView];
    
    // 載入一個網頁示例
    NSURL *url = [NSURL URLWithString:@"https://www.apple.com"];
    NSURLRequest *request = [NSURLRequest requestWithURL:url];
    [self.webView loadRequest:request];
}

@end

2. 載入本地檔案

除了載入網路資源外,WKWebView還可以載入本地檔案:

NSString *htmlPath = [[NSBundle mainBundle] pathForResource:@"index" ofType:@"html"];
NSURL *baseURL = [NSURL fileURLWithPath:[[NSBundle mainBundle] bundlePath]];
NSString *htmlContent = [NSString stringWithContentsOfFile:htmlPath encoding:NSUTF8StringEncoding error:nil];
[self.webView loadHTMLString:htmlContent baseURL:baseURL];

3. 導航控制

WKWebView提供了豐富的導航控制方法,幫助我們處理網頁的前進、後退和重新整理等操作:

// 重新整理當前頁面
[self.webView reload];

// 停止載入
[self.webView stopLoading];

// 後退到上一頁面
[self.webView goBack];

// 前進到下一頁面
[self.webView goForward];

4. 獲取網頁內容

WKWebView的一個強大功能是可以直接執行JavaScript程式碼並獲取返回值:

[self.webView evaluateJavaScript:@"document.title" completionHandler:^(id result, NSError *error) {
    if (!error) {
        NSLog(@"Page title: %@", result);
    }
}];

三、WKWebView的代理與回撥

WKWebView提供了兩個主要的代理協議:WKNavigationDelegateWKUIDelegate,它們分別處理導航和使用者介面方面的回撥。

1. WKNavigationDelegate

該協議管理網頁內容的載入過程,包括開始、完成、失敗等事件:

@interface ViewController () <WKNavigationDelegate>
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    // 設定導航代理
    self.webView.navigationDelegate = self;
    
    // 載入網頁
    NSURL *url = [NSURL URLWithString:@"https://www.apple.com"];
    NSURLRequest *request = [NSURLRequest requestWithURL:url];
    [self.webView loadRequest:request];
}

// 頁面開始載入
- (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(WKNavigation *)navigation {
    NSLog(@"頁面開始載入");
}

// 內容開始返回
- (void)webView:(WKWebView *)webView didCommitNavigation:(WKNavigation *)navigation {
    NSLog(@"內容開始返回");
}

// 頁面載入完成
- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation {
    NSLog(@"頁面載入完成");
}

// 頁面載入失敗
- (void)webView:(WKWebView *)webView didFailProvisionalNavigation:(WKNavigation *)navigation withError:(NSError *)error {
    NSLog(@"頁面載入失敗,錯誤: %@", error.localizedDescription);
}
@end

2. WKUIDelegate

該協議處理網頁中的UI事件,比如顯示JavaScript的alertconfirmprompt對話方塊:

@interface ViewController () <WKUIDelegate>
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    // 設定使用者介面代理
    self.webView.UIDelegate = self;
    
    // 載入網頁
    NSURL *url = [NSURL URLWithString:@"https://www.apple.com"];
    NSURLRequest *request = [NSURLRequest requestWithURL:url];
    [self.webView loadRequest:request];
}

// JavaScript alert框
- (void)webView:(WKWebView *)webView runJavaScriptAlertPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(void))completionHandler {
    UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"提示" message:message preferredStyle:UIAlertControllerStyleAlert];
    UIAlertAction *ok = [UIAlertAction actionWithTitle:@"確定" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
        completionHandler();
    }];
    [alert addAction:ok];
    [self presentViewController:alert animated:YES completion:nil];
}

// JavaScript confirm框
- (void)webView:(WKWebView *)webView runJavaScriptConfirmPanelWithMessage:(NSString *)message initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(BOOL result))completionHandler {
    UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"確認" message:message preferredStyle:UIAlertControllerStyleAlert];
    UIAlertAction *ok = [UIAlertAction actionWithTitle:@"確定" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
        completionHandler(YES);
    }];
    UIAlertAction *cancel = [UIAlertAction actionWithTitle:@"取消" style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) {
        completionHandler(NO);
    }];
    [alert addAction:ok];
    [alert addAction:cancel];
    [self presentViewController:alert animated:YES completion:nil];
}

// JavaScript prompt框
- (void)webView:(WKWebView *)webView runJavaScriptTextInputPanelWithPrompt:(NSString *)prompt defaultText:(NSString *)defaultText initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSString * _Nullable result))completionHandler {
    UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"輸入" message:prompt preferredStyle:UIAlertControllerStyleAlert];
    [alert addTextFieldWithConfigurationHandler:^(UITextField * _Nonnull textField) {
        textField.text = defaultText;
    }];
    UIAlertAction *ok = [UIAlertAction actionWithTitle:@"確定" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
        NSString *input = alert.textFields.firstObject.text;
        completionHandler(input);
    }];
    UIAlertAction *cancel = [UIAlertAction actionWithTitle:@"取消" style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) {
        completionHandler(nil);
    }];
    [alert addAction:ok];
    [alert addAction:cancel];
    [self presentViewController:alert animated:YES completion:nil];
}

@end

四、WKWebView的進階使用

1. 與JavaScript互動

透過WKScriptMessageHandler協議,WKWebView可以和網頁中的JavaScript進行雙向互動。

前提配置

需要在WKWebViewConfiguration中配置內容控制器WKUserContentController並註冊JavaScript訊息處理器:

@interface ViewController () <WKScriptMessageHandler>
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init];
    WKUserContentController *contentController = [[WKUserContentController alloc] init];
    [contentController addScriptMessageHandler:self name:@"nativeHandler"];
    config.userContentController = contentController;

    self.webView = [[WKWebView alloc] initWithFrame:self.view.bounds configuration:config];
    [self.view addSubview:self.webView];

    NSString *html = @"<html><body><button onclick=\"window.webkit.messageHandlers.nativeHandler.postMessage('Hello from JS!');\">Click Me</button></body></html>";
    [self.webView loadHTMLString:html baseURL:nil];
}

- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
    if ([message.name isEqualToString:@"nativeHandler"]) {
        NSLog(@"Received message from JS: %@", message.body);
    }
}

- (void)dealloc {
    [self.webView.configuration.userContentController removeScriptMessageHandlerForName:@"nativeHandler"];
}

@end

這樣,當點選網頁按鈕時,JavaScript會將訊息傳送到原生程式碼並觸發userContentController:didReceiveScriptMessage:回撥。

2. Loading進度條

透過監聽WKWebViewestimatedProgress屬性,我們可以實現網頁載入過程中的進度條顯示:

@interface ViewController ()

@property (nonatomic, strong) UIProgressView *progressView;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    // 初始化WKWebView
    self.webView = [[WKWebView alloc] initWithFrame:self.view.bounds];
    [self.view addSubview:self.webView];

    // 初始化進度條
    self.progressView = [[UIProgressView alloc] initWithProgressViewStyle:UIProgressViewStyleDefault];
    self.progressView.frame = CGRectMake(0, 88, self.view.bounds.size.width, 2);
    [self.view addSubview:self.progressView];

    // 觀察estimatedProgress屬性
    [self.webView addObserver:self forKeyPath:@"estimatedProgress" options:NSKeyValueObservingOptionNew context:nil];

    // 載入網頁
    NSURL *url = [NSURL URLWithString:@"https://www.apple.com"];
    NSURLRequest *request = [NSURLRequest requestWithURL:url];
    [self.webView loadRequest:request];
}

- (void)dealloc {
    [self.webView removeObserver:self forKeyPath:@"estimatedProgress"];
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    if ([keyPath isEqualToString:@"estimatedProgress"]) {
        self.progressView.progress = self.webView.estimatedProgress;
        if (self.webView.estimatedProgress >= 1.0) {
            [UIView animateWithDuration:0.5 animations:^{
                self.progressView.alpha = 0.0;
            }];
        } else {
            self.progressView.alpha = 1.0;
        }
    } else {
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}

@end

3. 處理檔案上傳

WKWebView支援檔案上傳,透過實現UIDocumentPickerViewController,我們可以定製上傳檔案的操作:

@interface ViewController () <WKUIDelegate, UIDocumentPickerDelegate>
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    // 初始化WKWebView
    WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init];
    self.webView = [[WKWebView alloc] initWithFrame:self.view.bounds configuration:configuration];
    self.webView.UIDelegate = self;
    [self.view addSubview:self.webView];

    // 載入網頁
    NSURL *url = [NSURL URLWithString:@"https://example.com"];
    NSURLRequest *request = [NSURLRequest requestWithURL:url];
    [self.webView loadRequest:request];
}

- (void)webView:(WKWebView *)webView runOpenPanelWithParameters:(WKOpenPanelParameters *)parameters initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSArray<NSURL *> * _Nullable URLs))completionHandler {
    UIDocumentPickerViewController *documentPicker = [[UIDocumentPickerViewController alloc] initWithDocumentTypes:@[@"public.item"] inMode:UIDocumentPickerModeOpen];
    documentPicker.delegate = self;
    documentPicker.completionHandler = ^(NSArray<NSURL *> * _Nonnull urls) {
        completionHandler(urls);
    };
    [self presentViewController:documentPicker animated:YES completion:nil];
}

@end

五、WKWebView的效能最佳化

由於WKWebView在實際使用中可能會面臨效能問題,以下是一些效能最佳化的建議:

1. 快取策略

透過使用合適的快取策略,你可以避免重複載入相同的資源,從而提高載入速度。如使用URLCache配置:

NSURLCache *urlCache = [[NSURLCache alloc] initWithMemoryCapacity:1024 * 1024 * 10
                                                    diskCapacity:1024 * 1024 * 50
                                                        diskPath:@"wkwebview_cache"];
[NSURLCache setSharedURLCache:urlCache];

NSURLRequest *request = [NSURLRequest requestWithURL:url cachePolicy:NSURLRequestReturnCacheDataElseLoad timeoutInterval:30];
[self.webView loadRequest:request];

2. 非同步載入資源

避免同步載入資源導致主執行緒阻塞,可以使用非同步載入的方法來處理:

dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
    NSURL *url = [NSURL URLWithString:@"https://example.com/resource"];
    NSData *data = [NSData dataWithContentsOfURL:url];
    dispatch_async(dispatch_get_main_queue(), ^{
        [self.webView loadData:data MIMEType:@"text/html" characterEncodingName:@"UTF-8" baseURL:[NSURL URLWithString:@"https://example.com"]];
    });
});

3. 減少DOM操作

在需要頻繁操作DOM時,儘量將多個操作合併為一次,以減少引擎的渲染負擔:

function updateContent() {
    let container = document.getElementById('container');
    let fragment = document.createDocumentFragment();

    for (let i = 0; i < 1000; i++) {
        let div = document.createElement('div');
        div.textContent = `Item ${i}`;
        fragment.appendChild(div);
    }

    container.appendChild(fragment);
}

六、OC與JavaScript通訊進階

如果只是傳遞簡單的使用者資訊資料,除了透過 WKScriptMessageHandler 的方式,還有以下幾種方法可以將資料從客戶端(Objective-C/Swift)傳遞給 JavaScript。

1、透過 URL Scheme

這種方法主要是在載入網頁的時候,將使用者資訊作為查詢引數(query parameter)嵌入到 URL 中傳遞給頁面。這種方式適用於初始載入頁面的資料傳遞。

// 構建使用者資訊資料
NSString *userInfo = @"userId=12345&userName=JohnDoe";
NSString *urlString = [NSString stringWithFormat:@"https://example.com?%@", userInfo];
NSURL *url = [NSURL URLWithString:urlString];
NSURLRequest *request = [NSURLRequest requestWithURL:url];
[self.webView loadRequest:request];

在 JavaScript 中可以透過 window.location.search 獲取查詢引數。

2、透過 evaluateJavaScript 執行 JavaScript

evaluateJavaScript:completionHandler: 是一個簡單直接的方法,可以在客戶端執行任意 JavaScript 程式碼並透過回撥獲取執行結果。

// 構建JavaScript程式碼
NSString *userId = @"12345";
NSString *userName = @"JohnDoe";
NSString *jsCode = [NSString stringWithFormat:@"setUserInfo('%@', '%@');", userId, userName];

// 執行JavaScript程式碼
[self.webView evaluateJavaScript:jsCode completionHandler:^(id result, NSError *error) {
    if (error) {
        NSLog(@"Error: %@", error.localizedDescription);
    }
}];

在網頁中,需要定義對應的 JavaScript 函式來接收這些資料:

<script>
function setUserInfo(userId, userName) {
    console.log("User ID: " + userId);
    console.log("User Name: " + userName);
    // 其他業務邏輯
}
</script>

3、透過 User Scripts

如果你想在頁面載入的初始階段注入資料,可以使用 WKUserScript 來新增 JavaScript 預處理。

// 構建JavaScript程式碼
NSString *userId = @"12345";
NSString *userName = @"JohnDoe";
NSString *scriptSource = [NSString stringWithFormat:@"window.userInfo = {userId: '%@', userName: '%@'};", userId, userName];

// 建立使用者指令碼
WKUserScript *userScript = [[WKUserScript alloc] initWithSource:scriptSource injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES];

// 新增使用者指令碼到配置
WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init];
[config.userContentController addUserScript:userScript];

// 建立並載入 WKWebView
self.webView = [[WKWebView alloc] initWithFrame:self.view.bounds configuration:config];
NSURL *url = [NSURL URLWithString:@"https://example.com"];
NSURLRequest *request = [NSURLRequest requestWithURL:url];
[self.webView loadRequest:request];

透過上述方法,頁面在載入時就會自動注入使用者資訊,網頁可以在任何地方直接訪問 window.userInfo

4、透過 Document.cookie(不推薦)

雖然不太推薦,但我們也可以透過設定 Document.cookie 將資訊傳遞給網頁。以下是示例:

NSString *userId = @"12345";
NSString *userName = @"JohnDoe";
NSString *cookieScript = [NSString stringWithFormat:@"document.cookie = 'userId=%@; path=/'; document.cookie = 'userName=%@; path=/';", userId, userName];
[self.webView evaluateJavaScript:cookieScript completionHandler:^(id result, NSError *error) {
    if (error) {
        NSLog(@"Error: %@", error.localizedDescription);
    }
}];

在網頁中,可以透過 JavaScript 解析 document.cookie 獲取使用者資訊。

function getCookie(name) {
    let value = `; ${document.cookie}`;
    let parts = value.split(`; ${name}=`);
    if (parts.length === 2) return parts.pop().split(';').shift();
}

let userId = getCookie('userId');
let userName = getCookie('userName');
console.log("User ID: " + userId);
console.log("User Name: " + userName);

選擇

以上方法各有優劣,根據實際使用場景選擇適合的方法:

  • 如果是初始載入時傳遞資料,用 URL Scheme 比較簡單直接。
  • 如果需要在頁面載入後隨時傳遞資料,evaluateJavaScript:completionHandler: 非常靈活。
  • 需要在頁面載入前就注入資料,WKUserScript 是一種好方法。
  • Document.cookie 方式雖然可以傳遞資料,但不推薦用於敏感資訊。

七、總結

WKWebView提供了現代化的網頁檢視解決方案,具有高效能、高穩定性的優勢。透過理解其底層架構、掌握常用和進階的使用方法、如何與JavaScript進行互動和處理實際應用中的各種需求,你可以更好地實現複雜的網頁載入與互動功能,提升應用的使用者體驗和效能。

相關文章