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_STORAGE或WRITE_EXTERNAL_STORAGE,SAF是用户主动授权的,系统会直接认可。 - 持久权限很重要:
takePersistableUriPermission必须加,不然APP重启后就访问不了用户选的文件夹了。 - 避免阻塞主线程:如果下载任务特别多,建议在原生端用线程池处理文件写入,或者Flutter端用Isolate来跑下载任务,避免界面卡顿。
这样一套流程下来,既能解决FilePicker的兼容问题,又能完美支持用户选择任意文件夹,还能实现并发下载,绝对比之前的方案靠谱!
备注:内容来源于stack exchange,提问作者user21571707




