在现代 Android 应用中,许多功能依赖于内嵌的 WebView。其中一个常见需求是:用户长按 WebView 中的图片时,能够将图片保存到系统相册或复制到剪切板。
这个需求在 Android 10 (Q) 引入 Scoped Storage (分区存储) 后变得复杂。本文将提供一个完整的 Kotlin 解决方案,通过扩展方法和统一的存储管理,完美兼容所有 Android 版本。
一、捕获长按事件:WebView 扩展方法
首先,我们需要一个优雅的方式来捕获长按事件,并筛选出只有图片命中的情况。我们采用 Kotlin 扩展方法 onImageLongClick 来封装 HitTestResult 的检查逻辑。
1. 扩展方法 onImageLongClick
我们将图片源(URL 或 Base64 Data URL)作为回调参数返回,简化 Activity/Fragment 的逻辑。
kotlin
// WebViewExtensions.kt
import android.webkit.WebView
import android.webkit.WebView.HitTestResult
/**
* 为 WebView 添加图片长按事件处理器。
* 只有当长按命中的是图片或图片链接时,才执行回调。
*/
fun WebView.onImageLongClick(callback: (imageUrl: String) -> Unit) {
this.setOnLongClickListener { v ->
val hitTestResult = this.hitTestResult
// 检查是否命中图片或图片链接
if (hitTestResult.type == HitTestResult.IMAGE_TYPE ||
hitTestResult.type == HitTestResult.SRC_IMAGE_ANCHOR_TYPE
) {
val sourceUrl = hitTestResult.extra
if (sourceUrl != null && sourceUrl.isNotBlank()) {
callback.invoke(sourceUrl)
return@setOnLongClickListener true // 消耗事件
}
}
return@setOnLongClickListener false // 未命中图片,不消费事件
}
}
二、统一存储管理:FileDownloadManager
kotlin
package com.yourpackage.utils
import android.content.ClipData
import android.content.ClipboardManager
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.util.Base64
import android.webkit.MimeTypeMap
import android.widget.Toast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.IOException
object FileDownloadManager {
private val client = OkHttpClient()
// --- 公共入口方法 ---
/**
* (公共) 将 Base64 Data URL 保存到公共 MediaStore
*/
suspend fun saveBase64DataUrl(context: Context, dataUrl: String): Uri? {
return withContext(Dispatchers.IO) {
try {
val (mimeType, base64Data) = parseDataUrl(context, dataUrl) ?: return@withContext null
val extension = getExtensionFromMimeType(mimeType)
val fileName = "image_${System.currentTimeMillis()}.$extension"
val imageBytes = Base64.decode(base64Data, Base64.DEFAULT)
return@withContext saveBytesToMediaStore(context, imageBytes, mimeType, fileName)
} catch (e: Exception) {
e.printStackTrace()
showToast(context, "Base64 保存失败: ${e.message}")
return@withContext null
}
}
}
/**
* (公共) 将网络 URL 文件下载并保存到公共 MediaStore
*/
suspend fun downloadAndSaveFile(context: Context, fileUrl: String): Uri? {
return withContext(Dispatchers.IO) {
try {
val request = Request.Builder().url(fileUrl).build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
showToast(context, "下载失败: ${response.code}")
return@withContext null
}
val contentType = response.header("Content-Type", "application/octet-stream")
val mimeType = contentType ?: "application/octet-stream"
val extension = getExtensionFromMimeType(mimeType)
val fileName = "download_${System.currentTimeMillis()}.$extension"
val imageBytes = response.body?.bytes()
if (imageBytes != null) {
return@withContext saveBytesToMediaStore(context, imageBytes, mimeType, fileName)
} else {
showToast(context, "下载内容为空")
return@withContext null
}
} catch (e: IOException) {
e.printStackTrace()
showToast(context, "下载异常: ${e.message}")
return@withContext null
}
}
}
/**
* (公共) 将 Content URI 复制到系统剪切板
*/
fun copyImageToClipboard(context: Context, imageUri: Uri) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newUri(context.contentResolver, "Image", imageUri)
clipboard.setPrimaryClip(clip)
showToast(context, "图片已复制到剪切板")
}
// --- 内部核心逻辑 ---
/**
* (私有-核心) 保存字节到公共 MediaStore (兼容 P- 和 Q+)
*/
private suspend fun saveBytesToMediaStore(
context: Context,
imageBytes: ByteArray,
mimeType: String,
fileName: String
): Uri? {
return withContext(Dispatchers.IO) {
val resolver = context.contentResolver
val values = ContentValues()
var fileUri: Uri? = null
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// --- Q+ 逻辑:使用 Scoped Storage ---
val collectionUri: Uri
if (mimeType.startsWith("image/")) {
values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
collectionUri = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
} else {
values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
collectionUri = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
}
values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
values.put(MediaStore.MediaColumns.IS_PENDING, 1)
fileUri = resolver.insert(collectionUri, values)
fileUri?.let { uri ->
resolver.openOutputStream(uri).use { it?.write(imageBytes) }
values.clear()
values.put(MediaStore.MediaColumns.IS_PENDING, 0)
resolver.update(uri, values, null, null)
}
} else {
// --- P- 逻辑:使用 Legacy Storage 和 _DATA 字段 ---
val publicDir: File
val collectionUri: Uri
if (mimeType.startsWith("image/")) {
publicDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
collectionUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
} else {
publicDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
collectionUri = MediaStore.Files.getContentUri("external")
}
if (!publicDir.exists()) publicDir.mkdirs()
val file = File(publicDir, fileName)
values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
values.put(MediaStore.MediaColumns.DATA, file.absolutePath)
fileUri = resolver.insert(collectionUri, values)
fileUri?.let { uri ->
resolver.openOutputStream(uri).use { it?.write(imageBytes) }
}
}
if (fileUri != null) {
showToast(context, "图片已保存到相册")
} else {
showToast(context, "保存失败")
}
return@withContext fileUri
} catch (e: Exception) {
e.printStackTrace()
if (fileUri != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
resolver.delete(fileUri, null, null)
}
showToast(context, "保存异常: ${e.message}")
return@withContext null
}
}
}
// --- 辅助方法 ---
private suspend fun parseDataUrl(context: Context, dataUrl: String): Pair<String, String>? {
if (!dataUrl.startsWith("data:")) {
showToast(context, "非 Data URL 格式")
return null
}
val commaIndex = dataUrl.indexOf(',')
if (commaIndex == -1) {
showToast(context, "Data URL 格式错误")
return null
}
val metadataPart = dataUrl.substring(5, commaIndex)
val base64Data = dataUrl.substring(commaIndex + 1)
val mimeType = metadataPart.split(';').firstOrNull() ?: "application/octet-stream"
return Pair(mimeType, base64Data)
}
private fun getExtensionFromMimeType(mimeType: String): String {
return MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?:
when (mimeType) {
"image/jpeg" -> "jpg"
"image/png" -> "png"
"image/webp" -> "webp"
"image/gif" -> "gif"
else -> "bin"
}
}
private fun showToast(context: Context, message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
三、集成与权限
这是将所有组件集成在一起的 Activity 代码,它处理了长按监听、流程分发和运行时权限请求。
Kotlin
kotlin
package com.yourpackage
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.webkit.WebView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.yourpackage.utils.FileDownloadManager
import com.yourpackage.utils.onImageLongClick // 导入扩展方法
import kotlinx.coroutines.launch
class WebViewActivity : AppCompatActivity() {
private lateinit var webView: WebView
private var pendingSaveUrl: String? = null // 用于权限回调后存储 URL
private val REQUEST_WRITE_STORAGE = 1001
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 假设您的布局包含一个 id 为 webview 的 WebView 组件
webView = WebView(this)
setContentView(webView)
setupWebViewLongClick()
webView.loadUrl("https://example.com/page_with_images") // 加载测试页面
}
private fun setupWebViewLongClick() {
// 使用扩展方法设置长按监听
webView.onImageLongClick { imageUrl ->
// 提示用户选择操作 (这里简化为直接走保存流程)
Toast.makeText(this, "发现图片,准备保存...", Toast.LENGTH_SHORT).show()
checkStoragePermissionAndSave(imageUrl)
}
}
// --- 权限检查与流程启动 ---
private fun checkStoragePermissionAndSave(sourceUrl: String) {
// API 29+ (Android 10+) 不需要权限
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startSaveProcess(sourceUrl)
return
}
// API 28- (Android 9-) 需要检查权限
if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.WRITE_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED
) {
startSaveProcess(sourceUrl)
} else {
pendingSaveUrl = sourceUrl
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
REQUEST_WRITE_STORAGE
)
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_WRITE_STORAGE) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
pendingSaveUrl?.let {
startSaveProcess(it)
pendingSaveUrl = null
}
} else {
Toast.makeText(this, "保存文件需要存储权限", Toast.LENGTH_SHORT).show()
pendingSaveUrl = null
}
}
}
// --- 核心保存流程 ---
private fun startSaveProcess(imageUrl: String) {
lifecycleScope.launch {
val savedUri = if (imageUrl.startsWith("data:")) {
// Base64 链接
FileDownloadManager.saveBase64DataUrl(this@MyWebViewActivity, imageUrl)
} else {
// 网络链接
FileDownloadManager.downloadAndSaveFile(this@MyWebViewActivity, imageUrl)
}
if (savedUri != null) {
// 保存成功后,复制图片到剪切板
FileDownloadManager.copyImageToClipboard(this@MyWebViewActivity, savedUri)
}
}
}
}