Flutter进阶:为了解决app冷启动遇到的问题,我实现一个文件浏览器
一、思路来源
Flutter 项目中偶尔会遇到一些杀死 app 冷启动会遇到的一些调试问题。或者需要读写一些数据到沙盒。都需要将沙盒文件透明化,简单来说就是文件可以随时访问分享出来的沙盒文件浏览器。
读取文件成功示例:
二、文件浏览器示例
class _AppSandboxFileDirectoryState extends State<AppSandboxFileDirectory> with DebugBottomSheetMixin {
var directorys = <PathProviderDirectory>[];
final cacheUserMapVN = ValueNotifier(<String, dynamic>{});
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
directorys = await PathProviderDirectory.initail();
directorys = directorys.where((e) => e.custom?["dir"] != null).toList();
DLog.d("directorys: ${directorys.length}");
setState(() {});
cacheUserMapVN.value = await getCacheUserMap();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: GestureDetector(
onLongPress: onDebugSheet,
child: Text("$widget"),
),
),
body: buildBody(),
);
}
Widget buildBody() {
return Container(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
...[
NButton(
constraints: BoxConstraints(maxHeight: 35),
title: "DocumentsDirectory",
onPressed: () async {
final directory = await getApplicationDocumentsDirectory();
Get.to(() => FileBrowserPage(directory: directory));
},
),
buildChooseDir(),
].map(
(e) => Padding(padding: EdgeInsets.only(bottom: 8), child: e),
),
],
),
);
}
/// 改变并跳转目录
buildChooseDir() {
return NMenuAnchor<PathProviderDirectory>(
dropItemPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
values: directorys,
initialItem: PathProviderDirectory.applicationDocumentsDirectory,
onChanged: (val) {
DLog.d("NMenuAnchor: $val");
final directory = val.custom?["dir"];
final exception = val.custom?["exception"] as String?;
if (exception?.isNotEmpty == true) {
ToastUtil.show(exception ?? "");
return;
}
Get.to(() => FileBrowserPage(directory: directory));
},
equal: (a, b) => a.name == b?.name,
cbName: (e) => e?.name ?? "-",
);
}
}
基于沙盒 Document 文件夹的读写
1、FileManager - 沙盒文件增改查
/// 文件管理类
class FileManager {
static final FileManager _instance = FileManager._();
FileManager._();
factory FileManager() => _instance;
static FileManager get instance => _instance;
///获取缓存目录路径
Future<Directory> getCacheDir() async {
var directory = await getTemporaryDirectory();
return directory;
}
///获取文件缓存目录路径
Future<Directory> getFilesDir() async {
var directory = await getApplicationSupportDirectory();
return directory;
}
///获取文档存储目录路径
Future<Directory> getDocumentsDir() async {
var directory = await getApplicationDocumentsDirectory();
return directory;
}
/// 文件创建
///
/// - fileName 文件名
///
/// - ext 文件扩展
///
/// - content 文件内容
///
/// - dir 保存文件夹
Future<File> createFile({
required String fileName,
String ext = "dart",
required String content,
Directory? dir,
bool cover = false,
}) async {
// final dateStr = "${DateTime.now()}".split(".").first ?? "";
/// 本地文件目录
Directory tempDir = dir ?? await getApplicationCacheDirectory();
if (Platform.isMacOS) {
final downloadsDir = await getDownloadsDirectory();
if (downloadsDir != null) {
tempDir = downloadsDir;
}
}
final fileNameNew = fileName.contains(".") ? fileName : '$fileName.$ext';
assert(fileNameNew.contains("."), "文件类型不能为空");
var path = '${tempDir.path}/$fileNameNew';
debugPrint("$this $fileNameNew: $path");
var file = File(path);
if (cover && file.existsSync()) {
file.deleteSync();
}
file.createSync();
file.writeAsStringSync(content);
return file;
}
/// 文件读取
///
/// - fileName 文件名
///
/// - ext 文件扩展
///
/// - content 文件内容
///
/// - dir 保存文件夹
Future<File> readFile({
required String fileName,
String ext = "dart",
Directory? dir,
}) async {
/// 本地文件目录
var tempDir = dir ?? await getApplicationCacheDirectory();
final fileNameNew = fileName.contains(".") ? fileName : '$fileName.$ext';
assert(fileNameNew.contains("."), "文件类型不能为空");
var path = '${tempDir.path}/$fileNameNew';
debugPrint("$this $fileNameNew: $path");
var file = File(path);
return file;
}
/// 存储 map
///
/// - fileName 文件名
///
/// - ext 文件类型, 默认 txt
///
/// - map 要存储的字典
Future<File> saveJson({
required String fileName,
String ext = "dart",
Directory? dir,
required Map<String, dynamic> map,
}) async {
final content = jsonEncode(map);
final file = await FileManager().createFile(fileName: fileName, ext: ext, content: content, dir: dir);
return file;
}
/// 读取 map
///
/// - fileName 文件名
///
/// - ext 文件类型, 默认 txt
///
/// - dir 目标文件夹
Future<Map<String, dynamic>?> readJson({
required String fileName,
String ext = "dart",
Directory? dir,
}) async {
final file = await FileManager().readFile(
fileName: fileName,
ext: ext,
dir: dir,
);
final fileExists = file.existsSync();
if (!fileExists) {
debugPrint("❌ $this $fileName.$ext: 文件不存在");
return null;
}
final content = await file.readAsString();
return jsonDecode(content);
}
}
2、CacheController - FileManager二次封装
class CacheController {
CacheController();
/// 从沙盒读取数据
Future<Map<String, dynamic>> readFromDisk({required String cacheKey}) async {
try {
final dir = await FileManager().getDocumentsDir();
final mapNew = await FileManager().readJson(fileName: cacheKey, dir: dir);
return mapNew ?? {};
} catch (e) {
DLog.d("$runtimeType readFromDisk $e");
}
return {};
}
/// 保存数据到沙盒
Future<File?> saveToDisk({required String cacheKey, required Map<String, dynamic> map}) async {
try {
final dir = await FileManager().getDocumentsDir();
final file = await FileManager().saveJson(fileName: cacheKey, map: map, dir: dir);
DLog.d("$runtimeType saveToDisk file: $file");
return file;
} catch (e) {
DLog.d("$runtimeType saveToDisk $e");
}
return null;
}
/// 保存数据到沙盒
Future<File?> readFile({required String cacheKey}) async {
try {
final dir = await FileManager().getDocumentsDir();
final file = await FileManager().readFile(fileName: cacheKey, dir: dir);
DLog.d("$runtimeType readFile file: $file");
return file;
} catch (e) {
DLog.d("$runtimeType readFile $e");
}
return null;
}
}
三、源码
1、app 项目沙盒目录 PathProviderDirectory
/// 基于 path_provider 沙盒文件路径获取
class PathProviderDirectory {
static PathProviderDirectory get temporaryDirectory => PathProviderDirectory(
name: "temporaryDirectory",
func: getTemporaryDirectory,
desc: "临时目录,系统可以随时清空的缓存文件夹",
);
static PathProviderDirectory get applicationSupportDirectory => PathProviderDirectory(
name: "applicationSupportDirectory",
func: getApplicationSupportDirectory,
desc: "应用程序支持目录,用于不想向用户公开的文件,也就是你不想给用户看到的文件可放置在该目录中,系统不会清除该目录,只有在删除应用时才会消失。",
);
static PathProviderDirectory get libraryDirectory => PathProviderDirectory(
name: "libraryDirectory",
func: getLibraryDirectory,
desc: "应用程序持久文件目录,主要存储持久文件的目录,并且不会对用户公开,常用于存储数据库文件,比如sqlite.db等。",
);
static PathProviderDirectory get applicationDocumentsDirectory => PathProviderDirectory(
name: "applicationDocumentsDirectory",
func: getApplicationDocumentsDirectory,
desc: "文档目录,用于存储只能由该应用访问的文件,系统不会清除该目录,只有在删除应用时才会消失。",
);
static PathProviderDirectory get applicationCacheDirectory => PathProviderDirectory(
name: "applicationCacheDirectory",
desc: "应用程序可以在其中放置特定于应用程序的目录的路径 cache 文件。如果此目录不存在,则会自动创建该目录。",
func: getApplicationCacheDirectory,
);
static PathProviderDirectory get externalStorageDirectory => PathProviderDirectory(
name: "externalStorageDirectory",
desc: "外部存储目录, 应用程序可以访问顶级存储的目录的路径。",
func: getExternalStorageDirectory,
);
static PathProviderDirectory get externalCacheDirectories => PathProviderDirectory(
name: "externalCacheDirectories",
desc: "外部存储缓存目录",
func: getExternalCacheDirectories,
);
static PathProviderDirectory get externalStorageDirectories => PathProviderDirectory(
name: "externalStorageDirectories",
desc: "可根据类型获取外部存储目录,如SD卡、单独分区等,和外部存储目录不同在于他是获取一个目录数组。但iOS不支持外部存储目录,目前只有Android才支持。",
func: getExternalStorageDirectories,
);
static PathProviderDirectory get downloadsDirectory => PathProviderDirectory(
name: "downloadsDirectory",
desc: "桌面程序下载目录,主要用于存储下载文件的目录,只适用于Linux、MacOS、Windows,Android和iOS平台无法使用。",
func: getDownloadsDirectory,
);
static List<PathProviderDirectory> get values => [
temporaryDirectory,
applicationSupportDirectory,
libraryDirectory,
applicationDocumentsDirectory,
applicationCacheDirectory,
externalStorageDirectory,
externalCacheDirectories,
externalStorageDirectories,
downloadsDirectory,
];
PathProviderDirectory({
required this.name,
required this.func,
required this.desc,
this.custom,
});
final String name;
/// 获取文件目录的异步方法
final Function func;
final String desc;
/// 自定义参数,用来存储获取到的文件目录路径及异常信息
Map<String, dynamic>? custom;
/// 初始化目录路径
static Future<List<PathProviderDirectory>> initail() async {
var list = <PathProviderDirectory>[];
for (final e in PathProviderDirectory.values) {
e.custom ??= {};
try {
final result = await e.func();
e.custom?["dir"] = result;
} catch (exception) {
debugPrint("获取目录失败 $exception");
e.custom?["exception"] = exception.toString();
continue;
} finally {
list.add(e);
}
}
// debugPrint("list: ${list.length}");
return list;
}
}
2、文件浏览器 FileBrowserPage
//
// FileBrowserPage.dart
// projects
//
// Created by shang on 2025/1/6 17:48.
// Copyright © 2025/1/6 shang. All rights reserved.
//
/// 查看本地缓存文件
class FileBrowserPage extends StatefulWidget {
const FileBrowserPage({
super.key,
required this.directory,
});
final Directory? directory;
@override
State<FileBrowserPage> createState() => _FileBrowserPageState();
}
class _FileBrowserPageState extends State<FileBrowserPage> with DebugBottomSheetMixin {
final _scrollController = ScrollController();
Directory? currentDirectory;
List<FileSystemEntity> files = [];
@override
void initState() {
super.initState();
_loadInitialDirectory();
}
Future<void> _loadInitialDirectory() async {
Directory directory = widget.directory ?? await getApplicationDocumentsDirectory();
currentDirectory = directory;
files = currentDirectory!.listSync();
setState(() {});
}
@override
void didUpdateWidget(covariant FileBrowserPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.directory != widget.directory) {
setState(() {});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("$widget"),
),
body: Column(
children: [
Text(
currentDirectory?.path ?? "",
style: const TextStyle(fontSize: 14),
),
Expanded(child: buildBody()),
],
),
);
}
Widget buildBody() {
final dirExsit = currentDirectory?.existsSync() == true;
if (!dirExsit) {
return const Center(
child: Text(
"目录不存在",
style: TextStyle(fontSize: 14),
),
);
}
return Scrollbar(
child: ListView.separated(
itemCount: files.length,
itemBuilder: (context, index) {
FileSystemEntity entity = files[index];
final isDir = entity is Directory;
final statSync = entity.statSync();
final modifiedStr = statSync.modified.toString().substring(0, 19);
return ListTile(
dense: true,
leading: Icon(
isDir ? Icons.folder : Icons.insert_drive_file,
color: isDir ? primary : null,
),
title: Text(entity.path.split('/').last),
subtitle: Row(
children: [
Expanded(child: Text(modifiedStr)),
Text(statSync.size.fileSizeDesc),
],
),
onTap: () {
if (isDir) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => FileBrowserPage(directory: entity),
),
);
} else if (entity is File) {
_openFile(entity);
}
},
);
},
separatorBuilder: (_, index) {
return const Divider(height: 1, color: lineColor);
},
),
);
}
void _openFile(File file) {
final path = file.path;
final title = path.split('/').last;
var content = '';
try {
content = file.readAsStringSync();
final obj = jsonDecode(content);
if (obj is Object) {
content = obj.formatedString();
}
} catch (e) {
debugPrint("$this $e");
content = "文件读取失败: $e";
}
onDebugBottomSheet(
title: title,
confirmTitle: Platform.isIOS ? "分享" : "下载",
onConfirm: () {
Share.shareXFiles([XFile(path)]);
},
content: Text(content),
);
}
}
3、文件内容弹窗封装 - DebugBottomSheetMixin
import 'package:flutter/material.dart';
import 'package:get/get.dart';
/// Debug 底部弹窗封装
mixin DebugBottomSheetMixin<T extends StatefulWidget> on State<T> {
/// 弹窗展示类型
Future<R?> onDebugBottomSheet<R>({
required String title,
required Widget content,
String confirmTitle = "确定",
VoidCallback? onConfirm,
}) {
return Get.bottomSheet<R>(
FractionallySizedBox(
heightFactor: 0.7,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: 50,
child: NavigationToolbar(
leading: InkWell(
onTap: () {
Navigator.of(context).pop();
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 15),
child: const Text(
"取消",
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: 15.0,
color: Color(0xff737373),
),
),
),
),
middle: Text(
title,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 15.0,
color: Color(0xff181818),
),
textAlign: TextAlign.center,
),
trailing: InkWell(
onTap: onConfirm,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 15),
child: Text(
confirmTitle,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 16.0,
color: Colors.blue,
),
),
),
),
),
),
Expanded(
child: Scrollbar(
child: SingleChildScrollView(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 15),
child: content,
),
),
),
),
const SizedBox(height: 34),
],
),
),
isScrollControlled: true,
backgroundColor: Colors.white,
);
}
}
总结
1、实现一个简易的文件浏览器之后,你就随时读取存储在沙盒的文件排查问题。尤其在排查 app杀退冷启动时的 debug 日志文件,极大的提高了排查疑难问题的效率。
2、通过 share_plus 实现分享
Share.shareXFiles([XFile(path)]);