☰
基于Matlab的停车场计费系统开发:datetime时间处理与App Designer实战
2026/9/30 4:58:50 网站建设 项目流程

在做停车场计费系统这件事上,工具重要,但更重要的是先把业务逻辑想透。我最近用 Matlab 从零搭了一套汽车出入库计时计费系统,入口记车牌和时间,出口自动算时长、按费率表算钱,所有进出流水可以存下来、查得到,还能导出 Excel 对账。整套东西用到了 datetime 时间体系、App Designer 界面和表格读写,代码量不大,但边界情况非常多,很值得拿出来聊聊。如果你正在做课程设计、毕业设计,或者想在上硬件之前先验证一下停车场计费算法,这篇内容可以直接照搬思路。

1. 系统需求拆解与总体设计思路

1.1 出入库计费到底在计什么

先把业务捋直。一个不需要人工干预的简易停车场,核心流程只有四步:车进场,记录车牌和入场时间;车出场,找到对应入场记录;用当前时间减入场时间得到停车时长;按计费规则换算成金额,生成流水。听起来简单,但真正落地的时候,很多细节会冒出来:车牌是手输还是扫描?离场时怎么匹配车辆?同牌车反复进出怎么办?“不足一小时按一小时”还是“按分钟累计”?免费时长怎么扣?有没有单日封顶?这些如果不在设计阶段敲定,等界面写完了再改,改到崩溃。

所以第一步是先列功能清单,而不是写代码。我这个系统最终确定的目标功能有六项:

  • 支持按车牌登记入场,自动写入当前系统时间。
  • 支持从“未结算车辆列表”中选中车辆,一键结算出场。
  • 支持配置免费分钟数、首小时费、续时费、日封顶等参数。
  • 所有进出记录和缴费记录保存到本地文件,启动时自动恢复。
  • 支持把流水导出为 Excel 表格,方便对账。
  • 提供一键生成测试数据的功能,便于演示多种边界场景。

没有做摄像头车牌识别,那部分涉及图像采集和识别,跟“计时计费”的主线是解耦的。后续如果要接识别模块,只需要把识别结果作为车牌输入写进来,后面的结算逻辑完全不用动。

1.2 为什么选择 Matlab 而不是 C# / Python

有人会问,停车场管理系统不都是 C#、Java 写的吗?用 Matlab 是不是太“学院派”?我的看法是,看你的目标是什么。

如果你的目标是部署到真实停车场,跟道闸、摄像头、支付终端对接,Matlab 确实不合适,编译部署和硬件接口都是弱项。但如果是快速验证算法、做一个能演示的交互原型、或者完成课程设计和毕业设计,Matlab 有四个天然优势:

  1. 时间处理省心。datetime、duration 直接支持跨天、跨月、跨年计算,不用自己写闰年和大小月判断。
  2. 表格和结构体顺手。停车记录天然是表格结构,table、struct 数组和矩阵操作能快速做增删改查。
  3. App Designer 拖拽生成界面。不用学 HTML/CSS/JavaScript,就能做出带按钮、表格、下拉框的桌面程序。
  4. 数据可视化自带。把营收曲线、停车时长分布画出来,plot、bar、pie 几行代码就够了,展示项目成果时非常加分。

Matlab 的缺点也很明显:打包后的 exe 体积大、启动慢、目标机器要装运行时。但这些对原型系统来说可以接受。老师或答辩评委更看重逻辑是否清晰,而不是部署体积。

1.3 三层结构:界面、逻辑、存储分离

代码写了几版之后,我最大的体会是:不要把所有东西堆到一个一千行的 m 文件里。小项目也要有最基本的“显示层—逻辑层—数据层”分离意识。

  • 显示层:一个 App Designer 的 .mlapp 文件,负责接收用户输入、调用函数、把结果展示到表格和标签上。
  • 逻辑层:一组独立函数文件,比如 calcParkingFee.m、loadRecords.m、saveRecords.m。这些函数不依赖界面,可以直接用脚本调用,也方便做单元测试。
  • 数据层:用 .mat 文件保存系统状态,包括未结算记录、已结算记录、费率配置。需要导出时用 writetable 输出 Excel。

这样做的直接好处是:调价时只需要改费率参数,不需要动界面;换存储方案时只替换数据层函数,界面依然不动。模块之间通过函数接口通信,可维护性会好很多。

2. 核心算法:停车时长与计费规则怎么定义

2.1 用 datetime 而不是 datenum,别给自己埋雷

Matlab 里计算时间差有三种常见做法:datenum、datetime、自己把字符串拆开做减法。第三种我不评价,但那是初学阶段。datenum 把时间转成一个代表“天数”的双精度浮点数,相减得到小数天数,再乘 24 乘 3600 换算秒。这套机制在老代码里很常见,但有两个问题:一是浮点数精度有限,出现秒级误差时不好查;二是代码可读性差,满屏乘 86400,不知道在干嘛。

我推荐用 R2014b 之后引入的 datetime 和 duration。基本用法是:

tIn = datetime('2026-03-02 08:30:00', 'InputFormat', 'yyyy-MM-dd HH:mm:ss'); tOut = datetime('2026-03-02 14:15:00'); parkingDuration = tOut - tIn; % 得到 duration 类型 totalMinutes = minutes(parkingDuration);

这里有一个易错点:datetime('2026-03-02 14:15:00') 是合法的,因为 Matlab 能自动识别常用格式;但如果输入字符串格式不统一,比如 '2026/3/2 14:15',就可能解析失败。最稳妥的做法是给所有输入指定 InputFormat。特别是在读写外部数据时,宁可多写两行,也不要把解析交给自动检测。

内部统一用“分钟”作为计量单位。计费粒度一般到分钟就够,分钟换算成小时也更容易控制取整逻辑。如果用秒,显示的时候还要二次转换,没必要。

2.2 一份能落地的分段计费规则

我假设一个典型收费方案:

  • 免费停车 30 分钟(含 30 分钟整)。
  • 超过免费时间后,第 1 个计费小时收费 5 元。
  • 超过 1 小时后,每增加 1 小时加收 3 元,不足 1 小时按 1 小时算。
  • 单日连续停车 24 小时内封顶 30 元;超过 24 小时,按“每天封顶 + 剩余时间重新按规则计费”累加。

这个方案覆盖了大多数停车场的基本逻辑。费率参数定义成结构体,方便以后调价:

cfg.freeMinutes = 30; % 免费分钟数 cfg.firstHourFee = 5; % 超过免费期后的首个计费小时费用 cfg.extraHourFee = 3; % 后续每小时费用 cfg.dailyCapFee = 30; % 24小时内封顶费用

计费函数的核心逻辑是:先判断总停留时间是否小于等于免费时长;如果大于,把“可收费时长”取出来。注意,免费时长是直接从总时长里扣除,而不是先按整段算完再减免。例如一辆车停了 45 分钟,免费 30,可收费 15 分钟,因为不足 1 小时按 1 小时算,所以收一个首小时费 5 元。再比如停 35 分钟,同样收 5 元。规则是否合理是运营的事,系统只负责忠实执行。

函数我写在下面,代码很短,但边界处理要仔细:

function fee = calcParkingFee(totalMinutes, cfg) if totalMinutes <= cfg.freeMinutes fee = 0; return; end billableMinutes = totalMinutes - cfg.freeMinutes; billableHours = ceil(billableMinutes / 60); fee = cfg.firstHourFee + max(0, billableHours - 1) * cfg.extraHourFee; % 小于一个“首小时”的计费,就按首小时费收 end

这里先只算 24 小时内的短时费用。为什么用 ceil 而不是 floor?因为“不足一小时按一小时”本身就是向上取整。如果停车场按“每 15 分钟收 1 元”,核心思想一样,把 billableMinutes 除以 15 后向上取整再乘单价,只是参数不同。

2.3 超过 24 小时怎么办:封顶和循环计费

上面的函数没有处理超过 24 小时的情况。如果车辆停了两天,直接套用短时费用公式,费用会高得离谱,不符合“日封顶”规则。我的做法是拆成“整天 + 余段”:

function fee = calcParkingFeeWithCap(totalMinutes, cfg) if totalMinutes <= cfg.freeMinutes fee = 0; return; end dayMinutes = 24 * 60; wholeDays = floor(totalMinutes / dayMinutes); % 完整的天数 remainMinutes = totalMinutes - wholeDays * dayMinutes; % 剩余分钟数 fee = wholeDays * cfg.dailyCapFee; if remainMinutes > 0 fee = fee + calcParkingFee(remainMinutes, cfg); end end

这个算法的含义是:每天封顶 30 元,超出整天的那部分,重新走免费时长 + 首小时 + 后续每小时的规则。有人会问“免费 30 分钟是不是每一天都能免?”按这个算法,是的。比如停了 24 小时 20 分钟,先按整天收 30 元,剩下 20 分钟小于免费时长,于是免费了。这确实会让人觉得“便宜”,但这是运营规则的问题,系统按照规则执行,而不是自己想当然。如果停车场规定“一次连续停车只享受一次免费时长”,余段计算时就要把之前没用掉的免费时长扣除。我建议在项目文档里写清楚这个规则选择,答辩时被问到才不慌。

2.4 计费边界条件测试表

算法写出来不算完,必须拿数据验证。我习惯先用命令行直接调用函数测边界,再接到界面上。下面这张表是必测项:

场景入场时间出场时间总时长期望结果说明
免费内08:0008:2929分钟0元未超过免费线
免费恰好08:0008:3030分钟0元含30分钟整
刚超免费08:0008:3131分钟5元首小时档
跨小时边界08:0009:3090分钟8元首小时5+续1小时3
差一秒到2小时08:0009:59119分钟5元不足2小时按1小时
满2小时整08:0010:00120分钟8元2个整小时
超一天第一天 08:00第二天 08:001440分钟30元整天封顶
一天多20分第一天 08:00第二天 08:201460分钟30元余20分小于免费
一天多31分第一天 08:00第二天 08:311471分钟35元余31分收首小时费

实际测试时,第 5 行很容易被写错。如果先用总分钟数减免费分钟,除以 60,再用 fix 而不是 ceil,119 - 30 = 89 分钟,fix(89/60) = 1,结果首小时费 5 元,碰巧对了。但如果是 91 分钟,91 - 30 = 61,fix(61/60) = 1,结果还是 5 元,但实际上 61 分钟超过了 1 小时零 1 分,按“不足一小时按一小时”应该算 2 小时,费用 8 元。必须用 ceil(billableMinutes / 60) 而不是先除再取整。这个坑非常经典,写代码时务必小心。

3. 从零搭建 App Designer 界面与数据持久化

3.1 界面布局:一个窗口搞定全部操作

打开 App Designer,新建空应用,拖拽组件。我推荐的布局是:

  • 左侧上半部分:车牌输入编辑框、车辆类型下拉框、入场时间显示(只读)、登记入场按钮。
  • 左侧下半部分:计费参数区,四个数值编辑框,分别对应免费分钟数、首小时费、续时费、日封顶,旁边一个“保存费率”按钮。
  • 右侧上半部分:未结算车辆表格,每行是一辆还没出场的车,包含车牌、车型、入场时间。
  • 右侧下半部分:已结算流水表格,包含车牌、入场时间、出场时间、停放分钟、应收金额、实收时间。
  • 底部一行:结算选中车辆按钮、模拟生成测试数据按钮、导出Excel按钮、清空历史按钮。

这里的关键是,入场和出场要操作两张不同的表格。车辆登记入场后出现在“未结算”表;结算完成,它要从“未结算”表里消失,同时在“已结算”表里多一行。如果只用一个总记录表,界面上很难直观看出哪些车还在场内。

关于组件命名,App Designer 自动生成的名称像 EditField、UITable,建议改成 app.plateEdit、app.vehicleTypeDrop、app.inTable、app.outTable、app.freeMinEdit 这种有意义的名称,回调代码里一眼就能看懂。

3.2 登记入场:输入校验和记录结构

登记入场按钮的回调要做三件事:校验车牌非空、记录当前时间、追加到未结算数据并刷新表格。代码大致是这样:

% 登记入场按钮回调 function RegisterButtonPushed(app, ~) plate = strtrim(upper(app.plateEdit.Value)); if isempty(plate) uialert(app, '车牌号不能为空', '输入错误', 'CloseFcn', @(s,e)app.plateEdit.Focus); return; end newRecord = struct( ... 'Plate', plate, ... 'VehicleType', app.vehicleTypeDrop.Value, ... 'TimeIn', datetime('now'), ... 'TimeOut', NaT, ... 'DurationMin', NaN, ... 'Fee', NaN, ... 'Settled', false); app.inData(end+1) = newRecord; % 结构体数组追加 refreshInTable(app); app.plateEdit.Value = ''; % 清空输入框,方便连扫车牌 end

结构体数组追加有一个坑:newRecord 的字段顺序必须和 inData 完全一致,否则 Matlab 报错。更稳妥的写法是把记录统一转成 table 或 cell 数组再操作,但结构体数组对原型来说够用。另一个经验是,每次操作完数据,都要有独立的“刷新表格”函数,不要直接写 app.UITable.Data = app.inData,因为需要转换类型和过滤。

刷新函数内部要做过滤:

function refreshInTable(app) if isempty(app.inData) app.inTable.Data = table(); else t = struct2table(app.inData); t = t(~t.Settled, :); % 只显示未结算 app.inTable.Data = t; end end

如果 inData 是空 struct 数组,struct2table 会报错,所以先判空。

3.3 出场结算:从选中行取时间,算钱,落库

出场结算按钮的回调比入场复杂。首先要判断“未结算表格”里有没有选中行。App Designer 的 UITable 可以通过 app.inTable.Selection 获取选中行序号。我实际用的逻辑是:

function SettleButtonPushed(app, ~) if isempty(app.inData) uialert(app, '当前没有未结算车辆', '提示'); return; end selIdx = app.inTable.Selection; if isempty(selIdx) || selIdx == 0 uialert(app, '请先在左侧表格选中一辆车', '提示'); return; end % 选中行索引不一定等于数据数组索引,需要映射 allRows = struct2table(app.inData); unSettled = find(~allRows.Settled); rowIndex = unSettled(selIdx(1)); record = app.inData(rowIndex); if record.Settled uialert(app, '这辆车已经结算过了', '提示'); return; end record.TimeOut = datetime('now'); durationMin = minutes(record.TimeOut - record.TimeIn); record.DurationMin = durationMin; record.Fee = calcParkingFeeWithCap(durationMin, app.cfg); record.Settled = true; app.inData(rowIndex) = record; % 更新原记录 refreshInTable(app); refreshOutTable(app); app.feeResultLabel.Text = sprintf('停车 %d 分钟,应收 %.2f 元', ... durationMin, record.Fee); end

这里有几个注意点:

  • UITable 选中行索引不是数据数组的行号。如果表格里只显示未结算的过滤子集,选中第 2 行,Selection 返回 2,但这条记录在 inData 里的实际下标可能是 7。我用逻辑索引 unSettled 做映射,保证不取错车。
  • 判断是否已结算,不要用“该行是否还在表格里”,因为表格是过滤后的,不可靠。应该用 Settled 字段。
  • 计费函数必须传完整 cfg,不能在函数内部硬编码数字。这样改价时只需改界面参数,核心函数不动。

3.4 数据持久化:用 .mat 保存状态,用 Excel 做账本

保存和读取是系统稳定的基础。最简单有效的方案是把数据状态保存为 .mat 文件,在应用启动时读取,每次数据变更后保存。保存函数:

function saveAllData(app) savePath = fullfile(app.StartupFolder, 'parking_data.mat'); inData = app.inData; outData = app.outData; cfg = app.cfg; save(savePath, 'inData', 'outData', 'cfg', '-mat'); end

启动时加载:

function startupFcn(app) app.StartupFolder = fileparts(mfilename('fullpath')); loadPath = fullfile(app.StartupFolder, 'parking_data.mat'); if exist(loadPath, 'file') S = load(loadPath); app.inData = S.inData; app.cfg = S.cfg; refreshInTable(app); refreshOutTable(app); else app.inData = struct('Plate',{},'VehicleType',{},'TimeIn',{}, ... 'TimeOut',{},'DurationMin',{},'Fee',{},'Settled',{}); app.cfg = defaultConfig(); end end

有一个小坑:不要用相对路径 save('parking_data.mat')。当 Matlab 当前文件夹被切到别处时,文件会出现在意外位置,下次启动又找不到。用 mfilename 获取 app 所在路径再拼绝对路径,才是最稳的。

Excel 导出就简单了:

function ExportButtonPushed(app, ~) if isempty(app.inData) uialert(app, '暂无数据可导出', '提示'); return; end t = struct2table(app.inData); t = t(t.Settled, :); t.TimeIn.Format = 'yyyy-MM-dd HH:mm:ss'; t.TimeOut.Format = 'yyyy-MM-dd HH:mm:ss'; exportPath = fullfile(app.StartupFolder, ... sprintf('parking_records_%s.xlsx', datestr(now,'yyyymmdd_HHMM'))); writetable(t, exportPath); uialert(app, sprintf('已导出到:%s', exportPath), '导出成功'); end

注意,writetable 对 datetime 列默认可能输出成英文格式,客户看起来不友好。要么先把列格式设好,要么转为字符串列再导出。文件名我加了时间戳,避免重复导出相互覆盖。

3.5 测试数据生成:答辩必备的一键造数

演示的时候最怕表格空空荡荡,观众不知道你在干嘛。我加了一个“模拟生成测试数据”按钮,一键填充 20 条历史记录。逻辑很简单:用随机数生成车牌序列,TimeIn 取过去几天内的随机时间,TimeOut = TimeIn + 随机分钟数,调用计费函数算费。每次演示都能拿出一个像模像样的账本。

这个功能不仅是给演示用的,我自己联调界面时也常用它快速测试表格刷新速度、筛选逻辑、导出功能,比手动一条一条录高效得多。注意生成的数据要放在已结算记录里且 Settled = true,不要污染未结算列表。

function GenerateDemoData(app, ~) rng('shuffle'); plates = {'京A12345';'沪B67890';'粤C55668';'浙D33321';'苏E88888';'鲁F99887';'川G10086';'湘H66666'}; N = 20; for i = 1:N rec.Plate = plates{randi(length(plates))}; rec.VehicleType = '小型车'; rec.TimeIn = datetime('now') - days(rand*5) - minutes(rand*300); rec.TimeOut = rec.TimeIn + minutes(randi([20, 2000])); rec.DurationMin = minutes(rec.TimeOut - rec.TimeIn); rec.Fee = calcParkingFeeWithCap(rec.DurationMin, app.cfg); rec.Settled = true; app.inData(end+1) = rec; end refreshInTable(app); refreshOutTable(app); end

这段代码在真实项目里可以继续扩展,比如随机生成“未结算”和“已结算”两种状态,模拟一个循环往复的停车场。

4. 常见问题排查与避坑实录

4.1 datetime 解析失败:字符串格式不统一

实际运行中,最常见的错误是 “Unable to convert text to datetime using the format...” 或者输入被解析成很大的日期。原因几乎都是字符串没有按约定格式输入。比如代码里写了yyyy-MM-dd HH:mm:ss,但用户从 Excel 读进来的字符串是2026/3/2 14:15,分隔符和月份位数都不一样。解决方法是,凡是外部输入,一律显式指定 InputFormat:

tIn = datetime(str, 'InputFormat', 'yyyy-MM-dd HH:mm:ss');

如果输入来源混杂两种格式,可以先预处理判断分隔符:

if contains(str, '/') t = datetime(str, 'InputFormat', 'yyyy/MM/dd HH:mm'); else t = datetime(str, 'InputFormat', 'yyyy-MM-dd HH:mm'); end

不要依赖 datetime 的自动检测,尤其批处理大量数据时,一个异常格式就可能导致整批失败。

4.2 计费函数里的取整陷阱

我在第 2 节已经讲了 ceil 和 fix 的区别,这里再补一个真实 bug。我第一次写的时候是先算总费用,再减去免费金额。比如 90 分钟,先按两小时算 8 元,再减免费 0 元,碰巧正确。但停 31 分钟时,先按一小时算 5 元,免费时长很难准确折价,容易产生 4.99 这种数。正确顺序是:先扣时长,再按档计费。不要算完钱再减免费部分。

另外,涉及金额时不要直接用浮点数比较相等。虽然 fee 是由整数运算得到的,但浮点精度可能造成 0.999999 的情况。建议统一用 round 处理费用到两位小数:fee = round(fee, 2);。

4.3 UITable 选中行索引错位

这个坑我头大了一晚上。App Designer 中 UITable 的 Selection 返回的是当前显示表格的行号,不是底层数据数组的行号。如果表格只显示了 3 条未结算记录,选中第 2 行,Selection 返回 2,但底层 inData 里这条记录的实际下标可能是 7。

我的解决方式,是在刷新表格时额外加一列“内部序号”存放底层行号,并把这列宽度设为 0。选中的行读取该列的值,就是底层索引。不过更简洁的代码方式,是用逻辑索引unSettled(selIdx)映射,这个在出场结算的代码里已经体现。

4.4 重复点击结算按钮导致重复扣费

因为是桌面应用,用户双击“结算”按钮很正常。第二次点击时,车辆可能还没从表格里移除,于是又算了一次钱。解决思路有两条:一是进入回调后先检查该行的 Settled 字段;二是在回调开始时禁用按钮,等整个结算流程结束再启用。我推荐两者都做,双保险。

检查字段的代码:

if app.inData(rowIndex).Settled uialert(app, '该车辆已完成结算', '提示'); return; end

按钮临时禁用:

app.settleButton.Enable = false; % ... 业务逻辑 ... app.settleButton.Enable = true;

4.5 跨天、跨年与系统时间被手动修改

跨天测试是必须做的。我一般会构造入场时间为昨天 23:50,出场时间今天 00:20,总时长 30 分钟,费用应为 0。datetime 能正确计算,这没问题。但如果有人手改了系统时间,比如入场后把电脑时间调快 2 小时,出场时计算的时长变成负数,费用函数会因为 totalMinutes 为负而直接返回 0,表面看没报错,实际是错的。需要在结算逻辑里加校验:

if durationMin < 0 uialert(app, '出场时间早于入场时间,请检查系统时间', '时间异常'); return; end

时区问题对国内项目基本不用管,如果部署到国外,或者用 UTC 时间存储,需要注意 datetime 的 TimeZone 属性。我建议停车场系统统一使用本地时间,不做时区转换,因为显示和计费都以本地自然时间为准。

4.6 中文乱码与保存路径

旧版 Matlab(R2016a 之前)中文显示容易乱码,新版本基本没问题。稳妥起见,所有中文字符串尽量在 App Designer 里直接输入,不要复制粘贴其他编码的文本。导出 Excel 时,writetable 生成的 .xlsx 一般能正确保存中文;但如果生成 CSV,Excel 默认用 ANSI 打开,中文会乱码,所以建议导出 xlsx 而不是 csv。

路径问题前面提过,再强调一遍:不要在代码里用 pwd 获取当前路径,因为 App Designer 运行时的当前文件夹可能是用户正在浏览的目录,而不是 app 文件所在目录。应该用mfilename('fullpath')获取当前文件路径,再用 fileparts 取目录。

4.7 让这个项目更好扩展的几条忠告

最后分享一个我个人的开发习惯,或者是踩过几次坑之后的调整:给这套系统加界面之前,我先把计费函数用脚本跑了一百多遍,把所有边界情况全部测完,才接进 App Designer。后来调价,只需要改 cfg 参数,界面和函数都不用动,改动成本非常低。这个“先写好纯逻辑,后上界面”的工作流,适用于所有以算法为核心的小项目。

另一个小技巧是,如果觉得手动输入车牌太慢,可以在入场输入框后面加一个“扫码枪输入模式”。本质上就是要求扫码设备把扫描到的车牌当成字符串,并在末尾自动加回车,界面完全不用多做处理。系统的核心还是计时计费,车牌只是索引。

扩展方向其实很多:接图像处理做车牌识别、用数据库替换 .mat、加入会员月卡计费逻辑、生成每日营收统计图。但地基要稳,后面加功能才不会到处返工。停车场的逻辑本身不复杂,复杂的是各种边界条件——免费临界点、跨天、重复点击、超时刷新。把这几样都处理干净,你就能挺起胸脯说,这套系统是我自己从零写出来的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询