足彩馆

iOS 端 h5 頁面秒開優化實踐

青爭哥哥· 2019-10-25

前言

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

先看效果

16d71accb40f7143.gif


優化思路

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

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

image.png

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

  1. 能夠緩存的就盡量緩存,用空間換時間。這里可以去攔截的 h5 頁面的所有資源請求,包括 html、css/js,圖片、數據等,右客戶端來接管資源的緩存策略(包括緩存的最大空間占用,緩存的淘汰算法、緩存過期等策略);

  2. 能夠預加載的,就提前預加載。可以預先處理一些耗時的操作,如在 App 啟動的時候就提前初始化好 webview 等待使用;

  3. 能夠并行的的,就并行進行,利用設備的多核能力。如在加載 webview 的時候就可以同時去加載需要的資源;

初始化 webview 階段

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

16d713d4101d51cb.png

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

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

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

+ (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) 時間復雜度,磁盤緩存使用了沙盒文件存儲。兩種緩存都實現了緩存時長、緩存數量、緩存大小三個維度的緩存管理。

使用協議的方式定義了接口 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.png

注意事項

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

image.png

image.png

解決方案:用一個字典持有 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/…


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

image.png

足彩馆 广东11选5规则 福建11选5 赣南麻将官方网站 打麻将口诀顺口溜大全 北京11选5一定牛 东北麻将玩法介绍 35选7彩走势图辽宁 上海时时乐 欧盘足球即时赔率 11选5山东开奖结 体彩6+1 手机上有百搭麻将吗 快乐双彩今晚开奖号码 球探体育比分手机版 乒乓球比赛比分规则 欢乐棋牌游戏