🌐

2024年以降でも Android で WebView ベースのアプリを作るあなたへ

2023/12/01に公開

こんにちは!アルダグラムでエンジニアをしている渡邊です!
本記事は株式会社アルダグラム Advent Calendar 2023 1日目の記事です。

早いもので2023年もあと一ヶ月となり、2024年もまもなくです。最近モバイルアプリのクロスプラットフォーム開発では Flutter や Kotlin Multiplatform などをよく見かけます。

しかしクロスプラットフォーム開発といえば... そう WebView を使った方法があります。 これは WebView で HTML を表示させることで Android や iOS などで共通に画面を作成する方法で、モバイルアプリ開発の初期からあった方法です。 プロジェクトの構成や工数、実装難易度によって WebView を使った開発を採用することもあると思いますが、個人的な意見としてそれは選択肢としてアリかと思っています。

最近アプリチームでも WebView を使った開発を行いましたので、今回はその時に得た知見を共有したいと思います。
内容としては以下となります。

  • WebView のデバッグ
  • WebViewAssetLoader で Android アプリのリソースを HTML で参照する
  • Android <-> JavaScript の連携
  • TypeScript を使用したスクリプトの実装

サンプルプロジェクトを公開していますので、こちらも参考にしてみてください。

WebView のデバッグ

Chrome の DevTools を使って WebView のデバッグを行うことができます。 そのために、以下のコードを設定しておく必要があります。

WebView.setWebContentsDebuggingEnabled(true)

注意点として上記のコードはデバッグ機能を有効にする設定のため、リリースされるアプリでは呼び出さないようにする設定が必要になります。 そのため、例として以下のように debuggable フラグが有効の場合だけ呼び出すなど、リリースアプリでは呼び出されないように注意してください。

if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) {
    WebView.setWebContentsDebuggingEnabled(true)
}

上記のコードが設定できたら Chrome の DevTools を起動します。 そのためには以下の手順が必要です。

  1. 端末で「開発者向けオプション」の画面を開く
    • 開発者向けオプションを有効にするにはこちらを参考
  2. 「USB デバッグ」を有効にする
  3. PC で Chrome を開く
  4. chrome://inspect#devices を開く
  5. 「Discover USB devices」を有効にする
  6. USB ケーブルを使って Android 端末と PC を繋ぐ
  7. アプリを起動して WebView を表示している画面を開く

上記手順を実行し、以下のキャプチャの赤枠のように端末が表示されれば準備は完了です。 赤枠内に表示されている insepct ボタンをクリックします。

すると、以下のような画面が表示されます。 この画面を使って HTML の表示の確認や変更、コンソールログの確認、ネットワーク通信の内容を見るといった様々なことができます。

開発する際には非常に便利ですので、WebView のデバッグができるように設定しておくことをオススメします。

WebViewAssetLoader で Android アプリのリソースを HTML で参照する

WebViewAssetLoader を使うことで、Android の以下のリソースをローカルファイルとしてロードすることができます。

  • assets フォルダ内のリソース
  • res フォルダ内のリソース
  • 内部ストレージ内のリソース

またローカルファイルをロードする際に、file:// の形式ではなく Web のような URL 形式を使うことができます。

WebViewAssetLoader を使うには androidx.webkit が必要になるため、依存関係を設定します。

app/build.gradle.kts
dependencies {
    implementation("androidx.webkit:webkit:1.8.0")
}

まずは WebViewAssetLoader のインスタンスを生成します。

val cacheImagesFile = File(context.cacheDir, "images").apply { mkdirs() }
val assetLoader = WebViewAssetLoader.Builder()
    .addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(context))
    .addPathHandler("/res/", WebViewAssetLoader.ResourcesPathHandler(context))
    .addPathHandler("/cache_images/", WebViewAssetLoader.InternalStoragePathHandler(context, cacheImagesFile))
    .build()

addPathHandler メソッドの第一引数にはパスを設定します。 第二引数には PathHandler のインスタンスを設定します。 これは指定したパスに対してどこからリソースを取得するかを表します。 WebViewAssetLoader では assets フォルダから取得する AssetsPathHandler、res フォルダから取得する ResourcesPathHandler、内部ストレージから取得する InternalStoragePathHandler が提供されています。

また WebViewAssetLoader はデフォルトで appassets.androidplatform.net というドメインが設定されています。 ですので例えば res/drawable フォルダ内にある image.png ファイルにアクセスしたい場合は https://appassets.androidplatform.net/res/drawable/image.png というパスでアクセスできます。

このドメインは以下のように setDomain メソッドを使うことで任意のドメインを設定することができます。 ですので HTML が Web 上にある場合は対象のドメインを設定するとよいでしょう。

val assetLoader = WebViewAssetLoader.Builder()
    .setDomein("raw.githubusercontent.com")
    ...

WebViewAssetLoader のインスタンスを作成したら WebViewClient のインスタンスを作成し、shouldInterceptRequest メソッドをオーバーライドします。 そのメソッド中で WebViewAssetLoader#shouldInterceptRequest を呼び出して設定は完了です。

webView.webViewClient = object: WebViewClientCompat() {
    override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest): WebResourceResponse? {
        return assetLoader.shouldInterceptRequest(request.url)
    }
}

例えば assets フォルダに sample.html があった場合に、以下のように HTML をロードすることができます。

webView.loadUrl("https://appassets.androidplatform.net/assets/sample.html")

また、例えば HTML 側では以下のように記述することで assets フォルダ内の bundle.js を読み込んだり、res/drawable フォルダ内の image.png を読み込んだりすることができるようになります。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Android WebView Demo</title>
    <script src="https://appassets.androidplatform.net/assets/bundle.js"></script>
</head>
<body>
    <img src="https://appassets.androidplatform.net/res/drawable/image.png" />
</body>
</html>

Android <-> JavaScript の連携

Android <-> JavaScript の連携では、以下の2つについて説明します。

  1. Android から JavaScript を実行する
  2. JavaScript から Android へ文字列を送る

注意点として、JavaScript 連携するためには JavaScript を有効にしておく必要があります。

webView.settings.javaScriptEnabled = true

Android から JavaScript を実行する

Android から JavaScript を実行するには、WebView#evaluateJavaScript を使います。

例えばコンソールログを表示させたい場合、以下のようにして呼び出すことができます。

webView.evaluateJavaScript("console.log('Hello, JavaScript!')", null)

JavaScript から Android へ文字列を送る

JavaScript からは Android に文字列を送ることができます。 これは android/view-widgets-samples のリポジトリに WebView を使ったサンプルのプロジェクトがある ので、こちらが参考になります。

このプロジェクトの JsObject ファイルに createJsObject というメソッドが定義されています。

JsObject.kt
JsObject.kt
fun createJsObject(
    webview: WebView,
    jsObjName: String,
    allowedOriginRules: Set<String>,
    onMessageReceived: (message: String) -> Unit
) {
    if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
        WebViewCompat.addWebMessageListener(
            webview, jsObjName, allowedOriginRules
        ) { _, message, _, _, _ -> onMessageReceived(message.data!!) }
    } else {
        webview.addJavascriptInterface(object {
            @JavascriptInterface
            fun postMessage(message: String) {
                // Use the handler to invoke method on UI thread
                handler.post { onMessageReceived(message) }
            }
        }, jsObjName)
    }
}

このメソッドを使うことで JavaScript から Android に文字列を送ることができます。 まず Android 側では以下のように送られてきた文字列を受け取る準備をします。

val webView: WebView = findViewById(R.id.web_view)
createJsObject(
    webView, 
    jsObjName = "jsObject", 
    allowedOriginRules = setOf("https://appassets.androidplatform.net")
) { message ->
    // JavaScript から文字列が送られてきた時の処理をここに書く...
}
webView.loadUrl("https://appassets.androidplatform.net/assets/sample.html")

jsObjName には JavaScript 側でメソッドを呼び出す際のオブジェクト名を指定します。 allowedOriginRules は許可するオリジンの一覧を指定します。 最後の引数にはラムダを指定します。 このラムダは JavaScript から文字列が送られてきた際に呼ばれます。

続いて JavaScript 側です。 呼び出す時に jsObjName で指定した変数名を使って postMessage() メソッドを呼び出します。

jsObject.postMessage('Hello, Android!')

これで JavaScript から Android に文字列を送ることができます。

もし JavaScript から色々なデータを送る必要がある場合、文字列だけのやり取りだと辛い可能性があるので、JSON 文字列を送って Android 側ではそれをパースして使うといった方法も有効かと思います。

jsObject.postMessage(JSON.stringify({ data1: ..., data2: ... }))

TypeScript を使用したスクリプトの実装

WebView での開発において JavaScript を実行することは大いに考えられます。 しかし令和も6年になろうとしている時代に素の JavaScript を書くのはツラいという方は多いのではないのでしょうか。 ここでは TypeScript を使って開発する方法を紹介します。

大まかな流れは以下になります。

  1. TypeScript でスクリプトを実装
  2. TypeScript を JavaScript に変換(トランスパイル)し、一つの JavaScript ファイルにまとめる(バンドル)
  3. JavaScript ファイルを assets フォルダに配置する

2と3の処理を実行するにあたり、カスタムの Gradle プラグインを作成して Android アプリのビルド時に実行されるようにします。 そうすることで、ビルドしたアプリで JavaScript が更新されていない、などのミスが防げるかと思います。
それでは順番に説明していきます。

TypeScript のインストール

まずは TypeScript 関連のファイルを格納するためのフォルダを作成し、そのフォルダに移動します。

$ mkdir app/typescript && cd app/typescript

移動したら npm で TypeScript をインストールします。(Node.js がインストールされていることが前提です)

$ npm init -y && npm install -D typescript

続いて tsconfig.json を作成します。 内容は以下のようにしておきます。

tsconfig.json
{  
  "compilerOptions": {  
    "target": "ES5",  
    "module": "ES2015",  
    "strict": true
  },  
  "include": ["src/**/*.ts"],  
  "exclude": ["node_modules"]  
}

TypeScript のコードは src フォルダに配置するため、src フォルダを作成しておきます。

$ mkdir src

rollup の設定

TypeScript のファイルをブラウザで実行させるためには JavaScript ファイルに変換する(トランスパイルという)必要があります。 また複数の JavaScript ファイルがあった場合、一つの JavaScript ファイルにまとめた方がブラウザで読み込む時に効率がよいので、JavaScript ファイルを一つにまとめる(バンドルという)ようにします。

JavaScript ファイルをバンドルするツールのことをバンドラーと言いますが、ここではバンドラーの一つである rollup を使ってバンドルを行います。 rollup にはプラグイン が提供されており、TypeScript から JavaScript への変換は @rollup/plugin-typescript によって行うことができます。

またバンドルした JavaScript ファイルを極力小さいサイズにするために @rollup/plugin-terser というプラグインも使用します。

これらのツールを以下のコマンドでインストールします。

$ npm install -D rollup tslib @rollup/plugin-typescript @rollup/plugin-terser

続いて rollup.config.mjs ファイルを以下の内容で作成します。

rollup.config.mjs
rollup.config.mjs
import terser from '@rollup/plugin-terser';
import typescript from '@rollup/plugin-typescript';
import tsconfig from "./tsconfig.json" assert { type: "json" };

export default {
    input: 'src/index.ts',
    output: {
        name: 'main',
        format: 'umd',
    },
    plugins: [
        typescript({
            ...tsconfig.compilerOptions,
            include: '**/*.{js,ts}'
        }),
        terser({
            keep_fnames: true
        })
    ]
};

このファイルでのポイントをいくつか解説します。

input: 'src/index.ts',

まずは input の設定でエントリーポイントとなるファイルを指定します。 一番ルートとなるファイルで、ここでは src/index.ts ファイルを指定しています。

output: {
    name: 'main',
    format: 'umd',
},

次に output の設定で出力の設定を行います。 formatumd にすることで JavaScript をブラウザ上でも実行できるように形式を指定しています。 この形式では JavaScript の関数を実行する際に name.関数名 のような形式で呼び出す必要があり、name はその際の名前を指定しています。 例えば doSomething() という関数があった場合、この場合は main.doSomething() という形で呼び出すことになります。

terser({
    keep_fnames: true
})

続いて terser プラグインの設定です。 こちらで出力される JavaScript ファイルのサイズが小さくなるように最適化するようにしています。 注意点として、何も設定しないとデフォルトで関数名が変わってしまうため、関数を実行する際に TypeScript で書いた関数名ではアクセスすることができなくなってしまいます。 そのため keep_fnames を true にすることで、関数名が変わらないように設定しています。

シェルスクリプトの設定

トランスパイルとバンドルの実行はシェルスクリプトで行うようにします。 bundle.sh というファイルを以下の内容で作成しておきます。

bundle.sh
#!/bin/bash

if [ "$#" -ne 1 ]; then
    echo "Usage: $0 <output_directory>"
    exit 1
fi

output_dir=$1

npm ci
npx rollup --file "$output_dir/bundle.js" -c

ここまでの手順を行うと、以下のようなフォルダ構成になっているかと思います。

app/typescript
├── bundle.sh
├── node_modules
├── package-lock.json
├── package.json
├── rollup.config.mjs
├── src
└── tsconfig.json

カスタムの Gradle プラグインの作成

続いてカスタムの Gradle プラグインの作成です。 このプラグインで TypeScript ファイル群を一つの JavaScript ファイルに変換し、この JavaScript ファイルを assets フォルダに格納します。

ここで参考になるのが GitHub で公開されている android/gradle-recipes という Gradle プラグインのサンプルプロジェクトです。
addCustomAsset というプロジェクトはまさに Gradle プラグイン側で生成したファイルを assets フォルダに格納するサンプルになっているため、こちらを参考に実装します。

基本的には上記のサンプルプロジェクトの build-logic フォルダをそのままプロジェクトにコピーし、一部内容を変更すればよいかと思います。 変更が必要な部分を順番に説明します。

最初に build-logic/plugins/build.gradle.kts ですが、以下の部分を変更してます。 id についてはプロジェクトに合わせて適宜設定してください。 ここで設定した id は後ほどこのプラグインを読み込むために必要になります。

gradlePlugin {
    plugins {
-       create("customPlugin") {
-           id = "android.recipes.custom_plugin"
-           implementationClass = "CustomPlugin"
+       create("bundleJsPlugin") {
+           id = "io.github.watabee.bundle_js_plugin"
+           implementationClass = "BundleJsPlugin"
        }
    }
}

続いて build-logic/plugins/src/main/kotlin/CustomPlugin.kt ですが、このファイルは丸々以下のように変更します。

BundleJsPlugin.kt
BundleJsPlugin.kt
import com.android.build.api.variant.AndroidComponentsExtension
import com.android.build.gradle.AppPlugin
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.FileTree
import org.gradle.api.tasks.Exec
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.TaskAction
import org.gradle.kotlin.dsl.register
import java.io.File

class BundleJsPlugin : Plugin<Project> {
    override fun apply(project: Project) {

        project.plugins.withType(AppPlugin::class.java) {
            val androidComponents = project.extensions.getByType(AndroidComponentsExtension::class.java)
            androidComponents.onVariants { variant ->
                variant.sources.assets
                    ?.let {
                        val variantName = variant.name.replaceFirstChar { char ->
                            if (char.isLowerCase()) char.titlecase(Locale.US) else it.toString()
                        }
                        val bundleJsTask = project.tasks.register<BundleJsTask>("bundleJs${variantName}") {
                            workingDir = File(project.projectDir, "typescript")
                            typescriptDir.set(workingDir)
                            nodeModulesDir.set(File(workingDir, "node_modules"))
                        }

                        it.addGeneratedSourceDirectory(
                            bundleJsTask,
                            BundleJsTask::outputDirectory
                        )
                    }
            }
        }
    }
}

abstract class BundleJsTask : Exec() {
    @Internal
    val typescriptDir: DirectoryProperty = project.objects.directoryProperty()

    @Internal
    val nodeModulesDir: DirectoryProperty = project.objects.directoryProperty()

    @get:InputFiles
    val inputFiles: FileTree
        get() = typescriptDir.asFileTree.matching {
            it.exclude("${nodeModulesDir.get().asFile.name}/**")
        }

    @get:OutputDirectory
    abstract val outputDirectory: DirectoryProperty

    @TaskAction
    override fun exec() {
        val outputDir = outputDirectory.get().asFile
        outputDir.mkdirs()

        commandLine = listOf("sh", "bundle.sh", "$outputDir")
        super.exec()
    }
}

上記の処理の内容は先ほど作成したシェルスクリプトの bundle.sh を実行するためのプラグインになります。 ポイントとして、このプラグインの処理が実行されるかどうかは入力内容に変更があったかどうかで変わります。

    @get:InputFiles
    val inputFiles: FileTree
        // typescript フォルダ内のファイルを入力ファイルの対象とする
        // ただし node_modules のディレクトリは除外
        get() = typescriptDir.asFileTree.matching {
            it.exclude("${nodeModulesDir.get().asFile.name}/**")
        }

上記の設定では typescript フォルダ内でかつ node_modules フォルダ配下以外のファイルに変更があった場合に処理が実行されるようになります。 そのため対象のファイルに変更がなかった場合は処理が実行されないため、ビルド時間の節約になります。

続いてプロジェクトルートにある settings.gradle.kts に以下の設定を追加します。

pluginManagement {
+   includeBuild("build-logic")
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
}

作ったプラグインを読み込む必要があります。 app/build.gradle.kts に以下の設定を追加します。

app/build.gradle.kts
plugins {
    ...
+   id("io.github.watabee.bundle_js_plugin")
}

これで準備が完了しました! 最後に app/typescript/src/index.ts ファイルを作成し、ビルドを実行してみましょう。 仮に以下のように適当な関数を追加しておきます。

app/typescript/src/index.ts
export function log(message: string) {
    return console.log(message)
}

ビルドの実行が完了すると app/build/generated/assets/bundleJsDebug フォルダ内に bundle.js ファイルが出力されているのが確認できるかと思います。 これによって assets フォルダ内に bundle.js を格納することができました!
また個別にプラグインの処理だけを実行したい場合は、以下のコマンドで実行することができます。

$ ./gradlew bundleJsDebug # Debug の部分は Build Variant によって変わる

これによって Android 側では以下のように JavaScript を呼び出すことができます。(HTML で bundle.js が script タグでロードされている必要がある)

webView.evaluateJavaScript("main.log('Hello, WebView!')")

最後に

以上、WebView でのアプリ開発で得た知見を共有させていただきました。 冒頭でも紹介しましたがサンプルプロジェクトを公開しています。 このプロジェクトでは HTML のボタンがクリックされると、Android 側でカメラを起動し、撮影した画像を HTML で表示する、といったアプリになっていますので、こちらもぜひ参考にしてみてください。

参考

もっとアルダグラムエンジニア組織を知りたい人、ぜひ下記の情報をチェックしてみてください!

アルダグラム Tech Blog

Discussion