普通视图

发现新文章,点击刷新页面。
昨天以前首页

Flutter进阶:为了解决app冷启动遇到的问题,我实现一个文件浏览器

作者 SoaringHeart
2025年1月17日 21:43

一、思路来源

Flutter 项目中偶尔会遇到一些杀死 app 冷启动会遇到的一些调试问题。或者需要读写一些数据到沙盒。都需要将沙盒文件透明化,简单来说就是文件可以随时访问分享出来的沙盒文件浏览器。

2025-01-1021.28.38-ezgif.com-video-to-gif-converter.gif

读取文件成功示例: Simulator Screenshot - iPhone 15 Pro - 2025-01-10 at 22.12.37.png

二、文件浏览器示例

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)]);

github

❌
❌