.NET 8 MAUI应用层级音频文件分级文件夹结构构建及相关实现咨询
问题描述
我正在构建一个.NET 8 MAUI应用,需要为医生就诊时的患者音频笔记构建如下分级文件夹存储结构:
Root +1001_John Dale -1002_Anna Simpson -2024-05-23 09-12-45.wav -2024-05-21 14-25-45.wav 17-05-22.wav -1003_Johnatan Doe -2024-05-23 09-32-45.wav -2024-05-20 16-25-45.wav
具体需求:
- 在.NET MAUI支持的所有平台上创建上述应用级文件夹与文件存储结构,同时需支持同步至Google Drive、Azure Blob Storage或Amazon S3,要求有统一接口适配本地与云端存储。
- 实现可展开的患者层级视图:医生点击+图标展开患者节点显示就诊日期及关联音频笔记;搜索患者ID/姓名片段仅得到一条记录时自动展开该节点。
- 加载文件夹结构时不显示根目录,且用户可在应用设置中配置顶层文件夹(如示例中的"Root")。
解决方案
1. 统一存储接口与跨平台实现
定义抽象存储接口
先定义统一的存储操作接口,屏蔽本地与云端的差异:
public interface IStorageService { // 创建文件夹 Task CreateFolderAsync(string path); // 获取指定路径下的子文件夹列表 Task<List<string>> GetFoldersAsync(string parentPath); // 获取指定路径下的文件列表 Task<List<string>> GetFilesAsync(string folderPath); // 上传文件到指定路径 Task UploadFileAsync(string localFilePath, string remotePath); // 下载文件到本地路径 Task DownloadFileAsync(string remoteFilePath, string localPath); // 删除文件/文件夹 Task DeleteAsync(string path); }
本地存储实现(跨平台)
利用.NET MAUI的FileSystem API实现本地存储,自动适配各平台的应用私有目录:
public class LocalStorageService : IStorageService { private readonly string _rootPath; public LocalStorageService() { _rootPath = FileSystem.AppDataDirectory; } public async Task CreateFolderAsync(string path) { var fullPath = Path.Combine(_rootPath, path); if (!Directory.Exists(fullPath)) { Directory.CreateDirectory(fullPath); await Task.CompletedTask; } } public async Task<List<string>> GetFoldersAsync(string parentPath) { var fullPath = Path.Combine(_rootPath, parentPath); if (!Directory.Exists(fullPath)) return new List<string>(); var folders = Directory.GetDirectories(fullPath) .Select(Path.GetFileName) .ToList(); await Task.CompletedTask; return folders; } public async Task<List<string>> GetFilesAsync(string folderPath) { var fullPath = Path.Combine(_rootPath, folderPath); if (!Directory.Exists(fullPath)) return new List<string>(); var files = Directory.GetFiles(fullPath) .Select(Path.GetFileName) .ToList(); await Task.CompletedTask; return files; } // 本地场景下上传/下载可简化为文件复制,删除直接调用Directory/File.Delete }
云端存储适配
针对Google Drive、Azure Blob、S3分别实现IStorageService接口,以Azure Blob为例:
public class AzureBlobStorageService : IStorageService { private readonly BlobContainerClient _containerClient; public AzureBlobStorageService(string connectionString, string containerName) { _containerClient = new BlobContainerClient(connectionString, containerName); } public async Task CreateFolderAsync(string path) { // Azure Blob无实际文件夹,通过占位Blob模拟 var blobClient = _containerClient.GetBlobClient($"{path}/.placeholder"); using var stream = new MemoryStream(); await blobClient.UploadAsync(stream, overwrite: true); } public async Task<List<string>> GetFoldersAsync(string parentPath) { var prefix = string.IsNullOrEmpty(parentPath) ? "" : $"{parentPath}/"; var folders = new HashSet<string>(); await foreach (var item in _containerClient.GetBlobsAsync(prefix: prefix, traits: BlobTraits.Metadata)) { var blobName = item.Name.Substring(prefix.Length); if (blobName.Contains("/")) { var folderName = blobName.Split('/')[0]; folders.Add(folderName); } } return folders.ToList(); } // 其他方法调用Azure Blob SDK完成对应操作 }
通过依赖注入切换存储实现(在MauiProgram.cs中注册):
builder.Services.AddSingleton<IStorageService>(sp => { var useCloud = Preferences.Get("UseCloudStorage", false); if (useCloud) { var connStr = Preferences.Get("AzureBlobConnStr", ""); return new AzureBlobStorageService(connStr, "audio-notes"); } return new LocalStorageService(); });
2. 可展开层级视图实现
定义层级数据模型
创建支持展开/折叠状态的树形数据模型:
public class TreeNode : ObservableObject { private bool _isExpanded; public string Name { get; set; } public string Path { get; set; } public NodeType Type { get; set; } public List<TreeNode> Children { get; set; } = new(); public bool IsExpanded { get => _isExpanded; set => SetProperty(ref _isExpanded, value); } } public enum NodeType { Patient, Date, AudioFile }
使用TreeView构建视图
在XAML中使用.NET MAUI的TreeView控件,结合数据模板区分不同节点:
<TreeView ItemsSource="{Binding RootNodes}"> <TreeView.ItemTemplate> <DataTemplate> <TreeViewItem IsExpanded="{Binding IsExpanded}"> <StackLayout Orientation="Horizontal" Spacing="5"> <Image Source="expand_icon.png" IsVisible="{Binding Type, Converter={StaticResource NodeTypeToFolderVisibilityConverter}}" WidthRequest="20" HeightRequest="20"/> <Label Text="{Binding Name}" FontSize="16" VerticalOptions="Center"/> </StackLayout> <TreeViewItem.ItemsSource> <Binding Path="Children"/> </TreeViewItem.ItemsSource> </TreeViewItem> </DataTemplate> </TreeView.ItemTemplate> </TreeView>
添加值转换器控制文件夹图标显示:
public class NodeTypeToFolderVisibilityConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { return value is NodeType nodeType && nodeType != NodeType.AudioFile; } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { throw new NotImplementedException(); } }
搜索与自动展开逻辑
在ViewModel中实现搜索功能,单结果时自动展开节点:
public class NotesViewModel : ObservableObject { private readonly IStorageService _storageService; public ObservableCollection<TreeNode> RootNodes { get; set; } = new(); private string _searchText; public string SearchText { get => _searchText; set { SetProperty(ref _searchText, value); SearchPatients(); } } public NotesViewModel(IStorageService storageService) { _storageService = storageService; LoadRootNodes(); MessagingCenter.Subscribe<SettingsViewModel>(this, "SettingsUpdated", (sender) => LoadRootNodes()); } private async void LoadRootNodes() { RootNodes.Clear(); var topFolder = Preferences.Get("TopLevelFolder", "Root"); var patientFolders = await _storageService.GetFoldersAsync(topFolder); foreach (var folder in patientFolders) { RootNodes.Add(new TreeNode { Name = folder, Path = Path.Combine(topFolder, folder), Type = NodeType.Patient }); } } private async void SearchPatients() { RootNodes.Clear(); var topFolder = Preferences.Get("TopLevelFolder", "Root"); var patientFolders = await _storageService.GetFoldersAsync(topFolder); var filtered = patientFolders.Where(f => f.Contains(SearchText, StringComparison.OrdinalIgnoreCase)).ToList(); foreach (var folder in filtered) { RootNodes.Add(new TreeNode { Name = folder, Path = Path.Combine(topFolder, folder), Type = NodeType.Patient }); } if (RootNodes.Count == 1) { var patientNode = RootNodes[0]; patientNode.IsExpanded = true; await LoadDateNodes(patientNode); } } private async Task LoadDateNodes(TreeNode patientNode) { var dateFolders = await _storageService.GetFoldersAsync(patientNode.Path); foreach (var folder in dateFolders) { var dateNode = new TreeNode { Name = folder, Path = Path.Combine(patientNode.Path, folder), Type = NodeType.Date }; patientNode.Children.Add(dateNode); dateNode.IsExpanded = true; await LoadAudioFiles(dateNode); } } private async Task LoadAudioFiles(TreeNode dateNode) { var audioFiles = await _storageService.GetFilesAsync(dateNode.Path); foreach (var file in audioFiles) { dateNode.Children.Add(new TreeNode { Name = file, Path = Path.Combine(dateNode.Path, file), Type = NodeType.AudioFile }); } } }
3. 根目录配置与结构加载
应用设置页面实现
添加设置页面让用户配置顶层文件夹:
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" x:Class="YourApp.Views.SettingsPage"> <StackLayout Padding="20"> <Label Text="顶层文件夹路径"/> <Entry Text="{Binding TopLevelFolder}" Placeholder="Root"/> <Button Text="保存" Command="{Binding SaveSettingsCommand}"/> </StackLayout> </ContentPage>
对应的ViewModel:
public class SettingsViewModel : ObservableObject { private string _topLevelFolder; public string TopLevelFolder { get => Preferences.Get("TopLevelFolder", "Root"); set => SetProperty(ref _topLevelFolder, value); } public ICommand SaveSettingsCommand => new Command(() => { Preferences.Set("TopLevelFolder", TopLevelFolder); MessagingCenter.Send(this, "SettingsUpdated"); }); }
跳过根目录加载
在主ViewModel的LoadRootNodes方法中,直接加载顶层文件夹下的患者文件夹,不显示根目录:
private async void LoadRootNodes() { RootNodes.Clear(); var topFolder = Preferences.Get("TopLevelFolder", "Root"); // 直接获取顶层文件夹下的患者文件夹,跳过根目录显示 var patientFolders = await _storageService.GetFoldersAsync(topFolder); foreach (var folder in patientFolders) { RootNodes.Add(new TreeNode { Name = folder, Path = Path.Combine(topFolder, folder), Type = NodeType.Patient }); } }
内容的提问来源于stack exchange,提问作者Rad
相关产品推荐
相关产品推荐

