iOS 端 h5 頁面秒開優化實踐

前言

最近公司項目中須要作秒開 h5 頁面的優化需求,因而調研了下市面上的方案,並結合本公司具體的業務需求作了一次這方面的優化實踐,這篇文章是對此次優化實踐的記錄,文末附上源代碼下載。javascript

先看效果

ezgif.com-optimize

優化思路

首先來看,在 iOS 平臺加載一個 H5 網頁,須要通過哪些步驟:css

初始化 webview -> 請求頁面 -> 下載數據 -> 解析HTML -> 請求 js/css 資源 -> dom 渲染 -> 解析 JS 執行 -> JS 請求數據 -> 解析渲染 -> 下載渲染圖片html

WebView启动时间

因爲在 dom 渲染前的用戶看到的頁面都是白屏,優化思路具體也是去分析在 dom 渲染前每一個步驟的耗時,去優化性價比最高的部分。這裏面又能夠分爲前端能作的優化,以及客戶端能作的優化,前端這個須要前端那邊配合,暫且不在這篇文章中討論,這邊文章主要討論的是客戶端能作的優化思路。整體思路大概也是這樣:前端

  1. 可以緩存的就儘可能緩存,用空間換時間。這裏能夠去攔截的 h5 頁面的全部資源請求,包括 html、css/js,圖片、數據等,右客戶端來接管資源的緩存策略(包括緩存的最大空間佔用,緩存的淘汰算法、緩存過時等策略);
  2. 可以預加載的,就提早預加載。能夠預先處理一些耗時的操做,如在 App 啓動的時候就提早初始化好 webview 等待使用;
  3. 可以並行的的,就並行進行,利用設備的多核能力。如在加載 webview 的時候就能夠同時去加載須要的資源;

初始化 webview 階段

在客戶端加載一個 網頁和在 PC 上加載一個網頁不太同樣,在 PC 上,直接在瀏覽器中輸入一個 url 就開始創建鏈接了,而在客戶端上須要先啓動瀏覽器內核,初始化一些 webview 的全局服務和資源,再開始創建鏈接,能夠看一下美團測試的這個階段的耗時大概是多少:java

image-20190927112358207

在客戶端第一次打開 h5 頁面,會有一個 webview 初始化的耗時,git

能夠看到數據在使用 WKWebView 的狀況下,首次初始化的時間耗時有 760 多毫秒,因此若是可以在打開網頁的時候使用已經初始化好了的 webview 來加載,那麼這部分的耗時就沒有了。github

這邊實現了一個 webview 緩衝池的方案,在 App 啓動的時候就初始化了,在須要打開網頁的時候直接從緩衝池裏面去取 webview 就行:web

+ (void)load
{
    [WebViewReusePool swiftyLoad];
}

@objc public static func swiftyLoad() {
    NotificationCenter.default.addObserver(self, selector: #selector(didFinishLaunchingNotification), name: UIApplication.didFinishLaunchingNotification, object: nil)
}

@objc static func didFinishLaunchingNotification() {
    // 預先初始化webview
    WebViewReusePool.shared.prepareWebView()
}

func prepareWebView() {
    DispatchQueue.main.async {
        let webView = ReuseWebView(frame: CGRect.zero, configuration: self.defaultConfigeration)
        self.reusableWebViewSet.insert(webView)
    }
}
複製代碼

創建鏈接 -> dom 渲染前階段

#攔截請求

在 iOS 11 及其以上系統上能夠 WKWebView 提供的 setURLSchemeHandler 方法添加自定義的 Scheme,相比較NSURLProtocol 私有 api 的方案沒有審覈風險,而後就能夠在 WKURLSchemeHandler 協議裏面攔截全部的自定義請求了:算法

// 自定義攔截請求開始
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
    let headers = urlSchemeTask.request.allHTTPHeaderFields
    guard let accept = headers?["Accept"] else { return }
    guard let requestUrlString = urlSchemeTask.request.url?.absoluteString else { return }

    if accept.count >= "text".count && accept.contains("text/html") {
        // html 攔截
        print("html = \(String(describing: requestUrlString))")
        // 加載本地的緩存資源
        loadLocalFile(fileName: creatCacheKey(urlSchemeTask: urlSchemeTask), urlSchemeTask: urlSchemeTask)
    } else if (requestUrlString.isJSOrCSSFile()) {
        // js || css 文件
        print("js || css = \(String(describing: requestUrlString))")
        loadLocalFile(fileName: creatCacheKey(urlSchemeTask: urlSchemeTask), urlSchemeTask: urlSchemeTask)

    } else if accept.count >= "image".count && accept.contains("image") {
        // 圖片
        print("image = \(String(describing: requestUrlString))")
        guard let originUrlString = urlSchemeTask.request.url?.absoluteString.replacingOccurrences(of: "customscheme", with: "https") else { return }
				
      	// 圖片可使用 SDWebImageManager 提供的緩存策略
        SDWebImageManager.shared.loadImage(with: URL(string: originUrlString), options: SDWebImageOptions.retryFailed, progress: nil) { (image, data, error, type, _, _) in
            if let image = image {
                guard let imageData = image.jpegData(compressionQuality: 1) else { return }
              	
                // 資源不存在就從新發送請求
                self.resendRequset(urlSchemeTask: urlSchemeTask, mineType: "image/jpeg", requestData: imageData)
            } else {
                self.loadLocalFile(fileName: self.creatCacheKey(urlSchemeTask: urlSchemeTask), urlSchemeTask: urlSchemeTask)
            }
        }

    } else {
        // other resources
        print("other resources = \(String(describing: requestUrlString))")
        guard let cacheKey = self.creatCacheKey(urlSchemeTask: urlSchemeTask) else { return }
        requestRomote(fileName: cacheKey, urlSchemeTask: urlSchemeTask)
    }
}

/// 自定義請求結束時調用
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
	
}
複製代碼
#實現資源緩存

這裏使用 swift 實現了內存和磁盤兩種緩存邏輯,主要參(chao)考(xi)了 YYCache 的思路和源碼,內存緩存利用雙鏈表(邏輯) + hashMap(存儲) 實現LRU緩存淘汰算法 ,增刪改查都是 O(1) 時間複雜度,磁盤緩存使用了沙盒文件存儲。兩種緩存都實現了緩存時長、緩存數量、緩存大小三個維度的緩存管理。json

使用協議的方式定義了接口 API:

protocol Cacheable {
    associatedtype ObjectType
    
    /// 緩存總數量
    var totalCount: UInt { get }
    /// 緩存總大小
    var totalCost: UInt { get }
    
    /// 緩存是否存在
    ///
    /// - Parameter key: 緩存key
    /// - Returns: 結果
    func contain(forKey key: AnyHashable) -> Bool
    
    /// 返回指定key的緩存
    ///
    /// - Parameter key:
    /// - Returns:
    func object(forKey key: AnyHashable) -> ObjectType?
    
    /// 設置緩存 k、v
    ///
    /// - Parameters:
    /// - object:
    /// - key:
    func setObject(_ object: ObjectType, forKey key: AnyHashable)
    
    /// 設置緩存 k、v、c
    ///
    /// - Parameters:
    /// - object:
    /// - key:
    /// - cost:
    func setObject(_ object: ObjectType, forKey key: AnyHashable, withCost cost: UInt)
    
    /// 刪除指定key的緩存
    ///
    /// - Parameter key:
    func removeObject(forKey key: AnyHashable)
    
    /// 刪除全部緩存
    func removeAllObject()
    
    /// 根據緩存大小清理
    ///
    /// - Parameter cost: 緩存大小
    func trim(withCost cost: UInt)
    
    /// 根據緩存數量清理
    ///
    /// - Parameter count: 緩存數量
    func trim(withCount count: UInt)
    
    /// 根據緩存時長清理
    ///
    /// - Parameter age: 緩存時長
    func trim(withAge age: TimeInterval)
}

extension Cacheable {
    func setObject(_ object: ObjectType, forKey key: AnyHashable) {
        setObject(object, forKey: key, withCost: 0)
    }
}
複製代碼
用法:
/// h5 頁面資源緩存
class H5ResourceCache: NSObject {
    /// 內存緩存大小:10M
    private let kMemoryCacheCostLimit: UInt = 10 * 1024 * 1024
    /// 磁盤文件緩存大小: 10M
    private let kDiskCacheCostLimit: UInt = 10 * 1024 * 1024
    /// 磁盤文件緩存時長:30 分鐘
    private let kDiskCacheAgeLimit: TimeInterval = 30 * 60
    
    private var memoryCache: MemoryCache
    private var diskCache: DiskFileCache
    
    override init() {
        memoryCache = MemoryCache.shared
        memoryCache.costLimit = kMemoryCacheCostLimit
            
        diskCache = DiskFileCache(cacheDirectoryName: "H5ResourceCache")
        diskCache.costLimit = kDiskCacheCostLimit
        diskCache.ageLimit = kDiskCacheAgeLimit
        
        super.init()
    }
    
    func contain(forKey key: String) -> Bool {
        return memoryCache.contain(forKey: key) || diskCache.contain(forKey: key)
    }
    
    func setData(data: Data, forKey key: String) {
        guard let dataString = String(data: data, encoding: .utf8) else { return }
        memoryCache.setObject(dataString.data(using: .utf8) as Any, forKey: key, withCost: UInt(data.count))
        diskCache.setObject(dataString.data(using: .utf8)!, forKey: key, withCost: UInt(data.count))
    }
    
    func data(forKey key: String) -> Data? {
        if let data = memoryCache.object(forKey: key) {
            print("這是內存緩存")
            return data as? Data
        } else {
            guard let data = diskCache.object(forKey: key) else { return nil}
            memoryCache.setObject(data, forKey: key, withCost: UInt(data.count))
            print("這是磁盤緩存")
            return data
        }
    }
    
    func removeData(forKey key: String) {
        memoryCache.removeObject(forKey: key)
        diskCache.removeObject(forKey: key)
    }
    
    func removeAll() {
        memoryCache.removeAllObject()
        diskCache.removeAllObject()
    }
}
複製代碼

效果

image-20190927122508611

注意事項

#1. WKURLSchemeHandler 對象實例被釋放後,網絡加載回調依然訪問了,這個時候就會出現崩潰The task has already been stopped的錯誤

image-20190926190443221

image-20190926190652788

解決方案:用一個字典持有 WKURLSchemeTask 實例的狀態,分別在攔截請求開始的地方和攔截請求結束的地方分別記錄
// MARK:- 請求攔截開始
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
    holdUrlSchemeTasks[urlSchemeTask.description] = true
}

/// 自定義請求結束時調用
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
    holdUrlSchemeTasks[urlSchemeTask.description] = false
}

// 須要用到的 urlSchemeTask 實例的地方,加一層判斷
// urlSchemeTask 是否提早結束,結束了調用實例方法會崩潰
if let isValid = self.holdUrlSchemeTasks[urlSchemeTask.description] {
    if !isValid {
        return
    }
}
複製代碼

#2. 網頁亂碼

添加網絡請求響應接收格式:

manager.responseSerializer.acceptableContentTypes = Set(arrayLiteral: "text/html", "application/json", "text/json", "text/javascript", "text/plain", "application/javascript", "text/css", "image/svg+xml", "application/font-woff2", "application/octet-stream")
複製代碼

#3. WKWebView 白屏

// 白屏
override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    if webview.title == nil {
        webview.reload()
    }
}

// 白屏
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
    webView.reload()
}
複製代碼

源代碼

github.com/ljchen1129/…

TODOList

  1. 撰寫單元測試
  2. 去除第三方庫 SDWebImage 和 AFNetworking,使用原生實現
  3. 資源預加載邏輯
  4. 統一的異常管理
  5. 更加 Swift style

參考資料

  1. blog.cnbang.net/tech/3477/
  2. mp.weixin.qq.com/s/0OR4HJQSD…
  3. juejin.im/post/5c9c66…
  4. tech.meituan.com/2017/06/09/…

分享我的技術學習記錄和跑步馬拉松訓練比賽、讀書筆記等內容,感興趣的朋友能夠關注個人公衆號「青爭哥哥」。

青爭哥哥
相關文章
相關標籤/搜索