Blog

November 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 ▶

December 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31

January 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 ▶

ウェブビューが速くなる「Yappli Booster」爆誕?!

2022-12-22T18:00:00+09:00

この記事はヤプリ #1 Advent Calendar 2022の22日目の投稿です!

来年4月にヤプリは創業10周年を迎えます。スマホ(アプリ)の黎明期から成熟期までを一気に駆け抜けた10年間でしたが、毎年必ず挙げられてきた、昔も今も変わらない要望が一つだけあります。

それは「ウェブビューを速くしてほしい!」です。

ええ、仰っていることはよくわかります。速くしたい理由だって耳タコですw 速くできるものなら速くしたい…けれど、次の理由から実現できずにいました。

パソコン(PC)には様々なレンダリングエンジンを搭載したウェブブラウザ(アプリ)が存在しますが、スマホ(iOS)アプリではアップルから提供されたウェブビュー(WKWebView)しか使えません。正確にはWKWebView以外を使ったアプリは公開審査に通りません。

となると、「ウェブページをレンダリングする(表示する)」部分は改善のしようがなく、残るは「サーバからウェブページを読み込む」部分の改善ということになります。それには(利用者に)速い回線を契約してもらうのが一番効くのですが、それでは身も蓋もありませんw 一度読み込んだデータを賢くキャッシュするのは当然として、読み込みをさらに速くするにはどうしたら良いのか。

すぐ思い付く方法に「先読み」があります。

利用者が開くであろうウェブページを事前に読み込んでしまえば「サーバからウェブページを読み込む」時間を実質ゼロにすることができます。しかしながら、それを正確に予測するのは難しく、利用者に見えないところで無駄な通信を行う(ギガを使うw)ことになってしまいます。技術的に可能とはいえ「それをやってはダメだろう」という良心がはたらき、これまで実装に至ることはありませんでした。

ところが、ひょんなことからウェブサイトの表示を高速化するサービスが注目を集めていると知り、解析してみるとService Workerで(利用者に見えないところで)先読みしているではないですか!佐野研究室としては見過ごすわけにはいかない!というわけで、先読みによるウェブビューの高速化を試してみました。

さて、まずは先読みデータを管理する仕組みをNSCacheで用意します。先読み処理のタスク(Task)とレスポンス(CachedURLResponse)をURLに紐付けて扱います。

200final class Cache {
201    enum Entry {
202        case inProgress(Task<CachedURLResponse, Error>)
203        case ready(CachedURLResponse)
204    }
205    let entry: Entry
206    init(entry: Entry) { self.entry = entry }
207}
208extension NSCache where KeyType == NSString, ObjectType == Cache {
209    subscript(_ url: URL) -> Cache.Entry? {
210        get {
211            let key = url.absoluteString as NSString
212            let value = object(forKey: key)
213            return value?.entry
214        }
215        set {
216            let key = url.absoluteString as NSString
217            guard let entry = newValue else { return removeObject(forKey: key) }
218            setObject(Cache(entry: entry), forKey: key)
219        }
220    }
221}

先読みデータ(キャッシュ)があればそれを返し、なければサーバから読み込む処理です。URLのホスト名がhostパラメータと一致した場合のみデータが保持されます(一致しない場合は単なる読み込み処理になります)。

169    @discardableResult func fetch(from url: URL, host: String? = nil) async throws -> CachedURLResponse {
170        if let entry = cache[url] {
171            switch entry {
172            case .inProgress(let task):
173                return try await task.value
174            case .ready(let cached):
175                return cached
176            }
177        }
178        let task = Task<CachedURLResponse, Error> {
179            let (data, response) = try await URLSession(configuration: self.configuration).data(from: url)
180            let cached = CachedURLResponse(response: response, data: data)
181            return cached
182        }
183        guard url.host == host else { return try await task.value }
184
185        self.cache[url] = .inProgress(task)
186        do {
187            let cached = try await task.value
188            self.cache[url] = nil
189            guard (cached.response as! HTTPURLResponse).statusCode == 200 else { return cached }
190            guard let url = (cached.response as! HTTPURLResponse).url else { return cached }
191            self.cache[url] = .ready(cached)
192            return cached
193        } catch {
194            self.cache[url] = nil
195            throw error
196        }
197    }

そして、現在表示中のウェブページからaタグを抽出し、
①リンク先のHTML
②HTMLに含まれるCSSや画像など
③CSSに含まれる画像
の順に先読みします。なお、各工程のリクエストはすべて並列で行われるので、サーバへの攻撃と間違われないようご注意くださいw(それにしても並列処理が簡単に書けるようになりましたね)

115    private func scrape(from urls: [URL?], host: String) async throws -> [(URL, String)] {
116        return try await withThrowingTaskGroup(of: CachedURLResponse.self, returning: [(URL, String)].self) { group in
117            for url in urls.filter({ $0?.host?.hasSuffix(host) ?? false }) {
118                guard let url = url, self.cache[url] == nil else { continue }
119                group.addTask {
120                    try await self.fetch(from: url, host: host)
121                }
122            }
123            var array = [(URL, String)]()
124            for try await cached in group {
125                let encoding: String.Encoding = {
126                    guard let textEncodingName = cached.response.textEncodingName else { return String.Encoding.utf8 }
127                    return String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(CFStringConvertIANACharSetNameToEncoding(textEncodingName as CFString)))
128                }()
129                guard let url = cached.response.url, let string = String(data: cached.data, encoding: encoding) else { continue }
130                array.append((url, string))
131            }
132            return array
133        }
134    }
135
136    private func cache(from urls: [URL?], host: String) async throws {
137        await withThrowingTaskGroup(of: CachedURLResponse.self) { group in
138            for url in urls.filter({ $0?.host?.hasSuffix(host) ?? false }) {
139                guard let url = url, self.cache[url] == nil else { continue }
140                group.addTask {
141                    try await self.fetch(from: url, host: host)
142                }
143            }
144        }
145    }
146
147    func ignite(webView: WKWebView) async {
148        guard let host = await webView.url?.host else { return }
149        await webView.evaluateJavaScript("[...document.querySelectorAll('a')].map((a) => { return a.href })") { object, error in
150            self.task?.cancel()
151            self.task = Task(priority: .background) {
152                var queue = try await self.scrape(from: OrderedSet(object as? [String] ?? []).map({ URL(string: $0) }), host: host)
153
154                try await self.cache(from: queue.flatMap { (url, html) in
155                    html.matches(of: /['"]{1}([^'"]+\.(gif|jpg|js|png|svg|webp)(\?[^'"]+)*)['"]{1}/).map { URL(string: String($0.1), relativeTo: url) }
156                }, host: host)
157
158                queue = try await self.scrape(from: queue.flatMap { (url, html) in
159                    html.matches(of: /['"]{1}([^'"]+\.css(\?[^'"]+)*)['"]{1}/).map { URL(string: String($0.1), relativeTo: url) }
160                }, host: host)
161
162                try await self.cache(from: queue.flatMap { (url, css) in
163                    css.matches(of: /url\(['"]*([^'")]+\.(gif|jpg|png|svg|webp)(\?[^'")]+)*)['"]*\)/).map { URL(string: String($0.1), relativeTo: url) }
164                }, host: host)
165            }
166        }
167    }

最後に、ウェブビュー内の通信を先読みしたデータに差し替える処理です。昔はNSURLProtocolでやっていたような記憶がありますが、今はWKURLSchemeHandlerという便利なものがあるのでそれを利用します。ただし、そのままでは(元々ウェブビューが取り扱うURLスキームである)httpsを登録することはできません。そこで闇魔法を少々…

223extension WKWebView {
224    static var swizzle: Void = {
225        guard let m1 = class_getInstanceMethod(object_getClass(WKWebView.self), #selector(WKWebView.handlesURLScheme(_:))),
226              let m2 = class_getInstanceMethod(object_getClass(WKWebView.self), #selector(WKWebView.swizzledHandlesURLScheme(_:))) else { return }
227        method_exchangeImplementations(m1, m2)
228    }()
229
230    @objc static func swizzledHandlesURLScheme(_ urlScheme: String) -> Bool {
231        return urlScheme == "https" ? false : handlesURLScheme(urlScheme)
232    }
233}

これでウェブビュー内の通信が自前のURLSchemeHandler経由で行われるようになりました!あとはカスタムURLスキームの場合と同じように、先読みしたデータをウェブビューに返すだけです。

56        func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
57            guard let url = urlSchemeTask.request.url else { return }
58            let task = Task.detached {
59                do {
60                    let cached = try await self.booster.fetch(from: url, host: webView.url?.host)
61                    try Task.checkCancellation()
62                    urlSchemeTask.didReceive(cached.response)
63                    urlSchemeTask.didReceive(cached.data)
64                    urlSchemeTask.didFinish()
65                } catch {
66                    if !Task.isCancelled {
67                        urlSchemeTask.didFailWithError(error)
68                    }
69                }
70                await self.tasks.removeTask(forURLSchemeTask: urlSchemeTask)
71            }
72            Task {
73                await self.tasks.addTask(task, forURLSchemeTask: urlSchemeTask)
74            }
75        }
76
77        func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
78            Task {
79                guard let task = await self.tasks.removeTask(forURLSchemeTask: urlSchemeTask) else { return }
80                task.cancel()
81            }
82        }

完成です!早速効果を検証…したいところですが、通常の(快適な)通信環境では1秒未満の差しか現れないことが予想されるため、Network Link Conditioner(Additional Tools for Xcode 14に含まれている)で通信速度を5 Mbpsまで落とし、先読み時に迷惑がかからないよう自社サイトで検証を進めます。

比較対象として、先読みなしで表示(通常のウェブビューでの初回表示)してみたところ、読み込み完了までに7.27秒かかりました。

さあ、いよいよ先読みありでの表示です!結果は5.11秒!!(30%高速化!!

インスペクタ上でも、先読み対象のデータはすべて数十ミリ秒で読み込まれていました。こうなると気になってくるのが、先読みできそうなものはすべて(表示中であるyapp.liドメイン以外のデータも)先読みしたらどこまで速くなるのか?(多少負荷をかけても大丈夫であろう)深夜だったのでこっそりやってみましたw

結果は2.94秒!!!(60%高速化!!!

もちろん通信速度を落としての結果ですし、何でもかんでも先読みしてしまうと広告や効果計測などで不具合が生じそうなのでそのままでは使えませんが、通信速度が良好な場合に先読み処理を実行し、通信速度が落ちた場合にそれを使うようにするだけでも、ウェブビューの体験向上に繋がりそうです。何と言っても、ウェブサイトには一切手を入れず高速化できるのが良いですね!

あれ、もしかして「Yappliにすればウェブサイトも速くなる!」日もそう遠くはない?!

カジュアル面談 / 株式会社ヤプリ