简介:面向.NET开发者的C#与SQLite图片存取示例项目,演示如何利用SQLite轻量级数据库完成图片二进制数据的写入与读取。核心代码涵盖创建SQLite连接、建立BLOB字段表、将本地图片转为字节数组插入,以及通过SELECT查询取出数据并显示到PictureBox控件,适合初学数据库操作或桌面文件管理的开发人员参考。压缩包共49个文件,以C#源文件(9个cs)、解决方案与项目配置(sln、csproj、config)为主,附有exe可执行程序、dll依赖库及png界面截图,整体约2.11MB,包含完整Visual Studio解决方案结构。目前已有2469人学习下载,示例包含可直接运行的窗体程序,通过界面选择本地图片上传至数据库,再从数据库读取显示,可对照代码理解SQLite参数化查询与图片类型转换的常见思路。
1. C# 把图片塞进 SQLite:存取本身不难,难的是存完读不出来
把图片直接塞进 SQLite 数据库,在 C# 上位机项目里几乎是绕不开的需求——设备拍照存档、检测结果留底、工单图片记录,全都要落到本地库。可你要是拿 SQLite 存过图片就会发现,存取本身不难,难的是存完读不出来:读出来是 0 字节、Image.FromStream直接抛参数无效、WPF 里Image控件死活不显示。这些坑我全踩过,这篇就是一份能照抄的 C# + SQLite 图片存取实战笔记,从建表、参数化写入、字节流读取到缩略图分页,每段代码都能直接粘进你的上位机项目里改一改就跑通。适合刚接触数据库存图片的新手,也适合想直接抄参数的老手。
2. 环境与工程搭建:从 NuGet 包到 SQLite 文件的正确打开方式
2.1 选包对比:System.Data.SQLite 与 Microsoft.Data.Sqlite 怎么选
存图片这件事首先卡在选包上。C# 连接 SQLite 常用的就两个库:System.Data.SQLite和Microsoft.Data.Sqlite。两者都能完成 BLOB 字段的读写,但行为差异很大,选错后面会多出很多不必要的调试时间。
| 对比项 | System.Data.SQLite | Microsoft.Data.Sqlite |
|---|---|---|
| 底层 | 原生 C++ 库,需带 SQLite.Interop.dll | 纯托管实现,基于 SQLitePCL.raw |
| API 风格 | 传统 ADO.NET,支持 DbType.Binary 隐式转换 | 轻量,只有 SQLiteParameter |
| 原生依赖 | 32/64 位需分开部署 | 无需额外部署,NuGet 自动处理 |
| 适合场景 | WinForms、老项目、需要完整 ADO.NET 功能 | WPF、跨平台、新项目 |
我的建议是:如果你的项目是 WinForms 上位机、现场机器可能还是 Win7,用System.Data.SQLite更稳,因为它的功能更全,遇到问题网上资料也多。如果是 WPF 新项目,直接上Microsoft.Data.Sqlite,部署省心,SQLitePCL.raw会在构建时自动带上对应运行时的原生库。
安装命令就看你自己项目了,一个命令的事,这里不占篇幅。装完之后记得检查项目输出目录里有没有SQLite.Interop.dll(System.Data.SQLite 包特有),没有的话运行时百分百报Unable to load DLL,这个问题在第 5 章专题说。
2.2 连接串与建库建表:一句话让数据库文件自动出现
SQLite 的特点是连接串指向一个本地文件,文件不存在时,第一次Open()会自动创建。下面是典型的建库建表代码:
using System.Data.SQLite; // 数据库文件放 exe 同级 Data 目录 var dataDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Data"); Directory.CreateDirectory(dataDir); var connStr = $"Data Source={Path.Combine(dataDir, "images.db")};Version=3;Pooling=True;"; using var conn = new SQLiteConnection(connStr); conn.Open(); string sql = @" CREATE TABLE IF NOT EXISTS Images ( Id INTEGER PRIMARY KEY AUTOINCREMENT, Name TEXT NOT NULL, ContentType TEXT NOT NULL, Width INTEGER NOT NULL DEFAULT 0, Height INTEGER NOT NULL DEFAULT 0, Data BLOB NOT NULL, CreateTime DATETIME DEFAULT CURRENT_TIMESTAMP );"; using var cmd = new SQLiteCommand(sql, conn); cmd.ExecuteNonQuery();这段代码里有几个参数值得说清楚:DataSource是文件路径,Version=3是老版 SQLite 文件格式,实际现在 3 以上就是标准格式,写不写都行;Pooling=True开启连接池,上位机频繁开关数据库的场景下能明显减少打开耗时。AUTOINCREMENT保证 Id 单调递增,删除记录后不会复用旧 Id,对需要在 UI 上按 Id 定位图片的场景很重要。
表结构里Data BLOB NOT NULL是核心字段,存的就是图片原始字节数组。Width和Height是冗余字段,插入时顺手填上,读取列表时可以只查这两个字段不查Data,避免把整张图片的字节都捞出来,列表页速度翻倍不止。
建好表之后,接下来就是写入环节。BLOB 字段写入的坑比读取更多,最常见的错误是拿到一个byte[]就直接ToString()拼进 SQL 字符串,这种行为在 SQLite 下会把字节数组转成类似System.Byte[]的字符串存进去,读出来直接傻眼,后面所有的反序列化逻辑全白搭。
3. 图片写入的完整链路:byte[]、BLOB 字段与参数化 SQL
3.1 图片文件转换 byte[]:文件、内存流两种来源的实现
写入 SQLite 之前,得先把图片变成byte[]。来源不同,写法也不同。最常见的两种来源是磁盘文件和内存中的Image对象。
// 方式一:从磁盘文件直接读,一步到位 byte[] fileBytes = File.ReadAllBytes(@"C:\shots\camera_01.bmp"); // 这种方式最简单,但要注意图片文件可能被其他进程占用 // 方式二:从内存中的 Image 对象转,先转 MemoryStream 再取字节 using var ms = new MemoryStream(); bitmap.Save(ms, System.Drawing.Imaging.ImageFormat.Jpeg); byte[] imageBytes = ms.ToArray(); // 用 MemoryStream 中转的原因:Image.Save 需要流对象,而 byte[] 没有流的概念方式一适合的是上位机上已经落盘的图片文件,比如工业相机保存的原始帧。方式二适合的是程序里自己绘制的图片,比如把检测结果画框之后的内存 Bitmap。不管哪种来源,最终都得到一个byte[],这就是准备写入 BLOB 字段的数据。
有个细节要留意:Image.Save(MemoryStream)之后得到的字节数组和原图字节不一定一致,因为Save会按指定格式重新编码,JPEG 会做有损压缩,BMP 则体积膨胀。如果在意体积和速度,建议直接用File.ReadAllBytes保持原始文件格式,数据库存储的字节就是磁盘上的原始字节,读出来可以直接落盘,不用重新解码。
3.2 参数化插入:给 BLOB 传参时别让类型被当成整数
BLOB 写入的正确姿势是参数化 SQL,把byte[]通过参数对象传给命令,让 ADO.NET 驱动去处理二进制映射。千万不要把字节数组转成字符串拼接 SQL,这是新手最容易犯的错误。
using var conn = new SQLiteConnection(connStr); conn.Open(); const string sql = @" INSERT INTO Images (Name, ContentType, Width, Height, Data) VALUES (@name, @contentType, @width, @height, @data);"; using var cmd = new SQLiteCommand(sql, conn); cmd.Parameters.Add(new SQLiteParameter("@name", DbType.String) { Value = "camera_01.bmp" }); cmd.Parameters.Add(new SQLiteParameter("@contentType", DbType.String) { Value = "image/bmp" }); cmd.Parameters.Add(new SQLiteParameter("@width", DbType.Int32) { Value = 640 }); cmd.Parameters.Add(new SQLiteParameter("@height", DbType.Int32) { Value = 480 }); cmd.Parameters.Add(new SQLiteParameter("@data", DbType.Binary) { Value = fileBytes }); cmd.ExecuteNonQuery();这里最关键的参数是new SQLiteParameter("@data", DbType.Binary)。DbType.Binary明确告诉驱动这个参数是二进制大对象,驱动内部会把它映射成 SQLite 的 BLOB 存储类。如果把DbType.Binary漏掉或者写成DbType.Object,在部分版本的 System.Data.SQLite 驱动下会把byte[]当成未知类型处理,保存结果可能是 0 字节,也可能是乱码,这两种我都见过。
Width和Height字段这里用了DbType.Int32,目的是和表结构的 INTEGER 对应。这些字段虽然不影响 BLOB 存取,但第 4 章做列表查询时要用,插入时不填,后面查询出来的宽度就是 0。
3.3 事务批量写入:一次塞进上百张图片的正确做法
上位机做批量导入时,比如把设备一整天拍的照片入库,一张一张插入会有严重的性能问题。SQLite 单条插入默认走自动提交模式,每条语句都要做一次完整的事务提交,1000 张图就是 1000 次磁盘同步,耗时奔着分钟级去。解决办法是手动开启事务,把批量插入包在一个事务里。
using var conn = new SQLiteConnection(connStr); conn.Open(); using var transaction = conn.BeginTransaction(); using var cmd = new SQLiteCommand(sql, conn, transaction); // 把参数创建移到循环外,只改 Value,避免重复创建参数对象 var pName = new SQLiteParameter("@name", DbType.String); var pContentType = new SQLiteParameter("@contentType", DbType.String); var pWidth = new SQLiteParameter("@width", DbType.Int32); var pHeight = new SQLiteParameter("@height", DbType.Int32); var pData = new SQLiteParameter("@data", DbType.Binary); cmd.Parameters.AddRange(new[] { pName, pContentType, pWidth, pHeight, pData }); foreach (var file in Directory.GetFiles(@"C:\shots", "*.bmp")) { var bytes = File.ReadAllBytes(file); var img = Image.FromFile(file); pName.Value = Path.GetFileName(file); pContentType.Value = "image/bmp"; pWidth.Value = img.Width; pHeight.Value = img.Height; pData.Value = bytes; cmd.ExecuteNonQuery(); } transaction.Commit();参数对象在循环外创建一次、循环内只改Value,这是批量写入性能的关键。SQLiteParameter对象本身有解析和绑定逻辑,反复创建销毁会拖慢速度。我把这组代码放在离线导入工具里,300 张 640x480 的 BMP 图,事务包起来之后大概两秒左右落库,而逐条插入要半分钟,差别非常明显。
还有一点血泪经验:事务一定要包在try-catch里,catch中执行transaction.Rollback(),否则一旦循环中某条数据抛异常,整个事务就悬挂在那,连接池里的连接一直占用,后续所有数据库操作全部阻塞。这个坑排查起来特别玄学,日志看起来是死锁,实际上是没回滚的事务堵着连接。
4. 图片读取与还原:DataReader 到 Bitmap 的四种姿势
4.1 查询与字节流读取:先取长度,再定义长度
读取 BLOB 字段比写入稍麻烦一点,核心问题是byte[]长度的确定。SQLite 的 BLOB 字段在驱动里返回的是long类型,直接强转成int在图片超过 2GB 时会溢出,但实际场景中单张图片不会到那个量级,所以更常见的坑反而出现在GetBytes的调用方式上。
using var conn = new SQLiteConnection(connStr); conn.Open(); const string sql = "SELECT Id, Name, Width, Height, Data FROM Images WHERE Id = @id;"; using var cmd = new SQLiteCommand(sql, conn); cmd.Parameters.Add(new SQLiteParameter("@id", DbType.Int32) { Value = 1 }); using var reader = cmd.ExecuteReader(); if (!reader.Read()) return; // 推荐做法:直接类型转换为 byte[] byte[] data = (byte[])reader["Data"]; // 备选做法:用 GetBytes 分步读取,适合超大 BLOB long blobLength = reader.GetBytes(4, 0, null, 0, 0); // 先传 null 获取长度 byte[] buffer = new byte[blobLength]; reader.GetBytes(4, 0, buffer, 0, (int)blobLength);两种做法我平时都写。推荐直接(byte[])reader["Data"],一行代码拿到完整字节数组,效率也最高。备选做法适合的是超大文件按块读取的场景,比如一次读 1MB,循环读完全部数据,可以避免一次性分配几十 MB 的连续内存。但要注意:两次GetBytes调用的fieldIndex参数,也就是第一个参数,传的是列序号,从 0 开始数。上面 SQL 里Data是第 5 列,所以传 4,这个数字写错会拿到别的列的数据,翻车的时候很难察觉。
还有个常见误用是直接用reader["Data"].ToString()之后再Encoding.UTF8.GetBytes(),这等于把二进制经过了一次文本编码转换,图片字节早就损坏了,存进去的 BMP 读出来打不开,这类问题几乎隔三差五就有人踩一次。
4.2 WinForms 与 WPF 的图片还原差异:一个用 Bitmap,一个用 BitmapImage
拿到byte[]之后,还原成界面能显示的图片对象。WinForms 和 WPF 的处理方式完全不同,混用会直接导致界面黑屏或者异常。
// WinForms 还原方式 using var ms = new MemoryStream(data); using var bitmap = new Bitmap(ms); pictureBox1.Image = (Bitmap)bitmap.Clone(); // 注意:Bitmap.FromStream 生成的 Bitmap 会一直持有 ms 的引用, // 所以 ms 不能提前 using 释放,要么不释放,要么像上面这样 Clone 出来 // WPF 还原方式 var msWpf = new MemoryStream(data); var bitmapImage = new BitmapImage(); bitmapImage.BeginInit(); bitmapImage.CacheOption = BitmapCacheOption.OnLoad; bitmapImage.StreamSource = msWpf; bitmapImage.EndInit(); bitmapImage.Freeze(); // 跨线程使用必须 Freeze imageControl.Source = bitmapImage;WinForms 里的Bitmap.FromStream(ms)有一个隐晦的行为:Bitmap 对象在垃圾回收之前,ms流不能被释放。如果ms被using提前释放,后面pictureBox1.Image画图时会抛ArgumentException,而且异常信息很模糊。我一般用Clone()复制出一份副本,然后把原始 Bitmap 和 MemoryStream 都释放掉,这样最干净。
WPF 这边有两个重点。第一是CacheOption = BitmapCacheOption.OnLoad,告诉BitmapImage在EndInit()时立刻把流里的数据全部加载进内存,否则它会等StreamSource的流被读取时才懒加载。第二是Freeze(),把BitmapImage变成只读对象,这样它才能跨线程使用。上位机里照片列表和详情页经常在不同线程刷新,不Freeze()的话界面刷新时会抛InvalidOperationException,这个异常信息会直接指向你调用Freeze()的地方。
这两个还原方式封装成两个方法放在工具类里,所有界面共用。WPF 项目尤其建议把这个封装好,因为同一个byte[]转出来的BitmapImage和Bitmap在底层是两套完全不同的解码链路,混用会出现显示异常但代码不报错的诡异现象。
5. 避坑:SQLite 存图片的五个踩坑记录
5.1 存进去之后再查出来是 0 字节
现象:插入操作没有任何报错,但用 DB Browser for SQLite 打开数据库文件,看到Data字段是空的,或者长度是 0。
原因:参数化 SQL 里给@data传值时没有指定DbType.Binary,驱动把它当成了字符串或未知类型处理。在 System.Data.SQLite 里,未指定类型的参数在部分版本会走文本绑定路径,byte[]被截断或丢弃。
解决:创建参数时强制指定DbType.Binary,代码示例见第 3.2 节。如果你用了第三方的 ORM 框架,比如 Dapper,需要把参数声明成DbType.Binary而不是默认的DbType.Object。
5.2 读取时 GetBytes 只返回了第一段数据
现象:图片能读出来,但只有第一段字节是正确的,后面的全丢,图片打开发灰或者加载一半。
原因:GetBytes方法的buffer长度参数填错了。很多人第一次调用GetBytes(4, 0, buffer, 0, buffer.Length),以为能一次性读完,但 BLOB 总长超过 buffer 长度时,这个方法只会填满 buffer 然后返回实际读取长度,并不会报错。
解决:先用GetBytes(4, 0, null, 0, 0)拿到实际长度long,再创建对应长度的buffer,最后调用完整的GetBytes。或者干脆不用GetBytes,直接用(byte[])reader["Data"]类型转换,后者不需要关心长度问题。
5.3 WPF 里 Image 控件加载后不显示图片
现象:代码执行了,Source赋值了,运行不报错,但界面上的图片区域是空白的。
原因:BitmapImage的CacheOption没设置成OnLoad。默认的Default缓存策略会导致流对象在EndInit()之后不再保留数据,等 WPF 渲染器要去读的时候,StreamSource已经被释放或数据已经不可用。
解决:BeginInit()之后必须设置bitmapImage.CacheOption = BitmapCacheOption.OnLoad;,并且在EndInit()之后调用Freeze()。这两个属性是 WPF 显示图片的黄金组合,少一个都会出问题。
5.4 运行时找不到 SQLite.Interop.dll 导致连接失败
现象:程序启动后第一次连接数据库,直接抛DllNotFoundException: Unable to load DLL 'SQLite.Interop.dll'。
原因:System.Data.SQLite 包需要对应架构的SQLite.Interop.dll。新式 SDK 风格项目会自动把依赖复制到输出目录,但老式非 SDK 项目或者手动引用 DLL 的项目,需要手动把x86和x64目录下的 Interop 文件复制到运行目录。现场机器如果是 64 位系统但程序编译成AnyCPU,驱动会优先加载 x64 Interop,缺了就报错。
解决:项目配置改成明确的目标平台,比如 x64(多数上位机推广机器都是 64 位),然后检查输出目录x64子文件夹里有没有SQLite.Interop.dll。没有的话去 NuGet 包目录里复制进去。这也是我用Microsoft.Data.Sqlite替换老库的原因,后者不需要手工处理 Interop。
5.5 同一天多次写入后,数据库文件越来越大
现象:图片数量没增加多少,但 .db 文件体积翻倍,删除图片后文件大小也没有变化。
原因:SQLite 删除数据只是标记,不会自动归还磁盘空间给操作系统。频繁插入、删除、更新 BLOB 字段,数据库文件会留下大量空白页,体积持续膨胀。同时Pooling=True会让连接长时间占用,VACUUM 操作也需要在无连接的情况下执行。
解决:定期执行VACUUM命令重组数据库文件。做法是把所有连接关闭后执行new SQLiteCommand("VACUUM", conn).ExecuteNonQuery()。注意 VACUUM 执行期间数据库文件会临时膨胀到原来的两倍大小,磁盘剩余空间不够时会执行失败,现场部署时给数据库所在磁盘留足余量。
6. 进阶:缩略图分离存储与图片分页读取
6.1 缩略图双表设计:列表页几百条不卡
把原图直接读出来渲染成列表,图片一大直接卡死。更合理的做法是双表设计:原图表存完整 BLOB,缩略图表只存一个小尺寸图片数据,列表页只查缩略图。
const string createThumbSql = @" CREATE TABLE IF NOT EXISTS Thumbnails ( Id INTEGER PRIMARY KEY AUTOINCREMENT, PhotoId INTEGER NOT NULL REFERENCES Images(Id), Width INTEGER NOT NULL, Height INTEGER NOT NULL, Data BLOB NOT NULL );"; // 插入原图后,生成缩略图并入库 using var thumbMs = new MemoryStream(); var thumb = new Bitmap(img, new Size(200, 150)); thumb.Save(thumbMs, System.Drawing.Imaging.ImageFormat.Jpeg); const string insertThumbSql = @" INSERT INTO Thumbnails (PhotoId, Width, Height, Data) VALUES (@photoId, @width, @height, @data);"; var thumbCmd = new SQLiteCommand(insertThumbSql, conn, transaction); thumbCmd.Parameters.Add(new SQLiteParameter("@photoId", DbType.Int32) { Value = newId }); thumbCmd.Parameters.Add(new SQLiteParameter("@width", DbType.Int32) { Value = 200 }); thumbCmd.Parameters.Add(new SQLiteParameter("@height", DbType.Int32) { Value = 150 }); thumbCmd.Parameters.Add(new SQLiteParameter("@data", DbType.Binary) { Value = thumbMs.ToArray() }); thumbCmd.ExecuteNonQuery();缩略图列表页只查Thumbnails表,每张图撑死几 KB,加载 500 条也就十几 MB 内存。用户双击某一张再按Id去查原图表,这样详情页和列表页彻底解耦。
6.2 分页读取与 Debug 可视化验证
列表页数据量上来之后,没有分页就会出现滚轮一滚就卡顿的情况。配合 SQLite 的LIMIT/OFFSET做分页,每页 50 条足够流畅。
const string pageSql = @" SELECT t.Id, t.PhotoId, t.Width, t.Height, t.Data FROM Thumbnails t ORDER BY t.PhotoId DESC LIMIT @pageSize OFFSET @offset;"; using var cmd = new SQLiteCommand(pageSql, conn); cmd.Parameters.Add(new SQLiteParameter("@pageSize", DbType.Int32) { Value = 50 }); cmd.Parameters.Add(new SQLiteParameter("@offset", DbType.Int32) { Value = pageIndex * 50 });分页参数用参数化 SQL 传入,不要拼接字符串。OFFSET越大,查询会扫描越多的索引页,这块 SQLite 就这么设计,数据量在几万条以内不用纠结,坚持用主键Id排序分页就行。
做图片存取功能时,我习惯在 Debug 模式下加一个可视化验证:读取出来的byte[]直接写到临时目录并用系统图片查看器打开,或者用 WinForms 的PictureBox在测试窗体里显示。这样能快速区分问题是出在写入端还是读取端。
最后说个我自己定的规矩:每次写完图片存取代码,强制走一遍「写入 → 读回 → 对比字节」三步验证。从 SQLite 读出的字节和写入前的原始字节逐位比较,完全一致才算通过。从那以后我做的几个上位机项目,图片存取这块再没出过前端图片打不开的事故。希望帮到你。
本文还有配套的精品资源,点击获取