Android实战场景 - 保存WebView中的图片到相册

news2025/1/19 11:18:27

去年同事写了一个 “在H5中保存图片到相册” 的功能,虽然有大致实现思路,实现起来也没问题,但是感觉同事考虑问题的很周全,当时候就想着去学习一下,但是项目太赶没顾得上,索性现在有时间,准备好好学习一下

业务场景:Android端使用WebView加载H5时,如果用户长按其内部图片,则弹框提示用户可保存图片

简单说一下我的实现思路:首先监听WebView长按事件 → 判断长按的内容是否为图片类型 → 判断图片类型是url、还是base64 → 如果是url就下载图片保存 → 如果是base64则转Bitmap进行保存 → 保存成功刷新相册图库

      • 功能分析
        • H5中是否支持长按事件监听?
        • H5中长按时如何判断保存的是图片?而不是文案?
        • 保存图片涉及用户隐私,需适配6.0动态权限
        • 如何确定要保存的图片是Url?还是base64?
        • 保存图片
        • 刷新图库
      • 扩展函数
        • Bitmap 扩展函数
        • ContentResolver 扩展函数
        • Uri 扩展函数
        • String扩展函数(图片格式)
        • PictureSave 顶层文件(涵盖所用扩展函数)
      • 项目实战

功能分析

Here:根据业务场景,来拆分一下具体实现中需要考虑的事情

H5中是否支持长按事件监听?

首先在 WebView支持通过setOnLongClickListener监听长按事件

    override fun setOnLongClickListener(l: OnLongClickListener?) {
        super.setOnLongClickListener(l)
    }

H5中长按时如何判断保存的是图片?而不是文案?

WebView 提供了 HitTestResult 类,方便获取用户操作时的类型结果

在这里插入图片描述

可以通过类型判断,得知用户是否在操作图片

    val hitTestResult: HitTestResult = hitTestResult
    // 如果是图片类型或者是带有图片链接的类型
    if (hitTestResult.type == HitTestResult.IMAGE_TYPE ||
    hitTestResult.type == HitTestResult.SRC_IMAGE_ANCHOR_TYPE
    ) {
        val extra = hitTestResult.extra
        Timber.e("图片地址或base64:$extra")
    }

结合长按监听统一写在一起,可直接获取用户长按时的操作结果

    setOnLongClickListener {
        val hitTestResult: HitTestResult = hitTestResult
        // 如果是图片类型或者是带有图片链接的类型
        if (hitTestResult.type == HitTestResult.IMAGE_TYPE ||
            hitTestResult.type == HitTestResult.SRC_IMAGE_ANCHOR_TYPE
        ) {
            val extra = hitTestResult.extra
            Timber.e("图片地址或base64:$extra")
            longClickListener?.invoke(extra)
        }
        true
    }

保存图片涉及用户隐私,需适配6.0动态权限

关于 Android6.0适配 是很老的东西了,具体使用哪种方式可自行定义(同事使用的是Google原始权限请求方式)

Look:当用户拒绝授权后,再次申请权限时需跳转应用设置内开启授权,关于这方面也可做兼容适配,具体适配方式记录于 Android兼容适配 - 不同机型跳转应用权限设置页面

	private val permission by lazy { Manifest.permission.WRITE_EXTERNAL_STORAGE }
    private val permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
        if (it) return@registerForActivityResult savePicture()
        if (shouldShowRequestPermissionRationale(permission)) {
            activity?.alertDialog {
                setTitle("权限申请")
                setMessage("我们需要获取写文件权限, 否则您将无法正常使用图片保存功能")
                setNegativeButton("取消")
                setPositiveButton("申请授权") { checkPermission() }
            }
        } else {
            activity?.alertDialog {
                setTitle("权限申请")
                setMessage("由于无法获取读文件权限, 无法正常使用图片保存功能, 请开启权限后再使用。\n\n设置路径: 应用管理->华安基金->权限")
                setNegativeButton("取消")
                setPositiveButton("去设置") {
                    activity?.let { context -> PermissionPageUtils(context).jumpPermissionPage() }
                }
            }
        }
    }

    private fun checkPermission() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
                && ContextCompat.checkSelfPermission(AppContext, Manifest.permission.WRITE_EXTERNAL_STORAGE)
                != PackageManager.PERMISSION_GRANTED) { // 无权限
            return permissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
        }
        savePicture()
    }

下方为 Context 扩展出来的 Dialog函数 ,无需太过关注,上方弹框可自定义样式(或用原始Dialog);

项目中 Dialog 用到的addOnGlobalLayoutListener 监听

fun Context.alertDialog(builder: AppDialogBuilder.() -> Unit): AppDialogBuilder {
    val alertDialogUi = AlertDialogUi(this)
    alertDialogUi.viewTreeObserver.addOnGlobalLayoutListener {
        if (alertDialogUi.height > AppContext.screenHeight / 3 * 2) {
            alertDialogUi.updateLayoutParams<ViewGroup.LayoutParams> {
                height = AppContext.screenHeight / 3 * 2 - dip(20)
            }
        }
    }
    val alertDialogBuilder = AlertDialog.Builder(this).setCancelable(false).setView(alertDialogUi)
    val appDialogBuilder = AppDialogBuilder(alertDialogUi, alertDialogBuilder)
    appDialogBuilder.builder()
    appDialogBuilder.show()
    return appDialogBuilder
}

如何确定要保存的图片是Url?还是base64?

在双端交互时涉及到图片展示、保存相关需求的话,一般会有俩种传递方式,一种为图片的url地址,一种为base64串;

去年年初的时候有一个交互需求是H5调用拍照、相册功能,然后将所选照片传给H5,这里我使用的方式就是将图片转为了base64串,然后传给H5用于展示,其中涉及到了一些相关知识,不了解的话,可以去学习一下 - Android进阶之路 - 双端交互之传递Base64图片

话说回头,继续往下看

因为在长按时我们已经判断肯定是图片类型了,接下来通过 URLUtil.isValidUrl(extra) 判断其有效性;由此区分是图片url还是base64,然后将其转为bitmap用于存储

  • URLUtilGoogle 提供的原始类
  • extra 是用户长按时我们获取到的
    val bitmap = if (URLUtil.isValidUrl(extra)) {
        activity?.let { Glide.with(it).asBitmap().load(extra).submit().get() }
    } else {
        val base64 = extra?.split(",")?.getOrNull(1) ?: extra
        val decode = Base64.decode(base64, Base64.NO_WRAP)
        BitmapFactory.decodeByteArray(decode, 0, decode.size)
    }

URLUtil.isValidUrl() 内部实现

在这里插入图片描述

保存图片

我项目里用了协程切换线程,具体可根据自身项目场景使用不同方式去实现;图片下载方式用的是Glide框架,如果对 Glide 基础方面,了解不足的话,可以去我的Glide基础篇简单巩固下

关于 saveToAlbum 函数具体实现,会在下方的扩展函数中声明

    private fun savePicture() {
        lifecycleScope.launch(Dispatchers.IO) {
            try {
                withContext(Dispatchers.Main) { loadingState(LoadingState.LoadingStart) }
                val bitmap = if (URLUtil.isValidUrl(extra)) {
                    activity?.let { Glide.with(it).asBitmap().load(extra).submit().get() }
                } else {
                    val base64 = extra?.split(",")?.getOrNull(1) ?: extra
                    val decode = Base64.decode(base64, Base64.NO_WRAP)
                    BitmapFactory.decodeByteArray(decode, 0, decode.size)
                }
                Timber.d("保存相册图片大小:${bitmap?.byteCount}")
                saveToAlbum(bitmap, "ha_${System.currentTimeMillis()}.png")
            } catch (throwable: Throwable) {
                Timber.e(throwable)
                showToast("保存到系统相册失败")
            } finally {
                withContext(Dispatchers.Main) { loadingState(LoadingState.LoadingEnd) }
                dismissAllowingStateLoss()
            }
        }
    }

    private suspend fun saveToAlbum(bitmap: Bitmap?, fileName: String) {
        if (bitmap.isNull() || activity.isNull()) {
            return showToast("保存到系统相册失败")
        }
        val pictureUri = activity?.let { bitmap.saveToAlbum(it, fileName) }
        if (pictureUri == null) showToast("保存到系统相册失败") else showToast("已保存到系统相册")
    }

刷新图库

其实同事考虑的问题也挺完善,内部也做了兼容(不可直接使用,需结合下方的扩展函数)

/**
 * 插入图片到媒体库
 */
@Suppress("DEPRECATION")
private fun ContentResolver.insertMediaImage(fileName: String, outputFileTaker: OutputFileTaker? = null): Uri? {
    // 图片信息
    val imageValues = ContentValues().apply {
        val mimeType = fileName.getMimeType()
        if (mimeType != null) {
            put(MediaStore.Images.Media.MIME_TYPE, mimeType)
        }
        val date = System.currentTimeMillis() / 1000
        put(MediaStore.Images.Media.DATE_ADDED, date)
        put(MediaStore.Images.Media.DATE_MODIFIED, date)
    }
    // 保存的位置
    val collection: Uri
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        imageValues.apply {
            put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
            put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
            put(MediaStore.Images.Media.IS_PENDING, 1)
        }
        collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
        // 高版本不用查重直接插入,会自动重命名
    } else {
        // 老版本
        val pictures = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
        if (!pictures.exists() && !pictures.mkdirs()) {
            Timber.e("save: error: can't create Pictures directory")
            return null
        }
        // 文件路径查重,重复的话在文件名后拼接数字
        var imageFile = File(pictures, fileName)
        val fileNameWithoutExtension = imageFile.nameWithoutExtension
        val fileExtension = imageFile.extension
        var queryUri = this.queryMediaImage28(imageFile.absolutePath)
        var suffix = 1
        while (queryUri != null) {
            val newName = fileNameWithoutExtension + "(${suffix++})." + fileExtension
            imageFile = File(pictures, newName)
            queryUri = this.queryMediaImage28(imageFile.absolutePath)
        }
        imageValues.apply {
            put(MediaStore.Images.Media.DISPLAY_NAME, imageFile.name)
            Timber.e("save file: $imageFile.absolutePath") // 保存路径
            put(MediaStore.Images.Media.DATA, imageFile.absolutePath)
        }
        outputFileTaker?.file = imageFile// 回传文件路径,用于设置文件大小
        collection = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
    }
    // 插入图片信息
    return this.insert(collection, imageValues)
}

扩展函数

创建一个顶层文件 PictureSave,放置图片相关的顶层函数,更加方便调用

Bitmap 扩展函数

/**
 * 保存Bitmap到相册的Pictures文件夹
 *
 * 官网文档:https://developer.android.google.cn/training/data-storage/shared/media
 *
 * @param context 上下文
 * @param fileName 文件名。 需要携带后缀
 * @param quality 质量(图片质量决定了图片大小)
 */
internal fun Bitmap.saveToAlbum(context: Context, fileName: String, quality: Int = 75): Uri? {
    // 插入图片信息
    val resolver = context.contentResolver
    val outputFile = OutputFileTaker()
    val imageUri = resolver.insertMediaImage(fileName, outputFile)
    if (imageUri == null) {
        Timber.e("insert: error: uri == null")
        return null
    }
    // 保存图片
    (imageUri.outputStream(resolver) ?: return null).use {
        val format = fileName.getBitmapFormat()
        this@saveToAlbum.compress(format, quality, it)
        imageUri.finishPending(context, resolver, outputFile.file)
    }
    return imageUri
}

private fun Uri.outputStream(resolver: ContentResolver): OutputStream? {
    return try {
        resolver.openOutputStream(this)
    } catch (e: FileNotFoundException) {
        Timber.e("save: open stream error: $e")
        null
    }
}

ContentResolver 扩展函数

/**
 * 插入图片到媒体库
 */
@Suppress("DEPRECATION")
private fun ContentResolver.insertMediaImage(fileName: String, outputFileTaker: OutputFileTaker? = null): Uri? {
    // 图片信息
    val imageValues = ContentValues().apply {
        val mimeType = fileName.getMimeType()
        if (mimeType != null) {
            put(MediaStore.Images.Media.MIME_TYPE, mimeType)
        }
        val date = System.currentTimeMillis() / 1000
        put(MediaStore.Images.Media.DATE_ADDED, date)
        put(MediaStore.Images.Media.DATE_MODIFIED, date)
    }
    // 保存的位置
    val collection: Uri
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        imageValues.apply {
            put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
            put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
            put(MediaStore.Images.Media.IS_PENDING, 1)
        }
        collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
        // 高版本不用查重直接插入,会自动重命名
    } else {
        // 老版本
        val pictures = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
        if (!pictures.exists() && !pictures.mkdirs()) {
            Timber.e("save: error: can't create Pictures directory")
            return null
        }
        // 文件路径查重,重复的话在文件名后拼接数字
        var imageFile = File(pictures, fileName)
        val fileNameWithoutExtension = imageFile.nameWithoutExtension
        val fileExtension = imageFile.extension
        var queryUri = this.queryMediaImage28(imageFile.absolutePath)
        var suffix = 1
        while (queryUri != null) {
            val newName = fileNameWithoutExtension + "(${suffix++})." + fileExtension
            imageFile = File(pictures, newName)
            queryUri = this.queryMediaImage28(imageFile.absolutePath)
        }
        imageValues.apply {
            put(MediaStore.Images.Media.DISPLAY_NAME, imageFile.name)
            Timber.e("save file: $imageFile.absolutePath") // 保存路径
            put(MediaStore.Images.Media.DATA, imageFile.absolutePath)
        }
        outputFileTaker?.file = imageFile// 回传文件路径,用于设置文件大小
        collection = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
    }
    // 插入图片信息
    return this.insert(collection, imageValues)
}

/**
 * Android Q以下版本,查询媒体库中当前路径是否存在
 * @return Uri 返回null时说明不存在,可以进行图片插入逻辑
 */
@Suppress("DEPRECATION")
private fun ContentResolver.queryMediaImage28(imagePath: String): Uri? {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) return null
    val imageFile = File(imagePath)
    if (imageFile.canRead() && imageFile.exists()) {
        Timber.e("query: path: $imagePath exists")
        // 文件已存在,返回一个file://xxx的uri
        return Uri.fromFile(imageFile)
    }
    // 保存的位置
    val collection = MediaStore.Images.Media.EXTERNAL_CONTENT_URI

    // 查询是否已经存在相同图片
    val query = this.query(
            collection,
            arrayOf(MediaStore.Images.Media._ID, MediaStore.Images.Media.DATA),
            "${MediaStore.Images.Media.DATA} == ?",
            arrayOf(imagePath), null
    )
    query?.use {
        while (it.moveToNext()) {
            val idColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
            val id = it.getLong(idColumn)
            return ContentUris.withAppendedId(collection, id)
        }
    }
    return null
}

Uri 扩展函数

private fun Uri.outputStream(resolver: ContentResolver): OutputStream? {
    return try {
        resolver.openOutputStream(this)
    } catch (e: FileNotFoundException) {
        Timber.e("save: open stream error: $e")
        null
    }
}

@Suppress("DEPRECATION")
private fun Uri.finishPending(context: Context, resolver: ContentResolver, outputFile: File?) {
    val imageValues = ContentValues()
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
        if (outputFile != null) {
            imageValues.put(MediaStore.Images.Media.SIZE, outputFile.length())
        }
        resolver.update(this, imageValues, null, null)
        // 通知媒体库更新
        val intent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, this)
        context.sendBroadcast(intent)
    } else {
        // Android Q添加了IS_PENDING状态,为0时其他应用才可见
        imageValues.put(MediaStore.Images.Media.IS_PENDING, 0)
        resolver.update(this, imageValues, null, null)
    }
}

String扩展函数(图片格式)

@Suppress("DEPRECATION")
private fun String.getBitmapFormat(): Bitmap.CompressFormat {
    val fileName = this.lowercase()
    return when {
        fileName.endsWith(".png") -> Bitmap.CompressFormat.PNG
        fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") -> Bitmap.CompressFormat.JPEG
        fileName.endsWith(".webp") -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
            Bitmap.CompressFormat.WEBP_LOSSLESS else Bitmap.CompressFormat.WEBP
        else -> Bitmap.CompressFormat.PNG
    }
}

private fun String.getMimeType(): String? {
    val fileName = this.lowercase()
    return when {
        fileName.endsWith(".png") -> "image/png"
        fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") -> "image/jpeg"
        fileName.endsWith(".webp") -> "image/webp"
        fileName.endsWith(".gif") -> "image/gif"
        else -> null
    }
}

PictureSave 顶层文件(涵盖所用扩展函数)

package xxx

import android.content.*
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import timber.log.Timber
import java.io.File
import java.io.FileNotFoundException
import java.io.OutputStream

private class OutputFileTaker(var file: File? = null)

/**
 * 保存Bitmap到相册的Pictures文件夹
 *
 * https://developer.android.google.cn/training/data-storage/shared/media
 *
 * @param context 上下文
 * @param fileName 文件名。 需要携带后缀
 * @param quality 质量
 */
internal fun Bitmap.saveToAlbum(context: Context, fileName: String, quality: Int = 75): Uri? {
    // 插入图片信息
    val resolver = context.contentResolver
    val outputFile = OutputFileTaker()
    val imageUri = resolver.insertMediaImage(fileName, outputFile)
    if (imageUri == null) {
        Timber.e("insert: error: uri == null")
        return null
    }
    // 保存图片
    (imageUri.outputStream(resolver) ?: return null).use {
        val format = fileName.getBitmapFormat()
        this@saveToAlbum.compress(format, quality, it)
        imageUri.finishPending(context, resolver, outputFile.file)
    }
    return imageUri
}

private fun Uri.outputStream(resolver: ContentResolver): OutputStream? {
    return try {
        resolver.openOutputStream(this)
    } catch (e: FileNotFoundException) {
        Timber.e("save: open stream error: $e")
        null
    }
}

@Suppress("DEPRECATION")
private fun Uri.finishPending(context: Context, resolver: ContentResolver, outputFile: File?) {
    val imageValues = ContentValues()
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
        if (outputFile != null) {
            imageValues.put(MediaStore.Images.Media.SIZE, outputFile.length())
        }
        resolver.update(this, imageValues, null, null)
        // 通知媒体库更新
        val intent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, this)
        context.sendBroadcast(intent)
    } else {
        // Android Q添加了IS_PENDING状态,为0时其他应用才可见
        imageValues.put(MediaStore.Images.Media.IS_PENDING, 0)
        resolver.update(this, imageValues, null, null)
    }
}

@Suppress("DEPRECATION")
private fun String.getBitmapFormat(): Bitmap.CompressFormat {
    val fileName = this.lowercase()
    return when {
        fileName.endsWith(".png") -> Bitmap.CompressFormat.PNG
        fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") -> Bitmap.CompressFormat.JPEG
        fileName.endsWith(".webp") -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
            Bitmap.CompressFormat.WEBP_LOSSLESS else Bitmap.CompressFormat.WEBP
        else -> Bitmap.CompressFormat.PNG
    }
}

private fun String.getMimeType(): String? {
    val fileName = this.lowercase()
    return when {
        fileName.endsWith(".png") -> "image/png"
        fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") -> "image/jpeg"
        fileName.endsWith(".webp") -> "image/webp"
        fileName.endsWith(".gif") -> "image/gif"
        else -> null
    }
}

/**
 * 插入图片到媒体库
 */
@Suppress("DEPRECATION")
private fun ContentResolver.insertMediaImage(fileName: String, outputFileTaker: OutputFileTaker? = null): Uri? {
    // 图片信息
    val imageValues = ContentValues().apply {
        val mimeType = fileName.getMimeType()
        if (mimeType != null) {
            put(MediaStore.Images.Media.MIME_TYPE, mimeType)
        }
        val date = System.currentTimeMillis() / 1000
        put(MediaStore.Images.Media.DATE_ADDED, date)
        put(MediaStore.Images.Media.DATE_MODIFIED, date)
    }
    // 保存的位置
    val collection: Uri
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        imageValues.apply {
            put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
            put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
            put(MediaStore.Images.Media.IS_PENDING, 1)
        }
        collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
        // 高版本不用查重直接插入,会自动重命名
    } else {
        // 老版本
        val pictures = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
        if (!pictures.exists() && !pictures.mkdirs()) {
            Timber.e("save: error: can't create Pictures directory")
            return null
        }
        // 文件路径查重,重复的话在文件名后拼接数字
        var imageFile = File(pictures, fileName)
        val fileNameWithoutExtension = imageFile.nameWithoutExtension
        val fileExtension = imageFile.extension
        var queryUri = this.queryMediaImage28(imageFile.absolutePath)
        var suffix = 1
        while (queryUri != null) {
            val newName = fileNameWithoutExtension + "(${suffix++})." + fileExtension
            imageFile = File(pictures, newName)
            queryUri = this.queryMediaImage28(imageFile.absolutePath)
        }
        imageValues.apply {
            put(MediaStore.Images.Media.DISPLAY_NAME, imageFile.name)
            Timber.e("save file: $imageFile.absolutePath") // 保存路径
            put(MediaStore.Images.Media.DATA, imageFile.absolutePath)
        }
        outputFileTaker?.file = imageFile// 回传文件路径,用于设置文件大小
        collection = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
    }
    // 插入图片信息
    return this.insert(collection, imageValues)
}

/**
 * Android Q以下版本,查询媒体库中当前路径是否存在
 * @return Uri 返回null时说明不存在,可以进行图片插入逻辑
 */
@Suppress("DEPRECATION")
private fun ContentResolver.queryMediaImage28(imagePath: String): Uri? {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) return null
    val imageFile = File(imagePath)
    if (imageFile.canRead() && imageFile.exists()) {
        Timber.e("query: path: $imagePath exists")
        // 文件已存在,返回一个file://xxx的uri
        return Uri.fromFile(imageFile)
    }
    // 保存的位置
    val collection = MediaStore.Images.Media.EXTERNAL_CONTENT_URI

    // 查询是否已经存在相同图片
    val query = this.query(
            collection,
            arrayOf(MediaStore.Images.Media._ID, MediaStore.Images.Media.DATA),
            "${MediaStore.Images.Media.DATA} == ?",
            arrayOf(imagePath), null
    )
    query?.use {
        while (it.moveToNext()) {
            val idColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
            val id = it.getLong(idColumn)
            return ContentUris.withAppendedId(collection, id)
        }
    }
    return null
}

项目实战

Activity

    webView.setLongClickListener {
        ComponentService.service?.savePicture(this, it)// 弹出保存图片的对话框
    }

Fragment

    webView.setLongClickListener {
        activity?.run { ComponentService.service?.savePicture(this, it) }// 弹出保存图片的对话框
    }

原项目中使用了接口包装,我们只看 savePicture 具体实现

    override fun savePicture(activity: FragmentActivity, extra: String?) {
        if (extra.isNullOrEmpty()) return
        activity.currentFocus?.clearFocus()
        activity.showAsync({ PictureSaveBottomSheetDialogFragment() }, tag = "PictureSaveBottomSheetDialogFragment") {
            this.extra = extra
        }
    }

因为项目用的MVI框架,可自行忽略部分实现,主要关注自己想看的...

PictureSaveBottomSheetDialogFragment

internal class PictureSaveBottomSheetDialogFragment : BaseMavericksBottomSheetDialogFragment() {

    private val permission by lazy { Manifest.permission.WRITE_EXTERNAL_STORAGE }
    var extra: String? = null

    private val permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
        if (it) return@registerForActivityResult savePicture()
        if (shouldShowRequestPermissionRationale(permission)) {
            activity?.alertDialog {
                setTitle("权限申请")
                setMessage("我们需要获取写文件权限, 否则您将无法正常使用图片保存功能")
                setNegativeButton("取消")
                setPositiveButton("申请授权") { checkPermission() }
            }
        } else {
            activity?.alertDialog {
                setTitle("权限申请")
                setMessage("由于无法获取读文件权限, 无法正常使用图片保存功能, 请开启权限后再使用。\n\n设置路径: 应用管理->华安基金->权限")
                setNegativeButton("取消")
                setPositiveButton("去设置") {
                    activity?.let { context -> PermissionPageUtils(context).jumpPermissionPage() }
                }
            }
        }
    }

    override fun settingHeader(titleBar: TitleBar) {
        titleBar.isGone = true
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        lifecycleScope.launchWhenResumed { postInvalidate() }
    }

    override fun epoxyController() = simpleController {
        pictureSaveUi {
            id("pictureSaveUi")
            cancelClick { _ -> dismissAllowingStateLoss() }
            saveClick { _ -> checkPermission() }
        }
    }

    private fun checkPermission() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
                && ContextCompat.checkSelfPermission(AppContext, Manifest.permission.WRITE_EXTERNAL_STORAGE)
                != PackageManager.PERMISSION_GRANTED) { // 无权限
            return permissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
        }
        savePicture()
    }

    private fun savePicture() {
        lifecycleScope.launch(Dispatchers.IO) {
            try {
                withContext(Dispatchers.Main) { loadingState(LoadingState.LoadingStart) }
                val bitmap = if (URLUtil.isValidUrl(extra)) {
                    activity?.let { Glide.with(it).asBitmap().load(extra).submit().get() }
                } else {
                    val base64 = extra?.split(",")?.getOrNull(1) ?: extra
                    val decode = Base64.decode(base64, Base64.NO_WRAP)
                    BitmapFactory.decodeByteArray(decode, 0, decode.size)
                }
                Timber.d("保存相册图片大小:${bitmap?.byteCount}")
                saveToAlbum(bitmap, "ha_${System.currentTimeMillis()}.png")
            } catch (throwable: Throwable) {
                Timber.e(throwable)
                showToast("保存到系统相册失败")
            } finally {
                withContext(Dispatchers.Main) { loadingState(LoadingState.LoadingEnd) }
                dismissAllowingStateLoss()
            }
        }
    }

    private suspend fun saveToAlbum(bitmap: Bitmap?, fileName: String) {
        if (bitmap.isNull() || activity.isNull()) {
            return showToast("保存到系统相册失败")
        }
        val pictureUri = activity?.let { bitmap.saveToAlbum(it, fileName) }
        if (pictureUri == null) showToast("保存到系统相册失败") else showToast("已保存到系统相册")
    }

    private suspend fun showToast(message: String) {
        withContext(Dispatchers.Main) { ToastUtils.showToast(message) }
    }
}

全都过一次后,也是收获满满,争取明天再进一步,加油 > < ~

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/197608.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

2023-02-01 读书笔记:《有趣的统计》-1-基础知识

2023-02-01 读书笔记&#xff1a;《有趣的统计》-1-基础知识 75招学会数据分析 —— 2014 Doctor.Bruce Frey 序 统计学&#xff1a; 最初&#xff0c;用于确定某些事情发生的可能性&#xff1b;不断发展&#xff0c;根据样本数据准确推断总体数据特征的方法&#xff08;推…

c语言基础之分支和循环语句

c语言基础之分支和循环语句分支语句和循环语句什么是语句&#xff1f;if语句switch语句在switch语句中的 breakwhile循环while语句中的break和continuefor循环break和continue在for循环中do...while()循环goto语句写在最后&#xff1a;&#x1f4cc;————本章重点————&a…

全球疫情期间的校园招聘:可以学到的6个教训

疫情放开后&#xff0c;校园招聘逐渐回暖&#xff0c;谁能率先有效整合线上线下校招&#xff0c;企业将从一开始就处于战略领先地位。下面梳理了全球招聘团队在疫情期间的6个校招教训&#xff0c;希望对你有启发。01重新规划线上工具的应用玩法现如今&#xff0c;学生已通过网课…

【C++提高编程】vector容器详解(附测试用例与结果图)

目录1.vector容器1.1 vector基本概念1.2 vector构造函数1.3 vector赋值操作1.4 vector容量和大小1.5 vector插入和删除1.6 vector数据存取1.7 vector互换容器1.8 vector预留空间1.vector容器 1.1 vector基本概念 功能&#xff1a; vector数据结构和数组非常相似&#xff0c;…

【C++】类和对象(上)

文章目录1. 面向过程和面向对象的初步认识2. 类的引入3. 类的定义4. 类的访问限定符及封装4.1 访问限定符4.2 封装5. 类的作用域6. 类的实例化7. 类对象模型7.1 如何计算对象的大小7.2 类对象的存储方式7.3 结构体内存对齐规则8. this指针8.1 this指针的引出8.2 this指针的特性…

性能技术分享|Jmeter+InfluxDB+Grafana搭建性能平台(二)

二、CentOS安装&#xff1a;方式一&#xff1a;把下载的.rpm包推送到服务器上&#xff1b;方式二&#xff1a;直接命令行安装#下载wget https://dl.influxdata.com/influxdb/releases/influxdb-1.7.1.x86_64.rpm#安装yum localinstall?influxdb-1.7.1.x86_64.rpm -y2.3 修改配…

三十一、RabbitMQ(2)

&#x1f33b;&#x1f33b; 目录一、RabbitMQ 入门及安装1.1 概述1.3 Erlang 安装1.2.1 安装下载1.2.3 安装依赖环境1.2.4 安装 Erlang1.2.4 Erlang安装成功1.3 安装 RabbitMQ1.5启动 rabbitmq 服务1.4 开启管理界面及配置1.5.1 设置配置文件二、RabbitMQWeb 管理界面及授权操…

【JVM】垃圾回收算法与分代回收

文章目录1. 垃圾回收算法概述2. 标记-清除算法3. 标记-复制算法4. 标记-整理算法5. 分代回收本文参考&#xff1a;深入理解Java虚拟机&#xff1a;JVM高级特性与最佳实践&#xff08;第3版&#xff09; 1. 垃圾回收算法概述 根据判定对象消亡的角度来看&#xff0c;垃圾收集算…

基于PHP的学院社团管理系统

摘 要“互联网”的战略实施后&#xff0c;很多行业的信息化水平都有了很大的提升。但是目前很多学校日常工作仍是通过人工管理的方式进行&#xff0c;需要在各个岗位投入大量的人力进行很多重复性工作&#xff0c;这样就浪费了许多的人力物力&#xff0c;工作效率较低&#xff…

Python爬虫4-Scrapy爬虫框架入门了解

目录1、Scrapy爬虫框架介绍1.1 requests库和Scarpy爬虫的比较1.2 Scrapy爬虫的常用命令2、Scrapy爬虫基本使用2.1 步骤2.2 yield关键字的使用1、Scrapy爬虫框架介绍 安装Scrapy库&#xff1a;pip install scrapy 爬虫框架&#xff1a;是实现爬虫功能的一个软件结构和功能组件集…

手动挂载apex镜像

手动挂载apex镜像 1.loop设备 在类 UNIX 系统里&#xff0c;loop 设备是一种伪设备(pseudo-device)&#xff0c;或者也可以说是仿真设备。它能使我们像块设备一样访问一个文件。 这要先从mount的流程来理解&#xff0c;挂载操作&#xff0c;实际上就是把设备上的文件系统/目…

【MyBatis】核心配置文件,三大对象的作用域,#{}和${}的区别

1. environment环境:一般一个环境environment会对应一个SqlSessionFactory对象sqlSessionFactoryBuilder.build(Resources.getResourceAsStream("mybatis-config.xml"),"另一个环境的id");// 数据库环境配置在这个标签里 // 这里的default表示默认使用的环…

Spring Security 源码解读 :基本架构及初始化

Spring Security 是基于web的安全组件&#xff0c;所以一些相关类会分散在 spring-security包和web包中。Spring Security通过自定义Servlet的Filter的方式实现&#xff0c;具体架构可参考官网Spring Security: Architecture 这里使用Spring Boot 2.7.4版本&#xff0c;对应Sp…

安某客滑块分析

本文仅供学习&#xff0c;不参与商业应用 目标连接&#xff1a; aHR0cHM6Ly93d3cuYW5qdWtlLmNvbS9jYXB0Y2hhLXZlcmlmeS8/Y2FsbGJhY2s9c2hpZWxkJmZyb209YW50aXNwYW0 接口分析 刷新链接可以看到getInfoTp的接口&#xff0c;请求参数sessionId及dInfo是加密参数 返回的参数包含…

Cesium 生成terrain地形数据

Cesium 生成terrain地形数据 处理地形数据 由于CTB工具不支持DEM为NoData值和float的数据,所以需要对数据进行处理。 多个tif必须县合并镶嵌成一张tifpixeltype从float转为intNoData值处理为0我使用的是ArcGis10.5,打开ArcMap: 打开 ArcToolbox->Data Management Tools…

C语言基础知识(55)

C语言程序在不使用数组的情况下找到“N”个数字中的最大数字参考以下代码实现&#xff1a;#include<stdio.h>intmain(){int count 0;int numb1 0;int numb2 0;int i 0;printf("Enter count of numbers ");scanf("%d",&count);if(count <0){p…

HTTP绕WAF之浅尝辄止

0X00前言 最近参加重保&#xff0c;和同事闲聊时间&#xff0c;谈起来了外网&#xff0c;彼时信心满满&#xff0c;好歹我也是学了几年&#xff0c;会不少的。结果&#xff0c;扭头看完do9gy师傅的《腾讯 WAF 挑战赛回忆录》&#xff0c;就啪啪打脸了。说来惭愧&#xff0c;最…

【数据结构(5)】2.3 线性表的类型定义

文章目录1. 线性表的抽象数据类型定义2. 线性表的基本操作1. 线性表的抽象数据类型定义 数据对象&#xff1a;就是一些元素&#xff0c;元素的个数大于等于 0。数据关系&#xff1a;ai-1 是 ai 的前驱&#xff0c;同时 ai 是 ai-1 的后继&#xff0c;他们都属于集合 D 2. 线性…

1月的碎碎念,但是很有必要

从今年开始每个月会整理一个我生活的琐碎但觉得有必要的事&#xff0c;一来方便年底回顾&#xff0c;二来也希望这些事情对大家有也有些参考。 不高大上&#xff0c;但是希望某一天再看到的时候会觉得充满趣味。1.新的1年的1月开始了&#xff0c;想了很多计划&#xff0c;搬新办…

rocketmq源码-consumerQueue和indexFile文件写入

前言 在rocketmq的文件中&#xff0c;除了commitLog文件&#xff0c;还有两个重要的文件&#xff0c;分别是indexFile文件和consumerQueue文件&#xff0c;这篇笔记主要记录这两个文件的数据是怎么写进去的 consumeQueue文件中对应topic下的一个queue&#xff0c;在consumeQue…