###### tags: `Android` `Kotlin` # WebView 想在應用程式中呈現網頁,而不是開啟瀏覽器,就必須使用 WebView 寫這篇文章的起因是 Apple Sign In,因為 iOS 在 2020/04 以後,只要有第三方註冊、登入的應用程式都必須要加上去,iOS 13 可以輕鬆地處理,但 iOS 13 以下及其他平台必須使用網頁的方式處理,蘋果登入跟 Android 有什麼關係? 如果有 iOS 使用者跳槽到 Android,那就大有關係了,因此 Android 也跟著遭殃...,於是開始研究 WebView,並在此寫下種種的坑 ## 常用方法 ### 基本設定 ```kotlin= webView.settings.apply { javaScriptEnabled = true //允許執行 js javaScriptCanOpenWindowsAutomatically = true //允許 js 自動開啟視窗 setSupportMultipleWindows(true) //允許支援多個視窗(與 WebChromeClient 搭配使用) } ``` ### 資料傳遞 ```kotlin= inner class JsInterface { @JavascriptInterface fun onSuccess(msg: String) { Log.e("debug", "onSuccess:$msg") } @JavascriptInterface fun onFailure(msg: String) { Log.e("debug", "onFailure:$msg") } } ``` ```kotlin= webView.addJavascriptInterface(JsInterface(), "android") ``` ```htmlmixed= ``` ### 呼叫JS方法 https://stackoverflow.com/questions/4325639/android-calling-javascript-functions-in-webview ## 生命週期 ### WebViewClient shouldInterceptRequest:攔截所有的請求 shouldOverrideUrlLoading:載入請求但 post 好像不會進入,預設回傳 false 表示未處理(讓此 WebView 處理),true 表示已處理(自己處理不讓此 WebView 處理) onPageFinished:載入完成 onReceivedHttpError:收到 http 的錯誤 ### WebChromeClient onCreateWindow:開啟新視窗 ## 測試 WebView.setWebContentsDebuggingEnabled(true) ## 採坑 ### 監聽不到 Apple Sign In 後的資料 首先要檢查 redirect 的 addJavascriptInterface 有沒有接好、setSupportMultipleWindows 是 true 再來是要使用 WebChromeClient 的 onCreateWindow 去載入蘋果的登入網頁 因為不使用 WebChromeClient 進行跳頁,原本的 redirect 網頁是監聽不到的 redirect 與蘋果網頁必須要是彈出視窗的關係,這樣蘋果回傳的資料才能透過 addJavascriptInterface 監聽到 參考:https://stackoverflow.com/questions/23308601/android-open-pop-up-window-in-my-webview [浅谈WebView在新窗口浏览网页(setSupportMultipleWindows()与onCreateWindow()关系)](https://www.cnblogs.com/ufreedom/p/4229590.html) ### 網頁大小問題 有時候網頁的元素是 load 完後又再更新,更新後長寬又有變化,此時要在 webViewClient onPageFinished 的環節更新 layout 長寬 ## 實作 Apple Sign In 可以先從蘋果官方製作圖片 https://appleid.apple.com/signinwithapple/button 何謂 JWT https://medium.com/%E9%BA%A5%E5%85%8B%E7%9A%84%E5%8D%8A%E8%B7%AF%E5%87%BA%E5%AE%B6%E7%AD%86%E8%A8%98/%E7%AD%86%E8%A8%98-%E9%80%8F%E9%81%8E-jwt-%E5%AF%A6%E4%BD%9C%E9%A9%97%E8%AD%89%E6%A9%9F%E5%88%B6-2e64d72594f8 線上 JWT 解析 https://jwt.io/ Android 參考專案 https://github.com/johncodeos-blog/SignInWithAppleAndroidExample 流程說明: 首先要請 iOS 設定好相關配置,Web 要架設 redirect 的路由網頁,並且進入網頁後, 直接開啟蘋果登入頁面的視窗,登入成功後,蘋果會回傳 JWT, 用線上解析工具可以分析出我們要的欄位是 sub(即蘋果帳號的識別碼), 這時候 Web 要回傳 JWT 給 Android,然後Android 用 JWT 解析工具去解析, 取得 sub 後就可以打 API 跟 Server 要求登入帳號 在 gradle 引入 JWT 解析工具 ``` implementation 'com.auth0.android:jwtdecode:2.0.0' ``` AppleLoginActivity ```kotlin= class AppleLoginActivity : AppCompatActivity() { //與 Web 溝通的 Callback inner class JsInterface { @JavascriptInterface fun onSuccess(msg: String) { //將回傳結果進行 JWT 解析 val parse = JWT(msg) val id = parse.getClaim("sub").asString() val b = Bundle() b.putString("ID", id) setResult(Activity.RESULT_OK, Intent().putExtras(b)) finish() } @JavascriptInterface fun onFail(msg: String) { finish() } } @SuppressLint("SetJavaScriptEnabled") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_apple_login) webView.settings.apply { javaScriptEnabled = true javaScriptCanOpenWindowsAutomatically = true setSupportMultipleWindows(true) } webView.addJavascriptInterface(JsInterface(), "android") webView.webChromeClient = object : WebChromeClient() { //監聽被開啟的新視窗 override fun onCreateWindow( view: WebView?, isDialog: Boolean, isUserGesture: Boolean, resultMsg: Message? ): Boolean { val newWebView = WebView(this@AppleLoginActivity) newWebView.settings.javaScriptEnabled = true newWebView.settings.setSupportZoom(true) newWebView.settings.builtInZoomControls = true newWebView.settings.setSupportMultipleWindows(true) view?.addView(newWebView) val transport = resultMsg?.obj as WebView.WebViewTransport transport.webView = newWebView resultMsg.sendToTarget() var isFirstPageFinish = true //因為認證頁面會進入兩次,所以用 flag 判斷 newWebView.webViewClient = object : WebViewClient() { override fun shouldOverrideUrlLoading(view: WebView, url: String?): Boolean { view.loadUrl(url) return true } override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url) if (url != null && url.contains("https://appleid.apple.com/auth/authorize")) { if (isFirstPageFinish) isFirstPageFinish = false else { DialogManager.instance.cancelLoading() webView.visibility = View.VISIBLE } } //重設頁面高度 val displayRectangle = Rect() val layoutParams = view?.layoutParams window.decorView.getWindowVisibleDisplayFrame(displayRectangle) layoutParams?.height = displayRectangle.height() view?.layoutParams = layoutParams } } return true } } //載入 Server 的 redirect 網址,並用 platform 參數區分平台,以便 Web Callback 正確傳遞 //ex: https://manage-dev.italkutalk.com/redirect?platform=2 DialogManager.instance.showLoading(this) webView.loadUrl(APPLE_SIGN_IN_URL) } } ``` ## 參考文章 [官方文件](https://developer.android.com/guide/webapps) [Android:这是一份全面 & 详细的Webview使用攻略](https://www.jianshu.com/p/3c94ae673e2a) [使用Kotlin:让Android与JS交互的详解](https://www.jianshu.com/p/826a39ed81e6) [Android WebView使用詳解及注意事項](https://codertw.com/%E7%A8%8B%E5%BC%8F%E8%AA%9E%E8%A8%80/508317/) ## 其他文章 [WebView WebViewClient onReceivedHttpError() return 404 ?](https://www.jianshu.com/p/3508789b3de5) [如何利用javascript判斷網頁是否在in-app瀏覽器(WebView)開起](https://medium.com/@chienrongkhor/%E5%A6%82%E4%BD%95%E5%88%A9%E7%94%A8javascript%E5%88%A4%E6%96%B7%E7%B6%B2%E9%A0%81%E6%98%AF%E5%90%A6%E5%9C%A8in-app%E7%80%8F%E8%A6%BD%E5%99%A8-webview-%E9%96%8B%E8%B5%B7-ae8aeb209270) [Android:手把手教你构建 全面的WebView 缓存机制 & 资源加载方案](https://www.jianshu.com/p/5e7075f4875f) ## 其他問題 ### Logcat:Java exception was raised during method invocation 問題原因:在JS呼叫Android去對UI進行操作時產生 解決方法:進行UI操作的程式碼需要放置於主執行緒 參考:https://blog.csdn.net/qq_36347817/article/details/104818942 <details> <summary>範例</summary> ```kotlin= private class JsInterface(private val context: Context) { @JavascriptInterface fun showInfoFromJs(name: String?) { (context as Activity).runOnUiThread { (context as Activity).findViewById<ProgressBar>(R.id.progress).visibility = View.GONE (context as Activity).findViewById<WebView>(R.id.webView).visibility = View.VISIBLE Toast.makeText(context, name, Toast.LENGTH_SHORT).show() } } } ``` </details> ### WebView的視頻在播放前會出現灰色三角形圖示 解決方法:覆寫 WebChromeClient 中的 getDefaultVideoPoster 方法,使其回傳一個透明的bitmap 參考:https://blog.csdn.net/petterp/article/details/104933790 <details> <summary>範例</summary> ```kotlin= webView.webChromeClient = object : WebChromeClient() { ... override fun getDefaultVideoPoster(): Bitmap? { return Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) } } ``` </details> ### WebView的視頻無法自動播放 問題原因:自動播放的[新規定](https://developer.mozilla.org/en-US/docs/Web/Media/Autoplay_guide) 解決方法:目前讓網頁載完後用程式執行遠端播放解決 <details> <summary>範例</summary> ```kotlin= webView.webViewClient = object : WebViewClient() { override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url) //Fix remote can not auto play webView.loadUrl("javascript:(function() { document.querySelector('#remoteVideo').play(); })()") } override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) { super.onReceivedError(view, request, error) Log.e("error", error?.description.toString()) } } webView.loadUrl("https://webrtc-96841.firebaseapp.com/") ``` </details> ### WebView的權限問題 問題原因:實作 WebRTC 時,需要相機、麥克風的權限 解決方法:在應用程式中定義需要的權限,並在 WebChromeClient 中同意權限 參考:[Can't access camera from Android webView (chrome frame) in context of webRTC](https://stackoverflow.com/questions/44180093/cant-access-camera-from-android-webview-chrome-frame-in-context-of-webrtc) <details> <summary>範例</summary> 基本權限包含 ```xml= <uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.MICROPHONE" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <uses-permission android:name="android.permission.RECORD_AUDIO"/> ``` WebView 同意權限的方式 ```kotlin= webView.webChromeClient = object : WebChromeClient() { override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { Log.e("console log", consoleMessage?.message() ?: "") return super.onConsoleMessage(consoleMessage) } override fun onPermissionRequest(request: PermissionRequest?) { request?.grant(request.resources) } override fun onPermissionRequestCanceled(request: PermissionRequest?) { super.onPermissionRequestCanceled(request) Log.e("RequestCanceled", request?.toString() ?: "") } } ``` </details>