Android WebView オフラインバンドル完全ガイド:Kotlin と Compose で Next.js の静的リソースを読み込む
概要
本記事では、Android でオフラインバンドル機能を実装し、Android WebView がリモートサーバーからダウンロードするのではなく、ローカルの assets/WebApp フォルダからフロントエンドリソースを読み込めるようにする方法を解説します。これにより、より高速な読み込みとオフライン機能が実現できます。
この機能を実装する主な理由は、ネットワーク速度の制限を考慮し、ある程度 UX 体験を向上させるためです。
完全なフロー図
前提条件
-
まず、対応するフロントエンド、または自分で作成したフロントエンドが必要です
そして、フロントエンドを静的リソースとしてパッケージ化し
Android の assets フォルダ配下に配置できるようにします -
ここでは Next.js を使用してテストします(理論的には他のフレームワークも使用可能) すでに Next.js のフロントエンドプロジェクトがあると仮定します
- ビルドコマンドを実行:
npm run build- 静的リソースをエクスポートするコマンドを実行:
npm run export- 最後にビルドが完了するのを待つと カレントディレクトリに /out フォルダが見つかります その中に対応する静的リソースが含まれています
コアコンポーネント
1. LocalWebResourceLoader
責務:
- ローカル Web リソースが利用可能かチェック
- オフラインバンドルのバージョン情報を読み取り
- ローカル URL を構築
主な機能:
1.1 リソース可用性のチェック
private val isAvailableCache: Boolean by lazy {
try {
context.assets.open(INDEX_FILE).use {
val available = it.available() > 0
Timber.d("Local web resources available: $available")
available
}
} catch (e: IOException) {
Timber.d("Local web resources not available: ${e.message}")
false
}
}
fun isLocalWebResourceAvailable(): Boolean {
return isAvailableCache
}
WebApp/index.htmlを開くことを試みて、オフラインバンドルが存在するかチェックします (ここでは assets パス配下に配置することを想定していますが、名前はカスタマイズできます。この例では WebApp を使用しています)
1.2 ローカル URL の構築
fun buildLocalUrl(path: String, params: Map<String, String>? = null): String {
val normalizedPath = if (path.startsWith("/")) path else "/$path"
val baseUrl = "https://local.offline.com$normalizedPath"
// クエリパラメータを追加...など
}
- ここではカスタム URL:
https://local.offline.comを使用します。WebView ではカスタムスキームを使用できないため、カスタムドメイン名のみ使用できます - フロントエンドのコンテンツに応じて、動的パスとクエリパラメータをサポートできます(必要に応じて)
2. LocalWebViewClient
責務:
- WebView のネットワークリクエストをインターセプト
- リクエストタイプを判定(静的リソース vs HTML ページ)
- assets から対応するリソースを読み込み
- Android ネイティブの
WebViewClientを継承
主要な定数:
private const val SCHEME = "https"
private const val HOST = "local.airdroid.com"
private const val WEB_RESOURCE_PATH = "WebApp"
private const val INDEX_FILE = "index.html"
private val RESOURCE_EXTENSIONS = setOf(
"js", "css", "png", "jpg", "jpeg", "svg", "ico",
"woff", "woff2", "ttf", "json", "map", "gif", "webp"
)
コアロジック:
2.1 リクエストインターセプト
- ここではネイティブの
WebViewClientを継承し、shouldInterceptRequest メソッドをオーバーライドして カスタム URL をインターセプトします:
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
val url = request?.url ?: return super.shouldInterceptRequest(view, request)
if (url.scheme != SCHEME || url.host != HOST) {
return super.shouldInterceptRequest(view, request)
}
return handleLocalRequest(url)
}
https://local.offline.comへのリクエストをインターセプト- その他のリクエストは親クラスで処理:
super.shouldInterceptRequest(view, request)
2.2 リソース処理
- 指定した URL であれば、
handleLocalRequestの判定を実行:
private fun handleLocalRequest(uri: Uri): WebResourceResponse? {
var path = uri.path!!.removePrefix("/")
val actualPath: String = if (isResourceFile(path)) {
path // 静的リソース:元のパスを直接使用
} else {
INDEX_FILE // HTML ページ:index.html を返す(フロントエンドルーティングをサポート)
}
val fullPath = "$WEB_RESOURCE_PATH/$actualPath"
val inputStream = context.assets.open(fullPath)
val mimeType = getMimeType(actualPath)
return WebResourceResponse(mimeType, "UTF-8", inputStream)
}
ロジック:
- 静的リソース (
.js、.css、.pngなどのファイル拡張子あり): 対応するファイルを直接読み込み - HTML ページ (拡張子なしまたはルートパス):
index.htmlを返し、フロントエンドルーターに処理させる
2.3 MIME Type マッピング
- 対応する MIME タイプを自分で処理し、最終的に
WebResourceResponseに返します
private fun getMimeType(path: String): String {
val ext = getFileExtension(path).lowercase()
return when (ext) {
"html", "htm" -> "text/html"
"js", "mjs" -> "application/javascript"
"css" -> "text/css"
"json", "map" -> "application/json"
"png" -> "image/png"
"jpg", "jpeg" -> "image/jpeg"
// ... その他のタイプ
else -> "application/octet-stream"
}
}
3. WebRepository
責務:
- ローカルまたはリモート URL の使用を決定
- 異なるページの URL を構築
- 環境パラメータを管理
URL 構築ロジック:
suspend fun getScreenAUrl(): String {
if (localWebResourceLoader.isLocalWebResourceAvailable()) {
// ローカルリソースを使用
// フロントエンドコードに応じてクエリ文字列をサポートできます
// 以下にいくつかの例を示します
val params = mutableMapOf(
"tk" to token,
"lang" to language,
"env" to env,
)
return localWebResourceLoader.buildLocalUrl(
path = "screen-a",
params = params
)
}
// オフラインバンドルリソースにアクセスできない場合はリモート URL にフォールバック
return "${appConfig.baseUrl}/screen-a/$queryString"
}
4. ChatHomeWebViewUseCase
責務:
- WebView を初期化
- ページナビゲーションを管理
- JavaScript Bridge を処理
実際の使用方法: 上記の WebRepository を元の WebView 初期化場所に接続
初期化プロセス:
- 最終的な実際の使用は、先ほど書いたコードを使って WebView を初期化することです
- さらに、先ほど継承した WebViewClient を追加してカスタム URL をインターセプトします
カスタム URL でリソースが利用可能な場合、カスタムリソースが読み込まれます
ただし、遭遇した状況が 1 つあります:Android はカスタムスキームを使用できません
Chrome の CORS ポリシーがカスタムスキームをブロックするためです
(この点については後ほど詳しく説明します)
fun initializeForMainScreen(
webView: WebView,
sessionName: String? = null,
title: String? = null,
userId: String? = null,
userName: String? = null,
) {
webView.addJavascriptInterface(jsBridge, JS_INTERFACE_NAME)
val url = when {
isScreenA -> webRepository.getScreenA(sessionName)
isScreenB -> webRepository.getScreenB(userId, sessionName, title, userName)
isScreenC -> webRepository.getScreenC(sessionName, title)
else -> webRepository.getScreenD()
}
webView.loadUrl(url)
}
技術ポイント分析
1. デュアルモードサポート
- ローカルモード: オフラインバンドルが利用可能な場合、assets から読み込み
- リモートモード: オフラインバンドルが利用不可の場合、ネットワークリクエストにフォールバック
2. リソース判定
private fun isResourceFile(path: String): Boolean {
val ext = getFileExtension(path).lowercase()
return ext.isNotEmpty() && RESOURCE_EXTENSIONS.contains(ext)
}
- ファイル拡張子で静的リソースかどうかを判定
- RESOURCE_EXTENSIONS は一般的な Web リソースタイプをサポートするように
カスタマイズ
3. Scheme
app://などではなく、https://local.offline.comを使用- Android は
CORSの問題を回避する必要があります:
Android WebView はカスタムスキームを使用できず、https のみ使用可能
カスタムスキームを使用すると Chromium がリクエストをブロックし、リソースにアクセスできなくなります
"Access to XMLHttpRequest at 'xxxxx' from origin 'app://' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: The 'Access-Control-Allow-Origin' header has a value 'app:' that is not equal to the supplied origin.", source: app:///.//
2025-11-20 13:49:03.179 11737-11737 chromium com.sand.goinsight I [INFO:CONSOLE(0)] "Access to XMLHttpRequest at 'https://biz-ucenter.airdroid.com/user/getJwtToken?mode_type=23&account_id=68745506&need_mode_type=24' from origin 'app://' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: The 'Access-Control-Allow-Origin' header has a value 'app:' that is not equal to the supplied origin.", source: app:///.//local.airdroid.com/embed-chat?tk=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhY2NvdW50X2lkIjo2ODc0NTUwNiwibW9kZV90eXBlIjoyMywiZXhwIjo0NjAxODU3NDIzfQ.erMp8PXuJD8a8NS9MCp6rcUA-K0bInTnu3xjU6hExaceK7-Eu2fnc1Pgrk8n6MhF82q0JZrfWMYmYf5g3GPZkHIs0HaWJD7EhW3NbSCeXgMSI6wEH9sE2ZrodXcyRjDoL-2lV4OUm0IEvRjG87z0_KpWS_6C93SZu_iXsP4DZhA&lang=en&env=production&vconsole=true (0)
データフロー
1. アプリ起動
↓
2. LocalWebResourceLoader が assets/WebApp/index.html の存在をチェック
↓
3. WebRepository がチェック結果に基づいて URL タイプを決定
↓
4. YourUseCase が URL を WebView に読み込み
↓
5. LocalWebViewClient が https://local.airdroid.com リクエストをインターセプト
↓
6. リクエストタイプを判定し、assets/WebApp から対応するリソースを読み込み
↓
7. 正しい MIME Type を設定し、WebResourceResponse を返す
↓
8. WebView がページをレンダリング
Assets ディレクトリ構造
app/src/main/assets/
└── WebApp/
├── index.html # メイン HTML ファイル
├── assets/ # 静的リソース
│ ├── js/
│ │ └── *.js
│ ├── css/
│ │ └── *.css
│ └── images/
│ └── *.png
└── ...
後記
- この実装では、関連するフロントエンドリソースをパッケージ化した後
Android プロジェクトの assets 配下に配置します
この方法でモバイルリソースの読み込みを高速化することを検討している場合
フロントエンドオフラインリソースをパッケージ化する際には
難読化を行うことをお勧めします
Android パッケージ化時にさらにハードニングを施すこともできます
また、設計上、機密データをフロントエンドのオフラインバンドルに含めないようにすることをお勧めします