从"能识别"到"好用":打造生产级的智能相册
引言:当AI遇到现实场景
在前四篇文章中,我们完成了基础设施搭建、云端API接入、本地模型部署,App已经能"看懂"图片了。但一个真正好用的智能相册,需要解决三个现实问题:
-
批量处理:用户相册里有成百上千张照片,一张张点太慢
-
结果持久化:识别一次后,下次打开不用重新识别
-
快速检索:根据物体标签找到相关照片
根据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,调试时可查看具体位置。