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

Flutter获取Android可见Downloads路径存储文件的问题排查

解决Flutter应用下载文件到Android公共Download目录的问题

核心原因:Android 10+的Scoped Storage限制

你遇到的权限拒绝不是调试模式导致的,而是Android 10(API 29)及以上引入的Scoped Storage机制:

  • 直接硬编码/storage/emulated/0/Download/写入会被系统拦截,即使申请了WRITE_EXTERNAL_STORAGE权限
  • API 30+开始,WRITE_EXTERNAL_STORAGE和READ_EXTERNAL_STORAGE权限已被废弃,仅用于兼容旧版本

正确实现步骤

1. 配置AndroidManifest.xml

根据不同Android版本针对性配置:

<manifest ...>
    <!-- 兼容Android 9及以下版本 -->
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" />

    <!-- 仅作为临时兼容方案(不推荐长期使用):开启旧存储模式 -->
    <!-- <application android:requestLegacyExternalStorage="true" ...> -->

    <application ...>
        ...
    </application>
</manifest>

2. 获取公共Download目录的正确方式

不要用硬编码路径或私有目录API,推荐以下两种方案:

方式一:使用path_provider获取公共目录(适配旧版本)

添加path_provider依赖后,通过官方API获取Download目录(Android 10+标记为废弃,但可配合临时兼容模式使用):

import 'package:path_provider/path_provider.dart';

Future<String> getPublicDownloadPath() async {
  final directory = await getExternalStoragePublicDirectory(DirectoryDownloads);
  return directory.path;
}

方式二:用MediaStore写入(Android 10+推荐)

通过MediaStore创建可写入的公共文件,避免权限问题,步骤如下:

  1. 先下载文件到应用临时目录
  2. 通过MethodChannel调用Android原生代码,将文件写入公共Download目录

Dart端代码:

import 'dart:io';
import 'package:dio/dio.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:flutter/services.dart';

Future<void> downloadToPublicDownload(String url, String fileName) async {
  final dio = Dio();
  
  // 下载到应用临时目录
  final tempDir = await getTemporaryDirectory();
  final tempFilePath = path.join(tempDir.path, fileName);
  await dio.download(url, tempFilePath);

  // 调用原生方法保存到公共Download目录
  final file = File(tempFilePath);
  final bytes = await file.readAsBytes();
  
  final channel = MethodChannel('com.example.file_handler');
  try {
    await channel.invokeMethod('saveToDownloads', {
      'fileName': fileName,
      'bytes': bytes,
    });
  } on PlatformException catch (e) {
    print('保存失败:${e.message}');
  }

  // 删除临时文件
  await file.delete();
}

Android原生端(MainActivity.kt)代码:

import android.content.ContentValues
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity : FlutterActivity() {
    private val CHANNEL = "com.example.file_handler"

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
            if (call.method == "saveToDownloads") {
                val fileName = call.argument<String>("fileName")
                val bytes = call.argument<ByteArray>("bytes")
                if (fileName == null || bytes == null) {
                    result.error("INVALID_ARGS", "参数缺失", null)
                    return@setMethodCallHandler
                }
                
                val contentValues = ContentValues().apply {
                    put(MediaStore.Downloads.DISPLAY_NAME, fileName)
                    put(MediaStore.Downloads.MIME_TYPE, getMimeType(fileName))
                    put(MediaStore.Downloads.RELATIVE_PATH, "Download/")
                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                        put(MediaStore.Downloads.IS_PENDING, 1)
                    }
                }

                val uri = contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
                if (uri != null) {
                    contentResolver.openOutputStream(uri)?.use { outputStream ->
                        outputStream.write(bytes)
                        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                            contentValues.put(MediaStore.Downloads.IS_PENDING, 0)
                            contentResolver.update(uri, contentValues, null, null)
                        }
                        result.success(true)
                    } ?: run {
                        result.error("WRITE_FAIL", "无法打开输出流", null)
                    }
                } else {
                    result.error("INSERT_FAIL", "无法创建公共文件", null)
                }
            } else {
                result.notImplemented()
            }
        }
    }

    private fun getMimeType(fileName: String): String {
        val extension = fileName.substringAfterLast('.', "")
        return when (extension.lowercase()) {
            "pdf" -> "application/pdf"
            "jpg", "jpeg" -> "image/jpeg"
            "png" -> "image/png"
            "txt" -> "text/plain"
            // 按需添加更多文件类型
            else -> "application/octet-stream"
        }
    }
}

3. 权限请求的正确逻辑

  • Android 9及以下:需要请求Permission.storage权限
  • Android 10+:通过MediaStore写入公共Download目录无需额外权限
  • Android 11+:仅批量操作文件时需要MANAGE_EXTERNAL_STORAGE权限,普通下载不需要

权限请求代码示例:

import 'package:permission_handler/permission_handler.dart';
import 'package:device_info_plus/device_info_plus.dart';

Future<bool> checkAndRequestPermissions() async {
  if (Platform.isAndroid) {
    final androidInfo = await DeviceInfoPlugin().androidInfo;
    if (androidInfo.version.sdkInt <= 28) {
      final status = await Permission.storage.request();
      return status.isGranted;
    }
  }
  return true;
}

常见误区澄清

  • 调试模式不影响权限:实体平板调试和正常运行的权限逻辑一致,权限拒绝是存储策略导致的,和调试模式无关
  • 不要硬编码路径:不同设备的公共存储路径可能存在差异,硬编码会引发兼容性问题
  • 优先适配MediaStore:依赖废弃API仅作为临时方案,长期需遵循Android官方存储规范

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.19 11:40:31