ウェブビューが速くなる「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に紐付けて扱います。
200
final 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
}
208
extension 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を登録することはできません。そこで闇魔法を少々…
223
extension 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
8
import WebKit
9
import SwiftUI
10
import OrderedCollections
11
12
struct 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
102
actor 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
200
final 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
}
208
extension 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
223
extension 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
8
import SwiftUI
9
10
struct ContentView: View {
11
var body: some View {
12
WebView(url: URL(string: "https://yapp.li")!)
13
}
14
}
15
16
struct 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
8
import SwiftUI
9
10
@main
11
struct YappliBoosterApp: App {
12
var body: some Scene {
13
WindowGroup {
14
ContentView()
15
}
16
}
17
}