说真的,作为C#学习者,前面几关你大概已经把语法、面向对象、集合、文件IO这些基础啃得差不多了,但“把数据存下来”这件事,很多人一直没迈过去。文件写到本地简单,可一旦要按条件筛选、去重、统计,文本文件就彻底不够用了。这个任务正好卡在关键点:用C#读取图片的Exif信息,再批量写入SQLite数据库,等于把“图像元数据”和“关系型存储”串成一条完整链路。做完这个任务,你不仅掌握了SQLite在C#里的标准玩法,还顺手把Exif解析这个经常被提起但很少有人讲透的技能点拿下了。对新手来说,这是从“写小玩具”过渡到“做真实工具”的一道分水岭。
我自己当初做完这个任务,最直观的感受是:原来处理几千张照片,靠肉眼翻简直要命,但一旦信息进了数据库,按相机、按时间、按拍摄地点筛选,都是几秒的事。这篇就把我的完整思路、选型理由、踩过坑和最终代码,一条条摊开说给你听。
1. 内容整体设计与思路拆解
1.1 先明确需求:我们要解决的到底是什么问题
很多C#学习者拿到这个题目会下意识地把它想成一个“API调用题”——调一个库读Exif,调一个库写数据库,完事。但实际动手时你会发现,真正需要设计的是这三层问题。
第一层是Exif读取。图片文件里藏着拍摄参数,但它们的存储位置和格式并不直观。第二层是数据库结构。Exif信息字段很多、类型各异,直接拍脑袋建几十个字段的表,后面想扩展、想查询、想统计,可能马上被表结构卡住。第三层是批量处理的稳定性。实际的图片文件夹里经常混着不完整的文件、损坏的文件、超大尺寸的原图,代码不能遇到一张坏图就崩。
我的做法是把任务拆成四个阶段:设计数据库结构、实现Exif提取、实现批量写入、实现查询接口。每个阶段单独调试,最终合在一起跑通一个完整流程。不要一上来就写一个“万能大函数”,那只会让你在调试时痛苦不堪。
1.2 技术选型:为什么选SQLite,为什么用MetadataExtractor
选SQLite的原因其实很朴素:它是嵌入式的,不需要额外安装数据库服务,整个数据库就是一个文件。C#这边有官方的Microsoft.Data.Sqlite,也有老牌的System.Data.SQLite,我用的是前者,因为它在.NET 6以上的跨平台场景下更省心。SQLite适合这类桌面工具场景,不需要一个MySQL服务常驻后台,也不需要考虑用户机器上装没装客户端。发布的时候把DLL带上,数据库文件在程序运行时自动创建,对学习项目来说再合适不过。
Exif读取是另一个关键选择。网上很多人会用System.Drawing里的Image.GetPropertyItem(),但这条老路现在越来越别扭:System.Drawing在.NET 6之后只支持Windows,而且拿Tag ID去查属性表,代码极其啰嗦,还要自己做字节解析。我最终选了MetadataExtractor这个开源库,它跨平台,支持JPEG、TIFF、PNG、WebP等多种格式,内部已经把所有Exif Tag封装成“目录+标签”的结构,读起来非常顺手。
打个比方,System.Drawing就像让你自己拿着一本英文说明书去拆照片里的信息,Tag ID还得对着表记忆;MetadataExtractor则直接给你一套分类好的中文档案夹,每个文件夹里标签都写好了名字,你只负责拿和记。
1.3 表结构怎么设计,才不会被后续需求打脸
这是整个任务里最值得花心思的一步。我第一版图省事,直接把“相机制造商、机型、拍摄时间、ISO、快门、光圈、GPS纬度、GPS经度”做成了固定字段。看上去直观,但用了一周就发现问题:不同相机写入的Exif字段差异太大,有的照片有镜头信息,有的有海拔高度,有的有作者版权,这些字段在固定表里根本无处安放。想扩展,只能改表结构,而SQLite虽然支持新增列,但列一旦多了,表会变得非常臃肿。
后来我改成了双表结构,一张主表存图片文件和基础信息,一张Exif属性表用“键值对”的方式存Key-Value。主表的字段是稳定且查询频率高的,比如文件路径、文件大小、拍摄时间、相机机型、GPS坐标;属性表则存放那些不固定的扩展标签,比如ISO、快门、光圈、镜头型号、作者等。
这样做的好处是,新相机出现再多的新Tag,你都不用改表结构,往属性表里Insert即可。查询时候用LEFT JOIN或者子查询,灵活度很高。这算是我个人实际踩坑之后总结的最重要经验。
2. 环境准备与数据库设计实操
2.1 创建项目与安装NuGet包
我用的环境是Windows 11 + Visual Studio 2022 + .NET 8,但下面这套流程换成.NET 6、7也一样跑。项目模板选“控制台应用”,如果你后面想加查询界面,可以选WinForms或WPF,核心代码完全复用。
创建好项目后,打开NuGet包管理器,安装两个包:
- MetadataExtractor:负责读取图片Exif信息
- Microsoft.Data.Sqlite:负责操作SQLite数据库
安装完在代码顶部写上:
using MetadataExtractor; using MetadataExtractor.Formats.Jpeg; using Microsoft.Data.Sqlite;如果你用的是命令行环境,也可以执行:
dotnet add package MetadataExtractor dotnet add package Microsoft.Data.Sqlite这里有个小提醒:MetadataExtractor有两个版本分支,老版本API是ImageMetadataReader.ReadMetadata(),新版本API可能略有差异,安装后确保调用的命名空间是对的。另外,如果你在WPF里用System.Drawing那一套,很可能还会触发平台兼容警告,这也是我推荐MetadataExtractor的直接原因。
2.2 数据库设计与初始化脚本
我习惯在程序启动时检查数据库文件是否存在,不存在就自动创建。这样用户拿到程序,双击就能用,不需要手动建库。创建表的SQL脚本如下:
CREATE TABLE IF NOT EXISTS Images ( Id INTEGER PRIMARY KEY AUTOINCREMENT, FilePath TEXT NOT NULL UNIQUE, FileName TEXT NOT NULL, DirectoryName TEXT NOT NULL, FileSize INTEGER, FileHash TEXT, CameraModel TEXT, DateTimeOriginal TEXT, Latitude REAL, Longitude REAL, ImportedAt TEXT DEFAULT (datetime('now', 'localtime')) ); CREATE TABLE IF NOT EXISTS ExifAttributes ( Id INTEGER PRIMARY KEY AUTOINCREMENT, ImageId INTEGER NOT NULL, TagName TEXT NOT NULL, TagValue TEXT, FOREIGN KEY (ImageId) REFERENCES Images(Id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_images_path ON Images(FilePath); CREATE INDEX IF NOT EXISTS idx_exif_imageid ON ExifAttributes(ImageId);为什么要加FilePath的唯一约束?因为重复导入是批量工具最常见的场景,没有唯一约束,同一张图会越导越多,最后数据全是垃圾。FileHash字段用来做内容级去重,即使两个不同路径指向同一个文件,通过文件哈希也能发现。这是我在整理一批重复备份的照片时实际遇到的问题——同一张图在好几个文件夹里都有,需要按内容去重。
Latitude和Longitude用REAL类型,因为坐标在查询和排序时需要参与运算,直接用数字比存字符串靠谱得多。DateTimeOriginal我用TEXT存ISO8601格式,比如2024:08:03 14:22:10。这样既能保持原样的可读性,也能用字符串比较和SQLite的日期函数处理排序。
2.3 表结构与字段类型避坑(含SQLite动态类型)
SQLite和MySQL、SQL Server有一个非常大的差异:它支持动态类型。你在建表时写了INTEGER列,往里塞字符串它也不会报错,顶多隐式转换或者原样存进去。这带来了很大的便利,但也带来了隐患。
最典型的是VARCHAR长度。你写VARCHAR(100),以为超过100字符会被截断或拒绝,实际上SQLite根本不检查这个长度。TagValue里存超长内容也会照单全收。所以我建议,如果你要在SQLite里做数据约束,别指望列类型帮你管,该用TEXT就TEXT,该用CHECK约束就用CHECK约束。
还有一个容易踩的坑是日期。SQLite没有专门的日期类型,大家习惯用TEXT存。但是Exif时间格式在不同相机之间并不统一,有的是2024:08:03 14:22:10,有的是2024/08/03 14:22:10,还有带时区偏移的。我的做法是在写入前统一做一次格式化,把它转成yyyy-MM-dd HH:mm:ss再入库。宁可写入前多一步,也不要在查询时被五花八门的格式坑。
此外,关于WAL模式,我强烈建议在每次建库后执行一句PRAGMA journal_mode=WAL;。这个设置允许多个连接并发读,写和读也不会互相阻塞,对我后面边导入边查询的场景帮助非常大。后续遇上“数据库被锁定”的报错,第一反应就该回到这里检查。
3. Exif信息读取与解析实战
3.1 Exif到底是什么,它长在哪里
Exif全称Exchangeable Image File Format,直译过来就是“可交换图像文件格式”。你可以把它理解成照片的“出厂档案封条”——拍摄时相机把光圈、快门、ISO、时间、地点、甚至镜头序列号等信息,按一套标准格式写在图片文件内部。JPEG文件的Exif藏在APP1标记段里,内部再用TIFF结构的目录方式组织。
这套TIFF结构里,IFD(Image File Directory)相当于一个个“文件柜抽屉”。每个IFD里有若干条目,每个条目12字节,记录着Tag ID、数据类型、长度和数据内容。MetadataExtractor做的事情,就是把这个“抽屉”逐层拆开,再把Tag ID翻译成人类看得懂的名字。
对于做应用的人来说,你并不需要真的去逐字节解析(那是另一个很有意思但非常耗时的方向),你只需要明白三个概念:目录(Directory)、标签(Tag)、值(Value)。目录代表信息类别,标签代表属性名,值就是具体内容。下面代码里你会看到,读取流程就是遍历目录,再读取特定标签。
3.2 用MetadataExtractor读取Exif:少写300行代码
核心读取代码可以非常简洁,大概长这样:
using MetadataExtractor; IEnumerable<MetadataExtractor.Directory> directories = ImageMetadataReader.ReadMetadata(filePath); foreach (var directory in directories) { foreach (var tag in directory.Tags) { Console.WriteLine($"{directory.Name} - {tag.Name} = {tag.Description}"); } }这个循环会把所有目录的所有标签全部打印出来。但在真实应用里,你不可能把所有标签都存进数据库,所以需要针对性地提取。比如拍摄时间,对应的是ExifSubIFDDirectory里的TagDateTimeOriginal;相机机型,对应的是ExifIFD0Directory里的TagModel;GPS坐标则要看GpsDirectory里的那几个标签。
using MetadataExtractor.Formats.Exif; var directories = ImageMetadataReader.ReadMetadata(filePath); var ifd0 = directories.OfType<ExifIFD0Directory>().FirstOrDefault(); if (ifd0 != null && ifd0.TryGetDescription(ExifIFD0Directory.TagMake, out string cameraMake)) { // 相机制造商 } var subIfd = directories.OfType<ExifSubIFDDirectory>().FirstOrDefault(); if (subIfd != null && subIfd.TryGetDescription(ExifSubIFDDirectory.TagDateTimeOriginal, out string dateTimeOriginal)) { // 拍摄时间 } var gpsDir = directories.OfType<GpsDirectory>().FirstOrDefault(); if (gpsDir != null && gpsDir.TryGetDescription(GpsDirectory.TagLatitude, out string latitude)) { // 纬度描述 }这套API的使用逻辑是:先用OfType拿到对应目录,再用TryGetDescription读取指定Tag的描述文本。为什么用TryGetDescription而不是直接访问Tag?因为不是每张照片都写了完整Exif,有的手机照片甚至完全没有GPS信息。TryGetDescription配合if判断,能优雅处理“这个Tag不存在”的情况,程序不会崩。
我之前试过直接遍历Tags然后按Name匹配字符串,虽然也能用,但兼容性差。同一相机型号的标签描述在不同版本的MetadataExtractor里可能有细微变化,而用强类型常量(比如TagModel)就稳定得多。
3.3 手写解析器会遇到哪些坑(如果你非要自己写)
如果你有钻研精神,想自己手动解析Exif,我劝你做好心理准备。这不是说做不到,而是有几道绕不过去的坎。
第一道坎是字节序。TIFF结构里会写明字节序是II还是MM,也就是小端还是大端,解析时每读一个整数都得判断当前字节序。第二道坎是类型长度,Exif里有效的类型包括BYTE、ASCII、SHORT、LONG、RATIONAL等,每种类型占的字节数不同,RATIONAL还是两个32位整数拼起来的分数结构,解析时很容易算错偏移量。第三道坎是指针跳转,IFD里的值如果超过4字节,存的是指向其他位置的偏移量,你得根据偏移量再跳回文件里读数据。GPS信息特别明显,纬度、经度分别由度、分、秒三个RATIONAL组成,每个都得拆出来拼在一起。第四道坎是缩略图,有些文件里嵌着IFD1缩略图,你不处理它,解析主图数据时偏移量可能算错。
总之,手动解析并不是不能做,但为了一个学习任务,真没必要在底层死磕。MetadataExtractor帮你把这些坑都填平了,专注业务逻辑才是正路。
4. 批量入库与查询展示实践
4.1 批量导入的完整代码实现
读取单张Exif不难,难的是怎么把整个文件夹下的所有图片都处理完,还要显示进度、处理错误、去重。下面是我实际使用的核心代码骨架:
using Microsoft.Data.Sqlite; using MetadataExtractor; using System.Security.Cryptography; string connectionString = "Data Source=photos.db;"; string folderPath = @"D:\照片库"; var sqliteConnection = new SqliteConnection(connectionString); sqliteConnection.Open(); InitializeDatabase(sqliteConnection); var imageFiles = Directory.EnumerateFiles(folderPath, "*.*", SearchOption.AllDirectories) .Where(f => f.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) || f.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) || f.EndsWith(".png", StringComparison.OrdinalIgnoreCase) || f.EndsWith(".webp", StringComparison.OrdinalIgnoreCase)) .ToList(); int total = imageFiles.Count; int processed = 0; int skipped = 0; int failed = 0; using var transaction = sqliteConnection.BeginTransaction(); foreach (string file in imageFiles) { processed++; try { InsertImageWithExif(sqliteConnection, transaction, file); } catch (Exception ex) { failed++; Console.WriteLine($"失败: {file} - {ex.Message}"); } if (processed % 100 == 0) { Console.WriteLine($"进度: {processed}/{total}, 跳过: {skipped}, 失败: {failed}"); } } transaction.Commit(); sqliteConnection.Close();这里有几个细节。Directory.EnumerateFiles是可枚举的,边遍历边处理,比先全部GetFiles进数组再遍历更省内存。配对谓词只挑常见图片扩展名,避免把PDF、ZIP也当成图片去解析。外层包一个事务,所有插入在同一个事务里完成,速度会快几个数量级。
InsertImageWithExif这个方法内部我会先计算文件哈希,然后查数据库,如果哈希已存在则跳过,避免重复导入。计算哈希用SHA256.HashData(File.ReadAllBytes(file)),虽然要读一遍整个文件,但对去重来说值得。如果文件很大,也可以只读取前1MB计算部分哈希,但完整文件哈希准确性更高。
4.2 事务与性能调优:十万张图片大约要多久
关于性能,我实测下来,一个包含两万张图片的文件夹,JPEG为主,在普通机械硬盘上,单线程逐张解析加写入,大约需要8到12分钟。如果换成SSD,能缩到5分钟左右。这个速度关键在于SQLite事务和参数化命令。
如果不使用事务,每插入一条记录都触发一次磁盘同步,速度可能慢20倍以上。SQLite默认的同步模式是FULL,也就是每一步写操作都要等数据落盘,这在批量导入时是灾难。我的建议是:
- 把几千条插入包在一个事务里
- 连接字符串或PRAGMA设置
journal_mode=WAL - 用参数化SQL语句,而不是拼接字符串
如果还想更快,可以调整PRAGMA synchronous=NORMAL。这个设置会让WAL模式下偶尔丢失最后几条事务的持久性,但对学习项目来说完全可以接受。我处理十万张图片时,就是配合事务和WAL,速度控制在20多分钟。
但要注意,一个事务里装的插入语句也不是越多越好。几千张一批比较合适。如果一批塞几万条,事务一旦出错回滚,代价也很大。我也会定期提交一次,把进度输出给用户看,确保不是卡死了。
4.3 查询展示:把数据库变回人话
存入数据库的最终目的是查出来用。最简单的查询,你可以用控制台输出:
string query = @" SELECT FileName, CameraModel, DateTimeOriginal, Latitude, Longitude FROM Images WHERE CameraModel IS NOT NULL AND DateTimeOriginal IS NOT NULL ORDER BY DateTimeOriginal DESC LIMIT 50;"; using var command = sqliteConnection.CreateCommand(); command.CommandText = query; using var reader = command.ExecuteReader(); while (reader.Read()) { Console.WriteLine($"{reader["FileName"]} | {reader["CameraModel"]} | {reader["DateTimeOriginal"]} | {reader["Latitude"]} | {reader["Longitude"]}"); }如果做成了WinForms或WPF界面,可以把查询结果绑定到DataGridView,再加一个文本框和按钮做筛选,按相机名、日期范围、是否有GPS条件过滤。对学习任务来说,能把控制台跑通已经算及格,但能和界面结合起来,则会更接近真实工具的水准。
另外,如果你后面把DateTimeOriginal存成了标准格式,还可以用SQLite的日期函数做统计。比如按月统计拍了多少张照片:
SELECT substr(DateTimeOriginal, 1, 7) AS Month, COUNT(*) FROM Images GROUP BY substr(DateTimeOriginal, 1, 7);这类统计在Excel里手工做很烦,但只要数据进了SQLite,一条SQL就出来了,这也是这个任务最直接的成就感来源。
5. 常见问题与排查技巧实录
5.1 数据库被锁定与并发写入
SQLite最出名的一个报错是database is locked。这个报错的根源在于SQLite同一时刻只允许一个写入连接。如果你的程序一边导入一边开另一个连接查数据,或者导入连接没有及时提交,就会触发。
我的习惯是,批量导入时只用一个连接提交事务,查询时用另一个连接,并且给连接字符串加上Default Timeout=30。更根本的解法是开WAL模式,然后执行PRAGMA busy_timeout = 3000;,这样即使连接被短暂占用,也会等待而不是立刻抛错。
还有一个容易忽略的场景:上一轮导入异常退出,事务没有回滚,导致数据库文件一直处于锁定状态。我一般在程序启动时先执行一个诊断SQL,检查是否有未关闭的事务。如果实在打不开,就用Db Browser for SQLite把数据库压缩一下,或者手动删掉-wal文件(前提是确定没有进程正在写库)。
5.2 GPS坐标转换与图片缺失Tag
Exif里的经纬度通常是39°54'22.5"N这种度分秒格式,而数据库里的坐标字段是十进制度数。所以写入前要转换。
public static double? ConvertDmsToDecimal(string dms) { if (string.IsNullOrWhiteSpace(dms)) return null; // 示例格式: 39°54'22.5"N dms = dms.Trim(); char hemi = dms[^1]; if (!"NSEW".Contains(hemi)) return null; string numericPart = dms.Substring(0, dms.Length - 1); var parts = numericPart.Split(new[] { '°', '\'', '"' }, StringSplitOptions.RemoveEmptyEntries); if (parts.Length < 3) return null; double deg = double.Parse(parts[0].Trim()); double min = double.Parse(parts[1].Trim()); double sec = double.Parse(parts[2].Trim()); double result = deg + min / 60.0 + sec / 3600.0; if (hemi == 'S' || hemi == 'W') result = -result; return result; }我这里用了一个简单的下标方式取最后一个字符,稳健的正则写法会更好。注意南纬和西经必须转成负数,不然你在地图工具上看到的坐标点会完全错位。这类细节,代码跑通了看不出毛病,实际一用就会露馅。
另外,不是每张照片都带GPS信息。很多手机默认未开启位置记录,或者第三方软件修图后把Exif清掉了。所以代码里一定要允许Latitude为NULL,界面上显示“无坐标”而不是报错。处理“Tag缺失”时,TryGetDescription比直接GetDescription更安全,前者返回false,后者可能抛异常。
5.3 踩坑:字段类型被SQLite“无视”和中文路径
SQLite动态类型带来的另一个坑在导入时悄然发生:如果你从Excel或别的系统导出的数据里带了不可见字符,或者编码是GBK,读取后写入TEXT字段,查询显示出来的中文可能是乱码。C#这边现在默认UTF-8,通常没问题。但如果你用老旧的System.Data.SQLite,并且没有在连接字符串指定编码,中文路径就可能出问题。我统一用Microsoft.Data.Sqlite,碰上路径里有中文的情况一切正常,跨平台也更稳。
还有路径归一化问题。同一个文件,用户第一次用绝对路径D:\照片\a.jpg导入,第二次用带..的路径导入,文件哈希能帮忙去重,但FilePath如果没做统一的Path.GetFullPath处理,就会出现两条记录指向同一物理文件。我后来写入前强制做了Path.GetFullPath(),并按小写存了一份路径用于查询对比,这个问题才算根治。
去重逻辑也很关键。FileHash字段建议加索引,否则数量大了之后查重会变慢。如果文件很大,光计算哈希就耗时,可以先按“文件大小+最后修改时间”粗筛,再对可能的重复项算哈希,性能会好很多。
5.4 特殊字符与SQL注入的防护
你可能会想:TagValue是字符串,直接拼进SQL不就行了?我见过很多半成品代码就是这么写的。结果遇到一个备忘录里有单引号的照片,SQL直接崩;遇到一个包含SQL片段的Tag,甚至可能被恶意利用。所有SQL都用参数化,一个都不能省。C#这边的写法是:
using var cmd = connection.CreateCommand(); cmd.CommandText = "INSERT INTO ExifAttributes (ImageId, TagName, TagValue) VALUES ($imgId, $tagName, $tagValue)"; cmd.Parameters.AddWithValue("$imgId", imageId); cmd.Parameters.AddWithValue("$tagName", tag.Name); cmd.Parameters.AddWithValue("$tagValue", tag.Description ?? ""); cmd.ExecuteNonQuery();参数名前面用$或@都可以,Microsoft.Data.Sqlite都认。参数化查询不仅防SQL注入,还省去了手动处理单引号转义的麻烦。这是写入数据库环节最基础也最重要的姿势。
6. 几个能直接提升体验的小技巧
6.1 用Db Browser for SQLite查看与验证数据
开发和调试阶段,我强烈建议安装一个Db Browser for SQLite(就是db4s),它是开源免费的图形化管理工具。你不需要写任何SQL就能看到所有表、所有记录,也能直接执行SQL语句验证查询。我每次导入完,第一件事就是用它的“浏览数据”页签看看Images表有多少条记录,再随机点开几条看ExifAttributes的键值,确保写入格式正确。
它还有一个功能是“数据库压缩”(VACUUM)。当你删了很多记录后,数据库文件大小不会自动缩小,执行一次VACUUM能回收空间。发布工具给用户前顺手做一次,文件体积能小不少。
6.2 文件删除后数据库记录怎么办
如果导入完成后,用户删除了某个图片文件,数据库里的记录就成了“幽灵数据”。如果你的场景需要同步,建议每次启动时对未经过校验的记录做一次文件存在性检查,并标一个FileExists字段。真正的库房管理工具甚至会用FileSystemWatcher监听文件夹变化,自动增删记录。
不过对任务五这种学习型项目,不需要做到这个程度。你只需要在查询界面上提示用户“路径可能失效”,或者在导出时检查文件是否存在即可。过度设计也是另一种坑。
6.3 后续还可以怎么扩展
这个任务做完后,你完全可以把它扩展成自己的第一个“作品级”工具。加一个WinForms界面,做一个图片列表,双击打开原图;按年份、相机型号分组统计;用异步和CancellationToken让批量导入界面不卡顿;甚至可以把GPS坐标批量导出成KML文件,丢到Google Earth里看照片足迹。朋友圈里的“年度照片足迹地图”,技术底层就是这套逻辑。
如果你想更加贴近实际生产环境,还可以引入增量导入:用文件Hash和导入时间做判断,只处理新增文件,避免每次全量跑一遍。这一步做完,你其实已经具备了一个小型素材管理系统的雏形。
说到这儿,我想起最开始写这个程序时踩过的一个坑:第一版把每个Exif标签都做成数据库的列,每次遇到新相机型号就要改表结构、加列,前前后后改了七八回。后来改成键值对结构,扩展问题一次性解决,心情舒畅得多。这种“先想清楚数据的常态和变体,再决定表结构”的思路,可能比代码本身更重要。项目本身不大,但它逼着你在动手之前做取舍,这种练习,等你以后真的开始做完整系统时,会非常值钱。