.NET + AI 跨平台实战系列(五):构建智能相册核心功能——批量处理与本地缓存

从"能识别"到"好用":打造生产级的智能相册

引言:当AI遇到现实场景

在前四篇文章中,我们完成了基础设施搭建、云端API接入、本地模型部署,App已经能"看懂"图片了。但一个真正好用的智能相册,需要解决三个现实问题:

  1. 批量处理:用户相册里有成百上千张照片,一张张点太慢

  2. 结果持久化:识别一次后,下次打开不用重新识别

  3. 快速检索:根据物体标签找到相关照片

根据Telerik 2026年的产品路线图,AI驱动的数据管理是今年开发者最关注的功能之一。Syncfusion也在最新版本中推出了AI增强的DataGrid,支持自然语言搜索。

本文目标:将AI识别能力转化为可用的产品功能。我们将实现:

  • SQLite本地缓存识别结果

  • 批量扫描相册图片

  • 按标签搜索照片

  • 识别历史的可视化展示

一、数据模型设计

1.1 创建模型类

Models文件夹下创建以下类:

cs 复制代码
csharp

// PhotoModel.cs - 照片实体
namespace SmartPhotoAlbum.Models;

public class PhotoModel
{
    public int Id { get; set; }
    public string FilePath { get; set; }
    public string FileName { get; set; }
    public DateTime DateAdded { get; set; }
    public DateTime? DateTaken { get; set; }
    public long FileSize { get; set; }
    public int Width { get; set; }
    public int Height { get; set; }
    public string ThumbnailPath { get; set; } // 缩略图路径
    public bool IsAnalyzed { get; set; }
    public DateTime? LastAnalyzed { get; set; }
}
cs 复制代码
csharp

// AnalysisResultModel.cs - 识别结果
namespace SmartPhotoAlbum.Models;

public class AnalysisResultModel
{
    public int Id { get; set; }
    public int PhotoId { get; set; }
    public string RawResponse { get; set; }
    public string Description { get; set; }
    public DateTime AnalyzedAt { get; set; }
    public string Provider { get; set; } // "cloud" 或 "local"
    public int PromptTokens { get; set; }
    public int CompletionTokens { get; set; }
    public double ProcessingTimeMs { get; set; }
    public string ModelVersion { get; set; }
    
    // 导航属性
    [Newtonsoft.Json.JsonIgnore]
    public PhotoModel Photo { get; set; }
}
cs 复制代码
csharp

// TagModel.cs - 标签(多对多关系)
namespace SmartPhotoAlbum.Models;

public class TagModel
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int Count { get; set; } // 使用次数
    public DateTime FirstSeen { get; set; }
    public DateTime LastSeen { get; set; }
    
    // 导航属性
    public ICollection<PhotoTag> PhotoTags { get; set; }
}

// 照片-标签关联表 public class PhotoTag { public int PhotoId { get; set; } public PhotoModel Photo { get; set; } public int TagId { get; set; } public TagModel Tag { get; set; } public double Confidence { get; set; } // 置信度 }

二、SQLite数据库服务

2.1 安装SQLite包

bash 复制代码
bash

dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet add package Microsoft.EntityFrameworkCore.Tools

2.2 创建数据库上下文

Services文件夹下创建AppDbContext.cs

cs 复制代码
csharp

using Microsoft.EntityFrameworkCore;
using SmartPhotoAlbum.Models;

namespace SmartPhotoAlbum.Services;

public class AppDbContext : DbContext
{
    public DbSet<PhotoModel> Photos { get; set; }
    public DbSet<AnalysisResultModel> AnalysisResults { get; set; }
    public DbSet<TagModel> Tags { get; set; }
    public DbSet<PhotoTag> PhotoTags { get; set; }

    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // 配置复合主键
        modelBuilder.Entity<PhotoTag>()
            .HasKey(pt => new { pt.PhotoId, pt.TagId });

        // 配置关系
        modelBuilder.Entity<PhotoTag>()
            .HasOne(pt => pt.Photo)
            .WithMany(p => p.PhotoTags)
            .HasForeignKey(pt => pt.PhotoId);

        modelBuilder.Entity<PhotoTag>()
            .HasOne(pt => pt.Tag)
            .WithMany(t => t.PhotoTags)
            .HasForeignKey(pt => pt.TagId);

        // 配置索引
        modelBuilder.Entity<PhotoModel>()
            .HasIndex(p => p.IsAnalyzed);

        modelBuilder.Entity<PhotoModel>()
            .HasIndex(p => p.DateTaken);

        modelBuilder.Entity<TagModel>()
            .HasIndex(t => t.Name)
            .IsUnique();

        modelBuilder.Entity<AnalysisResultModel>()
            .HasIndex(a => a.PhotoId)
            .IsUnique(); // 一张照片一个分析结果
    }
}

2.3 创建数据库服务接口

cs 复制代码
csharp

// IDatabaseService.cs
namespace SmartPhotoAlbum.Services;

public interface IDatabaseService
{
    Task InitializeAsync();
    Task<PhotoModel> AddPhotoAsync(string filePath, Stream imageStream);
    Task SaveAnalysisResultAsync(int photoId, ImageAnalysisResult result, string provider);
    Task<List<PhotoModel>> GetUnanalyzedPhotosAsync(int limit = 100);
    Task<List<PhotoModel>> SearchByTagAsync(string tagName);
    Task<List<TagModel>> GetAllTagsAsync();
    Task<PhotoModel> GetPhotoAsync(int id);
    Task<List<PhotoModel>> GetRecentPhotosAsync(int count = 50);
}

2.4 实现数据库服务

创建DatabaseService.cs

cs 复制代码
csharp

using Microsoft.EntityFrameworkCore;
using SmartPhotoAlbum.Models;
using System.IO;

namespace SmartPhotoAlbum.Services;

public class DatabaseService : IDatabaseService
{
    private readonly IDbContextFactory<AppDbContext> _dbContextFactory;
    private readonly IImagePickerService _imagePickerService;

    public DatabaseService(
        IDbContextFactory<AppDbContext> dbContextFactory,
        IImagePickerService imagePickerService)
    {
        _dbContextFactory = dbContextFactory;
        _imagePickerService = imagePickerService;
    }

    public async Task InitializeAsync()
    {
        using var context = await _dbContextFactory.CreateDbContextAsync();
        await context.Database.EnsureCreatedAsync();
    }

    public async Task<PhotoModel> AddPhotoAsync(string filePath, Stream imageStream)
    {
        using var context = await _dbContextFactory.CreateDbContextAsync();
        
        // 检查是否已存在
        var existing = await context.Photos.FirstOrDefaultAsync(p => p.FilePath == filePath);
        if (existing != null)
            return existing;

        // 读取图片信息
        using var memoryStream = new MemoryStream();
        await imageStream.CopyToAsync(memoryStream);
        var imageData = memoryStream.ToArray();

        // 生成缩略图
        var thumbnail = await _imagePickerService.ResizeImageAsync(imageData, 200, 200);
        var thumbnailPath = Path.Combine(FileSystem.CacheDirectory, $"thumb_{Guid.NewGuid()}.jpg");
        await File.WriteAllBytesAsync(thumbnailPath, thumbnail);

        var photo = new PhotoModel
        {
            FilePath = filePath,
            FileName = Path.GetFileName(filePath),
            DateAdded = DateTime.UtcNow,
            FileSize = imageData.Length,
            ThumbnailPath = thumbnailPath,
            IsAnalyzed = false
        };

        // 尝试读取EXIF中的拍摄时间
        try
        {
            // 简化处理,实际可用ExifLib
        }
        catch { }

        context.Photos.Add(photo);
        await context.SaveChangesAsync();
        
        return photo;
    }

    public async Task SaveAnalysisResultAsync(int photoId, ImageAnalysisResult result, string provider)
    {
        using var context = await _dbContextFactory.CreateDbContextAsync();
        
        // 删除旧结果(如果有)
        var existing = await context.AnalysisResults.FirstOrDefaultAsync(a => a.PhotoId == photoId);
        if (existing != null)
        {
            context.AnalysisResults.Remove(existing);
        }

        var analysis = new AnalysisResultModel
        {
            PhotoId = photoId,
            RawResponse = result.RawResponse,
            Description = result.Description,
            AnalyzedAt = DateTime.UtcNow,
            Provider = provider,
            PromptTokens = result.PromptTokens,
            CompletionTokens = result.CompletionTokens,
            ProcessingTimeMs = result.ProcessingTimeMs,
            ModelVersion = provider == "cloud" ? "GPT-4V" : "LLaVA:7b"
        };

        context.AnalysisResults.Add(analysis);

        // 更新照片状态
        var photo = await context.Photos.FindAsync(photoId);
        if (photo != null)
        {
            photo.IsAnalyzed = true;
            photo.LastAnalyzed = DateTime.UtcNow;
        }

        // 处理标签
        foreach (var tagName in result.Tags)
        {
            if (string.IsNullOrWhiteSpace(tagName)) continue;

            // 查找或创建标签
            var tag = await context.Tags.FirstOrDefaultAsync(t => t.Name == tagName);
            if (tag == null)
            {
                tag = new TagModel
                {
                    Name = tagName,
                    Count = 1,
                    FirstSeen = DateTime.UtcNow,
                    LastSeen = DateTime.UtcNow
                };
                context.Tags.Add(tag);
                await context.SaveChangesAsync(); // 先保存以生成ID
            }
            else
            {
                tag.Count++;
                tag.LastSeen = DateTime.UtcNow;
            }

            // 创建关联
            var photoTag = new PhotoTag
            {
                PhotoId = photoId,
                TagId = tag.Id,
                Confidence = 0.8 // 简化处理
            };
            context.PhotoTags.Add(photoTag);
        }

        await context.SaveChangesAsync();
    }

    public async Task<List<PhotoModel>> GetUnanalyzedPhotosAsync(int limit = 100)
    {
        using var context = await _dbContextFactory.CreateDbContextAsync();
        return await context.Photos
            .Where(p => !p.IsAnalyzed)
            .OrderBy(p => p.DateAdded)
            .Take(limit)
            .ToListAsync();
    }

    public async Task<List<PhotoModel>> SearchByTagAsync(string tagName)
    {
        using var context = await _dbContextFactory.CreateDbContextAsync();
        
        return await context.Photos
            .Where(p => p.PhotoTags.Any(pt => pt.Tag.Name.Contains(tagName)))
            .Include(p => p.PhotoTags)
            .ThenInclude(pt => pt.Tag)
            .OrderByDescending(p => p.LastAnalyzed)
            .Take(100)
            .ToListAsync();
    }

    public async Task<List<TagModel>> GetAllTagsAsync()
    {
        using var context = await _dbContextFactory.CreateDbContextAsync();
        return await context.Tags
            .OrderByDescending(t => t.Count)
            .ToListAsync();
    }

    public async Task<PhotoModel> GetPhotoAsync(int id)
    {
        using var context = await _dbContextFactory.CreateDbContextAsync();
        return await context.Photos
            .Include(p => p.PhotoTags)
            .ThenInclude(pt => pt.Tag)
            .Include(p => p.AnalysisResult)
            .FirstOrDefaultAsync(p => p.Id == id);
    }

    public async Task<List<PhotoModel>> GetRecentPhotosAsync(int count = 50)
    {
        using var context = await _dbContextFactory.CreateDbContextAsync();
        return await context.Photos
            .OrderByDescending(p => p.DateAdded)
            .Take(count)
            .Include(p => p.PhotoTags)
            .ThenInclude(pt => pt.Tag)
            .ToListAsync();
    }
}

2.5 配置数据库服务

MauiProgram.cs中添加:

cs 复制代码
csharp

using Microsoft.EntityFrameworkCore;

// 添加数据库
builder.Services.AddDbContextFactory<AppDbContext>(options =>
{
    var dbPath = Path.Combine(FileSystem.AppDataDirectory, "smartphoto.db");
    options.UseSqlite($"Data Source={dbPath}");
});

builder.Services.AddSingleton<IDatabaseService, DatabaseService>();

三、批量扫描服务

3.1 创建批量扫描服务接口

cs 复制代码
csharp

// IBatchScanService.cs
namespace SmartPhotoAlbum.Services;

public interface IBatchScanService
{
    Task<int> ScanGalleryAsync(IProgress<ScanProgress> progress = null);
    Task<int> ProcessPendingAnalysesAsync(IProgress<AnalysisProgress> progress = null);
    Task CancelAsync();
}

public class ScanProgress
{
    public int TotalFiles { get; set; }
    public int ProcessedFiles { get; set; }
    public int NewPhotos { get; set; }
    public string CurrentFile { get; set; }
}

public class AnalysisProgress
{
    public int TotalPending { get; set; }
    public int Processed { get; set; }
    public int Successful { get; set; }
    public int Failed { get; set; }
    public string CurrentPhoto { get; set; }
}

3.2 实现批量扫描服务

创建BatchScanService.cs

cs 复制代码
csharp

using System.Threading;

namespace SmartPhotoAlbum.Services;

public class BatchScanService : IBatchScanService
{
    private readonly IDatabaseService _databaseService;
    private readonly IAIService _aiService;
    private readonly IPermissionService _permissionService;
    private CancellationTokenSource _cancellationTokenSource;

    public BatchScanService(
        IDatabaseService databaseService,
        IAIService aiService,
        IPermissionService permissionService)
    {
        _databaseService = databaseService;
        _aiService = aiService;
        _permissionService = permissionService;
    }

    public async Task<int> ScanGalleryAsync(IProgress<ScanProgress> progress = null)
    {
        _cancellationTokenSource = new CancellationTokenSource();
        
        // 检查权限
        var hasPermission = await _permissionService.EnsureStoragePermissionAsync();
        if (!hasPermission)
        {
            throw new Exception("需要相册访问权限");
        }

        var newPhotos = 0;

        // 获取所有图片文件
        var picturesFolder = Environment.GetFolderPath(Environment.SpecialFolder.MyPictures);
        var imageFiles = Directory.GetFiles(picturesFolder, "*.*", SearchOption.AllDirectories)
            .Where(f => f.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) ||
                       f.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) ||
                       f.EndsWith(".png", StringComparison.OrdinalIgnoreCase))
            .ToList();

        progress?.Report(new ScanProgress
        {
            TotalFiles = imageFiles.Count,
            ProcessedFiles = 0,
            NewPhotos = 0
        });

        var processed = 0;
        foreach (var file in imageFiles)
        {
            if (_cancellationTokenSource.Token.IsCancellationRequested)
                break;

            try
            {
                progress?.Report(new ScanProgress
                {
                    TotalFiles = imageFiles.Count,
                    ProcessedFiles = processed,
                    NewPhotos = newPhotos,
                    CurrentFile = Path.GetFileName(file)
                });

                using var stream = File.OpenRead(file);
                var photo = await _databaseService.AddPhotoAsync(file, stream);
                if (photo != null && photo.Id > 0)
                {
                    newPhotos++;
                }

                processed++;
            }
            catch (Exception ex)
            {
                System.Diagnostics.Debug.WriteLine($"扫描文件失败 {file}: {ex.Message}");
            }
        }

        progress?.Report(new ScanProgress
        {
            TotalFiles = imageFiles.Count,
            ProcessedFiles = processed,
            NewPhotos = newPhotos
        });

        return newPhotos;
    }

    public async Task<int> ProcessPendingAnalysesAsync(IProgress<AnalysisProgress> progress = null)
    {
        _cancellationTokenSource = new CancellationTokenSource();

        var pending = await _databaseService.GetUnanalyzedPhotosAsync(100);
        if (!pending.Any())
            return 0;

        var analysisProgress = new AnalysisProgress
        {
            TotalPending = pending.Count,
            Processed = 0,
            Successful = 0,
            Failed = 0
        };

        progress?.Report(analysisProgress);

        foreach (var photo in pending)
        {
            if (_cancellationTokenSource.Token.IsCancellationRequested)
                break;

            try
            {
                analysisProgress.CurrentPhoto = photo.FileName;
                progress?.Report(analysisProgress);

                // 读取图片
                var imageData = await File.ReadAllBytesAsync(photo.FilePath);

                // 调用AI识别
                var result = await _aiService.AnalyzeImageAsync(imageData);

                // 保存结果
                await _databaseService.SaveAnalysisResultAsync(photo.Id, result, _aiService.GetCurrentProvider());

                analysisProgress.Successful++;
            }
            catch (Exception ex)
            {
                System.Diagnostics.Debug.WriteLine($"分析失败 {photo.FileName}: {ex.Message}");
                analysisProgress.Failed++;
            }
            finally
            {
                analysisProgress.Processed++;
                progress?.Report(analysisProgress);
            }
        }

        return analysisProgress.Successful;
    }

    public Task CancelAsync()
    {
        _cancellationTokenSource?.Cancel();
        return Task.CompletedTask;
    }
}

四、标签浏览与搜索页面

4.1 创建标签浏览页面

Views文件夹下创建TagBrowserPage.xaml

XML 复制代码
xml

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="SmartPhotoAlbum.Views.TagBrowserPage"
             Title="标签浏览">
    
    <Grid RowDefinitions="Auto,*,Auto">
        
        <!-- 搜索栏 -->
        <SearchBar x:Name="TagSearchBar"
                   Grid.Row="0"
                   Placeholder="搜索标签..."
                   SearchCommand="{Binding SearchCommand}"
                   SearchCommandParameter="{Binding Text, Source={x:Reference TagSearchBar}}"
                   Margin="10"/>
        
        <!-- 标签列表 -->
        <CollectionView x:Name="TagsCollection"
                       Grid.Row="1"
                       ItemsSource="{Binding Tags}"
                       SelectionMode="Single"
                       SelectionChanged="OnTagSelected">
            <CollectionView.ItemsLayout>
                <GridItemsLayout Orientation="Vertical"
                                Span="2"
                                HorizontalItemSpacing="10"
                                VerticalItemSpacing="10"/>
            </CollectionView.ItemsLayout>
            <CollectionView.ItemTemplate>
                <DataTemplate>
                    <Frame CornerRadius="10"
                           Padding="15,10"
                           BackgroundColor="#F0F0F0"
                           HasShadow="False">
                        <VerticalStackLayout>
                            <Label Text="{Binding Name}"
                                   FontSize="18"
                                   FontAttributes="Bold"/>
                            <Label Text="{Binding Count, StringFormat='{0} 张照片'}"
                                   FontSize="14"
                                   TextColor="Gray"/>
                            <Label Text="{Binding LastSeen, StringFormat='最近使用: {0:yyyy-MM-dd}'}"
                                   FontSize="12"
                                   TextColor="LightGray"/>
                        </VerticalStackLayout>
                    </Frame>
                </DataTemplate>
            </CollectionView.ItemTemplate>
        </CollectionView>
        
        <!-- 批量操作栏 -->
        <HorizontalStackLayout Grid.Row="2"
                               Spacing="10"
                               Padding="10"
                               BackgroundColor="{OnPlatform iOS=#F2F2F7, Android=#F5F5F5}">
            
            <Button Text="扫描相册"
                    Clicked="OnScanGalleryClicked"
                    HorizontalOptions="FillAndExpand"/>
            
            <Button Text="批量分析"
                    Clicked="OnBatchAnalyzeClicked"
                    HorizontalOptions="FillAndExpand"
                    BackgroundColor="#007AFF"
                    TextColor="White"/>
        </HorizontalStackLayout>
        
        <!-- 加载指示器 -->
        <Grid Grid.RowSpan="3"
              BackgroundColor="#80000000"
              IsVisible="{Binding IsBusy}">
            <VerticalStackLayout HorizontalOptions="Center"
                                VerticalOptions="Center"
                                Spacing="20">
                <ActivityIndicator IsRunning="True"
                                  Color="White"
                                  HeightRequest="50"
                                  WidthRequest="50"/>
                <Label Text="{Binding BusyMessage}"
                       TextColor="White"
                       FontSize="16"/>
            </VerticalStackLayout>
        </Grid>
        
    </Grid>
</ContentPage>

4.2 实现标签浏览页面逻辑

cs 复制代码
csharp

using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using SmartPhotoAlbum.Models;
using SmartPhotoAlbum.Services;

namespace SmartPhotoAlbum.Views;

public partial class TagBrowserPage : ContentPage, INotifyPropertyChanged
{
    private readonly IDatabaseService _databaseService;
    private readonly IBatchScanService _batchScanService;
    
    public ObservableCollection<TagModel> Tags { get; set; } = new();
    
    private bool _isBusy;
    public bool IsBusy
    {
        get => _isBusy;
        set { _isBusy = value; OnPropertyChanged(); }
    }
    
    private string _busyMessage;
    public string BusyMessage
    {
        get => _busyMessage;
        set { _busyMessage = value; OnPropertyChanged(); }
    }

    public TagBrowserPage(IDatabaseService databaseService, IBatchScanService batchScanService)
    {
        InitializeComponent();
        _databaseService = databaseService;
        _batchScanService = batchScanService;
        
        BindingContext = this;
        
        LoadTags();
    }

    protected override void OnAppearing()
    {
        base.OnAppearing();
        LoadTags();
    }

    private async void LoadTags()
    {
        try
        {
            var tags = await _databaseService.GetAllTagsAsync();
            Tags.Clear();
            foreach (var tag in tags)
            {
                Tags.Add(tag);
            }
        }
        catch (Exception ex)
        {
            await DisplayAlert("错误", $"加载标签失败: {ex.Message}", "确定");
        }
    }

    private async void OnTagSelected(object sender, SelectionChangedEventArgs e)
    {
        if (e.CurrentSelection.FirstOrDefault() is TagModel selectedTag)
        {
            // 导航到标签搜索结果页
            await Navigation.PushAsync(new TagSearchResultPage(_databaseService, selectedTag.Name));
            
            // 清除选中状态
            ((CollectionView)sender).SelectedItem = null;
        }
    }

    private async void OnScanGalleryClicked(object sender, EventArgs e)
    {
        try
        {
            IsBusy = true;
            BusyMessage = "正在扫描相册...";

            var progress = new Progress<ScanProgress>(p =>
            {
                BusyMessage = $"扫描中... {p.ProcessedFiles}/{p.TotalFiles} 文件, 发现 {p.NewPhotos} 新照片";
            });

            var newPhotos = await _batchScanService.ScanGalleryAsync(progress);
            
            await DisplayAlert("完成", $"扫描完成!发现 {newPhotos} 张新照片", "确定");
            
            // 提示是否开始分析
            if (newPhotos > 0)
            {
                var analyze = await DisplayAlert("提示", $"是否开始分析这 {newPhotos} 张新照片?", "开始分析", "稍后");
                if (analyze)
                {
                    await StartBatchAnalysis();
                }
            }
        }
        catch (Exception ex)
        {
            await DisplayAlert("错误", ex.Message, "确定");
        }
        finally
        {
            IsBusy = false;
        }
    }

    private async void OnBatchAnalyzeClicked(object sender, EventArgs e)
    {
        await StartBatchAnalysis();
    }

    private async Task StartBatchAnalysis()
    {
        try
        {
            IsBusy = true;
            BusyMessage = "准备批量分析...";

            var progress = new Progress<AnalysisProgress>(p =>
            {
                BusyMessage = $"分析中... {p.Processed}/{p.TotalPending} 完成, 成功: {p.Successful}, 失败: {p.Failed}";
            });

            var successCount = await _batchScanService.ProcessPendingAnalysesAsync(progress);
            
            await DisplayAlert("完成", $"批量分析完成!成功: {successCount} 张", "确定");
            
            // 刷新标签
            LoadTags();
        }
        catch (Exception ex)
        {
            await DisplayAlert("错误", ex.Message, "确定");
        }
        finally
        {
            IsBusy = false;
        }
    }

    public new event PropertyChangedEventHandler PropertyChanged;
    protected void OnPropertyChanged([CallerMemberName] string name = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
    }
}

4.3 创建标签搜索结果页

创建TagSearchResultPage.xaml

XML 复制代码
xml

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="SmartPhotoAlbum.Views.TagSearchResultPage"
             Title="{Binding TagName}">
    
    <CollectionView x:Name="PhotosCollection"
                   ItemsSource="{Binding Photos}"
                   SelectionMode="Single"
                   SelectionChanged="OnPhotoSelected">
        <CollectionView.ItemsLayout>
            <GridItemsLayout Orientation="Vertical"
                            Span="3"
                            HorizontalItemSpacing="5"
                            VerticalItemSpacing="5"/>
        </CollectionView.ItemsLayout>
        <CollectionView.ItemTemplate>
            <DataTemplate>
                <Grid>
                    <Image Source="{Binding ThumbnailPath}"
                           Aspect="AspectFill"
                           HeightRequest="120"/>
                    <Label Text="{Binding FileName}"
                           BackgroundColor="#80000000"
                           TextColor="White"
                           FontSize="10"
                           Padding="5"
                           VerticalOptions="End"/>
                </Grid>
            </DataTemplate>
        </CollectionView.ItemTemplate>
    </CollectionView>
</ContentPage>
XML 复制代码
csharp

public partial class TagSearchResultPage : ContentPage
{
    private readonly IDatabaseService _databaseService;
    public string TagName { get; set; }
    public ObservableCollection<PhotoModel> Photos { get; set; } = new();

    public TagSearchResultPage(IDatabaseService databaseService, string tagName)
    {
        InitializeComponent();
        _databaseService = databaseService;
        TagName = tagName;
        BindingContext = this;
        
        LoadPhotos();
    }

    private async void LoadPhotos()
    {
        var photos = await _databaseService.SearchByTagAsync(TagName);
        foreach (var photo in photos)
        {
            Photos.Add(photo);
        }
    }

    private async void OnPhotoSelected(object sender, SelectionChangedEventArgs e)
    {
        if (e.CurrentSelection.FirstOrDefault() is PhotoModel selected)
        {
            await Navigation.PushAsync(new PhotoDetailPage(_databaseService, selected.Id));
            ((CollectionView)sender).SelectedItem = null;
        }
    }
}

五、照片详情页

创建PhotoDetailPage.xaml展示单张照片的识别结果:

XML 复制代码
xml

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="SmartPhotoAlbum.Views.PhotoDetailPage"
             Title="照片详情">
    
    <ScrollView>
        <VerticalStackLayout Spacing="15" Padding="15">
            
            <Image Source="{Binding Photo.FilePath}"
                   Aspect="AspectFit"
                   HeightRequest="300"/>
            
            <Label Text="识别结果"
                   FontSize="18"
                   FontAttributes="Bold"/>
            
            <Frame CornerRadius="10"
                   Padding="15"
                   BackgroundColor="#F5F5F5">
                <VerticalStackLayout Spacing="10">
                    
                    <Label Text="{Binding Result.Description}"
                           FontSize="15"/>
                    
                    <FlexLayout Wrap="Wrap"
                               AlignContent="Start"
                               Margin="0,10,0,0">
                        <BindableLayout.ItemsSource>
                            <Binding Path="Photo.PhotoTags"/>
                        </BindableLayout.ItemsSource>
                        <BindableLayout.ItemTemplate>
                            <DataTemplate>
                                <Frame BackgroundColor="#E1F5FE"
                                       CornerRadius="15"
                                       Padding="10,5"
                                       Margin="0,0,5,5">
                                    <Label Text="{Binding Tag.Name}"
                                           TextColor="#0288D1"
                                           FontSize="13"/>
                                </Frame>
                            </DataTemplate>
                        </BindableLayout.ItemTemplate>
                    </FlexLayout>
                    
                </VerticalStackLayout>
            </Frame>
            
            <Label Text="详细信息"
                   FontSize="16"
                   FontAttributes="Bold"
                   Margin="0,10,0,0"/>
            
            <Grid ColumnDefinitions="Auto,*"
                  RowDefinitions="Auto,Auto,Auto,Auto,Auto"
                  RowSpacing="5"
                  ColumnSpacing="10">
                
                <Label Grid.Row="0" Grid.Column="0" Text="文件名:" TextColor="Gray"/>
                <Label Grid.Row="0" Grid.Column="1" Text="{Binding Photo.FileName}"/>
                
                <Label Grid.Row="1" Grid.Column="0" Text="拍摄时间:" TextColor="Gray"/>
                <Label Grid.Row="1" Grid.Column="1" Text="{Binding Photo.DateTaken, StringFormat='{0:yyyy-MM-dd HH:mm}'}"/>
                
                <Label Grid.Row="2" Grid.Column="0" Text="分析时间:" TextColor="Gray"/>
                <Label Grid.Row="2" Grid.Column="1" Text="{Binding Result.AnalyzedAt, StringFormat='{0:yyyy-MM-dd HH:mm}'}"/>
                
                <Label Grid.Row="3" Grid.Column="0" Text="AI服务:" TextColor="Gray"/>
                <Label Grid.Row="3" Grid.Column="1" Text="{Binding Result.Provider}"/>
                
                <Label Grid.Row="4" Grid.Column="0" Text="Token消耗:" TextColor="Gray"/>
                <Label Grid.Row="4" Grid.Column="1" Text="{Binding Result.TotalTokens, StringFormat='{0} (提示: {1}, 完成: {2})', PromptTokens={Binding Result.PromptTokens}, CompletionTokens={Binding Result.CompletionTokens}}"/>
                
            </Grid>
            
        </VerticalStackLayout>
    </ScrollView>
</ContentPage>

六、性能优化建议

6.1 缩略图缓存

AddPhotoAsync中我们已经生成了缩略图,大幅提升列表滚动性能。

6.2 批量处理限流

为避免耗尽API配额或本地资源,添加限流控制:

cs 复制代码
csharp

public async Task ProcessPendingAnalysesAsync(int concurrentLimit = 3)
{
    var semaphore = new SemaphoreSlim(concurrentLimit);
    var tasks = pending.Select(async photo =>
    {
        await semaphore.WaitAsync();
        try
        {
            await ProcessPhotoAsync(photo);
        }
        finally
        {
            semaphore.Release();
        }
    });
    await Task.WhenAll(tasks);
}

6.3 索引优化

我们在AppDbContext中已经添加了必要的索引,确保搜索性能。

七、小结与下期预告

至此,智能相册的核心功能已经全部实现:

功能 实现方式 状态
本地缓存 SQLite + Entity Framework
批量扫描 遍历相册文件夹
批量分析 队列处理 + 进度报告
标签搜索 多对多关系查询
照片详情 展示识别结果

下一篇文章,我们将专注于用户体验优化------异步加载、等待动画、错误处理、离线提示,让应用更加专业和易用。


本文代码基于 .NET 10 + MAUI 8.0 + SQLite 验证。 数据库文件默认保存在FileSystem.AppDataDirectory,调试时可查看具体位置。

相关推荐
逐梦苍穹2 小时前
你的龙虾为什么这么“蠢”,动不动就压缩上下文
人工智能·openclaw
新缸中之脑2 小时前
8 个最受欢迎的 Revit AI插件
人工智能
Memory_荒年2 小时前
AQS:Java并发包里的“包租公”,管理着你的锁和通行证!
java·后端
掘金者阿豪2 小时前
Joplin笔记告别局域网高效办公就靠cpolar
前端·后端
卡梅德生物科技2 小时前
卡梅德生物:ANGPT2(Angiopoietin-2)靶点机制解析与药物研发新趋势
人工智能·面试·学习方法·aav腺病毒·适配体
肯戳加勾2 小时前
JAVA最常见的装箱/拆箱坑
java·后端
恋猫de小郭2 小时前
Cursor 自己做了模型 PK ,Cursor 里哪个模型性价比最高?
前端·人工智能·ai编程
Memory_荒年2 小时前
ReentrantLock:AQS家的“锁二代”,但比 synchronized 更会“来事儿”
java·后端
北京软秦科技有限公司2 小时前
IACheck AI报告文档审核:驱动高端制造合规管理报告审核升级的新引擎
大数据·人工智能·制造