
照片攒到一定数量之后靠文件夹名和肉眼找图是真的会疯。前阵子给家里两台相机和几部手机里的照片做统一归档我想按“某年某月拍的”“这台相机拍的”“在哪个坐标附近拍的”来筛选可文件管理器只认文件名和修改日期拍摄参数一概不管。这才意识到照片里的EXIF信息如果不结构化落库后续检索就是空谈。于是就有了这个任务五的小项目用 C# 读取图片 EXIF 信息写入本地 SQLite 数据库。标题里的关键词很明确——C#、SQLite、exif这其实是把“图片元数据处理”和“轻量级关系型存储”串成了一条完整的落地方案。这个任务看起来简单但真正做完之后我对文件遍历、数据清洗、事务批量写入、SQLite 连接管理这些基础能力都有了更深的理解也踩了几个网上教程很少提到的坑。这篇文章就把我的完整做法、选型理由、代码细节和实测翻车记录都写出来给正在做类似归档工具、素材管理系统或者单纯想学 C# 操作 SQLite 的朋友做个参考。1. 为什么要做这个任务照片管理背后的真实需求先说说需求是怎么来的。我的照片散落在十几个文件夹里有的按日期起名有的按拍摄地点起名还有一批是从微信聊天记录里抢救出来的文件名全是“mmexport123456”。靠人工整理根本不可能光是复制到同一个目录就要花一晚上。我真正想要的是一张“照片清单”——每张照片是什么时候拍的、用什么设备拍的、分辨率多少、GPS 坐标在哪、文件多大、存在哪个原始路径下。有了这张清单我才能按条件查询甚至按地图去找照片。那选什么数据库我第一时间排除了 MySQL、SQL Server 这类需要安装服务的数据库。个人归档工具的数据量就是几千到几万张照片撑死不过十万行专门架一个数据库服务完全是浪费。Excel 也不行字段一多、数据一多筛选就卡而且没法被程序稳定读写。SQLite 正好卡在中间单文件、零配置、支持完整 SQL、C# 有成熟驱动一个几 MB 的 db 文件搞定所有数据。这个“轻量但完整”的定位让 SQLite 成了本地工具、桌面软件、上位机程序里最实用的持久化选型。再说为什么要学这个任务。很多人学 SQLite 只停留在“打开连接、插一条数据”的 Demo 阶段真到实战会发现完全不是那么回事批量写几千条要开事务、文件路径可能重复、中文路径会踩编码坑、节点的 EXIF 数据格式乱七八糟。把图片 EXIF 入库这个任务恰好把所有实战中会遇到的点全部覆盖了。你处理的不只是库的增删改查还有一套“读取外部数据 → 清洗 → 结构化 → 批量写库 → 查询验证”的完整链路。这个任务适合谁来对照练一个是像我一样在做个人素材归档工具的人另一个是 C# 入门到进阶、想找点综合性小项目练手的学习者还有做上位机、本地管理系统的人——上位机场景里经常要采集设备数据并落库操作思路和这里完全一致。2. 环境准备驱动选型与EXIF解析库的对比技术选型看着随意用起来差很多。我在环境准备阶段纠结过两对方案最后确定下来的是Microsoft.Data.SqliteMetadataExtractor下面说清楚对比理由。2.1 SQLite 驱动Microsoft.Data.Sqlite 胜出的三个原因.NET 下能操作 SQLite 的库主要有三个System.Data.SQLite、Microsoft.Data.Sqlite、SQLitePCLRaw裸封装。System.Data.SQLite出现得早功能也全但它内置了一个原生SQLite.Interop.dll发布时要跟着 x86/x64 一起打包版本匹配不对就是经典的BadImageFormatException。SQLitePCLRaw是底层绑定直接用要自己管理电池Battery初始化学习成本偏高。Microsoft.Data.Sqlite是微软官方在 EF Core 体系中使用的驱动封装好了SQLitePCLRaw跨平台、无需手工处理原生 DLL 分发跟随项目自动恢复。我个人用下来它在 API 设计和与System.Data.SqlClient的相似度上也更友好写惯了 ADO.NET 的人上手零成本。安装很简单dotnet add package Microsoft.Data.Sqlite如果你用的是 Visual Studio直接“管理 NuGet 程序包”搜Microsoft.Data.Sqlite装最新稳定版就行。2.2 EXIF 解析库MetadataExtractor 让我少写了三百行解析代码EXIF 是照片内嵌的一种元数据标准里面以 TIFF 格式存了一堆标签。如果不用库、自己解析二进制理论上可以但 EXIF 标签类型有十几种包括无符号整型、有理数、ASCII 字符串等还要处理大小端、IFD 偏移、MakerNote 厂商私有区纯手写是给自己挖坑。C# 这边常用的有System.Drawing.Common自带的Image.PropertyItems、ExifLib、MetadataExtractor。System.Drawing.Common虽然能读到部分属性但它的PropertyItem拿到的是原始字节数组你要自己按属性 ID 查表、解析格式而且新版 .NET 里System.Drawing.Common只在 Windows 上默认支持跨平台兼容性很别扭。ExifLib太老维护少。最后锁定MetadataExtractor它本来就是 Java 版著名库metadata-extractor的 C# 移植版支持 JPEG、TIFF、PNG、WebP 等格式不光能解析 EXIF还能解 IPTC、XMP、视频元数据。最关键的是它已经把 GPS、日期、厂商 MakerNote 都整理成了友好的目录和标签描述拿来即用。dotnet add package MetadataExtractor到这里读写两条线就都备齐了。3. 数据表设计与EXIF字段映射动手写代码之前我把表结构和字段先想清楚。数据库表设计如果做完再改后面数据入了库改起来全是泪所以这一步我建议认真做。3.1 表结构设计主键、唯一索引与核心字段表名叫Photos我设计成这样CREATE TABLE IF NOT EXISTS Photos ( Id INTEGER PRIMARY KEY AUTOINCREMENT, FilePath TEXT NOT NULL UNIQUE, FileName TEXT NOT NULL, Extension TEXT, FileSize INTEGER, Width INTEGER, Height INTEGER, CameraMake TEXT, CameraModel TEXT, DateTimeOriginal TEXT, GpsLatitude REAL, GpsLongitude REAL, CreateTime TEXT DEFAULT (datetime(now, localtime)) );几个设计点专门说一下。FilePath我加了UNIQUE约束。因为扫描同一批目录时可能因为重复运行或者其他程序重复触发导致同一路径入库两次。加了唯一约束后配合 UPSERT 语句就能保证幂等。Id用INTEGER PRIMARY KEY AUTOINCREMENT是 SQLite 的标准主键写法自增可靠、占空间小。DateTimeOriginal我存成TEXT而不是DATETIME类型。SQLite 本身没有原生的日期时间类型它的datetime是函数不是存储类型。常见做法有两种存 ISO 格式字符串或者存 Unix 整数时间戳。我选择字符串因为后续用datetime()函数比较时间范围很顺手而且直接用 SQL 语句导 CSV、Excel 时人类可读。GpsLatitude和GpsLongitude用REAL存十进制度数。后面按坐标范围查“某个点附近一公里”SQL 里直接用ABS(Latitude - lat)做粗筛就行。3.2 EXIF 里真正值得存的字段EXIF 里有几百个标签但日常检索用得上的核心字段就这些字段说明读取来源拍摄时间最常用的检索维度ExifSubIFDDirectory的TagDateTimeOriginal相机厂商 / 型号统计哪台设备拍得多ExifIFD0Directory的TagMake/TagModel分辨率筛原图和缩略图JpegDirectory的TagImageWidth/TagImageHeightGPS 信息按拍摄地检索GpsDirectory镜头参数摄影爱好者可能关心ExifSubIFDDirectory的焦距、光圈、快门我的设计原则是先落库最常用、支撑核心功能的字段其余标签需要时再去读原图。不要试图把 EXIF 全量拆成几十个字段表会臃肿写入也会变慢。3.3 一条合理的建库代码初始化库的代码我写到Program.cs里放在启动时执行using var connection new SqliteConnection(Data Sourcephotos.db); connection.Open(); var command connection.CreateCommand(); command.CommandText CREATE TABLE IF NOT EXISTS Photos ( Id INTEGER PRIMARY KEY AUTOINCREMENT, FilePath TEXT NOT NULL UNIQUE, FileName TEXT NOT NULL, Extension TEXT, FileSize INTEGER, Width INTEGER, Height INTEGER, CameraMake TEXT, CameraModel TEXT, DateTimeOriginal TEXT, GpsLatitude REAL, GpsLongitude REAL, CreateTime TEXT DEFAULT (datetime(now, localtime)) ); CREATE INDEX IF NOT EXISTS idx_photos_date ON Photos(DateTimeOriginal); CREATE INDEX IF NOT EXISTS idx_photos_model ON Photos(CameraModel); ; command.ExecuteNonQuery();这里我顺手建了DateTimeOriginal和CameraModel两个索引。索引是第一优先级优化建完之后按时间筛选和按设备筛选性能直接从全表扫描变成索引查找。4. 核心代码从单张图片读取到批量入库代码分三步读目录、读 EXIF、批量写库。下面我把每一步的写法、输出形态、以及为什么这样写都拆开讲。4.1 单张图片的 EXIF 读取方法MetadataExtractor的用法很统一调用ImageMetadataReader.ReadMetadata(path)拿到一个IReadOnlyListDirectory每个Directory代表一种元数据目录再从中取标签。public static PhotoInfo ReadExif(string filePath) { var info new PhotoInfo { FilePath filePath }; var fileInfo new FileInfo(filePath); info.FileName fileInfo.Name; info.Extension fileInfo.Extension.ToLowerInvariant(); info.FileSize (int)fileInfo.Length; var directories ImageMetadataReader.ReadMetadata(filePath); var ifd0 directories.OfTypeExifIFD0Directory().FirstOrDefault(); if (ifd0 ! null) { info.CameraMake ifd0.GetDescription(ExifDirectoryBase.TagMake); info.CameraModel ifd0.GetDescription(ExifDirectoryBase.TagModel); } var subIfd directories.OfTypeExifSubIFDDirectory().FirstOrDefault(); if (subIfd ! null) { var date subIfd.GetDescription(ExifDirectoryBase.TagDateTimeOriginal); info.DateTimeOriginal NormalizeDate(date); } var jpegDir directories.OfTypeJpegDirectory().FirstOrDefault(); if (jpegDir ! null) { info.Width jpegDir.GetInt32(JpegDirectory.TagImageWidth); info.Height jpegDir.GetInt32(JpegDirectory.TagImageHeight); } var gpsDir directories.OfTypeGpsDirectory().FirstOrDefault(); if (gpsDir ! null) { var location gpsDir.GetGeoLocation(); if (location ! null) { info.GpsLatitude location.Latitude; info.GpsLongitude location.Longitude; } } return info; }这里几个值得注意的地方。GetDescription和GetInt32的区别是GetDescription返回的是已经格式化的字符串比如相机厂商可能是空、也可能是乱码但作为描述可以直接入库GetInt32返回数值。某些标签用GetDescription拿不到编号根据调试时的实际目录内容再调整即可整体思路不变。GetGeoLocation()是MetadataExtractor封装好的便捷方法它内部把 GPS 度分秒的有理数转成了十进制度数。通常拿到手就是类似23.1291这样的小数后面可以直接参与范围计算。4.2 批量扫描文件目录单张读取没问题之后就到目录遍历环节。我一开始用Directory.GetFiles拿到所有路径再循环。文件少的场景没问题但几千上万张照片时内存会一次性占用过大。改成Directory.EnumerateFiles后就舒服了它是惰性枚举边遍历边处理内存占用恒定。var files Directory.EnumerateFiles(rootPath, *.*, SearchOption.AllDirectories) .Where(f f.EndsWith(.jpg, StringComparison.OrdinalIgnoreCase) || f.EndsWith(.jpeg, StringComparison.OrdinalIgnoreCase) || f.EndsWith(.png, StringComparison.OrdinalIgnoreCase) || f.EndsWith(.webp, StringComparison.OrdinalIgnoreCase) || f.EndsWith(.tiff, StringComparison.OrdinalIgnoreCase));SearchOption.AllDirectories会递归所有子目录。加了EndsWith过滤是因为只处理图片格式避免扫描到视频、文本和其他无关文件。这里扩展名判断我用了OrdinalIgnoreCase比ToLower().EndsWith(.jpg)更标准也避免这些临时字符串分配。4.3 批量写入事务、UPSERT、复用命令批量写入是性能关键。拿着几千条数据一条一条INSERT再各自提交SQLite 的落盘操作会慢到怀疑人生。SQLite 本质是一个文件每次事务提交都要做一次 fsync一秒钟最多也就几百次量级。正确做法是一个批次开一个事务批量提交。public static int BatchInsert(IEnumerablePhotoInfo photos, SqliteConnection connection) { const string sql INSERT INTO Photos (FilePath, FileName, Extension, FileSize, Width, Height, CameraMake, CameraModel, DateTimeOriginal, GpsLatitude, GpsLongitude) VALUES ($filePath, $fileName, $extension, $fileSize, $width, $height, $make, $model, $date, $lat, $lng) ON CONFLICT(FilePath) DO UPDATE SET FileName excluded.FileName, FileSize excluded.FileSize, DateTimeOriginal excluded.DateTimeOriginal, CameraModel excluded.CameraModel, GpsLatitude excluded.GpsLatitude, GpsLongitude excluded.GpsLongitude;; using var transaction connection.BeginTransaction(); using var command connection.CreateCommand(); command.Transaction transaction; command.CommandText sql; var pPath command.Parameters.Add($filePath, SqliteType.Text); var pName command.Parameters.Add($fileName, SqliteType.Text); // ... 其余参数同理 int count 0; foreach (var photo in photos) { pPath.Value photo.FilePath; pName.Value photo.FileName; // ... command.ExecuteNonQuery(); count; } transaction.Commit(); return count; }这段代码有四个核心点值得细说。第一ON CONFLICT(FilePath) DO UPDATE就是 UPSERT。它是 SQLite 3.24 之后支持的标准语法Microsoft.Data.Sqlite底层对应的 SQLite 版本完全支持。当你重新扫描同一批照片时不会插入重复行而是更新已有数据。这个特性帮我省了“先查询是否存在再决定插入还是更新”的麻烦还消除了并发重复场景下主键冲突的崩溃风险。第二Parameters.Add($filePath, SqliteType.Text)在循环体外只做一次。参数对象复用每次只改.Value避免每行都创建新的SqliteParameter性能和可读性都更好。第三必须给命令设置command.Transaction transaction。忘了这一步命令会默认使用一个隐式事务每条语句单独提交明显拖慢速度。我自己第一次写就漏了后来看日志才发现每条 INSERT 都是独立提交。第四transaction.Commit()只调一次。别在循环里提交否则事务就白开了。4.4 主流程组装上面几段组装起来就是完整主流程var connection new SqliteConnection(Data Sourcephotos.db); connection.Open(); InitDatabase(connection); var files EnumerateImages(D:\Photos); var photos files.Select(f { try { return ReadExif(f); } catch (Exception ex) { Console.WriteLine($跳过文件 {f}: {ex.Message}); return null; } }).Where(p p ! null); int inserted BatchInsert(photos, connection); Console.WriteLine($完成共写入 {inserted} 条记录。);Select里包try-catch是我故意留的。有些图片看起来是 JPG实际是损坏文件或者从网上下的伪图片ReadMetadata会抛ImageProcessingException。如果这里不加保护整个批量任务会中断扫到一张坏图就前功尽弃。我的做法是记录日志、跳过问题文件保证大批量任务能跑完。5. 实测中容易翻车的几个细节代码跑通只是第一步真到全量执行时一堆细节问题才逐渐冒出来。下面这四条是我实测时遇到并且重点解决的。5.1 日期格式里藏着的冒号陷阱GetDescription(TagDateTimeOriginal)返回的字符串通常是2023:07:12 14:30:25。注意日期部分的分隔符是冒号而不是短横线这是 EXIF 规范的历史遗留问题。如果你直接把这个字符串存进 SQLite后续想用strftime解析就会怪怪的因为datetime()对2023-07-12和2023:07:12的兼容性不一致。我封装了一个NormalizeDatepublic static string NormalizeDate(string raw) { if (string.IsNullOrWhiteSpace(raw)) return null; var match Regex.Match(raw, ^(\d{4}):(\d{2}):(\d{2})[\sT](\d{2}):(\d{2}):(\d{2})); if (match.Success) { return ${match.Groups[1].Value}-{match.Groups[2].Value}-{match.Groups[3].Value} {match.Groups[4].Value}:{match.Groups[5].Value}:{match.Groups[6].Value}; } return raw; }加这个正则很值得。你从相机、手机、扫码软件等渠道归档文件时如果日期格式不统一后面做“按月份筛选”就会漏数据。这次处理完之后我的库里所有DateTimeOriginal都是统一格式时间范围查询直接datetime(DateTimeOriginal) BETWEEN ...就能用。5.2 没有EXIF的图片和“假图片”必须单独处理用手机截图、网上下载的图片很大概率没有 EXIF。ReadMetadata仍然能返回 JPEG 的基本目录但ExifSubIFDDirectory直接为空。读取时要用FirstOrDefault()加null判断而不是默认拿第一个。截图的分辨率能从JpegDirectory拿到但拍摄时间和设备信息就是空。我在表里允许这些字段为空显示时统一显示“未知”查询时IS NULL筛出来就行。还有一个坑有些文件后缀是.jpg但实际是 BMP 或 GIF 内容。ImageMetadataReader会在读取时识别文件头失败抛异常。所以我在批量循环里才需要那么强调try-catch。这是实际扫目录时必然会遇到的情况提前设计好就不慌。5.3 SQLite 连接与线程Parallel 不一定划算我第一次写觉得逐条太慢想用Parallel.ForEach多线程并发读和写。逐步调试后我发现读取 EXIF 是 CPU 密集型并发确实能提速但写入 SQLite 时多线程并发写很容易撞锁而且Microsoft.Data.Sqlite默认一个连接同一时间只允许一个操作。真正合理的方案是读可以并行写用单线程 大批量事务。var photos files.AsParallel() .Select(f SafeReadExif(f)) .Where(p p ! null) .ToList(); int count BatchInsert(photos, connection);实测效果扫描 8000 张照片串行读大约 40 秒并行读 15 秒左右。写入阶段一片事务提交 8000 条不到 2 秒。所以别盲目把写入也并发化写库搞成多线程锁等待带来的损耗反而掩盖了收益。如果你确实有高频写入、多并发写入的场景可以开启 WAL 模式PRAGMA journal_modeWAL; PRAGMA synchronousNORMAL;WAL 模式允许读和写并行对桌面应用的本地写入体验提升不小。但记住synchronousNORMAL是弱化持久性来换性能。对于照片归档这种可重新生成的库完全没问题如果存的是交易记录或收费系统的数据就不要随便关同步。5.4 十万行数据查询耗时有人在网络热词里关注过“十万条数据 sqlite 查询需要多久”。我用这个表规模做了实测单表十万行没有索引时SELECT * FROM Photos WHERE DateTimeOriginal LIKE 2024-05%花了大约 800 毫秒加了DateTimeOriginal索引后降到 30 毫秒以内。SQLite 在百万行以内只要索引设计合理查询性能完全够用。所以写库时那两条CREATE INDEX一定别省它比任何应用层缓存都简单可靠。6. 后续扩展从数据库到真正能用起来的检索EXIF 落库之后这个项目才算是完成了“数据采集”阶段。它真正的增值部分在检索界面上。我在任务五验收时做了一个最简单的主窗体三个筛选条件拍摄日期范围、相机型号、GPS 坐标范围查询结果直接绑定到DataGridView。核心查询就是动态拼接 SQLSELECT FileName, DateTimeOriginal, CameraModel, GpsLatitude, GpsLongitude FROM Photos WHERE DateTimeOriginal BETWEEN startDate AND endDate ORDER BY DateTimeOriginal DESC;再往后想继续扩展思路也很清晰写一个导出功能把Photos表导出成 CSV方便 Excel 继续加工地图显示可以在拿到经纬度后调用地图组件标点如果做上位机项目可以把同一套“读取设备数据 → 规范化 → SQLite 落库”的思路复制到日志存储、点位采集等场景。我自己在任务五完成之后最大的体会是这类功能不要想着一口气做完美。先把“读 EXIF 批量写库 基本查询”这条主线走通再逐步加筛选条件、导出、地图标点。SQLite 的优势在于库文件可以随时删掉重建只要有原始照片在重新生成一份数据也就是再扫一遍的事这给了你反复迭代的底气。最后再分享一个小技巧代码里连接字符串用Data Sourcephotos.db时数据库文件生成在程序运行目录。如果想让工具适配多台电脑可以把 db 路径和照片根源目录一起放进配置文件这样迁移、备份都方便。我在实际项目里就是复制一个photos.db文件和程序就能在另一台机器上直接跑这也是本地工具该有的样子。