Xamarin、ご存知ですか?
Xamarin、ご存知ですか?
C#でAndroidやiOSアプリが作れるあれです。
Xamarin.Formsを使うとXamlで画面が作れて簡単にクロスになったり色々出来るのですが、もっぱら僕はAndroidアプリを、Xamarin.Androidをそのまま薄いラッパーとして使って作ってます。
Xamarin.Androidを使う理由
この章は主観的で、そしてとても長いので次まで飛んで大丈夫です。
理由は三つあります。
一つは、Javaが好きではないと言うこと。
もう本当に好きじゃなくて。好き嫌いで話しちゃだめなんですが、役割を果たしてない検査例外とか見てると辛いです。未だにプロパティも使えずゲッター、セッター使わないと駄目とかも悲しい。
JVMへの印象が悪いのも、今は昔の話なんでしょうが、悪いものはやっぱり悪いですし、未だにヒープの使い方は好きじゃないです。GCの動き方が好きじゃないというか。
もちろん今はkotlinという選択肢もありますが、Android以外の場所で使うときに、やっぱりJVMのしがらみに囚われるのが嫌なので、他のScalaなどのJVM言語と同じく、僕にとって使いたいものではないです。
もう一つは、.NETで書ける、.NETの資産が使えるということ。
どういう事かと言うと、今までC#で書いてきたライブラリや、使ってきたライブラリがほとんどそのまま使えるんですよね。
特にここ数年のものは、.NET Standardで作ってるので、もっと活用できる。
それに、.NETのOSSライブラリもなかなか凄くて、LiteDBという僕がよく使ってる組み込みデータベースなんかはホントに超便利ですし、他にもなかなか良いものが揃ってます。
また、C#はとても良い言語です。Linqなんかも凄いですし、Linqを実現してる拡張メソッドみたいな言語機能は他の言語にも欲しい機能の一つです。
もちろん今までは、どうしてもWindowsの言語という感じがありました。僕はずっとMonoも使ってましたが、マイナーな方かと。
ところが、.NET Coreでなんとこれが公式にクロスプラットフォームになったと言うのが超大きなインパクトで、Linuxでも、macでも使えるものになりました。
現に僕が使っているサーバでも動いてますし、ラズパイでも動かしてます。
.NET Core 3.0にしてから、メモリ消費もすごく減って、動作も早くなって、割と言う事なしの言語というかプラットフォームです。
そんなC#信者な部分が、AndroidでもC#を使えと囁いてきます。
最後のひとつは、Xamarin.Androidが他のクロスプラットフォームな言語と違って、本当にJavaでのAndroidに砂糖をかけたぐらいの薄いラッパーでしかない部分が凄く丁度良いというか、しっくりくるという事です。
クロスプラットフォームな言語って、最大公約数のAPIしかなかったりするんですよね。それ故に、Android自体のバージョンアップにとても弱かったり。
また、CordovaやRNなんかは、ネイティブモジュールをプラグインとして使用できる機能がありますが、いかんせん結構なインピーダンスミスマッチがあり、素直にやりたい事が出来なかったりします。OSSで良いものが見つからなかったら最終的には自分で書いたりしますが、その時にはプラットフォームとAndroid間に立っているものが多すぎて、正直、面倒くさくなります。これは、OSSの作者様も同じ思いなのか、放置されたプラグインもままあります。
その点、Xamarinは、単なるラッパーです。Javaのクラスは、普通にアッパーキャメルケースになって、.NET流の名前空間に従った適切なクラスになってます。メソッドも同じ。ゲッター、セッターに関しては、プロパティになっていると言う信じられない仕様です(後にkotlinも同じ事しましたが(笑))
なので、Javaのサンプルコードをほんの少し変更すると、割とそのままで動くんですよね。これは、Android Developersが割とそのまま使えると言うことで、とても有意義な事です。
最近のAndroidXに関しては、ちょっと様子見ですが(年内ぐらいに移行ツールが来るかもと言うのをどこかで読んだので、そう信じてます(笑))
Xamarin.AndroidでWebViewを使う
Xamarin.AndroidはあくまでAndroidのラッパーでしかないです。なので、Android流に画面を作る必要があります。
これはこれで悪くはないのですが、実はAndroidの画面作るのって結構面倒なんですよね。
小さいViewを作って、画面に並べてみて、今度はAdapter作って、と、結構な面倒くささです。
Xamarin.Androidでは、デザイナーで作って一度ビルドしたらActivityに生えたり直接インテリセンスが効くとか闇魔法が掛かってますが、それでも今風ではない。
かと言って、Xamarin.Formsではちょっと重厚過ぎたり、Activityのライフサイクルから外れたりするのでやりづらい。
ならば、と言う事で、僕は、画面にはWebViewを使うといういささか荒い方法で、WebView内でVue.jsを使用して、画面はWebViewにHTML5とJavaScript、ロジックはC#という方法でアプリを作ってます。
この方法の一番便利なところは、Cordovaと同様に画面の確認はウェブブラウザで済んでしまうというところです。
加えて、JS側に露出できるオブジェクトはすごくあっさりとした同期関数だけなので、モックを差し込みやすい。
画面側、C#側ともに、とてもテストが楽なのです。
ひな形のソースコード、公開します
と言う事で、サンプルプロジェクトを公開します。
VS2017形式で、加えてコメントがイマイチですが、僕はだいたいこれから作ってます。
何か不備がありましたら是非PRください。
簡単な解説
ソースが簡単なので、読んだらわかると言う方が大多数かと思いますが、僕なりの解説をしてみようと思います。
今回のキモは、いろいろありますが、まずこのクラスです
public class WebAppInterface : Java.Lang.Object { MainActivity main; public WebAppInterface(MainActivity activity) : base() { main = activity; } [Export] [JavascriptInterface] public void ShowToast(string toast) { Toast.MakeText(main, toast, ToastLength.Short).Show(); } [Export] [JavascriptInterface] public string HelloWorld(string foo) { return $"Hello World ,{foo}"; } [Export] [JavascriptInterface] public void HelloWorldEvent(string foo) { Task.Run(async () => { await Task.Delay(1000); main.RaiseEventBrowser("helloworld", $"Hello World ,{foo}"); }); } [Export] [JavascriptInterface] public void Initialized() { main.Initialized(); } [Export] [JavascriptInterface] public void Emit(string uri) { main.Emit(uri); } }
このクラスが、JavaScriptに露出するオブジェクトになります。
こいつの存在バレは結構なセキュリティホールになるので、外部からの信頼できないスクリプトは実行してはいけません。
このクラスにあるメソッドのうち、Export属性とJavascriptInterface属性がついているメソッドは、JavaScript側から呼ぶことができます。公開されるオブジェクト名は、MainActivityの下記の場所で設定できます。
//JavaScript側に露出するオブジェクト名 webView.AddJavascriptInterface(new WebAppInterface(this), "CS");
また、全て同期関数でしかもUIをブロックし、かつasyncは使えないので、時間がかかるものはTaskでwrapして、Taskの結果はMainActivity経由でRaiseEventBrowserメソッドを使ってイベントを起こします。大規模なアプリケーションを作るときはPromiseで包む事も必要かもしれませんが、基本的にはイベントをstoreへ適用する、Vuexで言うとcommitするようなもので充分です。
そのままではalertやconfirmが使えないので、雑にクラスを作っておきます。これはWebChromeClientクラスを継承したクラスに実装して、WebViewに与えるだけで良いです。特に面白いコードではないので、コメントは無いです。
class HybridWebChromeViewClient : WebChromeClient { public override void OnGeolocationPermissionsShowPrompt(string origin, GeolocationPermissions.ICallback callback) { base.OnGeolocationPermissionsShowPrompt(origin, callback); } public override bool OnJsConfirm(WebView view, string url, string message, JsResult result) { var dialog = new AlertDialog.Builder(view.Context); var dlg = dialog.SetTitle("?") .SetIcon(ContextCompat.GetDrawable(view.Context, Android.Resource.Drawable.IcDialogAlert)) .SetMessage(message) .SetNegativeButton(Android.Resource.String.No, (object sender, DialogClickEventArgs args) => { result.Cancel(); }).SetPositiveButton(Android.Resource.String.Yes, (object sender, DialogClickEventArgs args) => { result.Confirm(); }). Create(); dlg.SetCanceledOnTouchOutside(false); dlg.Show(); return true; } public override bool OnJsAlert(WebView view, string url, string message, JsResult result) { var dialog = new AlertDialog.Builder(view.Context); var dlg = dialog.SetTitle("!") .SetIcon(ContextCompat.GetDrawable(view.Context, Android.Resource.Drawable.IcDialogAlert)) .SetMessage(message) .SetPositiveButton(Android.Resource.String.Ok, (object sender, DialogClickEventArgs args) => { result.Confirm(); }).Create(); dlg.SetCanceledOnTouchOutside(false); dlg.Show(); return true; } }
あと、必要なのはリクエストのインターセプトでしょうか。特にアプリケーションのWeb側のみを動的に更新できるようにする場合には必須になると思います。これも下記のようにHybridWebViewClientにスタブを作ってあります。
//HybridWebViewClient.cs [Obsolete] public override bool ShouldOverrideUrlLoading(WebView webView, string url) { if (HookUrlLoad(url)) { return true; } return base.ShouldOverrideUrlLoading(webView, url); } public override bool ShouldOverrideUrlLoading(WebView view, IWebResourceRequest request) { if (HookUrlLoad(request.Url.ToString())) { return true; } return base.ShouldOverrideUrlLoading(view, request); } public override WebResourceResponse ShouldInterceptRequest(WebView view, IWebResourceRequest request) { var rep = InterceptByLocalProcess(request.Url.ToString()); if (rep != null) { return rep; } return base.ShouldInterceptRequest(view, request); } [Obsolete] public override WebResourceResponse ShouldInterceptRequest(WebView view, string url) { var rep = InterceptByLocalProcess(url); if (rep != null) { return rep; } return base.ShouldInterceptRequest(view, url); } private bool HookUrlLoad(string url) { //URLのLoadをインターセプトするならここ。 var scheme = "foo:"; if (!url.StartsWith(scheme)) return false; if (url.StartsWith(scheme + "//foo")) { //DO SOMETHING return true; } if (url.StartsWith(scheme + "//")) return false; var resources = url.Substring(scheme.Length).Split('?'); var method = resources[0]; //var parameters = resources.Length == 1 ? null : System.Web.HttpUtility.ParseQueryString(resources[1]); switch (method) { case "some": //DO SOMETHING break; case "thing": //DO SOMETHING break; case "hook": //DO SOMETHING break; default: return true; } return true; } private WebResourceResponse InterceptByLocalProcess(string url) { //URLへのアクセスをインターセプトするならここで。 return null; }
あと、アクティビティのライフサイクルに合わせて、JavaScript側にイベントを起こすようにしておきます。
このへんですね。
追加で、ライフサイクルに合わせてJavaScriptのタイマーを止めないとひどい目に合うので、この辺も実装します。
//MainActivity protected override void OnStart() { base.OnStart(); RaiseEventBrowser("onresume", ""); } public override void OnSaveInstanceState(Bundle outState, PersistableBundle outPersistentState) { webView.SaveState(outState); base.OnSaveInstanceState(outState, outPersistentState); } protected override void OnPause() { RaiseEventBrowser("onpause", ""); webView.PauseTimers(); base.OnPause(); } protected override void OnResume() { webView.ResumeTimers(); RaiseEventBrowser("onresume", ""); base.OnResume(); }
イベントはJavaScriptが受け取れる状態になってから送り始めないと、色々苦労します。
それに対応してるのが、イベントを送る下記の部分です。
//MainActivity public void RaiseEventBrowser(string eventName, object eventArgs) { lock (lockObj) { var eventArgument = JsonConvert.SerializeObject(eventArgs); if (eventName != "") EventWaiting.Enqueue(new string[] { eventName, eventArgument }); if (!initialized) return; if (eventrunning) return; eventrunning = true; try { if (EventWaiting.Any()) { var item = new string[] { }; while (EventWaiting.TryDequeue(out item)) { var itemx = item; RunOnUiThread(() => { webView.EvaluateJavascript($"window.dispatchEvent(new CustomEvent('{itemx[0]}', {{ detail: {itemx[1]} }}));", null); }); } } } finally { eventrunning = false; } } }
最後に、スプラッシュスクリーンを追加しておきます。
これはもうAndroid流のやりかたなので、ソースを見ていただければ一番早いです。
MainActivityから MainLauncher = trueを外し、SplashActivityに MainLauncher = true, NoHistory = trueを追加するだけです。
これでもう下ごしらえ完了
あとは、HTMLとJavaScriptをひたすら書くだけです。
もうこうなると簡単ですね。
他のアプリなんかと連携するときは、MainActivityのIntentFilterを設定して、インテント受けるスタブの部分に実装したり、JavaScript側に露出したEmitメソッドに呼び出したいUriを渡したりします。
あとは普通のSPAアプリです。
Vueなんかを使うと、とても簡単に作れると思います。
もちろんReactでも良いですよ。ReactNativeありますが。
デバッグ中はホストでserveしたい? ここのコメントを取ってください。
//MainActivity.cs private void NavigateTop() { RunOnUiThread(() => { #if DEBUG //debug中はローカルから配信したい場合は以下を活かす //webView.LoadUrl("http://10.0.2.2:8080/#"); webView.LoadUrl("file:///android_asset/index.html#"); #else webView.LoadUrl("file:///android_asset/index.html#"); #endif }); }
最近の端末は本当にスペックが上がってるのでWebViewでももっさり感は減ってきましたし、もっさり感をいかにお洒落に誤魔化すかはWeb屋の十八番だと思うので、力を発揮しちゃえます。
また、WebViewは最近の端末ではChromeのWebView同じものが使われるので、そんなにバベらずにも済みます。
C#側で重たい事しても、全然UI側には影響無かったり、本当に気にするべき事は減ってます。
唯一は、apkサイズが大きいと言う事で、こればっかりは今後の発展に期待ですね。。これでも小さくなった方なんです。
是非皆さんも気軽にAndroidアプリを作ってみてください。
2019/09/28 15:50 追記。
LaunchModeの設定を忘れていました。Intent受け取るときによくないことになると思いますので、設定したものをGithubに再度Pushしておきました。