AndroidのWebViewをJavascriptエンジンとして利用する

#イントロダクション
アプリのエンドユーザーに対する機能拡張の提供手段として外部スクリプトを実行できる仕様を設けることを考えた。
WebViewならJavascriptを実行できるのでこれを転用できないかと試み、使い物になる目途がついたので得られたノウハウを書き残しておく。

注意:こんな記事を書いておきながら記事作成者はJavascriptにはまるで素人ですので、おかしな記述がありましてもご容赦ください。

##対象環境
APIレベル26(Android8.0)をtargetSdkVersionとして本記事を記述しているが、Android4.2.2の端末でも動作を確認している。

##当記事で目標とする仕様
* WebViewを利用するがブラウザは画面表示しない
* スクリプトファイルはエンドユーザーが書き、端末の共有ストレージにコピーして利用する
* Javascript側からネイティブアプリ側のメソッドを呼び出し、戻り値を得ることができる
* ネイティブアプリ側からJavascript内の関数を呼び出し、戻り値を得ることができる

#WebViewの生成
Javascriptエンジンとしてだけ利用する場合、WebViewを画面にレイアウトする必要はない。
単に `new WebView(context);` するだけで利用可能。

ネット上のJavascript実行サンプルでは押しなべて画面に置かれたWebViewをfindViewById()で取得している例が並んでいるので心配になるが、実際にはレイアウトせず利用可能。

##Javascript実行可能にするためのWebViewの設定
要点をコードにすると次のようになる:

```java
private WebView mWv;

private void initializeWebView(Context context) {
	mWv=new WebView(context);
	mWv.setWebChromeClient(new WebChromeClient()); //alertダイアログを表示するには必要
	mWv.setWebViewClient(new WebViewClient(){
	    @Override
	    public void onPageFinished(WebView view, String url) {
	        //ページ読み込みが完了してからJavascriptが利用可能になる
	        onScriptReady();
	    }
	});
	WebSettings settings = mWv.getSettings();
	settings.setJavaScriptEnabled(true); //Javascript実行可能に設定
	if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {	//Android4.2未満での各設定はデフォルトでtrue
	    settings.setAllowUniversalAccessFromFileURLs(true); //スクリプト内部での <script src="..."> を動作可能にする
	    settings.setAllowFileAccessFromFileURLs(true); //スクリプトからのローカルファイルへのアクセスを許可する
	}
}

private void onScriptReady() {
	//最初のスクリプト内関数実行
	mWv.loadUrl("javascript:someFunction();");
}
```

このコードではまだスクリプトの読み込みは行っていないことに注意。

###必須項目
* `setWebViewClient(...);` でページ読み込み完了を拾えるようにする
* `getSettings().setJavaScriptEnabled(true);` でJavaScript実行を許可する

###オプション項目
* `setWebChromeClient(...);` でalert()等のダイアログを表示可能にする
* `getSettings().setAllowUniversalAccessFromFileURLs(true);` でスクリプト内部での `<script src="...">` を動作可能にする
* `getSettings().setAllowFileAccessFromFileURLs(true);` でスクリプトからのローカルファイルへのアクセスを許可する

WebChromeClientはその名前から後付け機能っぽく見えるが、APIレベル1から存在しているので旧バージョンのAndroidとの互換性を気にする必要はない。

##WebView生成に関する注意事項
###WebViewのコンストラクタに渡すContext
基本的にContext派生のオブジェクトであればスクリプトは動作するが、スクリプト内でalert()等のダイアログを表示したい場合以下の制約に従う必要がある:

* ContextにキャストしたActivityを渡す
    * 要するにActivity内で `new WebView(this);`でよい
    * こうしなければalertなどのダイアログが表示されない…getApplicationContext()を渡しても表示されない
* Activityが再生成されたらWebViewも新しいContext(=Activity)で生成しなおす
    * WebViewからのダイアログ表示の可用性はActivity終了に伴いに終了する
    * 生成コストを気にしてstaticで作ったきりにしたりするとActivity再起動後にダイアログが表示されなくなったりする

#スクリプトのファイル形式と読み込み方法
利用可能なスクリプトのファイル形式は次の通り:

* html形式
* js形式

いろいろと便利なのはhtml形式。

##html形式
普通のhtmlとして書かれたファイルの`<script>...</script>`内をスクリプトとして利用できる。
`<html>`,`<head>`,`<body>`等、ドキュメント要素のタグの一切を省略して`<script>...</script>`のみで構成されたファイルでも動作する。

ファイルの拡張子は.htmlである必要はない。
が、.htmlにしておくとデバッグの時にブラウザで直接開くことができるので便利。

`<script>`タグは標準でスクリプトの種類をJavascriptとみなすが、`<script type="text/javascript">`と書いた方が互換性の面で安心。

###読み込み方法
`WebView#loadUrl()` にローカルファイルのパスを与えることで読み込みが行われる。

内部共有ストレージの"/scripttest/script.html"を読み込む例:

```java
File file = new File(new File(Environment.getExternalStorageDirectory(), "scripttest"), "script.html");
mWv.loadUrl("file://" +file.getAbsolutePath());
```

READ_EXTERNAL_STORAGE のパーミッションが必要なことに注意。

###利点

* `<script src="..."></script>`の記述によってスクリプト内から別のjsファイルを利用できる
    * `setAllowUniversalAccessFromFileURLs()` で許可しておくことが必要
* ブラウザで開くだけでデバッグできるので楽

##js形式
通常のhtmlから利用する外部Javascriptファイルと同様に、`<script>...</script>`の内部だけを記述したファイル。

ファイルの拡張子は.jsである必要はない。

###読み込み方法
`WebView#loadDataWithBaseUrl()` によって読み込むことができる。

```java
File file = new File(new File(Environment.getExternalStorageDirectory(), "scripttest"), "script.js");
String html="<script type=\"text/javascript\" src=\"./" +file.getName()+ "\"></script>";
String baseUrl="file://"+file.getAbsolutePath();
mWv.loadDataWithBaseURL(baseUrl,html,"text/html",null,null);
```

READ_EXTERNAL_STORAGE のパーミッションが必要なことに注意。

###利点

* スクリプトがシンプルになる

#スクリプトの読み込み動作
スクリプトの読み込みは非同期的に行われる。
すなわち、`loadUrl()` あるいは `loadDataWithBaseURL()` で読み込みを指示してもこれらのメソッドから戻った時点では読み込みは完了していない。
また、読み込みを指示した同じメソッド内では読み込みは完了せず、完了を待つにはメソッドを抜ける必要がある。

読み込みが完了していない状態ではスクリプトは動作しない。
したがって次のようなコードは正しく動作しない:

```java
private void startScript() {
	mWv.loadUrl("file://" +file.getAbsolutePath()); //スクリプト読み込み指示
	mWv.loadUrl("javascript:someFunction();"); //スクリプト内関数の呼び出し…失敗して素通りする
}
```

読み込み完了を検出するには、`WebViewClient#onPageFinished()` が呼ばれるのを待つ必要がある。
上述の [Javascript実行可能にするためのWebViewの設定](#javascript実行可能にするためのwebviewの設定) のコード例を参考にされたい。

#スクリプトの実行開始
通常のhtmlに埋め込まれたスクリプトと同様に、ファイルの読み込みが完了した時点でスクリプト内のグローバルスコープに記述された命令が実行される。

```html
<script type="text/javascript">
	alert("test");
</script>
```

このようなスクリプトであれば、読み込みが完了した時点でアラートダイアログが表示される。

html形式の場合、 `<script src="..."></script>` に書かれた外部スクリプトのすべてが読み込まれた時点で実行が開始される。
`src="..."` で指定されたファイルが存在しない場合でもエラーで停止することはなく、そのファイルの読み込みはスキップされた上で実行される。

#スクリプト内からのアプリ内メソッドの呼び出し
JavascriptInterfaceを利用する。
次の手順でJavascriptから呼び出されるメソッドをアプリ側に用意する:

* クラスを一つ用意する
    * 何らかのクラスの継承やインターフェイスの実装は必要ない。素のクラスでもよいしActivityでもよい
    * publicなクラスでなくてもよいし、インナークラスでもよい。staticでもそうでなくてもよい。要するにどんなクラスでも構わない
* そのクラスにJavascriptから呼び出されるためのメソッドを用意し、`@JavascriptInterface` のアノテーションをつける
    * publicなメソッドでなければならない
* `WebView.addJavascriptInterface(Object object, String name)` を呼び出して、そのクラスをJavascriptからオブジェクトとして見えるように登録する
    * objectにはそのクラスのインスタンスを、nameにはJavascriptから見えるそのインスタンスの名前(任意)を登録する

実装例は次のようになる:

```java:アプリ側
//Javascriptから呼び出されるメソッドの用意
private class JsInterface {
	@JavascriptInterface
	public String getAppName(String text) {
		return getString(R.string.app_name);
	}
}

//Javascriptからの呼び出しを可能にする登録処理
private void initializeJavascriptInterface() {
	mWv.addJavascriptInterface(new JsInterface(),"android");
}
```

```javascript:Javascript側
//"android"で登録されたアプリ側オブジェクトの"getAppName()"メソッドを呼び出す
var text=android.getAppName();
```

要件さえ満たすのなら次のように、Activity等の直接のメンバとして、呼び出されるメソッドを実装しても構わない:

```java:アプリ側
public MainActivity extends Activity {
//…中略…
	//Javascriptから呼び出されるメソッドの用意
	@JavascriptInterface
	public String getAppName(String text) {
		return getString(R.string.app_name);
	}
	
	//Javascriptからの呼び出しを可能にする登録処理
	private void initializeJavascriptInterface() {
		mWv.addJavascriptInterface(this,"android");
	}
}
```

##引数
仕様が明示されたドキュメントが見当たらない。
ネット上でのサンプルではStringの単一引数がよく見かけられるが、他の型、配列、複数の引数も受け付ける。

Javascript側でnullを渡すとアプリ側でもnullを受け取る。
Javascript側で型の間違った引数を渡しても呼び出しに失敗することはなく、アプリ側では既定の値(intなら0、Stringなら"undefined"等)を受け取る。

##戻り値
引数と同様に仕様が明示されたドキュメントが見当たらないが、様々な型をとることができる。
アプリ側でnullを返すとJavascript側での戻り値はundefinedとして得られる。

##addJavascriptInterface()の呼び出しタイミング
スクリプトの読み込みが完了する前であれば、いつ呼び出しても構わない。

同一メソッド内であれば `loadUrl()` や `loadDataWithBaseURL()` による読み込み指示直後でも問題ない。
読み込み指示後に指示を出したメソッドを抜けた後では、読み込みが完了してしまう可能性があるのでタイミングとしては不適。

##addJavascriptInterface()での登録の存続/上書き
注意点は次の通り:

* スクリプトをまたいだ登録の存続
    * 別のスクリプトを読み込みなおした場合にも、それ以前に `addJavascriptInterface()` を呼び出した内容は残存することに注意
* 消去不可
    * `addJavascriptInterface(null,"<同じ名前>");` で上書き消去することはできない
* 上書き可能
    * `addJavascriptInterface(<別インスタンス>,"<同じ名前>");` で上書きすることは可能

#アプリからのスクリプト内関数の呼び出し
##loadUrl()による呼び出し
`WebView#loadUrl()`で呼び出す。

```java
mWv.loadUrl("javascript:someFunction()");
```

制約事項は次の通り:

* 関数の戻り値は得られない
    * Javascriptのreturn文による戻り値を取得する手段はない
    * 応答が必要な場合は、呼び出し先からアプリ側メソッドを呼び返すことで情報を渡す
* 非同期的に実行される
    * `loadUrl()`から戻ってもスクリプト側の関数実行は完了していない
    * 実行の完了を検知したい場合は呼び出し先からアプリ側メソッドを呼び返すことで通知する

###引数
`loadUrl()`の引数は文字列なので、その中に書かれる関数への引数も文字列化して渡すことになる。
文字列中の改行コードは"\\n"ではなく"\\\n"と書くことに注意。
文字列中のシングルクォート、ダブルクォートの記述も要注意。

###戻り値
`loadUrl()`では戻り値を得られないため、スクリプト側からアプリ側メソッドをコールバックをすることで応答を受け渡す。

```java:アプリ側
private void execFunction() {
	//2行の文字列を渡す
	mWv.loadUrl("javascript:someFunction('line1\\nline2')");
}

@JavascriptInterface
public void callback(String reply) {
	//execFunction()の応答を得る
	Log.d(TAG,"replay="+reply);
}
```

```javascript:スクリプト側
function someFunction(lines) {
	//1行目を返す
	android.callback(lines.split('\n')[0]);
}
```

##evaluteJavascript()による呼び出し
APIレベル19(Android4.4)以上ではこの方法も利用できる。

基本的な仕組みは`loadUrl()`による呼び出しと変わらない。
応答を得るためのコールバック機構がメソッドに組み込まれていると考えればよいが、スクリプト側ではアプリ側メソッドの呼び出しではなくreturn文で戻り値として応答を返すことができる。

Android4.4未満でも動作するアプリを作成するのであれば、処理の流れも変わらないので`loadUrl()`による呼び出しで統一して差し支えない。と思う。

```java:アプリ側
private void execFunction() {
	//2行の文字列を渡す
	mWv.evaluateJavascript("javascript:someFunction('line1\\nline2')",new ValueCallback<String>() {
		@Override
		public void onReceiveValue(String reply) {
			Log.d(TAG,"replay="+reply);
		}
	});
}
```

```javascript:スクリプト側
function someFunction(lines) {
	//1行目を返す
	return lines.split('\n')[0];
}
```

#WebViewを操作するスレッド
WebViewを操作するスレッドはメインスレッドでなければならない。

特に注意すべき状況として、スクリプトから呼び出されたアプリのメソッド内で、さらにスクリプトを呼び出す場合がある。
スクリプトからのアプリのメソッド呼び出しは、メインとは異なるスレッドで行われる。
そこからさらにスクリプトの関数を呼び出そうとすると、メインスレッド以外からの実行となるためエラーが発生してスクリプトの実行が停止する(アプリは終了しない)。

このような場合、Handlerを介するなどしてメインスレッドからの呼び出しが遂行されるように配慮する必要がある。

```java:NG
//メインスレッド以外で呼び出される
@JavascriptInterface
public void func() {
	//メインスレッド以外から呼び出すことになる
	mWv.loadUrl("javascript:someFunction();"); //NG:エラー発生
}
```

```java:OK
private Handler mHandler=new Handler();

//メインスレッド以外で呼び出される
@JavascriptInterface
public void func() {
	//あらためてメインスレッドから呼び出す
	mHandler.post(new Runnable(){
		@Override
		public void run() {
			mWv.loadUrl("javascript:someFunction('TEST');");
		}
	});
}
```

`WebViewClient#onPageFinished()` はメインスレッドで呼び出されるので、ここからのスクリプト側関数呼び出しについてはスレッドに関する考慮の必要はない。

#alert等のダイアログ
alert,confirm,promptの各ダイアログは利用できる。
ただしWevViewにWebChromeClientをセットしてある状態に限る。

```java
mWv.setWebChromeClient(new WebChromeClient());
```

制約事項は次の通り:

* タイトルは変更できない
    * html形式のスクリプトから表示した場合は "file://のページ"、js形式では "JavaScript" 等に固定されている(実行環境による差異がある)
* `WebChromeClient#onJsAlert()` 等をオーバーライドしてネイティブ側で代替ダイアログを作成できるが、その場合スクリプトはダイアログが閉じるのを待たず先へ進んでしまう

#関数の存在チェック
アプリの外部拡張スクリプトとして利用する場合、スクリプト内に既定の関数が用意されているかをチェックしてスクリプトの妥当性を確認する必要がある。

以下の記述で関数の存在チェックが可能:

```java:アプリ側
private void startValidation() {
	mWv.loadUrl("javascript:android.validate(typeof someFunction=='function');");
}

@JavascriptInterface
public void validate(boolean valid) {
	if(valid) {
		//関数が存在する
	}
	else {
		//関数が存在しない
	}
}
```

スクリプト側での何らかの記述は必要ない。

このように`loadUrl()`からアプリ側のメソッドを呼び出す方法はリリースビルドでは動作しないという報告が散見されるが、記事作成時点では意図したとおりに動作している。
proguardの設定に追記することなくminifyEnabledをtrueにして最適化を実施しても問題は出ていない。

ひょっとしたら見落としている要素があるかもしれないので要注意。

#サンプルコード
スクリプトファイルは実行端末の `<内部共有ストレージ>/scripttest/script.html` に置く。
AndroidManifest.xmlでREAD_EXTERNAL_STORAGEのパーミッションを与えておくこと。

##スクリプト

```javascript:script.html
<script>
	//スクリプト読み込み完了時に実行される
	alert("script loaded.");

	function firstFunction(text) {
		var result=text+"\nS:firstFunction";
		//アプリ側のメソッドを呼び出す
		android.callbackToScript(result);    //someFunction()が呼び返される
	}

	function someFunction(text) {
		var result=text+"\nS:someFunction";
		var appName=android.getAppName();
		alert(appName +"\n"+ result);
	}
</script>
```

##シンプルなアプリ

```java:MainActivity.java
public class MainActivity extends Activity {
	private WebView mWv;
	
	protected void onCreate(Bundle savedInstanceState) {
		
		super.onCreate(savedInstanceState);
		setContentView(R.layout.activity_main);
		
		//WebView初期化
		initializeWebView(this);
		
		//Javascriptからの呼び出しを可能にする登録処理
		mWv.addJavascriptInterface(this,"android");

		//スクリプト読み込み実行
		File file = new File(Environment.getExternalStorageDirectory(), "scripttest/script.html");
		mWv.loadUrl("file://" +file.getAbsolutePath());
	}
	
	//WebViewの初期設定
	private void initializeWebView(Context context) {
		mWv=new WebView(context);
		mWv.setWebChromeClient(new WebChromeClient()); //alertダイアログを表示するには必要
		mWv.setWebViewClient(new WebViewClient(){
			@Override
			public void onPageFinished(WebView view, String url) {
				//ページ読み込みが完了したタイミングでスクリプト内関数実行
				//渡した文字列にアプリ名とスクリプトの関数名を付与してalert()で表示する
				mWv.loadUrl("javascript:someFunction('called');");
			}
		});
		WebSettings settings = mWv.getSettings();
		settings.setJavaScriptEnabled(true); //Javascript実行可能に設定
		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {  //Android4.2未満での各設定はデフォルトでtrue
			settings.setAllowUniversalAccessFromFileURLs(true); //スクリプト内部での <script src="..."> を動作可能にする
			settings.setAllowFileAccessFromFileURLs(true); //スクリプトからのローカルファイルへのアクセスを許可する
		}
	}
	
	//Javascriptから呼び出されるメソッドの用意
	//アプリの名前をスクリプト側に提供する
	@JavascriptInterface
	public String getAppName() {
		return getString(R.string.app_name);
	}
}
```

##少し凝ったアプリ

```java:MainActivity.java
public class MainActivity extends Activity {
	private static final String TAG = MainActivity.class.getSimpleName();
	
	private WebView mWv;
	private static Handler mHandler=new Handler();
	
	protected void onCreate(Bundle savedInstanceState) {
		
		super.onCreate(savedInstanceState);
		setContentView(R.layout.activity_main);
		
		//WebView初期化
		initializeWebView(this);
		initializeJavascriptInterface();

		//スクリプト読み込み実行
		File file = new File(new File(Environment.getExternalStorageDirectory(), "scripttest"), "script.html");
		mWv.loadUrl("file://" +file.getAbsolutePath());
	}
	
	//WebViewの初期設定
	private void initializeWebView(Context context) {
		mWv=new WebView(context);
		mWv.setWebChromeClient(new WebChromeClient()); //alertダイアログを表示するには必要
		mWv.setWebViewClient(new WebViewClient(){
			@Override
			public void onPageFinished(WebView view, String url) {
				//ページ読み込みが完了してからJavascriptが利用可能になる
				onScriptReady();
			}
		});
		WebSettings settings = mWv.getSettings();
		settings.setJavaScriptEnabled(true); //Javascript実行可能に設定
		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {  //Android4.2未満での各設定はデフォルトでtrue
			settings.setAllowUniversalAccessFromFileURLs(true); //スクリプト内部での <script src="..."> を動作可能にする
			settings.setAllowFileAccessFromFileURLs(true); //スクリプトからのローカルファイルへのアクセスを許可する
		}
	}
	
	//Javascriptからの呼び出しを可能にする登録処理
	private void initializeJavascriptInterface() {
		mWv.addJavascriptInterface(new JsInterface(),"android");
	}
	
	//スクリプト読み込み完了
	private void onScriptReady() {
		//最初のスクリプト内関数実行
		//スクリプト内に必要な関数が用意されているかチェックする
		startValidation();
	}
	
	//関数の存在チェック
	private void startValidation() {
		//validate()が呼び返される
		mWv.loadUrl("javascript:android.validate(typeof firstFunction=='function' && typeof someFunction=='function');");
	}
	
	//スクリプトを使った処理のサンプル
	//	引数として受け取った文字列に自身の関数名を追加して次を呼び出す処理を繰り返す
	//	A:startScriptAction → S:firstFunction → A:callbackToScript → S:someFunction の順に呼ばれる
	private void startScriptAction() {
		mWv.loadUrl("javascript:firstFunction('A:startScriptAction');");	//callbackToScript()が呼び返される
	}
	
	//Javascriptから呼び出されるメソッドの用意
	private class JsInterface {
		//関数の存在チェック応答
		@JavascriptInterface
		public void validate(boolean valid) {
			if(valid) {
				//関数が存在する…スクリプトを使った処理を開始する
				//ここはメインスレッドではないのであらためてメインスレッドで処理を継続する
				mHandler.post(new Runnable() {
					@Override
					public void run() {
						startScriptAction();
					}
				});
			}
			else {
				//関数が存在しない…なにもしない
			}
		}
		
		//アプリの名前をスクリプト側に提供する
		@JavascriptInterface
		public String getAppName() {
			return getString(R.string.app_name);
		}
		
		//スクリプトからのメソッド呼び出しから、さらにスクリプト関数を呼び出すサンプル
		@JavascriptInterface
		public void callbackToScript(String text) {
			//Javascriptに改行付き文字列を渡す場合は改行文字をエスケープしなければならない
			final String result=text.replaceAll("\n","\\\\n") + "\\nA:callbackToScript";
			
			//あらためてメインスレッドから呼び出す
			mHandler.post(new Runnable(){
				@Override
				public void run() {
					mWv.loadUrl("javascript:someFunction('" + result + "');");
				}
			});
		}
	}
}
```