WKWebview秒开实践分享及问题解决方案

·  阅读 13718

背景

作为外汇资讯类的App,查看新闻资讯一直是用户核心需求,也是老板一直说能不能再提高点速度不想看到加载的过程.[如果大家想看基本的简单使用,请绕过,基本学完之后,再次看会有不同的感受的]

在资讯中,我们项目咨询详情页是通过WKWebView来承载足够丰富的样式和逻辑性的统一[相比比Native来说性能比较差],所以作为研发就踏上了优化新闻详情页路程中. 经过不断的优化和探索, App详情页在线上的打开速度整整由2-4秒优化到从肉眼上基本感受不到加载过程. 下面将讲述逐渐讲述不断优化WKWebView的过程.

一、知识储备

打开一个h5页面通常会有较长时间出现白屏,以及几秒后出现内容,在这几秒中到底做了什么事情?因为在这其中做了很多事情,大约包括:

初始化 webview -> 请求页面 -> 下载数据 -> 解析HTML -> 请求 js/css 资源 -> dom 渲染 -> 解析 JS 执行 -> JS 请求数据 -> 解析渲染 -> 下载渲染图片
复制代码

一般来说, Webview渲染需要经过下面的几个步骤:

  1. 解析HTML文件
  2. 加载 JavaScript 和 CSS 文件
  3. 解析并执行 JavaScript
  4. 构建 DOM 结构
  5. 加载图片等资源
  6. 页面加载完毕

一般页面是在dom渲染后才能展示,h5首屏渲染白屏问题的原因关键在于,如何去优化请求下载->渲染之间的耗时成了重点.下面我们将围绕这个问题进行进行一系列的知识讲解,都是本人项目中使用到的.

二、优化方式【Swift版本】

终极大礼包,demo代码

优化之路包括前端和客户端,常规的前端和后端的性能优化已有前辈们总结过最佳实践,主要的是:

降低请求量:合并资源,减少 HTTP 请求数,minify / gzip 压缩,webP,lazyLoad。
加快请求速度:预解析DNS,减少域名数,并行加载,CDN 分发。
缓存:HTTP 协议缓存请求,离线缓存 manifest,离线数据缓存 localStorage。
渲染:JS/CSS优化,加载顺序,服务端渲染模板直出。
复制代码

下面将以项目实际中运用到的进行优化方案讲解!

2.1  WKWebview复用池

在不断的启动打开webView的过程中,发现首次打开webView的速度会比第二次打开的速度多几百毫秒。据美团做统计在iOS10系统上,首次打开比再次打开会多700ms。因此尝试预先初始化webView复用方案,速度会快很多。

复用原理

预先预备两个Set,一个是正被visiableWebViewSet,一个是空闲等待使用的reusableWebViewSet。在启动AppDelegate页面的时候首先创建出来单例对象ZXYWebViewPool【因为不想在启动过程中占用太多时间】我们将初始化webView放在了首页加载完成后。在首页加载完成后,通过通知告知ZXYWebViewPool,初始化一个webView,并加入到reusableWebViewSet,当h5页面需要使用时候,就从reusableWebViewSet中取出放入到visiableWebViewSet中,使用完成后(dealloc)放回到reusableWebViewSet中。

2.1.1 AppDelegate里面初始化缓冲池【启动】

// webView缓存池
let _ = ZXYWebViewPool.shared
复制代码

2.1.2 首页加载完成后,通知缓冲池,初始化webView,加入到reusableWebViewSet

viewDidLoad里面添加监听

NotificationCenter.default.post(name: NSNotification.Name(kMainControllerInitSuccessNotiKey), object: nil)
复制代码

缓冲池里面监听首页加载完成后

// 监听首页初始化完成
        NotificationCenter.default.addObserver(self,
                                               selector: #selector(mainControllerInit),
                                               name: NSNotification.Name(kMainControllerInitSuccessNotiKey),                                               object: nil)
复制代码

主要看mainControllerInit方法,异步初始化webView

@objc func mainControllerInit() {
        DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.25) {
            self.prepareReuseWebView()
        }
    }
复制代码

查看prepareReuseWebview

func prepareReuseWebView() {
        guard reusableWebViewSet.count <= 0 else { return }
        let webview = ZXYWebView(frame: CGRect.zero, configuration: ZXYWebView.defaultConfiguration())
        self.reusableWebViewSet.insert(webview)
}
复制代码

然后在使用完成之后,webView持有者销毁,则放回可复用池中

deinit {
        if showProgress {
             webView?.removeObserver(self, forKeyPath: "estimatedProgress")
        }
        webView?.removeObserver(self, forKeyPath: "title")
        ZXYWebViewPool.shared.tryCompactWeakHolders()
    }

/// 使用中的webView持有者已销毁,则放回可复用池中
    func tryCompactWeakHolders() {
        lock.wait()
        let shouldReusedWebViewSet = visiableWebViewSet.filter{ $0.holderObject == nil }
        for webView in shouldReusedWebViewSet {
            webView.webviewWillEnterPool()
            visiableWebViewSet.remove(webView)
            reusableWebViewSet.insert(webView)
        }
        lock.signal()
    }
复制代码

当内存警告,清除复用池

// 监听内存警告,清除复用池
        NotificationCenter.default.addObserver(self,
                                               selector: #selector(didReceiveMemoryWarningNotification),
                                               name: UIApplication.didReceiveMemoryWarningNotification,
                                               object: nil)
@objc fileprivate func didReceiveMemoryWarningNotification() {
        lock.wait()
        reusableWebViewSet.removeAll()
        lock.signal()
    }
复制代码

上面是整个的逻辑,下面是相应的ZXYWebViewPoolProtocol缓冲池代码和逻辑【以及资源同步加锁等】-Swift版本

import UIKit

protocol ZXYWebViewPoolProtocol: class {
    func webviewWillLeavePool()
    func webviewWillEnterPool()
}

public class ZXYWebViewPool: NSObject {

    // 当前有被页面持有的webview
    fileprivate var visiableWebViewSet = Set<ZXYWebView>()
    // 回收池中的webview
    fileprivate var reusableWebViewSet = Set<ZXYWebView>()
    
    fileprivate let lock = DispatchSemaphore(value: 1)

    public static let shared = ZXYWebViewPool()
    
    public override init() {
        super.init()
        // 监听内存警告,清除复用池
        NotificationCenter.default.addObserver(self,
                                               selector: #selector(didReceiveMemoryWarningNotification),
                                               name: UIApplication.didReceiveMemoryWarningNotification,
                                               object: nil)
        // 监听首页初始化完成
        NotificationCenter.default.addObserver(self,
                                               selector: #selector(mainControllerInit),
                                               name: NSNotification.Name(kMainControllerInitSuccessNotiKey),
                                               object: nil)
    }
    
    deinit {
        // 清除set
    }
}


// MARK: Observers
extension ZXYWebViewPool {
    
    @objc func mainControllerInit() {
        DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.25) {
            self.prepareReuseWebView()
        }
    }
    
    @objc fileprivate func didReceiveMemoryWarningNotification() {
        lock.wait()
        reusableWebViewSet.removeAll()
        lock.signal()
    }
}


// MARK: Assistant
extension ZXYWebViewPool {
    
    /// 使用中的webView持有者已销毁,则放回可复用池中
    func tryCompactWeakHolders() {
        lock.wait()
        let shouldReusedWebViewSet = visiableWebViewSet.filter{ $0.holderObject == nil }
        for webView in shouldReusedWebViewSet {
            webView.webviewWillEnterPool()
            visiableWebViewSet.remove(webView)
            reusableWebViewSet.insert(webView)
        }
        lock.signal()
    }
    
    /// 预备一个空的webview
    func prepareReuseWebView() {
        guard reusableWebViewSet.count <= 0 else { return }
        let webview = ZXYWebView(frame: CGRect.zero, configuration: ZXYWebView.defaultConfiguration())
        self.reusableWebViewSet.insert(webview)
    }
}


// MARK: 复用池管理
public extension ZXYWebViewPool {
    
    /// 获取可复用的webView
    func getReusedWebView(forHolder holder: AnyObject?) -> ZXYWebView {
        assert(holder != nil, "ZXYWebView holder不能为nil")
        guard let holder = holder else {
            return ZXYWebView(frame: CGRect.zero, configuration: ZXYWebView.defaultConfiguration())
        }
        
        tryCompactWeakHolders()
        let webView: ZXYWebView
        lock.wait()
        if reusableWebViewSet.count > 0 {
            // 缓存池中有
            webView = reusableWebViewSet.randomElement()!
            reusableWebViewSet.remove(webView)
            visiableWebViewSet.insert(webView)
            // 出回收池前初始化
            webView.webviewWillLeavePool()
        } else {
            // 缓存池没有,创建新的
            webView = ZXYWebView(frame: CGRect.zero, configuration: ZXYWebView.defaultConfiguration())
            visiableWebViewSet.insert(webView)
        }
        
        webView.holderObject = holder
        lock.signal()
        
        return webView
    }
    
    /// 回收可复用的webView到复用池中
    func recycleReusedWebView(_ webView: ZXYWebView?) {
        guard let webView = webView else { return }
        lock.wait()
        // 存在于当前使用中,则回收
        if visiableWebViewSet.contains(webView) {
            // 进入回收池前清理
            webView.webviewWillEnterPool()
            visiableWebViewSet.remove(webView)
            reusableWebViewSet.insert(webView)
        }
        lock.signal()
    }
    
    /// 移除并销毁所有复用池的webView
    func clearAllReusableWebViews() {
        lock.wait()
        for webview in reusableWebViewSet {
            webview.webviewWillEnterPool()
        }
        reusableWebViewSet.removeAll()
        lock.signal()
    }
}
复制代码

2.2 HTML/JS/CSS模板抽离

图文详情通过WebView来承载的,而webView最简单的做法是直接通过URL去加载一个线上页面。当从浏览器输入一个URL到页面中间经历了什么?

从上面看出用户每次进入详情页都要经过多次网络加载,中间是极易受到网络波动的影响,在无法保证页面加载的时长和成功率的情况下,会很大影响用户体验。

于是在本项目中将资讯详情页的公共样式CSS和逻辑JS都抽离出来,以及资源文件HTML还有一些图片资源模板直接内置于客户端中【因为项目中h5页面样式等都不一样,使用到了不同模板】,这样进入资讯详情页只需要本地加载模板,而且加载模板的同时也可以并行进行网络请求详情页数据,再将数据注入到模板中。此时用户点击到看到页面内容只需要经历下面阶段:

2.2.1 项目使用的

下面是项目需要使用的模板【由于本项目中资源模板不经常改动,所以直接内嵌于项目中】

首先加载本地的HTML【HTML里面会预加载好JS和CSS】,然后调用JS里面的loadNewsJson方法,进入loadDatas将数据注入页面,然后渲染出来

override public func viewDidLoad() {
        super.viewDidLoad()
        self.setNavBar()
        //首先加载HTML【HTML里面会加载好JS、CSS资源】
        self.loadNewsWebFiles()
        //紧接着调用js里面的loadNewsJson方法进行数据的填充
        self.loadNewsJson()
    }
复制代码

加载本地的HTML【HTML里面会预加载好JS和CSS】

//加载本地HTML【HTML里面会加载好JS、CSS资源】
    func loadNewsWebFiles() {
        let bundle = Bundle(for: ZXYLocalWebViewController.self)
        var htmlName: String?
        switch newsType {
        case .dealerComments, .dealerActivity, .dealerAnnouncement:
            htmlName = "BrokerDetails"
            
        case .IBPolicy:
            htmlName = "ibPolicy"
            
        case .brokerIntroduction:
            htmlName = "Introduction"
        
        case .bannerDetail:
            htmlName = "banner"
            
        default:
            htmlName = "news"
        }
        guard let path = bundle.path(forResource: htmlName, ofType: "html") else {
            return
        }
        
        DispatchQueue.global().async {
            guard let htmlString = try? String(contentsOfFile: path, encoding: String.Encoding.utf8) else {
                return
            }
            DispatchQueue.main.async {
                let baseURL = URL(fileURLWithPath: path, isDirectory: false)
                self.webView?.loadHTMLString(htmlString, baseURL: baseURL)
            }
        }
    }
复制代码

每次进入webView时候,然后调用loadNewsJson方法,数据通过请求,通过调用JS方法里面的loadNewsJson方法进入注入数据,然后渲染【针对不同模板通过枚举区别,注入不同模板数据】

func loadNewsJson() {
        let request = BLRequestEntity()
        
        switch newsType {
        case .coolForeignCurrency:
            request.api = ZXYApi.home.HomeFxNewsDetailApi
            request.params = ["newsId": dataId]
            
        case .IBPolicy, .bannerDetail:
            request.api = ZXYApi.home.GetBannerDetailApi
            request.params = ["id": dataId]
            
        case .brokerIntroduction:
            request.api = ZXYApi.broker.BrokerIntroductionApi
            request.params = ["brokerId": dataId, "type": 1, "contentType": 1]
            
        default:
            request.api = ZXYApi.home.HomeNewsDetailApi
            request.params = ["id": dataId]
        }
        
        ZXYHttpManager.shared.get(request: request, success: { (response) in
            guard response.code == HttpRequestResult.success, let newsData = response.bodyMessage, !newsData.isEmpty else {
                self.view.addPlaceholder(type: .noData)
                return
            }
            
            self.view.removePlaceholder()
            switch self.newsType {
            case .coolForeignCurrency:
                self.noticesEntity = NewsNoticesEntity.deserialize(from: newsData, designatedPath: "NewsDetail")
                
            case .IBPolicy, .bannerDetail, .brokerIntroduction:
                self.noticesEntity = NewsNoticesEntity.deserialize(from: newsData)
                
            default:
                self.noticesEntity = NewsNoticesEntity.deserialize(from: newsData, designatedPath: "Notices")
            }
            
            self.newsJson = newsData
            if self.isFinished {
                let traderInfoCard = self.isShowBroker ? 1 : 0
                self.callJSMethod(name: "loadNewsJson(\(newsData), \(traderInfoCard), \(self.isShowSourceRegulator))")
            }
            
        }, failure: { (error) in
            self.showToast(message: error)
            self.view.addPlaceholder(type: .webviewLoadFail, handler: {[weak self] in
                self?.loadNewsJson()
            })
        }, completed: {
            
        })
    }

/// 调用 JS 方法
    ///
    /// - Parameter name: 方法名
    public func callJSMethod(name: String) {
        if !isFinished {return}
        self.webView?.evaluateJavaScript(name, completionHandler: { (_, error) in
            if error != nil {
                // self.showErrorHUD(message: "操作失败", image: #imageLiteral(resourceName: "MBHUD_Error"))
            }
        })
    }

复制代码

那么可不可以继续优化呢?当然可以。经过上面的优化之后,页面加载的瓶颈变成了本地模板的加载时间,所以我们也可以通过优化模板来减少时间。

  • 模板合并:正常来说,webView需要加载完HTML之后再去加载JS和CSS,需要多次I/O操作。我们优化之路可以将JS和CSS还有一些图片内联到一个文件中,此时加载模板就只需要一次I/O操作。
  • 让h5那边精简不必要的CSS样式和JS代码,不断压缩模板,或者将非必要的脚本异步化拉取。

拓展

self.callJSMethod(name: "loadNewsJson(\(newsData), \(traderInfoCard), \(self.isShowSourceRegulator))")
复制代码

代码js里面loadNewsJson需要有三个参数,然后我们紧接着随意查看js文件的loadNewsJson

将接口请求的数据通过JS loadNewsJson中loadDatas注入到模板数据中:

2.2.2 插曲-原本方案

原本的方式:离线包的分发-根据公司的CDN实现离线包的分发,在对象存储着放置离线包文件和一个配置文件info.json,格式如下:

{
    "version":"1.2.2",
    "files": [
       "https://xxx/news.html",
       "https://xxx/broker.png",
       "https://xxx/news.js",
       "https://xxx/news.css",
       "https://xxx/news.json"
    ]
}
复制代码

然后App有当前的版本,当发现info.json文件中的版本发生改变不一样时,代表是资源有所更新,需要更新下载。

拓展:离线包知识点

离线包内容:css,js,html,通用的图片等-可以尽量的优化,像上面说的那样
下载时机:在app启动的时候,开启线程下载资源,注意不要影响app的启动。
存放位置:选用沙盒中的/Library/Caches。因为资源会不定时更新,而/Library/Documents更适合存放一些重要的且不经常更新的数据。
更新逻辑:请求CDN上的info.json资源,返回的version与本地保存的不同,则资源变化需更新下载。注:第一次运行时,需要在/Library/Caches中创建自定义文件夹,并全量下载资源。
复制代码

代码如下:

NSURLSession *session = [NSURLSession sharedSession];
NSURLSessionDownloadTask *downLoadTask = [session downloadTaskWithRequest:request completionHandler:^(NSURL * _Nullable location, NSURLResponse * _Nullable response, NSError * _Nullable error) {
  if (!location) {
      return ;
  }
  
  //下载成功,移除旧资源
  [fileManager removeFileAtPath:dirPath fileExtesion:nil];
  
  //脚本临时存放路径
  NSString *downloadTmpPath = [NSString stringWithFormat:@"%@broker_%@.zip", NSTemporaryDirectory(), version];
  // 文件移动到指定目录中
  NSError *saveError;
  [fileManager moveItemAtURL:location toURL:[NSURL fileURLWithPath:downloadTmpPath] error:&saveError];
  //解压zip
  BOOL success = [解压 unzipFileAtPath:downloadTmpPath toDestination:dirPath];
  if (!success) {
      [fileManager removeItemAtPath:downloadTmpPath error:nil];
      [fileManager removeFileAtPath:dirPath fileExtesion:nil];
      return;
  }
  //更新版本号
  [[NSUserDefaults standardUserDefaults] setValue:version forKey:brokerFileKey];
  [[NSUserDefaults standardUserDefaults] synchronize];
  //清除临时文件和目录
  [fileManager removeItemAtPath:downloadTmpPath error:nil];
}];
[downLoadTask resume];
[session finishTasksAndInvalidate];
复制代码

通过上面代码将所有资源文件整合成zip形式,一次性下载到本地,然后解压到指定位置,紧接着更新version即可。然后下载时间在app启动和前后切换的时候做一次版本更新检查就可以了。

假如离线资源包仅仅发生了一点点改变,每个用户如果都要下载全部的离线包,那就太恐怖了【针对用户量巨大的话,带宽也是个大问题,大家可以参考qq有关方案-博客地址如下:mp.weixin.qq.com/s/evzDnTsHr…

2.3 拦截离线包

当打开自定义协议sheme H5页面时,webView请求页面,native 就可以依次收到html,js,css以及图片类型的拦截响应。可以使用webKit中的WKURLSchemeHandler。

下面尝试拦截文档中的标签,当有标签时,我们使用的图片第三方是Kingfisher,用Kingfisher缓存下来,这样就不需要再次用H5请求图片【改成用本地请求】提升速度。会将图片和视频等非文字内容通过原生组件的方式放在客户端进行渲染,既可以提高渲染效率,也可以减少不必要的流量消耗

下面是项目中代码使用的内容:

public func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
        print("🚀开始加载图片\(urlSchemeTask.request.url)", Date.init().timeIntervalSince1970)
        
        if let url = urlSchemeTask.request.url {
            var components = URLComponents.init(string: url.absoluteString)
            components?.scheme = "https"
            if let url = components?.url {
                KingfisherManager.shared.retrieveImage(with: url, options: nil, progressBlock: nil) { (image, _, _, _) in
                    var data: Data? = nil
                    if let image = image?.kf.gifRepresentation() {
                        data = image
                    }else if let image = image?.kf.pngRepresentation() {
                        data = image
                    }else if let image = image?.kf.jpegRepresentation(compressionQuality: 1) {
                        data = image
                    }
                    print("🚀结束加载图片\(urlSchemeTask.request.url)", Date.init().timeIntervalSince1970)
                    if let data = data {
                        let response = URLResponse.init(url: url, mimeType: nil, expectedContentLength: data.count, textEncodingName: nil)
                        urlSchemeTask.didReceive(response)
                        urlSchemeTask.didReceive(data)
                        urlSchemeTask.didFinish()
                    }else {
                        urlSchemeTask.didFailWithError(NSError.init(domain: "10086", code: 10086, userInfo: nil))
                    }
                }
                return
            }
        }
        urlSchemeTask.didFailWithError(NSError.init(domain: "10086", code: 10086, userInfo: nil))
    }
复制代码

**拓展:**WKURLSchemeHandler

小demo:【oc版】

假设 HTML 文档中有一个 标签,希望它显示本地的一张图片 test.jpg,那么 H5 可以这样编码:

<imag src="customScheme://www.test.com/test.jpg">
复制代码

Native 可以这样写:

#import "ViewController.h"
#import <WebKit/WebKit.h>

@interface CustomURLSchemeHandler : NSObject<WKURLSchemeHandler>
@end

@implementation CustomURLSchemeHandler
//这里拦截到URLScheme为customScheme的请求后,读取本地图片test.jpg,并返回给WKWebView显示
- (void)webView:(WKWebView *)webView startURLSchemeTask:(id)urlSchemeTask {    
    NSURLRequest *request = urlSchemeTask.request;    
    UIImage *image = [UIImage imageNamed:@"test.jpg"];    
    NSData *data = UIImageJPEGRepresentation(image, 1.0);    
    NSURLResponse *response = [[NSURLResponse alloc] initWithURL:urlSchemeTask.request.URL MIMEType:@"image/jpeg" expectedContentLength:data.length textEncodingName:nil];    
    [urlSchemeTask didReceiveResponse:response];   
    [urlSchemeTask didReceiveData:data];   
    [urlSchemeTask didFinish];
}

- (void)webView:(WKWebView *)webVie stopURLSchemeTask:(id)urlSchemeTask {
}
@end

@implementation ViewController
- (void)viewDidLoad {    
    [super viewDidLoad];    
    WKWebViewConfiguration *configuration = [WKWebViewConfiguration new];
    //设置URLSchemeHandler来处理特定URLScheme的请求,URLSchemeHandler需要实现WKURLSchemeHandler协议
    //本例中WKWebView将把URLScheme为customScheme的请求交由CustomURLSchemeHandler类的实例处理    
    [configuration setURLSchemeHandler:[CustomURLSchemeHandler new] forURLScheme: @"customScheme"];    
    WKWebView *webView = [[WKWebView alloc] initWithFrame:self.view.bounds configuration:configuration];    
    self.view = webView;    
    [webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"http://www.test.com"]]];
}
@end
复制代码

2.4 H5那边模板【模板优化】 --共同协调测试

一般来说HTML在开始接收到返回数据的时候就开始解析HTML并构建DOM树,如果没有JS阻塞的话一般会相继完成。【此模块需要提醒H5那边,H5与客户端协调测试

页面的header部分有这样的代码:【此为demo】

.....
<link href="//ms0.xxxx.net/css/eve.9d9eee71.css" rel="stylesheet" onload="MT.pageData.eveTime=Date.now()"/>
<script>
window.fk = function (callback) {
require(['util/native/risk.js'], function (risk) {
    risk.getFk(callback);
});
}
</script>
</head>
....
复制代码

通常情况下,上面的link部分和script如果单独出现,都不会阻塞页面的解析。【css不会阻止页面向下继续,内联的JS很快执行完,然后继续解析HTML】

但是如果两部分同时出现,问题就来了:

CSS加载阻塞了下面的一段内联的JS的执行,而被阻塞的内联JS则阻塞了HTML的解析
复制代码

通常情况下,CSS不会阻塞HTML的解析,但如果CSS后面有JS,则会阻塞JS的执行直到CSS加载完成,从而间接阻塞HTML的解析。从这可看出,一个小小的内联的JS放错位置也会让性能下降很多。

优化

  • CSS的加载会在HTML解析到CSS的标签时开始,所以CSS的标签要尽量靠前
  • 但是,CSS链接下面不要有任何的JS标签,否则会阻塞HTML的解析
  • 如果必须要在头部增加内联脚本,一定要放在CSS标签之前。

以上就是本项目中使用到的WKWebView优化策略,经本项目测试打开H5页面只需要0.2-0.4s,几乎肉眼看不到白屏或者等待状态**。
**

下面介绍一些其他优化的方面。

2.5 CDN加速

知识点:

CDN 的全称是 Content Delivery Network,即内容分发网络。其目的是通过在现有的 Internet 中增加一层新的网络架构,将网站的内容发布到最接近用户的网络“边缘”,使用户可以就近取得所需的内容,提高用户访问网站的响应速度。CDN 有别于镜像,因为它比镜像更智能,或者可以做这样一个比喻:CDN=更智能的镜像+缓存+流量导流。因而,CDN 可以明显提高 Internet 网络中信息流动的效率。从技术上全面解决由于网络带宽小、用户访问量大、网点分布不均等问题,提高用户访问网站的响应速度。

对于新闻资讯【外汇、金融、新闻】的行业,接口返回的数据比较大,对于每次的实时返回,对网络的压力比较大【用户基数比较大的时候】

对于条件允许的公司,可以考虑将新闻详情页内容数据分为静态和动态两部分,将正文内容、标题等内容基本不会更改的托管到CDN上。当内容托管到CDN后,用户可以直接从最近的最佳节点获取数据,也会大大的节省带宽的成本。

三、问题解决方案

3.1 白屏

在UIWebView上当内存占用太大的时候,App Process会崩溃crash;而当WKWebView总体的内存占用比较大的时候,WebContent Process会崩溃crash,从而导致白屏现象。遇到的场景是加载到WKWebView页面后,前/后台来回切换APP。

解决方案

3.1.1 借助 WKNavigtionDelegate

在iOS 9以后WKNavigtionDelegate新增一个回调函数

/*! @abstract Invoked when the web view's web content process is terminated.
 @param webView The web view whose underlying web content process was terminated.
 */
- (void)webViewWebContentProcessDidTerminate:(WKWebView *)webView API_AVAILABLE(macos(10.11), ios(9.0));
复制代码

当WKWebView总体内存占用过大即将白屏的时候,系统会调用上面的回调函数,只需要在函数里执行webView.reload()【这个时候webView.URL取值尚不为nil】解决白屏问题。

// 白屏
public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
    webView.reload()
}
复制代码

3.1.2 WebView.title是否为空

在WKWebView白屏的时候,另个现象会webView.title会被置空,因为我们可以在viewWillAppear检测webView.title 是否为空来reload页面。如下:

3.1.3 有没有WKCompositingView【尝试】

下面是对比白屏与正常显示的区别!

对比两图之间可以看到,白屏之后上层的WKCompsitingView都消失了,那就可以通过WKWebView是否存在WKCompsitingView来判断是否白屏?在执行js之前,只要发现count == 0就开始执行[webview reload]

func getWKCompositingCount(_ view: UIView, count: Int) {
        var compsitionClass: AnyClass =  NSClassFromString("WKCompositingView")!
        for subview in view.subviews {
            
            if (subview is compsitionClass) {
                count = count + 1
            }
            if subview.subviews.count > 0 {
                self.getWKCompositingCount(subview, count: count)
            }
        }
    }
复制代码

3.2 Post请求丢失body问题

WKWebView通过loadRequest方法加载Post请求会丢失请求体的内容,进而导致服务器拿不到body中的内容。问题产生原因是WKWebView的网络请求进程与APP不是同一个进程。

网络请求的过程: 由APP所在的进程发起请求request,然后通过IPC进程间通信将请求的相关信息【请求头、请求行、请求】 传递给webkit网络线进程接收包装,进行数据的HTTP请求,最终再进行IPC的通信回传给APP所在的进程。 这里发送的请求如果是Post请求的话,由于进行IPC数据传递,传递的请求体body中根据系统调度,将其舍弃,最终在WKWebView网络进程接受的时候请求体body中的内容变成了空,导致此种情况下服务器获取不了请求体,从而问题出现。

针对这种情况,大约有三种解决方法:

  1. 注册拦截的自定义的scheme

  2. 重写loadRequest()方法,根据request的method方法是否为POST进行URL的拦截替换

  3. 在URLProtocol中进行request的重新包装【获取请求的body内容】使用NSURLConnection进行HTTP请求并数据回传。

    这里说明一下为什么要自己去注册自定义的scheme,而不是直接拦截https/http。 主要原因是:如果注册了https/http的拦截,那么所有的http(s)请求都会交由系统进程处理,那么此时系统进程会通过IPC的形式传递给实现URLProctol协议的类去处理,在通过IPC传递的过程中丢失body体(上面有讲到),所以在拦截的时候是拿不到POST方法的请求体body的。然而并不是所有的http请求都会走loadrequest()方法(比如js中的ajax请求),所以导致一些POST请求没有被包装(将请求体body内容放到请求头header)就被拦截了,进而丢失请求体body内容,问题一样会产生。所以为了避免这样的问题,我们需要自己去定一个scheme协议,保证不过度拦截并且能够处理我们需要处理的POST请求内容。

下面就以重写loadRequest方法为例:

//包装请求头内容
- (WKNavigation *)loadRequest:(NSURLRequest *)request{
    NSLog(@"发起请求:%@ method:%@",request.URL.absoluteString,request.HTTPMethod);
    NSMutableURLRequest *mutableRequest = [request mutableCopy];
    NSMutableDictionary *requestHeaders = [request.allHTTPHeaderFields mutableCopy];
    //判断是否是POST请求,POST请求需要包装request中的body内容到请求头中(会有丢失body问题的产生)
    //,包装完成之后重定向到拦截的协议中自己包装处理请求数据内容,拦截协议是GCURLProtocol,请自行搜索
    if ([mutableRequest.HTTPMethod isEqualToString:@"POST"] && ([mutableRequest.URL.scheme isEqualToString:@"http"] || [mutableRequest.URL.scheme isEqualToString:@"https"])) {
        NSString *absoluteStr = mutableRequest.URL.absoluteString;
        if ([[absoluteStr substringWithRange:NSMakeRange(absoluteStr.length-1, 1)] isEqualToString:@"/"]) {
            absoluteStr = [absoluteStr stringByReplacingCharactersInRange:NSMakeRange(absoluteStr.length-1, 1) withString:@""];
        }
        
        if ([mutableRequest.URL.scheme isEqualToString:@"https"]) {
            absoluteStr = [absoluteStr stringByReplacingOccurrencesOfString:@"https" withString:WkCustomHttps];
        }else{
            absoluteStr = [absoluteStr stringByReplacingOccurrencesOfString:@"http" withString:WkCustomHttp];
        }
        
        mutableRequest.URL = [NSURL URLWithString:absoluteStr];
        NSString *bodyDataStr = [[NSString alloc]initWithData:mutableRequest.HTTPBody encoding:NSUTF8StringEncoding];
        [requestHeaders addEntriesFromDictionary:@{@"httpbody":bodyDataStr}];
        mutableRequest.allHTTPHeaderFields = requestHeaders;
        
        NSLog(@"当前请求为POST请求Header:%@",mutableRequest.allHTTPHeaderFields);
        
    }
    return [super loadRequest:mutableRequest];
}
复制代码

机会❤️❤️❤️🌹🌹🌹

如果想和我一起共建抖音,成为一名bytedancer,Come on。期待你的加入!!!

截屏2022-06-08 下午6.09.11.png

四、总结

WKWebView虽然还比较新,有许多问题需要去解决。但是新技术会带来种种诱惑,随着iOS系统的版本不断迭代,问题会不断减少,技术方案会不断的更新。

上面就是项目中使用到的WKWebView的封装和秒开实践,也是历时半个月的博客出品,如果有时间会将WKWebView的封装抽取出来,供大家参考和提意见。喜欢本篇或者对你有所技术有所思路的小伙伴们,欢迎关注本人以及点赞博客,后面会陆续出更多的作品供大家参考,谢谢!!!

今天是2020年10月24日,也是程序员的节日!祝大家2020-996 = 1024【新的一年告别996】,千万不要2020 - 1024 = 996呀。

分类:
iOS
标签:
分类:
iOS
标签:
收藏成功!
已添加到「」, 点击更改