09、WKWebView的用法

1、WKWebView的常见属性和方法

1.1 配置WKWebView

 //初始化
        webView = WKWebView(frame: CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height))
        //设置ui代理
        webView.uiDelegate = self
        //设置导航代理
        webView.navigationDelegate = self
        //是否允许手势左滑返回上一级,类似导航栏的左滑返回
        webView.allowsBackForwardNavigationGestures = true
        //可返回的页面列表, 存储已打开过的网页
        let backForwardList = webView.backForwardList
        //页面后退
        webView.goBack()
        //页面前进
        webView.goForward()
        //刷新当前页面
        webView.reload()
        //加载本地HTML
        let htmlString = ""
        webView.loadHTMLString(htmlString, baseURL: nil)
//监听是否可以前进后退,修改btn.enable属性
webView.addObserver(self, forKeyPath: "loading", options: .New, context: nil)
//监听加载进度
webView.addObserver(self, forKeyPath: "estimatedProgress", options: .New, context: nil)

//重写self的kvo方法
override func observeValueForKeyPath(keyPath: String?, ofObject object: AnyObject?, change: [String : AnyObject]?, context: UnsafeMutablePointer<Void>) {
    if (keyPath == "loading") {
        gobackBtn.enabled = webView.canGoBack
        forwardBtn.enabled = webView.canGoForward
    }
    if (keyPath == "estimatedProgress") {
        //progress是UIProgressView
        progress.hidden = webView.estimatedProgress==1
        progress.setProgress(Float(webView.estimatedProgress), animated: true)
    }
}
        

1.2 WKWebViewConfiguration

为添加WKWebView配置信息

  //创建网页配置对象
        let config = WKWebViewConfiguration()
        //创建设置对象
        let preference = WKPreferences()
        
        //最小字体大小,当将JavaScriptEnabled属性设置为false时效果最明显
        preference.minimumFontSize = 0;
        //设置是否支持javaScript,默认是支持的
        preference.javaScriptEnabled = true
        //在iOS上默认为false,表示允许不经过用户交互由javaScript自动打开窗口
        preference.javaScriptCanOpenWindowsAutomatically = true
        config.preferences = preference
        
        //使用h5的视频播放器,还是使用原生播放器全屏播放
        config.allowsInlineMediaPlayback = true
        //设置视频是否需要用户手动播放,设置为false则会允许自动播放
        config.requiresUserActionForMediaPlayback = true
        //设置是否允许画中画技术,在特定设备上有效
        config.allowsPictureInPictureMediaPlayback = true
        //设置请求的User-Agent信息中应用程序名称,iOS9之后可用
        config.applicationNameForUserAgent = "淘宝网"
       
        //这个类主要用来做native与JavaScript的交互管理
        let userContentController = WKUserContentController()引用问题
        //会引起强y
        //注册一个name为jsToOcNoPrams的js方法
        userContentController.add(self, name: "jsToOcNoPrams")
        config.userContentController = userContentController
        
       // 用完记得移除 /移除注册的js方法
        webView.configuration.userContentController.removeScriptMessageHandler(forName: "jsToOcNoPrams")
        

1.3 WKUserScript

用于进行JavaScript注入

   let jsString = ""
        let userScript = WKUserScript(source: jsString, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
        config.userContentController.addUserScript(userScript)

1.4 WKUserContentController

        //这个类主要用来做native与JavaScript的交互管理
        let userContentController = WKUserContentController()引用问题
        //会引起强y
        //注册一个name为jsToOcNoPrams的js方法
        userContentController.add(self, name: "jsToOcNoPrams")
        config.userContentController = userContentController
        
       // 用完记得移除 /移除注册的js方法
        webView.configuration.userContentController.removeScriptMessageHandler(forName: "jsToOcNoPrams")

1.5 WKScriptMessageHandler

这个协议类专门用来处理监听JavaScript方法从而调用原生Swift方法,和WKUserContentController搭配使用
注意:遵守WKScriptMessageHandler协议,代理是由WKUserContentControl设置

//通过接收JS传出消息的name进行捕捉的回调方法
extension ViewController : WKScriptMessageHandler {
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        print("方法名:\(message.name)")
        print("参数:\(message.body)")
    }
}

2、WKWebView的代理方法

2.1 WKNavigationDelegate的代理方法

主要处理一些跳转、加载处理操作

extension ViewController : WKNavigationDelegate{
    
    //页面开始加载时调用
    func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
        
    }
    //页面加载失败时调用
    func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
        
    }
    //当内容开始返回时调用
    func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
        
    }
    //当页面加载完成之后调用
    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        
    }
    //提交发生错误时调用
    func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
        
    }
    //接收到服务器跳转请求即服务重定向时之后调用
    func webView(_ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!) {
        
    }
    //根据WebView对于即将跳转的HTTP请求头信息和相关信息来决定是否跳转
    func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        
        decisionHandler(.allow) //允许跳转
//        decisionHandler(.cancel) //不允许跳转
    }
    // 根据客户端受到的服务器响应头以及response相关信息来决定是否可以跳转
    func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
        decisionHandler(.allow) //允许跳转
//        decisionHandler(.cancel)不允许跳转
    }
    需要响应身份验证时调用 同样在block中需要传入用户身份凭证
    func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
        //用户身份信息
        let newCred = URLCredential(user: "lichangan", password: "123456", persistence: .none)
        challenge.sender?.use(newCred, for: challenge)
        completionHandler(.useCredential,newCred)
    }
    //进程终止调用时间
    func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
        
    }
}

2.2 WKUIDelegate

主要处理JS脚本,确认框,警告框等

extension ViewController : WKUIDelegate {
    
    /// web界面中有弹出alert警告框时调用
    /// - Parameters:
    ///   - webView: 实现该代理的webview
    ///   - message: 警告框中的内容
    ///   - frame: frame description
    ///   - completionHandler: 警告框消失调用
    func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
       
    }
    //JavaScript调用confirm方法后回调的方法 confirm是js中的确定框,需要在block中把用户选择的情况传递进去
    func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
        
    }
    //JavaScript调用prompt方法后回调的方法 prompt是js中的输入框 需要在block中把用户输入的信息传入
    func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void) {
        
    }
    // 页面是弹出窗口 _blank 处理
    func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
        if let isMainFrame = navigationAction.targetFrame?.isMainFrame,isMainFrame == false{
            webView.load(navigationAction.request)
        }
        
        return nil
    }
}

3、 iOS如何调试Web代码

3.1 iOS 模拟器调试web代码

以前直接用safari可以调试,在偏好设置里面打开开发菜单,但是最近调试发现不太行。
在这里插入图片描述
展示为这样,没有内容。
在这里插入图片描述

然后在设置里面找到这样一个入口,苹果应该是出了一个专门的代码检查器。
在这里插入图片描述
下载地址,根据你的系统版本下载对应的Safari Technology Preview。安装完后,又可以正常的玩耍了。
在这里插入图片描述
然后还是一样的步骤,在顶部有个develop选项,在里面打开对应的模拟器。
在这里插入图片描述

3.2 通过alert等相关方法

由于安全机制的问题,WKWebView默认对JavaScript下的alert类的方法(包括alert、confirm、prompt)做了拦截,如果想要正常使用,需要设置三个代理方法:

    /** @abstract Displays a JavaScript alert panel.
     @param webView The web view invoking the delegate method.
     @param message The message to display.
     @param frame Information about the frame whose JavaScript initiated this
     call.
     @param completionHandler The completion handler to call after the alert
     panel has been dismissed.
     @discussion For user security, your app should call attention to the fact
     that a specific website controls the content in this panel. A simple forumla
     for identifying the controlling website is frame.request.URL.host.
     The panel should have a single OK button.
    
     If you do not implement this method, the web view will behave as if the user selected the OK button.
     */
    @available(iOS 8.0, *)
    optional func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void)

    
    /** @abstract Displays a JavaScript confirm panel.
     @param webView The web view invoking the delegate method.
     @param message The message to display.
     @param frame Information about the frame whose JavaScript initiated this call.
     @param completionHandler The completion handler to call after the confirm
     panel has been dismissed. Pass YES if the user chose OK, NO if the user
     chose Cancel.
     @discussion For user security, your app should call attention to the fact
     that a specific website controls the content in this panel. A simple forumla
     for identifying the controlling website is frame.request.URL.host.
     The panel should have two buttons, such as OK and Cancel.
    
     If you do not implement this method, the web view will behave as if the user selected the Cancel button.
     */
    @available(iOS 8.0, *)
    optional func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void)

    
    /** @abstract Displays a JavaScript text input panel.
     @param webView The web view invoking the delegate method.
     @param prompt The prompt to display.
     @param defaultText The initial text to display in the text entry field.
     @param frame Information about the frame whose JavaScript initiated this call.
     @param completionHandler The completion handler to call after the text
     input panel has been dismissed. Pass the entered text if the user chose
     OK, otherwise nil.
     @discussion For user security, your app should call attention to the fact
     that a specific website controls the content in this panel. A simple forumla
     for identifying the controlling website is frame.request.URL.host.
     The panel should have two buttons, such as OK and Cancel, and a field in
     which to enter text.
    
     If you do not implement this method, the web view will behave as if the user selected the Cancel button.
     */
    @available(iOS 8.0, *)
    optional func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void)

用法:

extension ArticleContentController : WKUIDelegate,WKNavigationDelegate {
    func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping () -> Void) {
        print(message)
        completionHandler()
    }
}

这样可以通过在js代码中添加alert(‘测试’)来调试相关代码。

4、获取WKWebView的准确高度

4.1 通过注入JS代码

extension ArticleContentController : WKUIDelegate,WKNavigationDelegate {
    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
  //获取最大高度,通过这样的方式来获取准确高度,展示全部内容
let jsStr = "Math.max(document.body.scrollHeight, document.body.offsetHeight, document.documentElement.clientHeight, document.documentElement.scrollHeight, document.documentElement.offsetHeight)" 
webView.evaluateJavaScript(jsStr) { (result, error) in
            if let height = result as? CGFloat {
                self.webViewHeight = height
                self.tableView.reloadData()
                self.webView.frame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: self.webViewHeight)
                self.containerScrollView.frame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: self.webViewHeight)
                self.containerScrollView.contentSize = CGSize(width: UIScreen.main.bounds.width, height: self.webViewHeight)
            }
        }
        
    }
   

4.2 通过KVO的方式

这种方式获取的高度较为准确,但要注意表格中多次回调高度的问题。

  • 添加监听者
webView.scrollView.addObserver(self, forKeyPath: "contentSize", options: .new, context: nil)
  • 监听高度变化
  override  func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        switch keyPath {
        case "contentSize":
            let height = webView.scrollView.contentSize.height
            print(height)
        default:
            break;
        }
    }
  • 移除监听者
 deinit {
        self.webView.scrollView.removeObserver(self, forKeyPath: "contentSize")
    }
    

5、WKWebView中Swift与JS交互

5.1 基础知识

5.1.1 WKScriptMessageHandler协议

WKScriptMessageHandler其实就是一个遵循的协议,它能让网页通过JS把消息发送给Swift。其中协议方法。

/** A class conforming to the WKScriptMessageHandler protocol provides a
 method for receiving messages from JavaScript running in a webpage.
 */
public protocol WKScriptMessageHandler : NSObjectProtocol {

    
    /** @abstract Invoked when a script message is received from a webpage.
     @param userContentController The user content controller invoking the
     delegate method.
     @param message The script message received.
     */
    @available(iOS 8.0, *)
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage)
}

从协议中我们可以看出这里使用了两个类WKUserContentControllerWKScriptMessageWKUserContentController可以理解为调度器,WKScriptMessage则是携带的数据。

5.1.2 WKUserContentController

WKUserContentController有两个核心方法,也是它的核心功能。

js注入,即向网页中注入我们的js方法,这是一个非常强大的功能,开发中要慎用。
  /** @abstract Adds a user script.
     @param userScript The user script to add.
    */
    open func addUserScript(_ userScript: WKUserScript)
    
/** @abstract Adds a script message handler to the main world used by page content itself.
     @param scriptMessageHandler The script message handler to add.
     @param name The name of the message handler.
     @discussion Calling this method is equivalent to calling addScriptMessageHandler:contentWorld:name:
     with [WKContentWorld pageWorld] as the contentWorld argument.
     */
    open func add(_ scriptMessageHandler: WKScriptMessageHandler, name: String)
    添加供js调用Swift的桥梁。这里的name对应WKScriptMessage中的name,多数情况下我们认为它就是方法名。

5.1.3 WKScriptMessage

WKScriptMessage就是js通知Swift的数据。其中有两个核心属性用的很多。

   /** @abstract The body of the message.
     @discussion Allowed types are NSNumber, NSString, NSDate, NSArray,
     NSDictionary, and NSNull.
     */
    open var body: Any { get } 携带的核心数据。

    /** @abstract The name of the message handler to which the message is sent.
     */
    open var name: String { get } 对应-add(_ scriptMessageHandler: WKScriptMessageHandler, name: String)

js调用时只需

window.webkit.messageHandlers.<name>.postMessage(<messageBody>)

这里的name就是我们添加的name。

5.2 JS调用Swift代码

<!DOCTYPE html>
<html>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <meta content="width=device-width,initial-scale=1,user-scalable=no" name="viewport">
<body style="background-color: white;">
    <script type="text/javascript">
        function jsCallNative() {
           window.webkit.messageHandlers.callNativeAndSend.postMessage('callcallcall');
           alert(callNative);
        }
    </script>
    
    <button type="button" onclick = "jsCallNative()" style="width:100%; height:30px;"/>调用Swift代码</button>
</body>
</html>

注意:window.webkit.messageHandlers.callNativeAndSend.postMessage('callcallcall');这行代码才是调用Swift的方法,方法名:callNativeAndSend 参数就是:callcallcall

Swift代码:

  let userContentController = WKUserContentController()
        userContentController.add(self, name: "callNativeAndSend")
        userContentController.add(self, name: "NativeObject.shareString")
        //WKWebView的配置
        let configuration = WKWebViewConfiguration()
        configuration.userContentController  = userContentController

上面的代码就是告诉JS有哪些方法, userContentController.add(self, name: "NativeObject.shareString")是测试之后是无效的,只有callNativeAndSend是有效的。

当JS调用OC方法之后,会执行以下回调函数,其中name是方法名,body是JS传过来的参数。

extension ViewController : WKScriptMessageHandler{
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
        print(message.body)
        print(message.name)
    }
    
}

当使用userContentController.add(self, name: "callNativeAndSend")会引发强引用,可以通过在viewWillAppear中添加,在viewWillDisappear中移除解除危机。

5.3 Swift调用JS

<html lang="en">
    
    <head>
        <meta charset="utf-8" />
        <title>JS交互</title>
        
        <style>
            
            body {
                font-size:30px;
                text-align:center;
            }
        
        * {
            margin: 30px;
            padding: 0;
        }
        
        h1{
            color: red;
        }
        
        button{
            width: 300px;
            height: 50px;
            font-size: 30px;
        }
        
            </style>
        
    </head>
    <body>
        <h1>WKWebview与iOS交互</h1>
        <h2></h2>
        <button onclick="testA()">点击alert弹框</button>
        <button onclick="testB('我是弹窗内容')">点击alert有参弹窗</button>
        <button onclick="testConfrim()">点击confrim弹窗</button>
        <button onclick="buttonAction()">向iOS端传递数据</button>
        <script type="text/javascript">
            
            //无参数函数
            function testA() {
                alert("我是JS中的弹窗消息");
            }
        
            //有参数函数
            function testB(value) {
                alert(value);
            }
        
            function testC(value) {
                return value + "value";
            }
        
            //接受iOS端传过来的参数,
            function testObject(name,age) {
                var object = {name:name,age:age};
                return object;
            }
        
            function testConfrim() {
                comfirm("确定修改数据吗?")
            }
        
            function buttonAction(){
                try {
                    <!-- js 向iOS 传递数据-->
                    window.webkit.messageHandlers.getMessage.postMessage("我是js传递过来的数据")
                }catch (e) {
                    console.log(e)
                }
            }
        </script>
        
   
    </body>
</html>
 webView.evaluateJavaScript("testObject('123')") { (result, error) in
            
}

6、WKWebView 打开target="_blank"的页面

WKWebView默认是不支持打开target="_blank"的页面,我们自己实现一个UIDelegate的回调方法,在回调里面自己处理。

    func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
        if let isMainFrame = navigationAction.targetFrame?.isMainFrame,isMainFrame == false{
            webView.load(navigationAction.request)
            //或者原生的打开新的Controller界面
        }
        
        return nil
    }
}

7、WKWebView禁止长按和选择

//document.documentElement.style.webkitTouchCallout = 'none' //禁止长按
//document.documentElement.style.webkitUserSelect = 'none'//禁止选择

let jsString = "document.documentElement.style.webkitTouchCallout = 'none';document.documentElement.style.webkitUserSelect = 'none';"
        let userScript = WKUserScript(source: jsString, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
        config.userContentController.addUserScript(userScript)

8、UITableView和WKWebView混合使用实现文章详情页/商品详情页

通过这种方式实现文章详情页面,需要面临的问题主要有两个:
(1)、高度计算的问题
(2)、高度计算准确之后,在tableView滑动的时候会很卡很卡,需要把webview加在UIScrollView之上,然后把scrollView添加到cell的contentView上面。

import UIKit
import WebKit

class ArticleContentController: BaseViewController {

    var uuid:String! //关注使用
    var contentUuid:String! //加载内容
    private var tableView:UITableView!
    private var webView:WKWebView!
    private var containerScrollView:UIScrollView!
    private var hud = ProgressHUDHelper()
    private var model:MediaLibraryModel?
    private var webViewHeight:CGFloat = 1
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setUI()
        loadContentData()
        isAttention() //加载是否已经关注文章
    }
    
    private func setUI(){
        setTableView()
        setWebView()
    }
    
    private func setTableView(){
        let frame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: view.bounds.height - CGFloat(HMConstant.Screen.tabBarHeight))
        tableView = UITableView(frame: frame, style: .plain)
        tableView.delegate = self
        tableView.dataSource = self
        tableView.showsVerticalScrollIndicator = false
        tableView.showsHorizontalScrollIndicator = false
        tableView.separatorStyle = .none
        tableView.register(ArticleHeaderCell.self)
        tableView.register(ArticleToolCell.self)
        tableView.register(ArticleRecommendCell.self)
        tableView.tableFooterView = UIView()
        
        view.addSubview(tableView)
    }
    
    //获取webView的frame
    private func webViewFrame()->CGRect{
        return CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: webViewHeight)
    }
    
    private func setWebView(){
        let config = WKWebViewConfiguration()
        let userContentController = WKUserContentController()
        config.userContentController = userContentController

        //文件尾部追加js,调整图片宽度比屏幕宽的问题,在html渲染完成之后在进行调整才会成功。如果图片宽度大于屏幕的宽度,则调整宽度为100%,然后设置高度为auto。
        
        let jsStr = "var arrList = document.getElementById('article-concrete-content').getElementsByTagName('*');for(var i = 0 ; i < arrList.length; i++){var boxWidth = document.documentElement.clientWidth;var width = arrList[i].clientWidth;\n if(width > boxWidth){arrList[i].style.width = '100%';arrList[i].style.height= 'auto'}}"
        let userScript = WKUserScript(source: jsStr, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
        userContentController.addUserScript(userScript)

        webView = WKWebView(frame: webViewFrame(), configuration: config)
        webView.backgroundColor = UIColor.white
        webView.isOpaque = false
        webView.sizeToFit()
        webView.scrollView.bounces = false
        
        
        webView.scrollView.isScrollEnabled = false //禁用webView滚动
        webView.scrollView.isUserInteractionEnabled = false //禁止交互
        webView.autoresizingMask = .flexibleHeight
        webView.uiDelegate = self
        webView.navigationDelegate = self
        
        containerScrollView = UIScrollView(frame: webViewFrame())
        containerScrollView.addSubview(webView)
        
    }
    
    //加载webView
    private func loadWebView() {
        //生成html
        func createHtml(content:String)->String{
            let header = "<!DOCTYPE html>"
                + "<html lang=\"zh-CN\">"
                + "<head>"
                + "<meta charset=\"utf-8\"/>"
                + "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1\"/>"
                + "<meta name=\"google\" content=\"notranslate\"/>"
                + "<meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\"/>"
                + "<link rel=\"stylesheet\" type=\"text/css\" >"
                + "</head>"
            
            let body = "<body>"
                + "<div id=\"article-concrete-content\">\(content)</div>"
                + "</body>"
            let footer = "<script type=\"text/javascript\" charset=\"utf-8\"></script>" +
            "</html>"
            let html = header + body + footer
            return html
        }
        
        if let content = model?.content {
            let html = createHtml(content: content)
            webView.loadHTMLString(html, baseURL: nil)
        }
    }
    
    //加载数据
    private func loadContentData(){
        hud.showIndicator(self.view, status: "正在加载中...",isShowBackgroundView: true)
        GeneralDataProvider.mediaLibraryDetail(uuid: contentUuid) { model, error in
            if let error = error {
                self.hud.showError(self.view, status: error)
                return
            }
            self.model = model
            self.loadWebView()
            self.tableView.reloadData()
        }
    }
    
    
    deinit {
    }
}

extension ArticleContentController : WKUIDelegate,WKNavigationDelegate {
    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        let jsStr = "Math.max(document.body.scrollHeight, document.body.offsetHeight, document.documentElement.clientHeight, document.documentElement.scrollHeight, document.documentElement.offsetHeight)"
        webView.evaluateJavaScript(jsStr) { (result, error) in
            if let height = result as? CGFloat {
                self.webViewHeight = height
                self.webView.frame = self.webViewFrame()
                self.containerScrollView.frame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: self.webViewHeight)
                self.containerScrollView.contentSize = CGSize(width: UIScreen.main.bounds.width, height: self.webViewHeight)
                self.tableView.reloadData()
                self.hud.hide()
            }
        }
    }
    
    func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
        self.hud.hide()
    }
    
    func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
        self.hud.hide()
    }
}

extension ArticleContentController : UITableViewDelegate,UITableViewDataSource {
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        if indexPath.section == 0 {
            if indexPath.row == 0 {
                let cell = tableView.dequeueReusableCell(withIdentifier:ArticleHeaderCell.reuseIdentifier , for: indexPath) as! ArticleHeaderCell
                cell.titleLabel.text = model?.title
                cell.doctorNameLabel.text = model?.doctor
                cell.jobTitleLabel.text = model?.docTitle
                cell.timeLabel.text = model?.createTime
                cell.hospitalLabel.text = model?.hospital
                return cell
            }else if indexPath.row == 1 {
                let cell = tableView.dequeueReusableCell(withIdentifier: ArticleContentCell.reuseIdentifier, for: indexPath) as! ArticleContentCell
                cell.contentView.addSubview(self.containerScrollView)
                return cell
            }
            else {
                let cell = tableView.dequeueReusableCell(withIdentifier: ArticleToolCell.reuseIdentifier, for: indexPath) as! ArticleToolCell
                return cell
            }
        }else  { //推荐内容
            let cell = tableView.dequeueReusableCell(withIdentifier: ArticleRecommendCell.reuseIdentifier, for: indexPath) as! ArticleRecommendCell
            
            return cell
        }
   
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        if section == 0 {
            return 3
        }else if section == 1 {
            return 3
        }else {
            return 0
        }
        
    }
    
    func numberOfSections(in tableView: UITableView) -> Int {
        return 2
    }
    
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        switch (indexPath.section,indexPath.row) {
        case (0,0):
            return UITableView.automaticDimension
        case (0,1):
            return webViewHeight
        case (0,2):
            return UITableView.automaticDimension
        default:
            return 0
        }
    }
}

  

9、深入理解 WKWebView(入门篇)—— WebKit 源码调试与分析

深入理解 WKWebView(入门篇)—— WebKit 源码调试与分析

前言

移动互联网时代,网页依旧是内容展示的重要媒介,这离不开 WebKit 浏览内核技术的支持与发展。在 iOS 平台下开发者们需要通过 WKWebView 框架来与 WebKit 打交道。虽然苹果官方提供了关于 WKWebView 的 API 与使用说明,但这并不能满足开发者们的需求,各类复杂场景依旧让我们焦头烂额,而解决方案却不易寻找。此时,优秀的开发者们将目光移向苹果开源的 WebKit 内核代码,试图从中寻找解惑之道,却发现依旧困难重重,坎坷不断,主要问题如下:

内核源码复杂难懂:动辄几个 G 的源码,且缺乏关键代码注释与说明,跟踪分析工作量大;系统兼容分支较多,一块代码可能区分 iOS、Mac、嵌入式等分支;历史代码或实验功能较多,导致查看源码并不容易缕清逻辑。

无法结合业务代码分析:异常问题往往在复杂场景下才会出现,缺乏业务代码的结合,问题无法复现,我们也就无法定位问题,最终容易走上猜测原因、更换方案尝试修复的路子。

无论你是出于兴趣还是以上原因,想要探索 WebKit 源码而不得其法,本文都将帮助你快速入门。接下来,我们将按照源码下载、源码编译、创建调试工程、源码实战分析的步骤助力你深入浏览内核探索之路。

源码下载

编译及调试之前我们首先需要获取一份苹果官方的 WebKit 源码。

github下载 (推荐): https://github.com/WebKit/WebKit

官网下载:https://WebKit.org/

下载后的 WebKit 工程通过 Xcode( Xcode 是苹果官方推荐的 iOS 应用开发工具)打开后目录如下图。

在这里插入图片描述

WebKit工程目录
其中gtest / MiniBrowser / MobblieMiniBrowser / TestWebKitAPI / WebKitTestRunner仓库为测试仓库。考虑到编译效率的问题,通常情况下不需要编译测试仓库。由于本文后面将描述如何有效利用这些测试仓库,我们此处选择进行全源码编译。

源码编译

获取到源码后,接下来我们介绍下命令行及 Xcode 的编译方式。

本文推荐先使用命令行编译一遍,再用 Xcode 编译。从实践来看,如果编译过程中出错,命令行编译方式更易追踪到具体异常信息。

1) Embedded Builds
下载的 WebKit 目录里面有一个Tools/Scripts 目录,这里面有各种脚本,包括使用命令行编译 WebKit 的脚本,其中一个重要的脚本就是 configure-Xcode-for-embedded-development,在 Mac 终端控制台运行如下命令:

sudo Tools/Scripts/configure-Xcode-for-embedded-development
之所以需要执行这个脚本,是因为 iOS 属于嵌入式平台,编译嵌入式平台的 WebKit 需要用到一些命令行工具,Xcode 正是利用该脚本构建这些命令行工具。否则,在编译诸如 JavaScriptCore 等工程的时候,就会报如下错误:‘com.apple.product-type.tool’, but there’s no such product type for the embedded platform,找不到对应的架构。

2)通过 Xcode 进行编译,设置构建产物存储位置
在打开工程后,选择 Xcode 的 File 菜单,选择Workspace Settings,然后打开 Workspace 设置窗口,如下图所示:

在这里插入图片描述

接下来我们选择 Advanced 按钮,打开如下窗口,按红框所示,将工程编译目录配置为 WebKitBuild,点击完成:

图片

准备工作终于完成了,接下来我们可以开始编译了。

3) 开始编译
首先选中 All Source 选项,配置 scheme 选择模拟器运行,然后点击 Xcode 的构建按钮开始构建。

在这里插入图片描述

此处请耐心等待,首次编译耗时较长,本文测试是在 i5 处理器 8G 内存 Mac Pro 机器上测试的,测试全源码编译耗时1h。编译成功后会弹出 MiniBrowser 不可用 警告(属于 Mac 应用工程),我们忽视即可。此时内核编译工作结束,接下来我们继续进入下一步,创建调试工程,进行源码探索。

创建调试工程

本文按照两类调试需求进行区分介绍,分别使用官方Demo工程和自定义工程进行调试,具体如下所示。

1)了解 WebKit 运行机制及源码:使用官方Demo工程调试
编译完成后,在我们的工程产物 WebKitBuild 目录中会有一个 MobileMiniBrower APP。此时我们可以在工程 scheme 配置中选择 MobileMiniBrowser APP 进行工程构建,该 APP 是苹果官方的浏览器 Demo (如下图所示),可通过地址栏执行地址输入,前进/后退以及多 Tab 等功能,可在源码里进行断点测试。
在这里插入图片描述
在这里插入图片描述

2)分析实际业务问题:使用自定义工程调试
针对这类需求,我们就需要按照如下步骤在工程中使用我们编译成功的 WebKit.framework 去替换系统的 WebKit.framework:

首先,用 Xcode 新建一个新的 Project,示例里面是 TestWKWebView,并将这个 Project 添加到 WebKit 的工程空间 WebKit.xcworkspace 中,编译产物按照 WebKit 编译所述,同样输出到 WebKitBuild 目录。

做好上面的设置之后,就可以编写测试程序,在测试程序中打上断点,这时你会发现系统 WebKit 库已经被替换,断点可跳转源码,即可愉快的进行源码探索了。
在这里插入图片描述
走到这一步后,大家可以发现,WebKit 源码很庞大,哪怕代码 run 起来了,如何下断点分析问题依旧很难把控。因此我们需要进行一些知识点的补充与理解,本文将进入实战环节,用 Demo 工程进行分析说明,给大家提供源码分析的思路。

源码实战分析

1)WebKit 的多进程机制
在 iOS 系统中,通常一个应用对应一个进程,但是在 WebKit 的发展过程中,基于稳定性与安全性考虑,引入了多进程的概念,避免单一页面的异常影响整体 app 运行,首先本文简单介绍下几个常见的 WebKit 进程,如下所示。

UIProcess —— 应用程序所在进程,WKWebView 代码和 WebKit 框架已加载到你的进程空间中;

WebContent —— 又称 WebProcess,JS 和 DOM 内存分配所在的位置,即网页内容渲染与 js 执行所处进程;

Network Process —— 负责发出与 Web 请求关联的基础网络请求;

Storage Process —— 用于数据库和服务工作者的存储。

接下来,我们用两个 Demo 进行内核分析:

Demo1 —— 单 webview 模型:

我们在 Demo1 工程中简单使用一个 WKWebView 来进行网络加载,以百度首页为例,运行项目后,点击调试模式中的 show the debug navigator 选项,该功能是 debug 下的资源分析模块。

现在我们可以查看各进程的 CPU、内存、磁盘、网络使用情况,当然也可以进行 Instruments 分析。

在这里插入图片描述

进程分布如下:

进程名
数量
Demo1 进程 1个
NetWorking 进程 1个
WebContent 进程 n个(每打开一个新页面,新增一个 WebContent 进程,可复用)

Demo2 —— 多 webview 模型:

使用多个 WKWebView 进行网络加载,每加载一个网页,创建一个新的 WKWebView 实例。

图片

进程分布如下 :

进程名
数量
Demo2 进程
1个
NetWorking 进程
1个
WebConent 进程
n个(每打开一个新页面,新增一个 WebContent 进程,可复用)

结合以上Demo工程,我们可以有一个直观上的理解:

WebContent 进程对应的是每一个新开的网页,该进程视内存情况可进行复用,某一 WebContent 进程的异常并不会影响到主 app 进程,常见的异常现象为白屏。

UIProcess 进程为 app 所在进程,WKWebView 在该进程中提供了大量 API 供开发者与内核交互,也是开发者最熟悉的一部分。

NetWorking 进程,无论多 WKWebView 还是单 WKWebView 场景,都只有唯一的 NetWorking 进程,这种设计主要便于网络请求管理以及保证网络缓存、cookie 等管理的一致性。

苹果官方文档中描述:配置同一 WKProcessPool 的多个 WKWebView 共享同一 WebContent 进程,即可以配置 WebContent 进程唯一( https://developer.apple.com/documentation/webkit/wkprocesspool )。
但源码头文件中的注释与官方文档不一致,源码头文件描述配置同一 WKProcessPool 的多个 WKWebView 共享的是同一 WebContent 进程池,该配置未限制 WebContent 进程数量,而是共享进程池。
从 Demo 实际测试看,官方文档描述并不准确,我们以源码注释为准。
有了上述理解,我们再去看 Xcode 下 WebKit 的文件目录,目录也按照进程职责进行了较为合理的划分。

在这里插入图片描述

因此,在调试过程中,除了根据已知关联 API 或代码堆栈进行全局搜索或单步断点调试外,我们还可以多结合三大进程的工作职责进行问题分析与查找。另外,既然可以查看各进程的 CPU、内存、磁盘、网络等状态了,对这方面有性能要求的,可以用来查看一个网页加载时各进程具体的资源消耗是多少。

2)TestWebKitAPI 工程
使用源码工程,除了代码分析外,苹果系统还提供了大量的系统 API 相关功能测试,这些测试基于 gtest 框架实现,集成在 TestWebKitAPI 工程里,实践中按照如下思路可利用 TestWebKitAPI 工程进行一些接口分析与测试:

了解各类 API(包括私有 API )的测试用例,通过这类代码示范与说明,便于我们深入了解接口的使用规范,更好的理解 API 的设计思路。

利用该框架可进行 gtest 测试,gtest 是一个跨平台的 (Liunx、Mac OS X、Windows、Cygwin、Windows CE and Symbian) C++单元测试框架,由 google 公司发布,它能在不同平台上编写 C++ 测试代码。gtest 框架提供了丰富的断言、致命和非致命判断、参数化、”死亡测试”等。在 WebKit 内核源码中已有大量的基于gtest 框架的测试代码积累,当我们做了一些 trick 操作时,基于 TestWebKitAPI 工程做自动化测试,也是一种不错的选择。

Tips

WebKit 源码调试可能在一般情况下不会用到,但是对于 WebKit 复杂问题的分析与解决,结合业务对 WebKit 源码进行探索与分析,还是有一定意义的。

特殊场景下,开发者可能对一些 API 进行特殊使用,这个时候可断点调试的源码能更好帮助我们规避风险。

苹果官方禁止了在真机上替换 WebKit 内核,我们可以编译对应的真机库,但是无法进行安装调试,因此本文里的内容都是在模拟器进行的。

因webkit源码在不断更新,因此下载编译过程中可能会遇到一些不兼容问题,一般可通过注释相关不兼容代码解决。

10、WKWebView 加载生命周期与代理方法剖析

WKWebView 加载生命周期与代理方法剖析

1. 前言

WebView 开始加载一条请求,到页面完整呈现这一过程发生了什么?无论是做 WebView 性能优化还是异常问题监控与排查,我们都离不开对这一问题的思考与探索。

在本篇文章中,我们将在上一篇《深入理解 WKWebView(入门篇)—— WebKit 源码调试与分析》的基础上,结合 iOS 端 WKWebView 的 WKNavigationDelegate 代理方法,站在移动端的视角深入分析 WKWebView 网络请求加载的生命周期流程,给大家提供更多业务上的思路。

2. iOS端WebKit加载框架

在《深入理解 WKWebView(入门篇)—— WebKit 源码调试与分析》中我们通过 Demo 验证了 WebKit 三大进程工作模型,并简述了三大进程的主要职责。接下来我们将展开描述下其中的一些细节。
在这里插入图片描述
三大进程间通信关系图
如上图所示,说明了 UIProcess、WebContent、NetworkProcess 三大进程间的通信关系,并列举了他们的主要职责。本系列的源码剖析工作始终围绕三大进程,对其作进一步说明:

**NetworkProcess进程:**主要负责网络请求加载,所有的网页共享这一进程。与原生网络请求开发一致,NetworkProcess 也是通过封装的 NSURLSession 发起并管理网络请求的。但不同的是,这一过程中有较多的网络进度的回调工作以及各类网络协议管理,比如资源缓存协议、HSTS 协议、cookie 管理协议等。

**WebContent进程:**主要负责页面资源的管理,包含前进后退历史,pageCache,页面资源的解析、渲染。并把该进程中的各类事件通过代理方式通知给 UIProcess。

**UIProcess进程:**主要负责与 WebContent 进行交互,与 APP 在同一进程中,可以进行 WebView 的功能配置,并接收来自 WebContent 进程的各类消息,配合业务代码执行任务的决策,例如是否发起请求,是否接受响应等。

理解了三大进程的主要工作职责后,接下来,我们首先结合三大进程描述 WebKit 从网络加载到渲染的全流程,让读者对网页加载有一个宏观上的理解。

3. iOS端WebKit加载流程

我们使用如下方法,从 UIProcess 层通过 loadReqeust 方法发起页面加载请求(此处 request 只能是 get 请求,如果配置为 post 请求,WebKit 内核基于性能考虑,在跨进程传输时,会将 body 数据丢弃,导致异常)。

[self.webView loadRequest:request];

通过跟踪 WebKit 源码,我们提取核心步骤如下:

UIProcess 中的 loadRequest 首先会触发 NetworkProcess 进程创建,然后通过进程间通信的方式将 request 发送给 NetworkProcess 进程进行 preconnect 预链接操作,通过网络三次握手建立 TCP 链接,以便加快后续网络资源请求速度。

UIProcess 通过进程间通信的方式将 request 发送给 WebContent 进程,WebContent 进程创建 DocumentLoader 加载器加载网络请求,并取消上个页面的所有还在加载的请求,然后通过字典绑定当前页面ID与创建好的 NetworkProcss 进程(便于服务端数据返回时,查找数据回填所对应的页面),最终将请求交付给 NetworkProcess 中的 NSURLSession 进行处理。

NetworkProcess 通过 NSURLSession 复用之前 preconnect 预链接,继续进行网络加载,此时等待网络请求返回,网络层会继续将数据通过进程间通信方式传输给 WebContent 进程进行处理,开始流式进行数据解析,一边接收一边处理,进行词法分析、语法分析,并在这一过程中加载解析出来的 js、css、图片、字体等子资源,最终动态的生成(DOM 树与 CSSOM 树合成)渲染树,在这一过程中,每次接受到新数据导致渲染树有变更后,就会触发一次 checkAndDispatchDidReachVisuallyNonEmptyState 方法,检查当前页面是否达到上屏状态,若达到上屏状态就进行上屏渲染。

达到上屏状态的条件如下:

如果返回的 data 是普通文本文字,或返回的数据中包含普通文本文字,那只需要达到非空200字节即可以触发上屏渲染;

如果返回的 data 是图片资源类,则判断像素大小 > 32*32,即可触发上屏渲染;

如果不满足以上条件,对于主文档,判断后面是否继续接收数据,如果不继续,则触发上屏渲染;如后续还有数据,则循环上述流程直至触发上屏。渲染完成,整个加载过程结束。

在这里插入图片描述
WebKit加载流程

对首屏渲染感兴趣的同学可以尝试配合服务端来针对部分场景(例如文字、图片)做一些数据分包优化,或许会有一些不错的收获。

在描述完网页核心加载过程后,为了更贴近我们日常的开发工作,接下来我们将重点描述以上工作流程如何与 UIProcess 进程(APP 进程)关联起来。

4. 加载生命周期代理方法

丨4.1 WKNavigationDelegate 方法简要介绍

@protocol WKNavigationDelegate <NSObject>

@optional
// 请求之前,决定是否要跳转:用户点击网页上的链接,需要打开新页面时,将先调用这个方法。
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler; 

// 页面开始加载时调用
- (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(null_unspecified WKNavigation *)navigation; 

// 接收到响应数据后,决定是否跳转
- (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler; 

// 主机地址被重定向时调用
- (void)webView:(WKWebView *)webView didReceiveServerRedirectForProvisionalNavigation:(null_unspecified WKNavigation *)navigation; 

// 当开始加载主文档数据失败时调用
- (void)webView:(WKWebView *)webView didFailProvisionalNavigation:(null_unspecified WKNavigation *)navigation withError:(NSError *)error; 

// 当内容开始返回时调用
- (void)webView:(WKWebView *)webView didCommitNavigation:(null_unspecified WKNavigation *)navigation; 

// 页面加载完毕时调用
- (void)webView:(WKWebView *)webView didFinishNavigation:(null_unspecified WKNavigation *)navigation; 

// 当主文档已committed时,如果发生错误将进行调用
- (void)webView:(WKWebView *)webView didFailNavigation:(null_unspecified WKNavigation *)navigation withError:(NSError *)error; 

// 如果需要证书验证,进行验证,一般使用默认证书策略即可 
- (void)webView:(WKWebView *)webView didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential *__nullable credential))completionHandler; 

// 9.0才能使用,web内容处理中断时会触发,可针对该情况进行reload操作,可解决部分白屏问题 
- (void)webViewWebContentProcessDidTerminate:(WKWebView *)webView NS_AVAILABLE(10_11, 9_0); 

@end

以上是 WKNavigationDelegate 代理方法及苹果官方接口描述,足够应付日常的开发工作了,但细节描述上有些粗糙,不能让我们写出踏实放心的代码,因此我们需要彻底理解这些方法背后的运行逻辑。接下来,我们将结合实践与源码调试对重点方法进行剖析。

丨4.2 深入理解 WKNavigationDelegate 方法

在这里插入图片描述
WKNavigationDelegate代理方法调用流程

如上图所示,描述了 WKNavigationDelegate 代理方法的调用流程,我们将在该图的基础上,重点描述带颜色标注的代理方法,如下:

1)decidePolicyForNavigationAction 剖析
如第3节讲述的网页加载流程,当 WebContent 即将创建 DocumentLoader 加载器时,会首先触发 decidePolicyForNavigationAction 代理方法。如果我们选择 cancel ,那么浏览内核会完全忽略这一操作,后续也不再继续执行其他操作,我们可以放心的使用 cancel 取消掉我们不想加载的主文档请求,而无需担忧任何异常。但当我们选择 alllow 后,我们会进入一个稍微复杂的逻辑判断,内核代码首先判断该该链接是否是 universalLink 类型的链接,如果判断是 universalLink 类型的链接,会尝试去调起三方 app,如果能调起,则会 cancel 当前请求,否则才会走到正常的网络加载逻辑(如果需要统计 universalLink 调起情况与或建设屏蔽能力,可以再仔细阅读该处源码)。

2)didStartProvisionalNavigation 理解
decidePolicyForNavigationAction 方法中选择 allow 并且判断为非 universalLink 链接后,会立即触发 didStartProvisionalNavigation 方法,表示即将开始加载主文档。这个方法看似只是对 decidePolicyForNavigationAction 方法的确认,但是值得思考的问题是方法名中的 Provisional 究竟是什么意思。其实,页面开始页面加载后为了更好的区分加载的各阶段,会将网络加载的初始阶段命名为临时状态,此时的页面是不会记入历史的,直到接收到首个数据包,才会对当前页面进行 committed 提交,并触发didCommitNavigation 方法通知 UIProcess 进程该事件,同时将网络 data 提交给 WebContent 进行渲染树生成。我们可由此引申出下一个问题,即 didFailProvisionalNavigation 与 didFailNavigation 的关系。

3)didFailProvisionalNavigation 与 didFailNavigation的分别在什么时候执行?他们之间有什么关系?
当 NetworkProcess 进程发生网络错误时,错误首先由 NSURLSession 回调到 WebContent 层。WebContent 会判断当前主文档加载状态,如果处于临时态,则错误会回调给 didFailProvisionalNavigation 方法;如果处于提交态,则错误会回调给 didFailNavigation 方法。
在这里插入图片描述
主文档加载状态图

4)didFinishNavigation 究竟什么时候执行?与页面上屏是否有关?

在上面的描述中,我们已经理解了 NetworkProcess 层也是使用 NSURLSession 加载主文档的。当 NSURLSession 接收到 finish 事件时,会将该消息通过进程通信方式传递给 WebContent 进程,WebContent 进程再传递给 UIProcess 进程,直到被我们的代理方法响应。因此 didFinishNavigation 在 NSURLSession 的网络加载结束时就会触发,但因为跨了两次进程通信,因此对比网络层,实际上是有一定的延迟的。与子资源加载和页面上屏无时间先后关系。
5. Tips
一定要紧密结合三大进程去理解 WebKit 源码,形成基于进程的知识体系。

可以直接修改源码验证猜想。例如在验证触发渲染条件时,可以在源码中禁止网络层 didfinish 事件执行,并自己构造数据返回,验证各类上屏触发条件。
6. 结语
以上是本文梳理的 WKWebView 加载生命周期与代理方法剖析。因篇幅有限,本文没有直接把大量的源码放上来逐行解释,很多网络加载的策略细节也没有呈现出来,但相信读者通过本文的整体框架分析和源码追踪思路,结合 WebKit 源码自己调试,一定能了解更多自己感兴趣的细节功能的原理,形成自己的知识体系。后面我们将继续从独特的视角分析 WKWebView 里那些有趣的功能细节,希望总有一块知识能帮助大家最终将好的想法落实在业务里,助力实际开发。

11、深入理解 WKWebView(基础篇)—— 聊聊 cookie 管理那些事

深入理解 WKWebView(基础篇)—— 聊聊 cookie 管理那些事

1. 前言

在浏览内核加载网络资源的过程中我们离不开 HTTP 协议。它是在 Web 上进行数据交换的基础,同时也是一种无状态的 client-server 协议。这种无状态的属性促使许多端存储技术产生,其中最重要的技术之一就是 cookie 存储技术,它能方便的将数据存储于客户端,且在每次请求中都会在请求头中携带 cookie 数据并发送给 server

cookie 技术的便捷性使得它在多种场景中被广泛使用,有时候甚至存在滥用情况,对同一 cookie 实例,前端、客户端、服务端都可以轻易的进行增删改查,我们在享受其便捷性的同时,也有必要确保其被正确、可控的使用。本文将在前系列文章的基础上,继续深入 WKWebView 源码,聊聊 cookie 管理那些事,希望给大家带来一些新的视角和认知,揭开 cookie 管理的迷雾。

2. Cookie 概述

MDN官网(https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies)对cookie的介绍如下:

HTTP cookie(也叫 Web cookie 或浏览器 cookie)是保存在浏览器本地的一小块数据,它会在浏览器向服务器发起请求时被携带并发送到服务器上。通常,它用于告知服务端两个请求是否来自同一浏览器,如保持用户的登录状态。cookie 使基于无状态的 HTTP 协议记录稳定的状态信息成为了可能。

cookie 主要用于以下三个方面:

  • 会话状态管理:如用户登录状态、购物车、游戏分数或其它需要记录的信息。
  • 个性化设置:如用户自定义设置、主题等。
  • 浏览器行为跟踪:如跟踪分析用户行为等。

简单介绍完 cookie的概念后,接下来我们再分别从前端、后端、客户端的视角聊聊 cookie 的基本使用。

3. Cookie 基本使用

丨3.1 前端通过 js 操作 cookie

详细 cookie 格式语法参考 MDN 语法链接:https://developer.mozilla.org/zh-CN/docs/Web/API/Document/cookie

// 读取所有可从当前页面访问的 cookie
allCookies = document.cookie;
// 写一个新 cookie
document.cookie = "someCookieName=true; expires=Fri, 31 Dec 9999 23:59:59 GMT; path=/";3.2 后端配置 cookie
详细 cookie 格式语法参考 MDN 语法链接:https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Cookies

response header 中返回需要种到端上的 cookie ,我们通过 Charles 工具抓包可以看到 header 中如下信息:

在这里插入图片描述

丨3.3 客户端操作 cookie

iOS 系统在 WKHTTPCookieStorage 类中提供如下 API 进行 cookie 操作:

@interface WKHTTPCookieStore : NSObject

/*! @abstract 获取所有 cookie
 @param completionHandler 获取所有 cookie 后回调
 */
- (void)getAllCookies:(void (^)(NSArray<NSHTTPCookie *> *))completionHandler;


/*! @abstract 设置一个 cookie 
 @param cookie 需要设置的 cookie 
 @param completionHandler cookie 设置成功的回调 
 */ 
- (void)setCookie:(NSHTTPCookie *)cookie completionHandler:(nullable void (^)(void))completionHandler;


/*! @abstract 删除指定的 cookie 
 @param completionHandler cookie 成功删除的回调 
 */ 
- (void)deleteCookie:(NSHTTPCookie *)cookie completionHandler:(nullable void (^)(void))completionHandler;

@end

可以看到,不同场景下的 cookie 操作都是极其简单的,我们似乎已经通过简单的封装接口掌握了 cookie 技术,那么问题来了:

(1)cookie 究竟是存储在哪的?内存,还是磁盘?

(2)三种不同场景的 cookie 操作是如何协同工作的?

现在,我们能回答这些问题吗?如果不能,请继续跟随我深入 WKWebView 源码,让代码告诉我们答案。

  1. WebKit Cookie 技术原理
    再次回到源码探索的道路,现在我们再回顾一下在《深入理解 WKWebView(入门篇)—— WebKit 源码调试与分析》提及的源码探索的核心技巧:紧紧围绕 UIProcess、WebContent、NetworkProcess 三大进程进行理解。

丨4.1 三大进程与三种场景

在这里插入图片描述

如上图所示,我们将 cookie 操作的三种场景与三大进程进行关联,其中,

(1)客户端操作在 UIProcess 进程(即我们的 app 进程),通过封装的 WKHTTPCookieStorage 进行操作。

(2)前端 js 函数,通过 JSCore 解析执行后最终调用了 WebContent 进程中的 C++ 函数进行操作,如下所示:

virtual String cookies(Document&, const URL&) const;
virtual void setCookies(Document&, const URL&, const String& cookieString);

(3)WKWebView 中的网络请求最终都是通过 NetworkProcess 中的 NSURLSession 管理的,服务端网络响应的 cookie 设置操作都在该进程中完成。

丨4.2 三种场景下的协同工作
在这里插入图片描述

cookie 管理协同图

如图所示,描述了三大场景下 cookie 的协同管理,接下来,我们将结合该图解答第二小节中提出的问题。

问题一:cookie 究竟是存储在哪的?内存,还是磁盘?

UIProcess:

UIProcess 进程为 app 进程(app 进程中其实有 NSHTTPCookieStorage 仓储进行 cookie 管理,但这不是本文的重点,因此不展开来讲),苹果系统为开发者提供了 WKHTTPCookieStorage API 进行 WebKit 内核的 cookie 管理,WKHTTPCookieStorage 其实并不提供实际的存储能力,而是封装了一系列基于进程间通信的方法,将 UIProcess 进程中发生的 cookie 操作,发送到 NetworkProcess 进程中进行处理,并将执行结果通过回调函数返回。

WebContent:

WebContent 进程是前端操作 cookie 的进程,原则上,每一个网页页面都只能操作当前页面域名下的cookie。因此基于性能考虑,每一个 WebContent 进程中会有一个 cookieCache 实例,它是 NetworkProcess 进程中存储 cookie 的子集,仅存储当前页面域名下的 cookie,因此 cookieCache 采取了内存缓存的方式,其特征是存储量小,查找速度快。

NetworkProcess:
在这里插入图片描述

NSHTTPCookieStorage setCookie 流程图

NetworkProcess 进程是 cookie 存储的最核心进程,它管理来自网络中服务端 response 中配置的 cookie,同时也接受来自前端和客户端的 cookie 操作,是最全的 cookie 存储中心。通过源码分析,我们发现其内部还是通过 NSHTTPCookieStorage 进行管理的, NSHTTPCookieStorage 有如下存储规则:

(1)allCookies:所有 cookie 都会存入字典 allCookies 中,方便快速查询。当我们杀死 app 后,位于内存中的 allCookies 字典也会一同清理掉。

(2)sessionOnly false cookie:对于某个 cookie,如果其属性中 sessionOnlyfalse,且设置的过期时间未到达,那我们判断该 cookie 是否具备持久性的逻辑如下:

let persistable = self.allCookies.filter { (_, value) in
            value.expiresDate != nil &&
            value.isSessionOnly == false &&
            value.expiresDate!.timeIntervalSinceNow > 0
        }

(3)持久性 cookie:具备持久性的 cookie 需要存储到磁盘文件中。存入路径规则如下:

let bundlePath = Bundle.main.bundlePath
var bundleName = bundlePath.components(separatedBy: "/").last!
if let range = bundleName.range(of: ".", options: .backwards, range: nil, locale: nil) {
    bundleName = String(bundleName[..<range.lowerBound])
}
let cookieFolderPath = URL(fileURLWithPath: bundleName, relativeTo: FileManager.default.urls(for: .applicationSupportDirectory, in:.userDomainMask)[0]).path
cookieFilePath = filePath(path: cookieFolderPath, fileName: "/.cookies." + cookieStorageName, bundleName: bundleName)

问题二:三种不同场景的 cookie 操作是如何协同工作的?

如 cookie 管理协同图 所示,不同场景下的 cookie 协同操作其本质就是三大进程间的通信:

(1)UIProcess 进程并没有直接管理 cookie,而是通过进程间通信的方式,在 NetworkProcess 进程中管理 cookie。

(2)所有 WebContent 进程都会注册监听 NetWorkProcess 中的 cookie 变更,及时进行相关变更的同步。

(3)前端 setCookie 操作会将 cookie 字符串解析为 NSHTTPCookie 实例,然后将该 cookie 存入 cookieCache 中,并同步到 NetworkProcess 中进行存储。前端执行 getCookie 操作会读取当前页面域名下的所有 cookie,若判断 cookieCache 中没有当前页面域名下的 cookie,考虑到异常情况,会兜底向 NetworkProcess 发送请求进行 cookie 查找。

(4)冷启动时,NetworkProcess 会初始化 NSHTTPCookieStorage ,并会将磁盘中的 cookie 读取出来,设置到内存字典 allCookies 中,同时将 allCookies 中的 cookie 变更通过广播的方式告知 WebContent 进程,发生了 cookie 变更,需要进行 cookie 同步。

(5)来自客户端的 cookie 操作或者来自服务端的 cookie 设置,导致了 NetworkProcess 中的 cookie 变更,都会通过广播的方式告知所有 WebContent 进程同时进行变更操作。

5. 总结

总而言之,cookie 操作简单,使用方便,多端同学都经常与其打交道。理清 WebKit 内部的 cookie 管理方式让我们在理论层面更了解 cookie 的技术原理。希望阅读此文后,相关开发同学在日常工作中,如果与 cookie 打了交道,一定要考虑清楚修改带来的影响面,谨慎操作。

NSHTTPCookieStorage 实现

NSHTTPCookieStorage 对应的 swift 版本开源代码如下, 里面有许多基础类库的设计思路,个人认为非常有参考价值,有兴趣的同学可以去研究相关实现:
https://github.com/apple/swift-corelibs-foundation/blob/main/Sources/FoundationNetworking/HTTPCookieStorage.swift

补充:跨域请求携带cookie

基于安全考虑,iOS14系统禁止了跨域请求携带cookie(https://webkit.org/tracking-prevention/)。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值