###### 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>