ZhgChg.Li

iOS WKWebView|ページ&ファイル資源のPreloadとCache活用で高速表示を実現

WKWebViewのページとファイル資源を事前にプリロード&キャッシュし、読み込み遅延を解消。高速表示でユーザー体験を大幅改善する手法を詳解します。

iOS WKWebView|ページ&ファイル資源のPreloadとCache活用で高速表示を実現
本記事は AI による翻訳です。お気づきの点があればお知らせください。

iOS WKWebView ページとファイルリソースのPreload(プリロード)/ Cache(キャッシュ)研究

iOS WKWebViewのリソース事前ダウンロードとキャッシュによるページ読み込み速度向上の研究。

Photo by Antoine Gravier

写真提供:Antoine Gravier

背景

なぜか「Cache(キャッシュ)」とは縁が深く、以前はAVPlayerの「iOS HLS Cache 実践方法探究の旅」や「AVPlayer 実践ローカル Cache 機能大全」の研究と実装を担当していました。ストリーミングキャッシュは再生時の通信量削減が目的ですが、今回はIn-app WKWebViewの読み込み速度向上が主なミッションであり、WKWebViewのプリロードとキャッシュの研究も含まれています。ただ正直に言うと、WKWebViewのシナリオはもっと複雑です。AVPlayerのストリーミング動画が一つまたは複数の連続したチャンクファイルで、それらのファイルをキャッシュすればよいのに対し、WKWebViewはページ本体のファイルだけでなく、読み込むリソースファイル(.js、.css、フォント、画像など)もあり、ブラウザエンジンを介してレンダリングされてユーザーに表示されます。この間にAppが制御できない部分が多く、ネットワークからフロントエンドのJavaScriptのパフォーマンス、レンダリング方法まで時間を要します。

本記事はiOSの技術的な実現可能性を検証したものであり、必ずしも最終的な解決策ではありません。総合的に見ると、この課題はフロントエンド側で対応する方が効果的です。 フロントエンドの皆さんは、最初の画面表示時間(First Contentful Paint)を最適化し、HTTPキャッシュ機構を整備してください。これにより、Web/mWebの速度が向上するだけでなく、Android/iOSのin-app WebViewの速度にも影響し、さらにGoogle SEOの評価も向上します。

技術的詳細情報

iOS の制限

Apple Review Guidelines 2.5.6 によると:

ウェブ閲覧アプリは、適切なWebKitフレームワークとWebKit JavaScriptを使用しなければなりません。アプリで代替のウェブブラウザエンジンを使用する権利を申請することも可能です。これらの権利について詳しくはこちら

アプリ内では Apple 提供の WebKit フレームワーク (WKWebView) のみ使用可能で、サードパーティや改変した WebKit エンジンの使用は許可されていません。違反すると審査でリジェクトされます。なお、iOS 17.4 以降、規制対応のため欧州連合地域では Apple の特別許可を取得すれば 他のブラウザエンジンを使用可能 です。

Appleが許可しないものは、私たちもできません。

[未検証] 調べたところ、iOS版のChromeやFirefoxもAppleのWebKit(WKWebView)しか使えないようです。

もう一つとても重要なこと:

WKWebViewはアプリのメインスレッドとは別の独立したスレッドで動作するため、すべてのリクエストや操作は私たちのアプリを経由しません。

HTTP Cacheのフロー

HTTPプロトコルにはCacheプロトコルが含まれており、ネットワーク関連のすべてのコンポーネント(URLSession、WKWebViewなど)でシステムがCache機構を実装しています。したがって、クライアントアプリ側で独自のCache機構を実装する必要はなく、推奨もされません。HTTPプロトコルをそのまま利用するのが最も速く、安定して効果的な方法です。

HTTP Cache の大まかな動作フローは上図の通りです:

  1. Client がリクエストを送信する

  2. Serverのレスポンスキャッシュ戦略はResponse Headerにあり、システムのURLSessionやWKWebViewはCache Headerに従って自動的にレスポンスをキャッシュし、後続のリクエストにもこの戦略が自動適用されます。

  3. 同じリソースを再度リクエストする場合、キャッシュが有効期限内であれば、メモリやディスクからローカルキャッシュを直接読み込み、Appに応答します。

  4. もし期限切れの場合(期限切れは無効を意味しません)、実際のネットワークリクエストをサーバーに送信します。内容が変更されていなければ(期限切れでも有効な場合)、サーバーは直接304 Not Modified(空のボディ)を返します。実際にリクエストは送信されますが、基本的にミリ秒単位で応答があり、レスポンスボディがないため、ほとんど通信量の消費はありません。

  5. 内容が変更された場合は、再度データとCache Headerを提供してください。

キャッシュはローカルだけでなく、ネットワークのプロキシサーバーや経路上にも存在する可能性があります。

よく使われる HTTP レスポンスキャッシュヘッダーのパラメータ:

expires: RFC 2822 日付
pragma: no-cache
# 新しいパラメータ:
cache-control: private/public/no-store/no-cache/max-age/s-max-age/must-revalidate/proxy-revalidate...
etag: XXX

よく使われる HTTP リクエストキャッシュヘッダーのパラメータ:

If-Modified-Since: 2024-07-18 13:00:00
IF-None-Match: 1234

iOSでは、ネットワーク関連のコンポーネント(URLSession、WKWebViewなど)がHTTPリクエスト/レスポンスのキャッシュヘッダーを自動的に処理し、キャッシュ管理を行うため、キャッシュヘッダーのパラメータを自分で操作する必要はありません。

より詳細なHTTPキャッシュの動作詳細については、「Huli 大大が書いた段階的に理解するHTTPキャッシュ機構」を参照してください。

iOS WKWebView 総覧

iOSに戻ると、AppleのWebKitしか使えないため、Appleが提供するWebKitの方法からしかプリロードやキャッシュの実現方法を探ることができません。

上図はChatGPT 4oを使用して紹介した、Apple iOSのWebKit(WKWebView)に関連するすべてのメソッドと簡単な説明です。緑色の部分はデータ保存に関するメソッドを示しています。

みなさんにいくつか面白い方法を共有します:

  • WKProcessPool:複数のWKWebView間でリソース、データ、Cookieなどを共有できます。

  • WKHTTPCookieStore:WKWebViewのCookieを管理でき、WKWebView間やApp内のURLSessionのCookieとWKWebViewのCookieを共有できます。

  • WKWebsiteDataStore:ウェブサイトのキャッシュファイルを管理します。(情報の読み取りと削除のみ可能)

  • WKURLSchemeHandler:WKWebViewが認識できないURLスキームを処理するために、カスタムハンドラーを登録できます。

  • WKContentWorld:注入するJavaScript(WKUserScript)スクリプトをグループ管理できます。

  • WKFindXXX:ページ内検索機能を制御できます。

  • WKContentRuleListStore:WKWebView内でコンテンツブロッカー機能(例:広告のブロックなど)を実装できます。

iOS WKWebView プリロードキャッシュの実現可能性に関する研究

完璧な HTTP キャッシュ ✅

前述で紹介したHTTPキャッシュ機構のように、WebチームにイベントページのHTTPキャッシュ設定を最適化してもらい、クライアントiOS側はCachePolicyの設定を簡単に確認するだけで、他の処理はシステムがすべて行ってくれます!

CachePolicy 設定

URLSession:

let configuration = URLSessionConfiguration.default
configuration.requestCachePolicy = .useProtocolCachePolicy // プロトコルのキャッシュポリシーを使用
let session = URLSession(configuration: configuration)

URLRequest/WKWebView:

var request = URLRequest(url: url)
request.cachePolicy = .reloadRevalidatingCacheData
// キャッシュを再検証してから再読み込み
wkWebView.load(request)
  • useProtocolCachePolicy : デフォルトでHTTPキャッシュ制御に従います。

  • reloadIgnoringLocalCacheData : ローカルキャッシュを使わず、毎回ネットワークからデータを取得します(ただしネットワークやプロキシのキャッシュは許可)。

  • reloadIgnoringLocalAndRemoteCacheData : ローカルとリモートのキャッシュを無視して常にネットワークからデータを取得します。

  • returnCacheDataElseLoad : キャッシュがあれば使用し、なければネットワークから取得します。

  • returnCacheDataDontLoad : キャッシュのみを使用し、キャッシュがなければネットワークリクエストを行いません。

  • reloadRevalidatingCacheData : ローカルキャッシュの有効期限を確認し、期限内なら(304 Not Modified)キャッシュを使い、期限切れならネットワークから再取得します。

キャッシュサイズの設定

App 全体:

let memoryCapacity = 512 * 1024 * 1024 // 512 MB
let diskCapacity = 10 * 1024 * 1024 * 1024 // 10 GB
let urlCache = URLCache(memoryCapacity: memoryCapacity, diskCapacity: diskCapacity, diskPath: "myCache")
        
URLCache.shared = urlCache

個別 URLSession:

let memoryCapacity = 512 * 1024 * 1024 // 512 MB
let diskCapacity = 10 * 1024 * 1024 * 1024 // 10 GB
let cache = URLCache(memoryCapacity: memoryCapacity, diskCapacity: diskCapacity, diskPath: "myCache")
        
let configuration = URLSessionConfiguration.default
configuration.urlCache = cache

前述の通り、WKWebView はアプリのメインスレッドとは別の独立したスレッドで動作しているため、URLRequest や URLSession のキャッシュは WKWebView と共有されません。

WKWebViewでSafari開発者ツールを使う方法

ローカルキャッシュを使用しているかどうかを確認する。

Safariで開発者機能を有効にする方法:

WKWebView の isInspectable を有効にする:

func makeWKWebView() -> WKWebView {
 let webView = WKWebView(frame: .zero)
 webView.isInspectable = true // iOS 16.4以降でのみ利用可能
 return webView
}

WKWebView に webView.isInspectable = true を設定すると、Debugビルド版でSafariの開発者ツールが使用可能になります。

p.s. これは私が別で作成したWKWebViewテスト用プロジェクトです

p.s. これは私が別に作成したWKWebViewテスト用のプロジェクトです。

webView.load の箇所にブレークポイントを設定してください。

テスト開始:

ビルド&実行:

webView.load のブレークポイントで「ステップ実行」をクリックしてください。

Safariに戻り、ツールバーの「開発」->「シミュレーター」->「あなたのプロジェクト」->「about:blank」を選択します。

  • ページがまだ読み込みを開始していないため、URLは about:blank になります。

  • about:blank が表示されない場合は、XCodeに戻って逐次デバッグボタンをもう一度クリックし、表示されるまで繰り返してください。

該ページに対応する開発者ツールが表示されます:

XCodeに戻って「続行」をクリックしてください:

再び Safari 開発者ツールに戻ると、リソースの読み込み状況や完全な開発者ツールの機能(コンポーネント、ストレージのデバッグなど)が確認できます。

もしネットワークリソースにHTTPキャッシュがある場合、転送サイズは「ディスク」と表示されます:

クリックしてもキャッシュ情報を見ることができます。

WKWebViewのキャッシュをクリアする

// クッキーを削除
HTTPCookieStorage.shared.removeCookies(since: Date.distantPast)

// 保存データとキャッシュデータを削除
let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes()
let store = WKWebsiteDataStore.default()
store.fetchDataRecords(ofTypes: dataTypes) { records in
 records.forEach { record in
  store.removeData(
   ofTypes: record.dataTypes,
   for: records,
   completionHandler: {
          print("clearWebViewCache() - \(record)")           
   }
  )
 }
}

上記の方法を使って、WKWebViewにキャッシュされたリソース、ローカルデータ、Cookieデータを削除できます。

しかし、HTTP Cacheの最適化はキャッシュ部分(2回目以降の高速化)にしか効果がなく、プリロード(初回アクセス)には影響しません。

完善なHTTPキャッシュ + WKWebViewでの全ページプリロード 😕

class WebViewPreloader {
    static let shared = WebViewPreloader()

    private var _webview: WKWebView = WKWebView()

    private init() { }

    func preload(url: URL) {
        let request = URLRequest(url: url)
        Task { @MainActor in
            webview.load(request) // ページをプリロードする
        }
    }
}

WebViewPreloader.shared.preload("https://zhgchg.li/campaign/summer")

HTTP Cacheを最適化した後、2回目のWKWebViewの読み込みはキャッシュが利用されます。リストやホーム画面で先に中のURLを一度読み込んでキャッシュを作っておけば、ユーザーがアクセスしたときにより速く表示されます。

テストの結果、原理的には可能ですが、性能やネットワークの負荷が非常に大きくなります ;ユーザーが詳細ページに入らない場合でも、プリロードのためにすべてのページを一度に読み込むため、無駄な通信が発生します。

個人的には現実的に不可能であり、利益より害が大きく、過剰反応だと思います。😕

完璧な HTTP キャッシュ + WKWebView 純リソースのプリロード🎉

上記の方法を基に最適化し、HTMLのLink Preloadを活用して、ページ内で使用されるリソースファイル(例:.js、.css、フォント、画像など)のみをPreloadします。これにより、ユーザーがアクセスした際にキャッシュされたリソースを直接利用でき、再度ネットワークリクエストを送ってリソースを取得する必要がなくなります。

つまり、ページ全体をプリロードするのではなく、ページで使用されるリソースファイルのみをプリロードします。これらのファイルはページ間で共有されることがあります。ページの.htmlファイルはネットワークから取得し、プリロードしたファイルと組み合わせてページをレンダリングします。

ご注意ください:ここでもHTTP Cacheを利用しているため、これらのリソースもHTTP Cacheに対応していなければ、後のリクエストは依然としてネットワーク経由になります。

ご注意ください:ここでもHTTP Cacheを利用しているため、これらのリソースもHTTP Cacheに対応していなければ、後のリクエストは依然としてネットワーク経由になります。

ご注意ください:ここでもHTTP Cacheを利用しているため、これらのリソースもHTTP Cacheに対応していなければ、後のリクエストは依然としてネットワーク経由になります。

<!DOCTYPE html>
<html lang="zh-tw">
 <head>
    <link rel="preload" href="https://cdn.zhgchg.li/dist/main.js" as="script">
    <link rel="preload" href="https://image.zhgchg.li/v2/image/get/campaign.jpg" as="image">
    <link rel="preload" href="https://cdn.zhgchg.li/assets/fonts/glyphicons-halflings-regular.woff2" as="font">
    <link rel="preload" href="https://cdn.zhgchg.li/assets/fonts/Simple-Line-Icons.woff2?v=2.4.0" as="font">
  </head>
</html>

よくサポートされているファイルタイプ:

  • .js スクリプト

  • .css スタイル

  • フォント

  • 画像

Webチームは上記のHTML内容をAppと約束したパスに配置し、私たちのWebViewPreloaderはそのパスをロードします。WKWebViewがロードされると同時に、<link>のpreloadリソースが解析されキャッシュが生成されます。

WebViewPreloader.shared.preload("https://zhgchg.li/campaign/summer/preload")
// またはすべてここで
WebViewPreloader.shared.preload("https://zhgchg.li/assets/preload")

テストの結果、通信量の損失とプリロードのバランスが良好に取れることが確認できました。🎉

欠点は、このCacheリソースリストのメンテナンスが必要なことと、やはりWeb側でページのレンダリングや読み込みを最適化しなければ、最初のページ表示の体感時間は依然として長くなることです。

URLProtocol

また、私たちの古くからの友人である URLProtocol を思い出しました。URL Loading System に基づくすべてのリクエスト(URLSession、openURLなど)は、すべてインターセプトして操作することが可能です。

class CustomURLProtocol: URLProtocol {
    override class func canInit(with request: URLRequest) -> Bool {
        // このリクエストを処理するかどうかを判断する
        if let url = request.url {
            return url.scheme == "custom"
        }
        return false
    }
    
    override class func canonicalRequest(for request: URLRequest) -> URLRequest {
        // リクエストを返す
        return request
    }
    
    override func startLoading() {
        // リクエストを処理しデータを読み込む
        // キャッシュ戦略に変更し、まずローカルファイルから読み込む
        if let url = request.url {
            let response = URLResponse(url: url, mimeType: "text/plain", expectedContentLength: -1, textEncodingName: nil)
            self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
            
            let data = "This is a custom response!".data(using: .utf8)!
            self.client?.urlProtocol(self, didLoad: data)
            self.client?.urlProtocolDidFinishLoading(self)
        }
    }
    
    override func stopLoading() {
        // データの読み込みを停止する
    }
}

// AppDelegate.swift didFinishLaunchingWithOptions:
URLProtocol.registerClass(CustomURLProtocol.self)

抽象的なアイデアは、バックグラウンドでURLRequestをこっそり送信し、URLProtocolを介してすべてのリソースを自分でダウンロードし、ユーザーはWKWebViewを通じてリクエストを送り、URLProtocolがプリロード済みのリソースを応答するというものです。

前述の通り、WKWebViewはAppのメインスレッドとは別の独立したスレッドで動作しているため、URLProtocolではWKWebViewのリクエストをインターセプトできません。

しかし、裏技的な方法はあるようですが、おすすめしません。別の問題(審査拒否など)を引き起こす可能性があります。

この道は通れません ❌。

WKURLSchemeHandler 😕

AppleがiOS 11で導入した新しい方法は、WKWebViewがURLProtocolを使用できない欠点を補うためのように感じられます。しかし、この方法はAVPlayerのResourceLoaderに似ており、システムが認識できないSchemeだけが自分たちで定義したWKURLSchemeHandlerに渡されて処理されます

抽象的なアイデアとしては、バックグラウンドでWKWebViewがリクエストを送り、WKURLSchemeHandlerがすべてのリソースを自分でダウンロードし、ユーザーがWKWebViewでリクエストを送ると、WKURLSchemeHandlerがプリロードしたリソースを返す、という流れです。

import WebKit

class CustomSchemeHandler: NSObject, WKURLSchemeHandler {
    func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
        // カスタム処理
        let url = urlSchemeTask.request.url!
        
        if url.scheme == "custom-scheme" {
            // キャッシュ戦略に変更し、まずローカルファイルを読み込む
            let response = URLResponse(url: url, mimeType: "text/html", expectedContentLength: -1, textEncodingName: nil)
            urlSchemeTask.didReceive(response)
            
            let html = "<html><body><h1>Hello from custom scheme!</h1></body></html>"
            let data = html.data(using: .utf8)!
            urlSchemeTask.didReceive(data)
            urlSchemeTask.didFinish()
        }
    }

    func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
        // 停止
    }
}

let webViewConfiguration = WKWebViewConfiguration()
webViewConfiguration.setURLSchemeHandler(CustomSchemeHandler(), forURLScheme: "mycacher")

let customURL = URL(string: "mycacher://zhgchg.li/campaign/summer")!
webView.load(URLRequest(url: customURL))
  • http/https はシステムが処理できるスキームのため、http/https の処理をカスタマイズすることはできません。スキームをシステムが認識しないもの(例:mycacher://)に変更する必要があります。

  • ページ内ではすべて相対パスを使用することで、自動的に mycacher:// が付与され、私たちのハンドラーがキャッチできます。

  • http/https を変更せずに http/https リクエストを取得したい場合は、ブラックマジックしかありませんが、推奨しません。 他の問題(審査拒否など)を引き起こす可能性があります。

  • ページファイルを自前でキャッシュして応答する場合、ページ内で使用される Ajax、XMLHttpRequest、Fetch のリクエストは CORS 同一生成元ポリシー によって ブロック される可能性があります。使用するにはサイトのセキュリティを下げる必要があります(mycacher:// から http://zhgchg.li/xxx にリクエストを送るため、異なるオリジンとなるため)。

  • キャッシュポリシーを自分で実装する必要があるかもしれません。例えば、いつ更新すべきか?有効期限はどのくらいか? (これはHTTPキャッシュが行っていることと同じです)

総合的に見ると、原理的には可能ですが、実装には大きな労力がかかります。全体的にコストパフォーマンスが悪く、拡張性や安定性の維持も難しいです 😕

WKURLSchemeHandler の方法は、ウェブページ内に大きなリソースファイルがある場合に適していると感じます。カスタムスキームを宣言して App に処理させ、協力してウェブページをレンダリングする形です。

WKWebViewのネットワークリクエストをAppから送信に切り替える 🫥

WKWebViewをAjax、XMLHttpRequest、Fetchの代わりに、Appが定めたインターフェース(WkUserScript)を使ってリソースをリクエストするように変更する。

このケースはあまり役に立ちません。なぜなら最初の画面の表示が遅いのであって、その後の読み込みが遅いわけではないからです。また、この方法はWebとAppの間に不自然で強い依存関係を生んでしまいます🫥

Service Workerから手を付ける

セキュリティ上の理由から、Apple純正のSafariアプリのみ対応しており、WKWebViewは対応していません❌。

WKWebView パフォーマンス最適化 🫥

WKWebViewのロードビューのパフォーマンスを最適化・向上する。

WKWebView自体は骨格のようなもので、Webページが肉体です。調査した結果、骨格の最適化(例:WKProcessPoolの再利用)の効果は非常に限定的で、0.0003秒から0.000015秒への違い程度です。

ローカルHTML、ローカルリソースファイル 🫥

Preload方式に似ていますが、アクティブページをApp Bundleに入れるか、起動時にリモートから取得する方法です。

HTML全体のページを置くとCORSの同一オリジンポリシーの問題が発生する可能性があります。ウェブリソースファイルのみを置く場合は、「充実したHTTPキャッシュ+WKWebViewによる純リソースのプリロード」方式で代替できそうです。Appバンドルに入れるとアプリサイズが増えるだけで、遠隔から取得するのがWKWebViewプリロードです🫥

フロントエンドの最適化に着手 🎉🎉🎉

Source: wedevs

Source: wedevs

参考 wedevs 優化建議 によると、フロントエンドのHTMLページは4つの読み込み段階があります。最初にページファイル(.html)を読み込み、First Paint(空白ページ)、次にFirst Contentful Paint(ページの骨組みの描画)、さらにFirst Meaningful Paint(ページ内容の補完)、最後にTime To Interactive(ユーザーが操作可能になる)へと進みます。

私たちのページでテストしたところ、ブラウザやWKWebViewはまずページ本体の.htmlをリクエストし、その後必要なリソースを読み込みます。同時にプログラムの指示に従って画面を構築しユーザーに表示します。記事と比較すると、ページの段階では実際にはFirst Paint(空白)からTime To Interactive(First Contentful Paintはナビゲーションバーのみであまりカウントされないと思われる)までしかなく、中間の段階的なレンダリングがユーザーに提供されていません。そのため、ユーザーの全体的な待機時間が長くなっています。

そして現在はリソースファイルのみがHTTPキャッシュ設定されており、ページ本体には設定されていません。

また、Google PageSpeed Insights の提案を参考に、圧縮やスクリプトサイズの削減などの最適化を行うこともできます。

in-app WKWebViewのコアはやはりWebページそのものであるため、フロントエンドのウェブページから調整するのが効果的な方法です。🎉🎉🎉

ユーザー体験へのアプローチ 🎉🎉🎉

シンプルな実装として、ユーザー体験を向上させるために、読み込み進捗バーを追加しましょう。空白ページだけを表示してユーザーを混乱させるのではなく、ページが読み込み中であることと進行状況を知らせます。🎉🎉🎉

結論

以上が今回のWKWebViewのプリロードとキャッシュに関する検討のまとめです。技術自体は最大の問題ではなく、重要なのはユーザーにとって最も効果的で、開発側のコストが最も低い方法を選ぶことです。正しい方法を選べば、少しの調整で目標を達成できますが、間違った方法を選ぶと多大なリソースを無駄にし、後のメンテナンスや運用が困難になる可能性があります。

方法は困難より多いことが多く、時には想像力の不足が原因です。

もしかすると私が考えていない神レベルの組み合わせもあるかもしれません。ぜひ皆さんのご意見や補足をお待ちしています。

参考資料

WKWebView プレロード純リソース🎉 の方法は以下の動画を参考にしてください

<iframe class=”embed-video” loading=”lazy” src=”https://www.youtube.com/embed/ZQvyfFieBfs” title=”“Preload strategies using WKWebView” by Jonatán Urquiza” frameborder=”0” allow=”accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture” allowfullscreen ></iframe>

また、著者はWKURLSchemeHandlerの方法についても言及しています。

動画内の完全なデモリポジトリは以下の通りです:

iOS ベテランエンジニア週報

老舗ドライバー週報のWKWebViewに関する共有も一見の価値があります。

雑談

久しぶりにiOS開発に関する長文記事を執筆します。

Post Mediumから変換済み、ZMediumToMarkdownによる。

GitHub で編集
この記事を改善
本記事は Medium で初公開
オリジナルを読む
この記事をシェア
リンクをコピー · SNS でシェア
ZhgChgLi
著者

ZhgChgLi

An iOS, web, and automation developer from Taiwan 🇹🇼 who also loves sharing, traveling, and writing.

コメント