You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

Flutter中更优简洁的SAF(存储访问框架)使用方案咨询

Flutter中更优简洁的SAF(存储访问框架)使用方案咨询

兄弟我太懂你的困扰了!FilePicker在部分定制ROM或者高版本Android上确实容易出兼容问题,尤其是存储权限这块儿绕不开的坑。用SAF绝对是更稳妥的选择——毕竟这是Android官方主推的存储访问方式,能避开很多权限限制,还能拿到用户主动授权的持久访问权。

我给你整理一套简洁高效的Flutter+原生SAF整合方案,直接解决你的需求:


核心思路

不用纠结把SAF的URI转成本地文件路径!从Android 10开始,直接访问文件路径已经被系统限制了,正确的姿势是通过ContentResolver直接操作URI对应的存储资源。我们通过Flutter的MethodChannel和原生通信,完成「用户选文件夹→获取持久权限→Flutter并发下载→原生写入文件」的全流程。

步骤1:Flutter端实现通信层

先写个简单的工具类,用来调用原生的文件夹选择和文件写入方法:

import 'dart:typed_data';
import 'package:flutter/services.dart';

class SafStorage {
  static const _channel = MethodChannel('saf_storage');

  // 调用原生选择文件夹,返回URI字符串
  static Future<String?> pickUserFolder() async {
    try {
      return await _channel.invokeMethod('pickFolder');
    } on PlatformException catch (e) {
      print('选文件夹翻车了:${e.message}');
      return null;
    }
  }

  // 向选中的文件夹写入文件
  static Future<bool> writeToFolder(String folderUri, String fileName, Uint8List fileBytes) async {
    try {
      return await _channel.invokeMethod('writeFile', {
        'folderUri': folderUri,
        'fileName': fileName,
        'fileBytes': fileBytes,
      });
    } on PlatformException catch (e) {
      print('写文件出错:${e.message}');
      return false;
    }
  }
}

步骤2:原生Kotlin端完善SAF逻辑

在你的MainActivity里实现SAF的文件夹选择、权限持久化和文件写入:

import android.content.Intent
import android.net.Uri
import android.os.Bundle
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.io.OutputStream

class MainActivity : FlutterActivity() {
    private val CHANNEL = "saf_storage"
    private val REQUEST_FOLDER = 1001
    private var resultCallback: MethodChannel.Result? = null

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
            when (call.method) {
                "pickFolder" -> {
                    resultCallback = result
                    // 启动SAF文件夹选择器
                    val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
                    startActivityForResult(intent, REQUEST_FOLDER)
                }
                "writeFile" -> {
                    val folderUriStr = call.argument<String>("folderUri") ?: run {
                        result.error("参数错误", "文件夹URI为空", null)
                        return@setMethodCallHandler
                    }
                    val fileName = call.argument<String>("fileName") ?: run {
                        result.error("参数错误", "文件名为空", null)
                        return@setMethodCallHandler
                    }
                    val bytes = call.argument<ByteArray>("fileBytes") ?: run {
                        result.error("参数错误", "文件数据为空", null)
                        return@setMethodCallHandler
                    }

                    val folderUri = Uri.parse(folderUriStr)
                    try {
                        // 在选中的文件夹下创建新文件
                        val fileUri = contentResolver.insert(folderUri, null) ?: run {
                            result.error("创建失败", "无法创建文件", null)
                            return@setMethodCallHandler
                        }
                        // 写入文件数据
                        contentResolver.openOutputStream(fileUri)?.use { outputStream ->
                            outputStream.write(bytes)
                            result.success(true)
                        } ?: result.error("写入失败", "无法打开输出流", null)
                    } catch (e: Exception) {
                        result.error("异常", e.message, null)
                    }
                }
                else -> result.notImplemented()
            }
        }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if (requestCode == REQUEST_FOLDER && resultCode == RESULT_OK) {
            data?.data?.let { uri ->
                // 获取持久读写权限,重启APP后依然有效
                val persistFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
                contentResolver.takePersistableUriPermission(uri, persistFlags)
                // 返回URI给Flutter
                resultCallback?.success(uri.toString())
            } ?: resultCallback?.error("选择失败", "未选中任何文件夹", null)
        } else {
            resultCallback?.error("取消", "用户取消了选择", null)
        }
        resultCallback = null
    }
}

步骤3:Flutter端实现并发下载

用Dart的Future.wait配合http包就能轻松实现并发下载,举个例子:

import 'dart:typed_data';
import 'package:http/http.dart' as http;

// 假设这是你要下载的文件列表
final downloadUrls = [
  "https://example.com/file1.jpg",
  "https://example.com/file2.pdf",
  "https://example.com/file3.zip",
];

// 并发下载并写入到用户选中的文件夹
Future<void> startConcurrentDownloads(String folderUri) async {
  // 生成每个下载任务的Future
  final tasks = downloadUrls.map((url) async {
    final fileName = url.split('/').last;
    final response = await http.get(Uri.parse(url));
    if (response.statusCode == 200) {
      return await SafStorage.writeToFolder(folderUri, fileName, response.bodyBytes);
    } else {
      print('下载$url失败:${response.statusCode}');
      return false;
    }
  });

  // 等待所有任务完成
  final results = await Future.wait(tasks);
  print('所有下载任务完成:成功${results.where((r) => r).length}个,失败${results.where((r) => !r).length}个');
}

关键注意事项

  • 不需要传统存储权限:AndroidManifest里不用加READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE,SAF是用户主动授权的,系统会直接认可。
  • 持久权限很重要takePersistableUriPermission必须加,不然APP重启后就访问不了用户选的文件夹了。
  • 避免阻塞主线程:如果下载任务特别多,建议在原生端用线程池处理文件写入,或者Flutter端用Isolate来跑下载任务,避免界面卡顿。

这样一套流程下来,既能解决FilePicker的兼容问题,又能完美支持用户选择任意文件夹,还能实现并发下载,绝对比之前的方案靠谱!

备注:内容来源于stack exchange,提问作者user21571707

火山引擎 最新活动