You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Android RecyclerView点击按钮逐一下载Firebase图片至设备存储报错

Android RecyclerView 实现Firebase Realtime Database图片逐张下载本地存储方案

问题描述

  • 开发场景:Android应用使用RecyclerView加载Firebase Realtime Database存储的多张图片,每个列表项配置下载按钮,点击按钮需要逐张下载当前项对应图片保存到设备本地存储
  • 应用布局效果:应用布局截图
  • 现存问题:尝试的多套方案要么适配Firestore Database、要么仅支持单张下载,无法满足需求
  • Firebase Realtime Database结构:数据库结构截图,库中共存储64张图片文件

报错版本代码与错误原因

初始版本错误

初始实现代码核心问题是混淆了Firebase Realtime Database和Firebase Storage的引用地址:

class AbstractAdapter(private val mContext: Context, private val abstractList: List<Abstract>) :
RecyclerView.Adapter<AbstractAdapter.ViewHolder>() {

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
    val view = LayoutInflater.from(parent.context).inflate(R.layout.abstract_image_view, parent, false)
    return ViewHolder(view)
}

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    holder.download_btn.setOnClickListener {
        downloadFile()
    }

    Glide.with(mContext)
        .load(abstractList[position].abstract)
        .into(holder.imageView)
}

override fun getItemCount(): Int {
    return abstractList.size
}

class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
    val imageView: ImageView = itemView.findViewById(R.id.abstractImageView)
    val download_btn: Button = itemView.findViewById(R.id.abstractDownloadBtn)
}

private fun downloadFile() {
    val storage = FirebaseStorage.getInstance()
    // 错误点:传入了Realtime Database的域名,不是Storage Bucket地址
    val storageRef = storage.getReferenceFromUrl("https://notes-72413.firebaseio.com/")
    val islandRef = storageRef.child("Abstract")
    val rootPath = File(Environment.getExternalStorageDirectory(), "abstract")
    if (!rootPath.exists()) {
        rootPath.mkdirs()
    }
    // 错误点:所有图片都用同一个固定文件名,会覆盖之前下载的文件
    val localFile = File(rootPath, "imageName.jpeg")
    islandRef.getFile(localFile)
        .addOnSuccessListener {
            Log.e("firebase ", ";local tem file created  created $localFile")
        }.addOnFailureListener { exception ->
            Log.e("firebase ",";local tem file not created  created $exception")
        }
}

companion object {
    private const val Tag = "RecyclerView"
}
}

运行报错信息:Firebase Storage URLs must point to an object in your Storage Bucket. Please obtain a URL using the Firebase Console or getDownloadUrl()

错误原因:getReferenceFromUrl()要求传入Firebase Storage的存储桶地址,传入的Realtime Database域名属于Firebase另一项服务,地址不通用;且点击下载时没有传入当前列表项对应的图片地址,所有按钮点击都只会尝试下载同一个路径的文件。

第一次修改错误

修改后的downloadFile代码:

private fun downloadFile() {
    val storage = FirebaseStorage.getInstance()
    // 错误点:传入的"abstract"不是合法的Storage URL
    val storageRef = storage.getReferenceFromUrl("abstract")

    val rootPath = File(Environment.getExternalStorageDirectory(), "abstract")
    if (!rootPath.exists()) {
        rootPath.mkdirs()
    }

    val localFile = File(rootPath, "imageName.jpeg")
    storageRef.child("Abstract").downloadUrl.addOnSuccessListener { 
        Log.e("firebase ", ";local tem file created  created $localFile")
    }.addOnFailureListener { exception ->
            Log.e("firebase ", ";local tem file not created  created $exception")
        }
}

运行报错信息:The storage Uri could not be parsed

错误原因:getReferenceFromUrl()必须传入带http/https协议的完整Storage资源地址,传入相对路径无法解析。

第二次修改错误

修改后代码核心问题是点击按钮时传入了空字符串作为下载地址:

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    holder.download_btn.setOnClickListener {
        // 错误点:传入空String作为url,没有取当前列表项的图片地址
        downloadFile(url = String(), file = String.toString())
    }

    Glide.with(mContext)
        .load(abstractList[position].abstract)
        .into(holder.imageView)
}

@Throws(IOException::class)
private fun downloadFile(url: String, file: String) {
    val urlObj = URL(url)
    val fileObj = File(file)
    val conn = urlObj.openConnection()
    val buffer = ByteArray(1024)
    // 手写IO流逻辑存在漏洞,没有处理子线程、权限问题
    object : BufferedOutputStream(FileOutputStream(fileObj)) {
        var `in` = BufferedInputStream(conn.getInputStream())
        init {
            var read: Int
            while (`in`.read(buffer, 0, buffer.size) >= 0.also { read = it });
            run {
                out.write(buffer, 0, read)
            }
            out.flush()
        }
    }.use { out -> }
}

运行崩溃日志核心错误:java.net.MalformedURLException: no protocol:

错误原因:传入空字符串构造URL对象,自然没有协议头;同时网络请求放在主线程执行会触发NetworkOnMainThreadException,手写IO流没有做异常捕获,也没有处理Android版本适配的存储权限问题。


正确实现方案

前置配置

  1. 确保AndroidManifest.xml中添加必要权限:
    <!-- 网络权限 -->
    <uses-permission android:name="android.permission.INTERNET"/>
    <!-- 存储权限,Android 13及以上用READ_MEDIA_IMAGES,低版本用WRITE_EXTERNAL_STORAGE -->
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
    <uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
    
  2. 依赖配置中确保已经引入Firebase Storage、Glide相关依赖,针对Android 10及以上版本在application标签下配置android:requestLegacyExternalStorage="true"适配分区存储。

适配器完整实现代码

import android.content.Context
import android.os.Environment
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.ImageView
import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.google.firebase.storage.FirebaseStorage
import java.io.File

class AbstractAdapter(private val mContext: Context, private val abstractList: List<Abstract>) :
    RecyclerView.Adapter<AbstractAdapter.ViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.abstract_image_view, parent, false)
        return ViewHolder(view)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val currentItem = abstractList[position]
        // 加载图片
        Glide.with(mContext)
            .load(currentItem.abstract)
            .into(holder.imageView)

        // 下载按钮点击事件,传入当前项的图片地址
        holder.download_btn.setOnClickListener {
            // 禁用按钮防止重复点击
            it.isEnabled = false
            downloadSingleImage(currentItem.abstract, holder.adapterPosition) { success ->
                // 下载结束恢复按钮状态
                it.isEnabled = true
                if (success) {
                    Toast.makeText(mContext, "图片下载成功", Toast.LENGTH_SHORT).show()
                } else {
                    Toast.makeText(mContext, "图片下载失败", Toast.LENGTH_SHORT).show()
                }
            }
        }
    }

    override fun getItemCount(): Int = abstractList.size

    class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val imageView: ImageView = itemView.findViewById(R.id.abstractImageView)
        val download_btn: Button = itemView.findViewById(R.id.abstractDownloadBtn)
    }

    /**
     * 下载单张图片到本地
     * @param imageUrl 列表项对应的图片Firebase地址
     * @param position 列表项位置,用于生成不重复的文件名
     * @param callback 下载结果回调
     */
    private fun downloadSingleImage(
        imageUrl: String,
        position: Int,
        callback: (Boolean) -> Unit
    ) {
        // 1. 获取Firebase Storage实例,直接通过图片URL获取对应的Storage引用
        val storageRef = FirebaseStorage.getInstance().getReferenceFromUrl(imageUrl)

        // 2. 创建本地存储目录
        val rootPath = File(
            mContext.getExternalFilesDir(Environment.DIRECTORY_PICTURES),
            "abstract"
        )
        if (!rootPath.exists()) {
            rootPath.mkdirs()
        }

        // 3. 生成本地文件,用位置+时间戳命名避免重复覆盖
        val localFile = File(rootPath, "abstract_${position}_${System.currentTimeMillis()}.jpeg")

        // 4. 执行下载
        storageRef.getFile(localFile)
            .addOnSuccessListener {
                Log.d("FirebaseDownload", "文件保存路径:$localFile")
                callback.invoke(true)
            }
            .addOnFailureListener { e ->
                Log.e("FirebaseDownload", "下载失败", e)
                callback.invoke(false)
            }
    }
}

关键注意点

  • 不需要手动写IO流逻辑,Firebase Storage SDK的getFile()方法已经封装了异步下载、流读写的全部逻辑,不会阻塞主线程
  • 点击下载时必须获取当前绑定列表项的图片URL,不能传空值或者固定路径
  • 本地文件名不能固定写死,否则多次下载会覆盖之前的文件,用列表位置+时间戳生成唯一文件名即可
  • 存储路径优先使用应用私有外部存储目录getExternalFilesDir(),不需要额外申请存储权限(Android 10及以上版本适配更简单),如果要保存到公共相册目录,需要配合MediaStore API实现
  • 下载过程中暂时禁用按钮,避免用户重复点击触发重复下载任务

内容的提问来源于stack exchange,提问作者Khumo Mashapa

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.09.02 08:33:34