ウェブビューが速くなる「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にすればウェブサイトも速くなる!」日もそう遠くはない?!
カジュアル面談 / 株式会社ヤプリ
WebView.swift
1//
2// WebView.swift
3// YappliBooster
4//
5// Created by Masafumi Sano on 2022/12/22.
6//
7
8import WebKit
9import SwiftUI
10import OrderedCollections
11
12struct WebView: UIViewRepresentable {
13 typealias UIViewType = WKWebView
14 let url: URL
15 func makeUIView(context: Context) -> WKWebView {
16 let configuration = WKWebViewConfiguration()
17 _ = WKWebView.swizzle
18 configuration.setURLSchemeHandler(context.coordinator, forURLScheme: "https")
19
20 let webView = WKWebView(frame: .zero, configuration: configuration)
21 webView.navigationDelegate = context.coordinator
22 return webView
23 }
24 func updateUIView(_ uiView: WKWebView, context: Context) {
25 uiView.load(URLRequest(url: url))
26 }
27
28 actor Tasks {
29 private var tasks = [ObjectIdentifier: Task<Void, Never>]()
30
31 func addTask(_ task: Task<Void, Never>, forURLSchemeTask urlSchemeTask: WKURLSchemeTask) {
32 tasks.updateValue(task, forKey: ObjectIdentifier(urlSchemeTask))
33 }
34
35 @discardableResult func removeTask(forURLSchemeTask urlSchemeTask: WKURLSchemeTask) -> Task<Void, Never>? {
36 return tasks.removeValue(forKey: ObjectIdentifier(urlSchemeTask))
37 }
38 }
39
40 class Coordinator: NSObject, WKURLSchemeHandler, WKNavigationDelegate {
41 private lazy var configuration: URLSessionConfiguration = {
42 let configuration = URLSessionConfiguration.default
43 configuration.httpAdditionalHeaders = ["User-Agent": userAgent]
44 return configuration
45 }()
46 private let userAgent: String
47 private var tasks = Tasks()
48 private lazy var booster: Booster = {
49 return Booster(configuratioin: configuration)
50 }()
51
52 init(userAgent: String) {
53 self.userAgent = userAgent
54 }
55
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 }
83
84 func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
85 Task {
86 await booster.separate()
87 }
88 }
89
90 func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
91 Task {
92 await booster.ignite(webView: webView)
93 }
94 }
95 }
96
97 func makeCoordinator() -> Coordinator {
98 return Coordinator(userAgent: WKWebView().value(forKey: "userAgent")! as! String)
99 }
100}
101
102actor Booster {
103 let configuration: URLSessionConfiguration
104 private let cache: NSCache<NSString, Cache> = NSCache()
105 private var task: Task<Void, Error>?
106
107 init(configuratioin: URLSessionConfiguration) {
108 self.configuration = configuratioin
109 }
110
111 func separate() {
112 self.task?.cancel()
113 }
114
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 }
168
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 }
198}
199
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}
222
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}
ContentView.swift
1//
2// ContentView.swift
3// YappliBooster
4//
5// Created by Masafumi Sano on 2022/12/22.
6//
7
8import SwiftUI
9
10struct ContentView: View {
11 var body: some View {
12 WebView(url: URL(string: "https://yapp.li")!)
13 }
14}
15
16struct ContentView_Previews: PreviewProvider {
17 static var previews: some View {
18 ContentView()
19 }
20}
YappliBoosterApp.swift
1//
2// YappliBoosterApp.swift
3// YappliBooster
4//
5// Created by Masafumi Sano on 2022/12/22.
6//
7
8import SwiftUI
9
10@main
11struct YappliBoosterApp: App {
12 var body: some Scene {
13 WindowGroup {
14 ContentView()
15 }
16 }
17}